From 03549e2fa641bdc9694ba3ddc017b95228389828 Mon Sep 17 00:00:00 2001 From: Shibo Lyu Date: Wed, 16 Aug 2023 16:36:33 +0800 Subject: [PATCH] feat: `fulfillTargetFrame`. --- Sources/CropImage/Comparable+clamped.swift | 15 ++ Sources/CropImage/CropImageView.swift | 65 +++++--- Sources/CropImage/DefaultControlsView.swift | 2 +- Sources/CropImage/DefaultCutHoleView.swift | 4 +- Sources/CropImage/UnderlyingImageView.swift | 156 ++++++++++++++------ 5 files changed, 172 insertions(+), 70 deletions(-) create mode 100644 Sources/CropImage/Comparable+clamped.swift diff --git a/Sources/CropImage/Comparable+clamped.swift b/Sources/CropImage/Comparable+clamped.swift new file mode 100644 index 0000000..1bcacb4 --- /dev/null +++ b/Sources/CropImage/Comparable+clamped.swift @@ -0,0 +1,15 @@ +// +// Comparable+clamped.swift +// +// +// Created by Shibo Lyu on 2023/8/16. +// + +import Foundation + +// https://stackoverflow.com/a/40868784 +extension Comparable { + func clamped(to limits: ClosedRange) -> Self { + return min(max(self, limits.lowerBound), limits.upperBound) + } +} diff --git a/Sources/CropImage/CropImageView.swift b/Sources/CropImage/CropImageView.swift index 6ead4c8..8a6376e 100644 --- a/Sources/CropImage/CropImageView.swift +++ b/Sources/CropImage/CropImageView.swift @@ -37,14 +37,18 @@ public struct CropImageView: View { /// The image to crop. public var image: PlatformImage - /// The region in which the image is initially fitted in, in points. - public var initialImageSize: CGSize - /// The intended size of the cropped image, in points. + /// The expected size of the cropped image, in points. public var targetSize: CGSize - /// The intended scale of the cropped image. + /// The expected scale of the cropped image. /// /// This defines the point to pixel ratio for the output image. Defaults to `1`. public var targetScale: CGFloat = 1 + /// Limit movement and scaling to make sure the image fills the target frame. + /// + /// Defaults to `true`. + /// + /// > Important: This option only works with 90-degree rotations. If the rotation is an angle other than a multiple of 90 degrees, the image will not be guaranteed to fill the target frame. + public var fulfillTargetFrame: Bool = true /// A closure that will be called when the user finishes cropping. /// /// The error should be a ``CropError``. @@ -58,14 +62,13 @@ public struct CropImageView: View { /// Create a ``CropImageView`` with a custom ``controls`` view. public init( image: PlatformImage, - initialImageSize: CGSize, targetSize: CGSize, targetScale: CGFloat = 1, + fulfillTargetFrame: Bool = true, onCrop: @escaping (Result) -> Void, @ViewBuilder controls: @escaping ControlClosure ) { self.image = image - self.initialImageSize = initialImageSize self.targetSize = targetSize self.targetScale = targetScale self.onCrop = onCrop @@ -76,13 +79,12 @@ public struct CropImageView: View { /// The default ``controls`` view is a simple overlay with a checkmark icon on the bottom-trailing corner to trigger crop action. public init( image: PlatformImage, - initialImageSize: CGSize, targetSize: CGSize, targetScale: CGFloat = 1, + fulfillTargetFrame: Bool = true, onCrop: @escaping (Result) -> Void ) where Controls == DefaultControlsView { self.image = image - self.initialImageSize = initialImageSize self.targetSize = targetSize self.targetScale = targetScale self.onCrop = onCrop @@ -95,6 +97,8 @@ public struct CropImageView: View { @State private var scale: CGFloat = 1 @State private var rotation: Angle = .zero + @State private var viewSize: CGSize = .zero + @MainActor func crop() throws -> PlatformImage { let snapshotView = UnderlyingImageView( @@ -102,7 +106,9 @@ public struct CropImageView: View { scale: $scale, rotation: $rotation, image: image, - initialImageSize: initialImageSize + viewSize: viewSize, + targetSize: targetSize, + fulfillTargetFrame: fulfillTargetFrame ) .frame(width: targetSize.width, height: targetSize.height) if #available(iOS 16.0, macOS 13.0, *) { @@ -150,14 +156,31 @@ public struct CropImageView: View { scale: $scale, rotation: $rotation, image: image, - initialImageSize: initialImageSize + viewSize: viewSize, + targetSize: targetSize, + fulfillTargetFrame: fulfillTargetFrame ) + .frame(width: viewSize.width, height: viewSize.height) + .clipped() } var cutHole: some View { DefaultCutHoleView(targetSize: targetSize) } + var viewSizeReadingView: some View { + GeometryReader { geo in + Rectangle() + .fill(.white.opacity(0.0001)) + .onChange(of: geo.size) { newValue in + viewSize = newValue + } + .onAppear { + viewSize = geo.size + } + } + } + @MainActor var control: some View { controls($offset, $scale, $rotation) { do { @@ -169,16 +192,15 @@ public struct CropImageView: View { } public var body: some View { - underlyingImage - .clipped() - .overlay(cutHole) + cutHole + .background(underlyingImage) + .background(viewSizeReadingView) .overlay(control) } } struct CropImageView_Previews: PreviewProvider { struct PreviewView: View { - @State private var initialImageSize: CGSize = .init(width: 200, height: 200) @State private var targetSize: CGSize = .init(width: 100, height: 100) @State private var result: Result? = nil @@ -186,17 +208,12 @@ struct CropImageView_Previews: PreviewProvider { VStack { CropImageView( image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!, - initialImageSize: initialImageSize, targetSize: targetSize - ) { result = $0 } + ) { + result = $0 + } + .frame(height: 300) Form { - Section { - TextField("Width", value: $initialImageSize.width, formatter: NumberFormatter()) - TextField("Height", value: $initialImageSize.height, formatter: NumberFormatter()) - } header: { - Text("Initial Image Size") - Text("The image will be fitted into this region.") - } Section { TextField("Width", value: $targetSize.width, formatter: NumberFormatter()) TextField("Height", value: $targetSize.height, formatter: NumberFormatter()) @@ -230,7 +247,7 @@ struct CropImageView_Previews: PreviewProvider { PreviewView() #if os(macOS) .frame(width: 500) - .frame(minHeight: 770) + .frame(minHeight: 600) #endif } } diff --git a/Sources/CropImage/DefaultControlsView.swift b/Sources/CropImage/DefaultControlsView.swift index 678e0f1..ff35908 100644 --- a/Sources/CropImage/DefaultControlsView.swift +++ b/Sources/CropImage/DefaultControlsView.swift @@ -7,7 +7,7 @@ import SwiftUI -/// The default controls view used when creating ``CropImageView`` using ``CropImageView/init(image:targetSize:targetScale:onCrop:)``. +/// The default controls view used when creating ``CropImageView`` using ``CropImageView/init(image:targetSize:targetScale:fulfillTargetFrame:onCrop:)``. /// /// It provides basic controls to crop, reset to default cropping & rotation, and rotate the image. public struct DefaultControlsView: View { diff --git a/Sources/CropImage/DefaultCutHoleView.swift b/Sources/CropImage/DefaultCutHoleView.swift index 9b021aa..86742a8 100644 --- a/Sources/CropImage/DefaultCutHoleView.swift +++ b/Sources/CropImage/DefaultCutHoleView.swift @@ -19,8 +19,8 @@ struct DefaultCutHoleView: View { var stroke: some View { Rectangle() - .strokeBorder(style: .init(lineWidth: 2)) - .frame(width: targetSize.width + 4, height: targetSize.height + 4) + .strokeBorder(style: .init(lineWidth: 1)) + .frame(width: targetSize.width + 2, height: targetSize.height + 2) .foregroundColor(.white) } diff --git a/Sources/CropImage/UnderlyingImageView.swift b/Sources/CropImage/UnderlyingImageView.swift index 4d0a4be..346c8eb 100644 --- a/Sources/CropImage/UnderlyingImageView.swift +++ b/Sources/CropImage/UnderlyingImageView.swift @@ -18,12 +18,69 @@ struct UnderlyingImageView: View { @Binding var scale: CGFloat @Binding var rotation: Angle var image: PlatformImage - var initialImageSize: CGSize + var viewSize: CGSize + var targetSize: CGSize + var fulfillTargetFrame: Bool @State private var tempOffset: CGSize = .zero @State private var tempScale: CGFloat = 1 @State private var tempRotation: Angle = .zero + // When rotated odd multiples of 90 degrees, we need to switch width and height of the image in calculations. + var isRotatedOddMultiplesOf90Deg: Bool { + rotation != .zero + && rotation.degrees.truncatingRemainder(dividingBy: 90) == 0 + && rotation.degrees.truncatingRemainder(dividingBy: 180) != 0 + } + + var imageWidth: CGFloat { + isRotatedOddMultiplesOf90Deg ? image.size.height : image.size.width + } + var imageHeight: CGFloat { + isRotatedOddMultiplesOf90Deg ? image.size.width : image.size.height + } + + var minimumScale: CGFloat { + let widthScale = targetSize.width / imageWidth + let heightScale = targetSize.height / imageHeight + return max(widthScale, heightScale) + } + + func xOffsetBounds(at scale: CGFloat) -> ClosedRange { + let width = imageWidth * scale + let range = (targetSize.width - width) / 2 + return range > 0 ? -range ... range : range ... -range + } + func yOffsetBounds(at scale: CGFloat) -> ClosedRange { + let height = imageHeight * scale + let range = (targetSize.height - height) / 2 + return range > 0 ? -range ... range : range ... -range + } + + func adjustToFulfillTargetFrame() { + guard fulfillTargetFrame else { return } + + let clampedScale = max(minimumScale, scale) + var clampedOffset = offset + clampedOffset.width = clampedOffset.width.clamped(to: xOffsetBounds(at: clampedScale)) + clampedOffset.height = clampedOffset.height.clamped(to: yOffsetBounds(at: clampedScale)) + + if clampedScale != scale || clampedOffset != offset { + withAnimation { + scale = clampedScale + offset = clampedOffset + } + } + } + + func setInitialScale(basedOn viewSize: CGSize) { + guard viewSize != .zero else { return } + let widthScale = viewSize.width / imageWidth + let heightScale = viewSize.height / imageHeight + print("setInitialScale: widthScale: \(widthScale), heightScale: \(heightScale)") + scale = min(widthScale, heightScale) + } + var imageView: Image { #if os(macOS) Image(nsImage: image) @@ -32,48 +89,58 @@ struct UnderlyingImageView: View { #endif } + var interactionView: some View { + Color.white.opacity(0.0001) + .gesture(dragGesture) + .gesture(magnificationgesture) + .gesture(rotationGesture) + } + + var dragGesture: some Gesture { + DragGesture() + .onChanged { value in + tempOffset = value.translation + } + .onEnded { value in + offset = offset + tempOffset + tempOffset = .zero + adjustToFulfillTargetFrame() + } + } + + var magnificationgesture: some Gesture { + MagnificationGesture() + .onChanged { value in + tempScale = value + } + .onEnded { value in + scale = scale * tempScale + tempScale = 1 + adjustToFulfillTargetFrame() + } + } + + var rotationGesture: some Gesture { + RotationGesture() + .onChanged { value in + tempRotation = value + } + .onEnded { value in + rotation = rotation + tempRotation + tempRotation = .zero + } + } + var body: some View { - ZStack { - imageView - .resizable() - .scaledToFit() - .frame(width: initialImageSize.width, height: initialImageSize.height) - .animation(.default, value: initialImageSize) - .scaleEffect(scale * tempScale) - .offset(offset + tempOffset) - .rotationEffect(rotation + tempRotation) - Color.white.opacity(0.0001) - .gesture( - DragGesture() - .onChanged { value in - tempOffset = value.translation - } - .onEnded { value in - offset = offset + tempOffset - tempOffset = .zero - } - ) - .gesture( - MagnificationGesture() - .onChanged { value in - tempScale = value - } - .onEnded { value in - scale = max(scale * tempScale, 0.01) - tempScale = 1 - } - ) - .gesture( - RotationGesture() - .onChanged { value in - tempRotation = value - } - .onEnded { value in - rotation = rotation + tempRotation - tempRotation = .zero - } - ) - } + imageView + .scaleEffect(scale * tempScale) + .offset(offset + tempOffset) + .rotationEffect(rotation + tempRotation) + .overlay(interactionView) + .onChange(of: viewSize) { newValue in + setInitialScale(basedOn: newValue) + } + .clipped() } } @@ -89,8 +156,11 @@ struct MoveAndScalableImageView_Previews: PreviewProvider { scale: $scale, rotation: $rotation, image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!, - initialImageSize: .init(width: 200, height: 200) + viewSize: .init(width: 200, height: 100), + targetSize: .init(width: 100, height: 100), + fulfillTargetFrame: true ) + .frame(width: 200, height: 100) } }