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