mirror of
				https://github.com/laosb/CropImage.git
				synced 2025-11-04 08:01:38 +00:00 
			
		
		
		
	feat: fulfillTargetFrame.
				
					
				
			This commit is contained in:
		
							parent
							
								
									428b3eb5e8
								
							
						
					
					
						commit
						03549e2fa6
					
				
					 5 changed files with 172 additions and 70 deletions
				
			
		
							
								
								
									
										15
									
								
								Sources/CropImage/Comparable+clamped.swift
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								Sources/CropImage/Comparable+clamped.swift
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>) -> Self {
 | 
			
		||||
        return min(max(self, limits.lowerBound), limits.upperBound)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -37,14 +37,18 @@ public struct CropImageView<Controls: View>: 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<Controls: View>: 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<PlatformImage, Error>) -> Void,
 | 
			
		||||
        @ViewBuilder controls: @escaping ControlClosure<Controls>
 | 
			
		||||
    ) {
 | 
			
		||||
        self.image = image
 | 
			
		||||
        self.initialImageSize = initialImageSize
 | 
			
		||||
        self.targetSize = targetSize
 | 
			
		||||
        self.targetScale = targetScale
 | 
			
		||||
        self.onCrop = onCrop
 | 
			
		||||
| 
						 | 
				
			
			@ -76,13 +79,12 @@ public struct CropImageView<Controls: View>: 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<PlatformImage, Error>) -> 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<Controls: View>: 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<Controls: View>: 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<Controls: View>: 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<Controls: View>: 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<PlatformImage, Error>? = 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
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<CGFloat> {
 | 
			
		||||
        let width = imageWidth * scale
 | 
			
		||||
        let range = (targetSize.width - width) / 2
 | 
			
		||||
        return range > 0 ? -range ... range : range ... -range
 | 
			
		||||
    }
 | 
			
		||||
    func yOffsetBounds(at scale: CGFloat) -> ClosedRange<CGFloat> {
 | 
			
		||||
        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)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue