From d071523aaf719fe89d770718610821cd19ff28fc Mon Sep 17 00:00:00 2001 From: v504 Date: Wed, 27 Aug 2025 19:33:53 +0800 Subject: [PATCH] Add background image selection and composition functionality in QRCodeSavedView. Introduced state variables for managing image picker and composer presentation. Updated addToPhotos method to initiate background image selection, enhancing user experience in QR code customization. --- .../xcdebugger/Breakpoints_v2.xcbkptlist | 60 +- MyQrCode/Views/ImageComposerView.swift | 460 ++++++++++++++ MyQrCode/Views/QRCodeSavedView.swift | 23 +- docs/IMAGE_COMPOSER_FEATURE_README.md | 580 ++++++++++++++++++ 4 files changed, 1113 insertions(+), 10 deletions(-) create mode 100644 MyQrCode/Views/ImageComposerView.swift create mode 100644 docs/IMAGE_COMPOSER_FEATURE_README.md 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 语法和状态管理 +- **手势识别**: 精确的手势识别和处理 +- **图片处理**: 高效的图片合成和变换 +- **边界计算**: 精确的边界约束算法 +- **状态同步**: 完善的状态同步机制 +- **性能优化**: 异步处理和内存管理