diff --git a/MyQrCode.xcodeproj/xcuserdata/yc.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/MyQrCode.xcodeproj/xcuserdata/yc.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
index 042b946..a40bd84 100644
--- a/MyQrCode.xcodeproj/xcuserdata/yc.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
+++ b/MyQrCode.xcodeproj/xcuserdata/yc.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
@@ -119,17 +119,65 @@
+ startingLineNumber = "152"
+ endingLineNumber = "152"
+ landmarkName = "ImageComposerView"
+ landmarkType = "14">
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MyQrCode/Views/ImageComposerView.swift b/MyQrCode/Views/ImageComposerView.swift
new file mode 100644
index 0000000..f3f44eb
--- /dev/null
+++ b/MyQrCode/Views/ImageComposerView.swift
@@ -0,0 +1,460 @@
+import SwiftUI
+import Photos
+import PhotosUI
+
+// MARK: - 背景图片布局偏好键
+struct BackgroundImageFramePreferenceKey: PreferenceKey {
+ static var defaultValue: CGRect = .zero
+
+ static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
+ value = nextValue()
+ }
+}
+
+struct ImageComposerView: View {
+ let qrCodeImage: UIImage
+ let backgroundImage: UIImage
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var qrCodePosition = CGPoint.zero // 将在 onAppear 中设置为图片中心
+ @State private var qrCodeScale: CGFloat = 1.0
+ @State private var qrCodeRotation: Double = 0.0
+ @State private var qrCodeRotationLast: Double = 0.0
+ @State private var isDragging = false
+ @State private var isScaling = false
+ @State private var isRotating = false
+ @State private var showingSaveSheet = false
+ @State private var composedImage: UIImage?
+ @State private var isComposing = false
+ @State private var isSelected = true // 二维码选择状态
+ @State private var actualEditingAreaSize: CGSize = .zero // 实际的编辑区域大小
+ @GestureState private var fingerLocation: CGPoint? = nil
+ @GestureState private var startLocation: CGPoint? = nil
+ // 手势状态
+ @State private var dragOffset = CGSize.zero
+ @State private var scaleOffset: CGFloat = 1.0
+ @State private var rotationOffset: Double = 0.0
+
+ var simpleDrag: some Gesture {
+ DragGesture()
+ .onChanged { value in
+ var newLocation = startLocation ?? qrCodePosition
+ newLocation.x += value.translation.width
+ newLocation.y += value.translation.height
+ // 约束位置到背景图边界
+ qrCodePosition = constrainPositionToImage(newLocation)
+ }
+ .updating($startLocation) { (value, startLocation, _) in
+ startLocation = startLocation ?? qrCodePosition
+ }
+ }
+
+ var fingerDrag: some Gesture {
+ DragGesture()
+ .updating($fingerLocation) { (value, fingerLocation, _) in
+ fingerLocation = value.location
+ }
+ }
+
+ //Editor State
+ enum EditorState {
+ case idle
+ case dragging
+ case scaling
+ case rotating
+ }
+
+ @State private var editorState: EditorState = .idle
+
+ // 防卡死状态管理
+ @State private var lastGestureTime = Date()
+
+ var body: some View {
+ NavigationView {
+ editingArea
+ .navigationTitle("Add to Picture")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Button(action: {
+ dismiss()
+ }) {
+ Image(systemName: "chevron.left")
+ }
+ }
+
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button("Save") {
+ composeAndSave()
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ .cornerRadius(8)
+ .font(.system(size: 16, weight: .semibold))
+ }
+ }
+ }
+ .sheet(isPresented: $showingSaveSheet) {
+ if let composedImage = composedImage {
+ ShareSheet(activityItems: [composedImage])
+ }
+ }
+ .onAppear {
+ // 设置二维码初始位置为图片中心并应用约束
+ let imageSize = getImageDisplaySize()
+ let centerPosition = CGPoint(x: imageSize.width / 2, y: imageSize.height / 2)
+ qrCodePosition = constrainPositionToImage(centerPosition)
+
+ // 启动轻量级防卡死检查
+ startLightweightAntiStuckCheck()
+ }
+ }
+
+ // MARK: - 编辑区域
+ private var editingArea: some View {
+ GeometryReader { geometry in
+ ZStack {
+ // 背景图片
+ Image(uiImage: backgroundImage)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .clipped()
+ // 二维码图层
+ qrCodeLayer
+
+ // 重置按钮
+ VStack {
+ Spacer()
+ HStack {
+ Spacer()
+ resetButton
+ .padding(.trailing, 20)
+ .padding(.bottom, 20)
+ }
+ }
+ }
+ .background(Color(.systemGray6))
+ .onAppear {
+ // 保存实际的编辑区域大小
+ DispatchQueue.main.async {
+ self.actualEditingAreaSize = geometry.size
+ }
+ }
+ .onChange(of: geometry.size) { newSize in
+ // 当编辑区域大小改变时更新
+ DispatchQueue.main.async {
+ self.actualEditingAreaSize = newSize
+ }
+ }
+ .clipped()
+ }
+ }
+
+ // MARK: - 重置按钮
+ private var resetButton: some View {
+ Button(action: resetQRCode) {
+ Image(systemName: "arrow.clockwise")
+ .font(.system(size: 18, weight: .medium))
+ .foregroundColor(.white)
+ .padding(12)
+ .background(Color.black.opacity(0.6))
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2)
+ }
+ }
+
+
+
+ // MARK: - 二维码图层
+ private var qrCodeLayer: some View {
+ ZStack {
+ ZStack {
+ // 选择框边框
+ RoundedRectangle(cornerRadius: 4)
+ .stroke(Color.teal, lineWidth: 2)
+ .frame(width: 140 * qrCodeScale, height: 140 * qrCodeScale)
+
+ // 二维码图片
+ Image(uiImage: qrCodeImage)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 100 * qrCodeScale, height: 100 * qrCodeScale)
+ // 操作图标
+ operationIcon
+ }
+ .rotationEffect(.degrees(qrCodeRotation))
+ .position(qrCodePosition)
+ .gesture(simpleDrag.simultaneously(with: fingerDrag))
+
+ if let fingerLocation = fingerLocation {
+ Circle()
+ .stroke(Color.teal, lineWidth: 2)
+ .frame(width: 20, height: 20)
+ .position(fingerLocation)
+ }
+ }
+ }
+
+ // MARK: - 操作图标
+ 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()
+
+ // 使用简单的水平移动计算旋转
+ 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秒没有新的手势,重置状态
+ }
+ }
+ }
+ )
+ }
+
+
+
+ // MARK: - 约束位置到图片边界
+ 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)
+ }
+
+ // MARK: - 获取图片显示尺寸
+ private func getImageDisplaySize() -> CGSize {
+ // 使用实际的编辑区域大小,如果还没有获取到则使用屏幕大小作为后备
+ let availableSize = actualEditingAreaSize != .zero ? actualEditingAreaSize : UIScreen.main.bounds.size
+ let imageAspectRatio = backgroundImage.size.width / backgroundImage.size.height
+ let availableAspectRatio = availableSize.width / availableSize.height
+
+ var displaySize: CGSize
+
+ if imageAspectRatio > availableAspectRatio {
+ // 图片更宽,以宽度为准
+ displaySize = CGSize(
+ width: availableSize.width,
+ height: availableSize.width / imageAspectRatio
+ )
+ } else {
+ // 图片更高,以高度为准
+ displaySize = CGSize(
+ width: availableSize.height * imageAspectRatio,
+ height: availableSize.height
+ )
+ }
+
+ return displaySize
+ }
+
+ // MARK: - 操作手势处理
+ 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
+
+ // 保持角度在 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
+
+ // 根据x、y的直接移动距离计算缩放
+ 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
+
+ // 限制缩放范围在 0.3 到 2.5 之间
+ qrCodeScale = max(0.3, min(2.5, newScale))
+ }
+
+ }
+
+ // MARK: - 重置二维码位置
+ 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 // 确保重置后二维码被选中
+ }
+ }
+
+ // MARK: - 轻量级防卡死检查
+ private func startLightweightAntiStuckCheck() {
+ Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in
+ // 如果超过2秒没有手势操作,且状态仍然为true,则重置状态
+ if Date().timeIntervalSince(lastGestureTime) > 2.0 {
+ if isScaling || isRotating || isDragging {
+ DispatchQueue.main.async {
+ isScaling = false
+ isRotating = false
+ isDragging = false
+ }
+ }
+ }
+ }
+ }
+
+ // MARK: - 合成并保存
+ 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
+ }
+ }
+ }
+
+ // MARK: - 图片合成
+ 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 imageRect = CGRect(origin: .zero, size: background.size)
+ let screenRect = UIScreen.main.bounds
+
+ // 计算缩放比例
+ let scaleX = background.size.width / screenRect.width
+ let scaleY = background.size.height / screenRect.height
+ let scale = min(scaleX, scaleY)
+
+ // 计算二维码在图片中的位置
+ let qrCodeX = qrCodePosition.x * scale
+ let qrCodeY = qrCodePosition.y * scale
+ let qrCodeWidth = qrCodeSize.width * scale
+ let qrCodeHeight = qrCodeSize.height * scale
+
+ // 创建二维码绘制区域
+ let qrCodeRect = CGRect(
+ x: qrCodeX - qrCodeWidth / 2,
+ y: qrCodeY - qrCodeHeight / 2,
+ width: qrCodeWidth,
+ height: qrCodeHeight
+ )
+
+ // 保存当前图形状态
+ context.cgContext.saveGState()
+
+ // 设置旋转
+ context.cgContext.translateBy(x: qrCodeX, y: qrCodeY)
+ context.cgContext.rotate(by: CGFloat(qrCodeRotation * .pi / 180))
+ context.cgContext.translateBy(x: -qrCodeX, y: -qrCodeY)
+
+ // 绘制二维码
+ qrCode.draw(in: qrCodeRect)
+
+ // 恢复图形状态
+ context.cgContext.restoreGState()
+ }
+ }
+}
+
+
+
+#Preview {
+ let sampleQRCode = UIImage(systemName: "qrcode") ?? UIImage()
+ let sampleBackground = UIImage(systemName: "photo") ?? UIImage()
+ return ImageComposerView(qrCodeImage: sampleQRCode, backgroundImage: sampleBackground)
+}
diff --git a/MyQrCode/Views/QRCodeSavedView.swift b/MyQrCode/Views/QRCodeSavedView.swift
index f7f0894..8fb4517 100644
--- a/MyQrCode/Views/QRCodeSavedView.swift
+++ b/MyQrCode/Views/QRCodeSavedView.swift
@@ -18,6 +18,9 @@ struct QRCodeSavedView: View {
@State private var showingAlert = false
@State private var alertMessage = ""
@State private var isSavingToPhotos = false
+ @State private var showingImageComposer = false
+ @State private var showingBackgroundImagePicker = false
+ @State private var selectedBackgroundImage: UIImage?
// 用于保存图片的辅助类
private let photoSaver = PhotoSaver()
@@ -61,6 +64,20 @@ struct QRCodeSavedView: View {
EmptyView()
}
)
+ .sheet(isPresented: $showingBackgroundImagePicker) {
+ ImagePicker(
+ onImageSelected: { image in
+ selectedBackgroundImage = image
+ showingImageComposer = true
+ },
+ shouldProcessImage: false
+ )
+ }
+ .sheet(isPresented: $showingImageComposer) {
+ if let backgroundImage = selectedBackgroundImage {
+ ImageComposerView(qrCodeImage: qrCodeImage, backgroundImage: backgroundImage)
+ }
+ }
}
// MARK: - 二维码图片视图
@@ -200,10 +217,8 @@ struct QRCodeSavedView: View {
// MARK: - 添加到图片
private func addToPhotos() {
- // 这里可以实现将二维码添加到现有图片的功能
- // 暂时显示提示信息
- alertMessage = "添加到图片功能开发中..."
- showingAlert = true
+ // 先选择背景图片,然后跳转到图片合成界面
+ showingBackgroundImagePicker = true
}
}
diff --git a/docs/IMAGE_COMPOSER_FEATURE_README.md b/docs/IMAGE_COMPOSER_FEATURE_README.md
new file mode 100644
index 0000000..cf803dd
--- /dev/null
+++ b/docs/IMAGE_COMPOSER_FEATURE_README.md
@@ -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 语法和状态管理
+- **手势识别**: 精确的手势识别和处理
+- **图片处理**: 高效的图片合成和变换
+- **边界计算**: 精确的边界约束算法
+- **状态同步**: 完善的状态同步机制
+- **性能优化**: 异步处理和内存管理