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...

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. 边界约束

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. 手势处理

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 控制选择框和操作图标的显示
  • 操作状态: isScalingisRotatingisDragging 防止手势冲突
  • 防卡死机制: 定时器检查操作状态,自动重置异常状态

7. 图片合成

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. 状态变量

@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. 视图结构

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. 编辑区域

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. 二维码层

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. 操作图标

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. 防卡死机制

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. 重置功能

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. 保存功能

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 函数计算触摸点相对于二维码中心的角度变化,实现更精确的旋转控制。

核心实现

// 判断主要操作方向
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. 状态管理: 确保 isRotatingisScaling 状态互斥

修复效果

  • 旋转操作不再受到缩放干扰
  • 操作更加精确和可预测
  • 用户体验得到显著改善

旋转算法优化修复

问题描述

用户报告"旋转存在bug",需要修复旋转算法的问题。

问题分析

  1. 角度跳跃问题: 当角度差超过180度时会出现突然的角度跳跃
  2. 中心点计算错误: 使用了错误的中心点坐标
  3. 角度累积问题: 角度计算不够平滑

解决方案

// 计算二维码中心点
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.locationv.startLocation,但这些坐标是相对于整个视图的,不是相对于二维码中心的
  3. 状态管理问题: 没有正确处理手势状态
  4. 复杂算法: 基于中心点的角度计算算法过于复杂,容易导致计算错误

解决方案

  1. 简化旋转算法: 回归到简单可靠的线性移动计算
  2. 正确的手势处理: 使用 value.translation.width 进行水平移动计算
  3. 状态管理优化: 添加手势时间戳和防卡死机制
  4. 角度累积: 使用 qrCodeRotationLast 保持旋转状态

核心实现

.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 语法和状态管理
  • 手势识别: 精确的手势识别和处理
  • 图片处理: 高效的图片合成和变换
  • 边界计算: 精确的边界约束算法
  • 状态同步: 完善的状态同步机制
  • 性能优化: 异步处理和内存管理