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