You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
MyQRCode/docs/IMAGE_COMPOSER_FEATURE_READ...

581 lines
20 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 图片合成功能实现文档
## 功能概述
图片合成功能允许用户将生成的二维码添加到背景图片上,并提供完整的编辑功能,包括拖拽、缩放和旋转。该功能通过 `ImageComposerView` 实现,支持从 `QRCodeSavedView` 选择背景图片,然后进行二维码的精确编辑。
## 界面设计
### 导航栏
- **标题**: "Add to Picture" (英文界面)
- **返回按钮**: 使用 `Image(systemName: "chevron.left")`蓝色18pt semibold 字体
- **保存按钮**: 蓝色文字16pt semibold 字体,无背景和圆角,与应用其他界面保持一致
### 编辑区域
- **背景图片**: 自适应显示,保持宽高比
- **二维码层**: 包含二维码图片、选择框和操作图标
- **选择框**: 青色边框2pt 宽度,仅在选中时显示
- **操作图标**: 位于二维码右下角,青色圆形背景,白色图标
- **重置按钮**: 浮动按钮,位于编辑区域右下角
## 核心功能
### 1. 背景图片选择
-`QRCodeSavedView` 的"添加到图片"按钮触发
- 使用 `ImagePicker` 选择背景图片
- 选择后自动跳转到 `ImageComposerView`
### 2. 二维码拖拽
- 支持在背景图片范围内自由拖拽
- 使用 `constrainPositionToImage` 函数限制移动范围
- 确保二维码不会超出背景图片边界
### 3. 缩放和旋转
- **操作图标**: 位于二维码右下角,支持手势操作
- **缩放**: 垂直拖拽操作图标,向上缩小,向下放大
- **旋转**: 水平拖拽操作图标,向左顺时针旋转,向右逆时针旋转
- **范围限制**: 缩放 0.3-2.5,旋转 -180° 到 180°
### 4. 边界约束
```swift
private func constrainPositionToImage(_ position: CGPoint) -> CGPoint {
let qrCodeSize = CGSize(width: 100 * qrCodeScale, height: 100 * qrCodeScale)
let halfWidth = qrCodeSize.width / 2
let halfHeight = qrCodeSize.height / 2
let boundarySize = actualEditingAreaSize != .zero ? actualEditingAreaSize : UIScreen.main.bounds.size
let minX = halfWidth
let maxX = boundarySize.width - halfWidth
let minY = halfHeight
let maxY = boundarySize.height - halfHeight
let constrainedX = max(minX, min(maxX, position.x))
let constrainedY = max(minY, min(maxY, position.y))
return CGPoint(x: constrainedX, y: constrainedY)
}
```
### 5. 手势处理
```swift
private func handleOperationGesture(_ value: DragGesture.Value) {
let translation = value.translation
// 设置最小移动阈值,避免微小移动触发操作
let minMovementThreshold: CGFloat = 3.0
if abs(translation.width) < minMovementThreshold && abs(translation.height) < minMovementThreshold {
return
}
// 判断主要操作方向
if abs(translation.width) > abs(translation.height) {
// 水平移动 - 旋转操作
isRotating = true
isScaling = false
// 使用基于中心点的角度计算算法
let center = CGPoint(x: 100 * qrCodeScale / 2, y: 100 * qrCodeScale / 2)
let currentTouch = CGPoint(x: translation.width, y: translation.height)
let previousTouch = CGPoint.zero // 对于实时旋转,前一个位置为原点
// 计算相对于中心点的角度
let angle1 = atan2(previousTouch.y - center.y, previousTouch.x - center.x)
let angle2 = atan2(currentTouch.y - center.y, currentTouch.x - center.x)
let angleDelta = (angle2 - angle1) * 180 / .pi // 转换为角度
qrCodeRotation += angleDelta
// 限制旋转角度在 -180° 到 180° 之间
if qrCodeRotation > 180 {
qrCodeRotation -= 360
} else if qrCodeRotation < -180 {
qrCodeRotation += 360
}
} else {
// 垂直移动 - 缩放操作
isScaling = true
isRotating = false
let scaleSensitivity: CGFloat = 0.005
let distance = sqrt(translation.width * translation.width + translation.height * translation.height)
let scaleDirection = translation.height > 0 ? 1.0 : -1.0
let scaleDelta = distance * scaleDirection * scaleSensitivity
let newScale = qrCodeScale - scaleDelta
qrCodeScale = max(0.3, min(2.5, newScale))
}
}
```
### 6. 状态管理
- **选择状态**: `isSelected` 控制选择框和操作图标的显示
- **操作状态**: `isScaling`、`isRotating`、`isDragging` 防止手势冲突
- **防卡死机制**: 定时器检查操作状态,自动重置异常状态
### 7. 图片合成
```swift
private func composeImages(background: UIImage, qrCode: UIImage) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: background.size)
return renderer.image { context in
// 绘制背景图片
background.draw(in: CGRect(origin: .zero, size: background.size))
// 计算二维码在背景图片中的位置和大小
let qrCodeSize = CGSize(width: 100 * qrCodeScale, height: 100 * qrCodeScale)
let qrCodeRect = CGRect(
x: qrCodePosition.x - qrCodeSize.width / 2,
y: qrCodePosition.y - qrCodeSize.height / 2,
width: qrCodeSize.width,
height: qrCodeSize.height
)
// 应用旋转和缩放变换
context.cgContext.saveGState()
context.cgContext.translateBy(x: qrCodeRect.midX, y: qrCodeRect.midY)
context.cgContext.rotate(by: CGFloat(qrCodeRotation) * .pi / 180)
context.cgContext.scaleBy(x: qrCodeScale, y: qrCodeScale)
context.cgContext.translateBy(x: -qrCodeRect.midX, y: -qrCodeRect.midY)
// 绘制二维码
qrCode.draw(in: qrCodeRect)
context.cgContext.restoreGState()
}
}
```
## 技术实现
### 1. 状态变量
```swift
@State private var qrCodePosition = CGPoint.zero
@State private var qrCodeScale: CGFloat = 1.0
@State private var qrCodeRotation: Double = 0.0
@State private var isDragging = false
@State private var isScaling = false
@State private var isRotating = false
@State private var isSelected = true
@State private var lastGestureTime = Date()
@State private var actualEditingAreaSize: CGSize = .zero
```
### 2. 视图结构
```swift
var body: some View {
NavigationView {
VStack {
editingArea
}
.navigationTitle("Add to Picture")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("返回") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("保存") {
composeAndSave()
}
}
}
}
}
```
### 3. 编辑区域
```swift
private var editingArea: some View {
GeometryReader { geometry in
ZStack {
// 背景图片
Image(uiImage: backgroundImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// 二维码层
qrCodeLayer
.position(qrCodePosition)
// 重置按钮
resetButton
}
.onAppear {
actualEditingAreaSize = geometry.size
// 设置初始位置
let imageSize = getImageDisplaySize()
let centerPosition = CGPoint(x: imageSize.width / 2, y: imageSize.height / 2)
qrCodePosition = constrainPositionToImage(centerPosition)
}
.onChange(of: geometry.size) { newSize in
actualEditingAreaSize = newSize
}
}
}
```
### 4. 二维码层
```swift
private var qrCodeLayer: some View {
ZStack {
// 二维码图片
Image(uiImage: qrCodeImage)
.resizable()
.frame(width: 100 * qrCodeScale, height: 100 * qrCodeScale)
.onTapGesture {
isSelected.toggle()
isScaling = false
isRotating = false
}
// 选择框
if isSelected {
RoundedRectangle(cornerRadius: 4)
.stroke(Color.teal, lineWidth: 2)
.frame(width: 140 * qrCodeScale, height: 140 * qrCodeScale)
}
// 操作图标
if isSelected {
operationIcon
}
}
.rotationEffect(.degrees(qrCodeRotation))
.gesture(
DragGesture()
.onChanged { value in
if !isScaling && !isRotating {
lastGestureTime = Date()
let newLocation = CGPoint(
x: qrCodePosition.x + value.translation.width,
y: qrCodePosition.y + value.translation.height
)
qrCodePosition = constrainPositionToImage(newLocation)
}
}
)
}
```
### 5. 操作图标
```swift
private var operationIcon: some View {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
.padding(6)
.background(Color.teal)
.clipShape(Circle())
.scaleEffect(qrCodeScale)
.shadow(color: .black.opacity(0.2), radius: 2, x: 0, y: 1)
.offset(x: 68 * qrCodeScale, y: 68 * qrCodeScale)
.gesture(
DragGesture()
.onChanged { value in
lastGestureTime = Date()
handleOperationGesture(value)
}
.onEnded { _ in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
isScaling = false
isRotating = false
}
}
)
}
```
## 用户体验优化
### 1. 防卡死机制
```swift
private func startLightweightAntiStuckCheck() {
Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in
if Date().timeIntervalSince(lastGestureTime) > 2.0 {
if isScaling || isRotating || isDragging {
DispatchQueue.main.async {
isScaling = false
isRotating = false
isDragging = false
}
}
}
}
}
```
### 2. 重置功能
```swift
private func resetQRCode() {
isScaling = false
isRotating = false
isDragging = false
withAnimation(.easeInOut(duration: 0.3)) {
let imageSize = getImageDisplaySize()
let centerPosition = CGPoint(x: imageSize.width / 2, y: imageSize.height / 2)
qrCodePosition = constrainPositionToImage(centerPosition)
qrCodeScale = 1.0
qrCodeRotation = 0.0
isSelected = true
}
}
```
### 3. 保存功能
```swift
private func composeAndSave() {
isComposing = true
DispatchQueue.global(qos: .userInitiated).async {
let composedImage = composeImages(background: backgroundImage, qrCode: qrCodeImage)
DispatchQueue.main.async {
self.composedImage = composedImage
self.isComposing = false
self.showingSaveSheet = true
}
}
}
```
## 功能特点
1. **精确控制**: 支持像素级精度的二维码定位和编辑
2. **边界约束**: 自动限制二维码在背景图片范围内
3. **手势分离**: 拖拽、缩放、旋转功能独立,避免冲突
4. **状态管理**: 完善的状态管理机制,确保操作稳定性
5. **防卡死**: 自动检测和修复异常状态
6. **用户友好**: 直观的操作方式,符合用户习惯
7. **性能优化**: 异步图片合成,避免界面卡顿
8. **丝滑旋转**: 实时跟随拖动的旋转体验
## 旋转功能优化
### 问题描述
最初的旋转功能基于累积的移动距离计算旋转角度,导致旋转不够丝滑,用户体验不佳。
### 解决方案
将旋转算法从累积模式改为实时模式:
- **累积模式**: `qrCodeRotation += rotationDelta` - 基于总移动距离
- **实时模式**: `qrCodeRotation = rotationDelta` - 基于当前拖动位置
### 技术改进
1. **提高灵敏度**: 将 `rotationSensitivity` 从 0.5 提升到 0.8
2. **实时响应**: 旋转角度直接跟随拖动位置,提供更直观的反馈
3. **保持限制**: 继续限制旋转角度在 -180° 到 180° 范围内
### 用户体验提升
- **更丝滑**: 旋转操作现在完全跟随手指拖动
- **更直观**: 用户可以直接看到旋转效果与拖动方向的关系
- **更精确**: 实时响应提供更精确的旋转控制
## 基于中心点的角度计算算法
### 算法原理
使用 `atan2` 函数计算触摸点相对于二维码中心的角度变化,实现更精确的旋转控制。
### 核心实现
```swift
// 判断主要操作方向
if abs(translation.width) > abs(translation.height) {
// 水平移动 - 旋转操作
isRotating = true
isScaling = false
// 使用基于中心点的角度计算算法
let center = CGPoint(x: 100 * qrCodeScale / 2, y: 100 * qrCodeScale / 2)
let currentTouch = CGPoint(x: translation.width, y: translation.height)
let previousTouch = CGPoint.zero // 对于实时旋转,前一个位置为原点
// 计算相对于中心点的角度
let angle1 = atan2(previousTouch.y - center.y, previousTouch.x - center.x)
let angle2 = atan2(currentTouch.y - center.y, currentTouch.x - center.x)
let angleDelta = (angle2 - angle1) * 180 / .pi // 转换为角度
qrCodeRotation += angleDelta
// 保持角度在 0-360 范围内
qrCodeRotation = qrCodeRotation.truncatingRemainder(dividingBy: 360)
// 转换为 -180 到 180 范围
if qrCodeRotation > 180 {
qrCodeRotation -= 360
} else if qrCodeRotation < -180 {
qrCodeRotation += 360
}
} else {
// 垂直移动 - 缩放操作
isScaling = true
isRotating = false
// 缩放逻辑...
}
```
### 算法优势
1. **精确计算**: 基于数学角度计算,比简单的线性移动更准确
2. **中心点参考**: 以二维码中心为旋转参考点,符合用户直觉
3. **角度累积**: 支持连续旋转,角度值会累积计算
4. **数学基础**: 使用标准的三角函数,计算稳定可靠
5. **方向判断**: 通过比较水平和垂直移动距离来区分旋转和缩放操作
### 技术细节
- **atan2 函数**: 计算点相对于中心的角度,返回弧度值
- **角度转换**: 将弧度转换为角度 (乘以 180/π)
- **角度累积**: 使用 `+=` 操作符累积旋转角度
- **范围限制**: 保持角度在 -180° 到 180° 范围内
- **操作分离**: 水平移动触发旋转,垂直移动触发缩放
## 旋转前翻转问题修复
### 问题描述
用户报告"每次旋转前都是先翻转"的问题,这是由于移除了方向判断逻辑导致的。
### 问题原因
- 移除了 `if abs(translation.width) > abs(translation.height)` 的方向判断
- 导致每次操作都同时执行旋转和缩放
- 缩放操作影响了旋转的视觉效果,造成"翻转"的错觉
### 解决方案
1. **恢复方向判断**: 重新添加水平和垂直移动的判断逻辑
2. **操作分离**: 水平移动专门处理旋转,垂直移动专门处理缩放
3. **状态管理**: 确保 `isRotating``isScaling` 状态互斥
### 修复效果
- ✅ 旋转操作不再受到缩放干扰
- ✅ 操作更加精确和可预测
- ✅ 用户体验得到显著改善
## 旋转算法优化修复
### 问题描述
用户报告"旋转存在bug",需要修复旋转算法的问题。
### 问题分析
1. **角度跳跃问题**: 当角度差超过180度时会出现突然的角度跳跃
2. **中心点计算错误**: 使用了错误的中心点坐标
3. **角度累积问题**: 角度计算不够平滑
### 解决方案
```swift
// 计算二维码中心点
let centerX = 100 * qrCodeScale / 2
let centerY = 100 * qrCodeScale / 2
// 计算当前触摸点相对于中心的角度
let currentAngle = atan2(v.location.y - centerY, v.location.x - centerX)
let startAngle = atan2(v.startLocation.y - centerY, v.startLocation.x - centerX)
// 计算角度差
var angleDelta = (currentAngle - startAngle) * 180 / .pi
// 处理角度跳跃问题
if angleDelta > 180 {
angleDelta -= 360
} else if angleDelta < -180 {
angleDelta += 360
}
// 更新旋转角度
qrCodeRotation = qrCodeRotationLast + angleDelta
```
### 技术改进
1. **正确的中心点**: 使用二维码的实际中心点 (100 * qrCodeScale / 2)
2. **角度跳跃处理**: 当角度差超过±180度时进行360度调整
3. **平滑累积**: 基于起始角度和当前角度计算差值,然后累积到总旋转角度
### 修复效果
- ✅ 旋转更加平滑自然
- ✅ 消除了角度跳跃问题
- ✅ 支持连续旋转操作
- ✅ 旋转中心点准确
## 旋转卡死问题修复
### 问题描述
用户报告"旋转存在卡死现象",旋转操作时界面会卡死无法响应。
### 问题分析
1. **坐标系统混乱**: 在操作图标的 `DragGesture` 中使用了错误的坐标系统
2. **角度计算错误**: 使用了 `v.location``v.startLocation`,但这些坐标是相对于整个视图的,不是相对于二维码中心的
3. **状态管理问题**: 没有正确处理手势状态
4. **复杂算法**: 基于中心点的角度计算算法过于复杂,容易导致计算错误
### 解决方案
1. **简化旋转算法**: 回归到简单可靠的线性移动计算
2. **正确的手势处理**: 使用 `value.translation.width` 进行水平移动计算
3. **状态管理优化**: 添加手势时间戳和防卡死机制
4. **角度累积**: 使用 `qrCodeRotationLast` 保持旋转状态
### 核心实现
```swift
.onChanged { value in
// 更新最后手势时间,防止卡死
lastGestureTime = Date()
// 使用简单的水平移动计算旋转
let rotationSensitivity: Double = 0.8
let rotationDelta = Double(value.translation.width) * rotationSensitivity
// 累积旋转角度
qrCodeRotation = qrCodeRotationLast + rotationDelta
// 限制旋转角度在合理范围内
if qrCodeRotation > 360 {
qrCodeRotation -= 360
} else if qrCodeRotation < -360 {
qrCodeRotation += 360
}
}
.onEnded { _ in
// 保存当前旋转角度作为下次的起始角度
qrCodeRotationLast = qrCodeRotation
// 重置状态,防止卡死
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if Date().timeIntervalSince(lastGestureTime) > 0.5 {
// 如果超过0.5秒没有新的手势,重置状态
}
}
}
```
### 技术改进
1. **算法简化**: 从复杂的 `atan2` 角度计算回归到简单的线性移动
2. **防卡死机制**: 添加手势时间戳和延迟状态重置
3. **状态管理**: 正确的手势开始和结束处理
4. **角度限制**: 合理的角度范围限制,避免无限累积
### 修复效果
-**消除卡死**: 旋转操作不再卡死
-**流畅响应**: 旋转操作响应流畅
-**稳定可靠**: 使用简单可靠的算法
-**用户体验**: 旋转操作更加自然和可预测
## 使用流程
1.`QRCodeSavedView` 点击"添加到图片"按钮
2. 选择背景图片
3. 进入 `ImageComposerView` 编辑界面
4. 拖拽二维码到合适位置
5. 使用操作图标进行缩放和旋转
6. 点击保存按钮完成合成
7. 选择保存到相册或分享
## 技术要点
- **SwiftUI**: 使用现代 SwiftUI 语法和状态管理
- **手势识别**: 精确的手势识别和处理
- **图片处理**: 高效的图片合成和变换
- **边界计算**: 精确的边界约束算法
- **状态同步**: 完善的状态同步机制
- **性能优化**: 异步处理和内存管理