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.

461 lines
17 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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
// 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)
}