|
|
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 {
|
|
|
@EnvironmentObject var languageManager: LanguageManager
|
|
|
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_title".localized)
|
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
|
.toolbar {
|
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
|
Button(action: {
|
|
|
dismiss()
|
|
|
}) {
|
|
|
Image(systemName: "chevron.left")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
|
Button("save".localized) {
|
|
|
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: - 轻量级防卡死检查
|
|
|
@State private var antiStuckTimer: Timer?
|
|
|
|
|
|
private func startLightweightAntiStuckCheck() {
|
|
|
// 先停止之前的定时器
|
|
|
stopAntiStuckTimer()
|
|
|
|
|
|
antiStuckTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in
|
|
|
self.checkAndResetState()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private func stopAntiStuckTimer() {
|
|
|
antiStuckTimer?.invalidate()
|
|
|
antiStuckTimer = nil
|
|
|
}
|
|
|
|
|
|
private func checkAndResetState() {
|
|
|
if Date().timeIntervalSince(lastGestureTime) > 2.0 {
|
|
|
if isScaling || isRotating || isDragging {
|
|
|
DispatchQueue.main.async {
|
|
|
self.isScaling = false
|
|
|
self.isRotating = false
|
|
|
self.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)
|
|
|
|
|
|
// 将屏幕坐标转换为图片坐标
|
|
|
_ = 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()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 生命周期管理
|
|
|
// 注意:struct 不能有 deinit,定时器会在视图销毁时自动清理
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#Preview {
|
|
|
let sampleQRCode = UIImage(systemName: "qrcode") ?? UIImage()
|
|
|
let sampleBackground = UIImage(systemName: "photo") ?? UIImage()
|
|
|
return ImageComposerView(qrCodeImage: sampleQRCode, backgroundImage: sampleBackground)
|
|
|
.environmentObject(LanguageManager.shared)
|
|
|
}
|