You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

463 lines
17 KiB

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
// xy
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
// 2true
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)
.environmentObject(LanguageManager.shared)
}