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.
20 KiB
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
控制选择框和操作图标的显示 - 操作状态:
isScaling
、isRotating
、isDragging
防止手势冲突 - 防卡死机制: 定时器检查操作状态,自动重置异常状态
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
}
}
}
功能特点
- 精确控制: 支持像素级精度的二维码定位和编辑
- 边界约束: 自动限制二维码在背景图片范围内
- 手势分离: 拖拽、缩放、旋转功能独立,避免冲突
- 状态管理: 完善的状态管理机制,确保操作稳定性
- 防卡死: 自动检测和修复异常状态
- 用户友好: 直观的操作方式,符合用户习惯
- 性能优化: 异步图片合成,避免界面卡顿
- 丝滑旋转: 实时跟随拖动的旋转体验
旋转功能优化
问题描述
最初的旋转功能基于累积的移动距离计算旋转角度,导致旋转不够丝滑,用户体验不佳。
解决方案
将旋转算法从累积模式改为实时模式:
- 累积模式:
qrCodeRotation += rotationDelta
- 基于总移动距离 - 实时模式:
qrCodeRotation = rotationDelta
- 基于当前拖动位置
技术改进
- 提高灵敏度: 将
rotationSensitivity
从 0.5 提升到 0.8 - 实时响应: 旋转角度直接跟随拖动位置,提供更直观的反馈
- 保持限制: 继续限制旋转角度在 -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
// 缩放逻辑...
}
算法优势
- 精确计算: 基于数学角度计算,比简单的线性移动更准确
- 中心点参考: 以二维码中心为旋转参考点,符合用户直觉
- 角度累积: 支持连续旋转,角度值会累积计算
- 数学基础: 使用标准的三角函数,计算稳定可靠
- 方向判断: 通过比较水平和垂直移动距离来区分旋转和缩放操作
技术细节
- atan2 函数: 计算点相对于中心的角度,返回弧度值
- 角度转换: 将弧度转换为角度 (乘以 180/π)
- 角度累积: 使用
+=
操作符累积旋转角度 - 范围限制: 保持角度在 -180° 到 180° 范围内
- 操作分离: 水平移动触发旋转,垂直移动触发缩放
旋转前翻转问题修复
问题描述
用户报告"每次旋转前都是先翻转"的问题,这是由于移除了方向判断逻辑导致的。
问题原因
- 移除了
if abs(translation.width) > abs(translation.height)
的方向判断 - 导致每次操作都同时执行旋转和缩放
- 缩放操作影响了旋转的视觉效果,造成"翻转"的错觉
解决方案
- 恢复方向判断: 重新添加水平和垂直移动的判断逻辑
- 操作分离: 水平移动专门处理旋转,垂直移动专门处理缩放
- 状态管理: 确保
isRotating
和isScaling
状态互斥
修复效果
- ✅ 旋转操作不再受到缩放干扰
- ✅ 操作更加精确和可预测
- ✅ 用户体验得到显著改善
旋转算法优化修复
问题描述
用户报告"旋转存在bug",需要修复旋转算法的问题。
问题分析
- 角度跳跃问题: 当角度差超过180度时,会出现突然的角度跳跃
- 中心点计算错误: 使用了错误的中心点坐标
- 角度累积问题: 角度计算不够平滑
解决方案
// 计算二维码中心点
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
技术改进
- 正确的中心点: 使用二维码的实际中心点 (100 * qrCodeScale / 2)
- 角度跳跃处理: 当角度差超过±180度时,进行360度调整
- 平滑累积: 基于起始角度和当前角度计算差值,然后累积到总旋转角度
修复效果
- ✅ 旋转更加平滑自然
- ✅ 消除了角度跳跃问题
- ✅ 支持连续旋转操作
- ✅ 旋转中心点准确
旋转卡死问题修复
问题描述
用户报告"旋转存在卡死现象",旋转操作时界面会卡死无法响应。
问题分析
- 坐标系统混乱: 在操作图标的
DragGesture
中使用了错误的坐标系统 - 角度计算错误: 使用了
v.location
和v.startLocation
,但这些坐标是相对于整个视图的,不是相对于二维码中心的 - 状态管理问题: 没有正确处理手势状态
- 复杂算法: 基于中心点的角度计算算法过于复杂,容易导致计算错误
解决方案
- 简化旋转算法: 回归到简单可靠的线性移动计算
- 正确的手势处理: 使用
value.translation.width
进行水平移动计算 - 状态管理优化: 添加手势时间戳和防卡死机制
- 角度累积: 使用
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秒没有新的手势,重置状态
}
}
}
技术改进
- 算法简化: 从复杂的
atan2
角度计算回归到简单的线性移动 - 防卡死机制: 添加手势时间戳和延迟状态重置
- 状态管理: 正确的手势开始和结束处理
- 角度限制: 合理的角度范围限制,避免无限累积
修复效果
- ✅ 消除卡死: 旋转操作不再卡死
- ✅ 流畅响应: 旋转操作响应流畅
- ✅ 稳定可靠: 使用简单可靠的算法
- ✅ 用户体验: 旋转操作更加自然和可预测
使用流程
- 在
QRCodeSavedView
点击"添加到图片"按钮 - 选择背景图片
- 进入
ImageComposerView
编辑界面 - 拖拽二维码到合适位置
- 使用操作图标进行缩放和旋转
- 点击保存按钮完成合成
- 选择保存到相册或分享
技术要点
- SwiftUI: 使用现代 SwiftUI 语法和状态管理
- 手势识别: 精确的手势识别和处理
- 图片处理: 高效的图片合成和变换
- 边界计算: 精确的边界约束算法
- 状态同步: 完善的状态同步机制
- 性能优化: 异步处理和内存管理