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