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

# 图片合成功能实现文档
## 功能概述
图片合成功能允许用户将生成的二维码添加到背景图片上,并提供完整的编辑功能,包括拖拽、缩放和旋转。该功能通过 `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 语法和状态管理
- **手势识别**: 精确的手势识别和处理
- **图片处理**: 高效的图片合成和变换
- **边界计算**: 精确的边界约束算法
- **状态同步**: 完善的状态同步机制
- **性能优化**: 异步处理和内存管理