mirror of
				https://github.com/laosb/CropImage.git
				synced 2025-10-31 06:11:38 +00:00 
			
		
		
		
	Compare commits
	
		
			No commits in common. "main" and "0.6.0" have entirely different histories.
		
	
	
		
	
		
					 10 changed files with 65 additions and 134 deletions
				
			
		
							
								
								
									
										2
									
								
								.github/FUNDING.yml
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/FUNDING.yml
									
										
									
									
										vendored
									
									
								
							|  | @ -1,2 +0,0 @@ | |||
| github: [laosb] | ||||
| buy_me_a_coffee: laosb | ||||
|  | @ -1,4 +1,4 @@ | |||
| // swift-tools-version: 5.10 | ||||
| // swift-tools-version: 5.8 | ||||
| // The swift-tools-version declares the minimum version of Swift required to build this package. | ||||
| 
 | ||||
| import PackageDescription | ||||
|  | @ -7,8 +7,7 @@ let package = Package( | |||
|     name: "CropImage", | ||||
|     platforms: [ | ||||
|         .iOS(.v14), | ||||
|         .macOS(.v13), | ||||
|         .visionOS(.v1) | ||||
|         .macOS(.v13) | ||||
|     ], | ||||
|     products: [ | ||||
|         // Products define the executables and libraries a package produces, and make them visible to other packages. | ||||
|  | @ -26,6 +25,5 @@ let package = Package( | |||
|         .target( | ||||
|             name: "CropImage", | ||||
|             dependencies: []) | ||||
|     ], | ||||
|     swiftLanguageVersions: [.version("6"), .v5] | ||||
|     ] | ||||
| ) | ||||
|  |  | |||
|  | @ -5,9 +5,9 @@ | |||
| 
 | ||||
| A simple SwiftUI view where user can move and resize an image to a pre-defined size. | ||||
| 
 | ||||
| Supports iOS 14.0 and above, visionOS 1.0 and above or macOS Ventura 13.0 and above. | ||||
| Supports iOS 14.0 and above, or macOS Ventura 13.0 and above. | ||||
| 
 | ||||
| - Supports iOS, visionOS and macOS | ||||
| - Supports both iOS and macOS | ||||
| - Use `ImageRenderer` to render the cropped image, when possible | ||||
| - Very lightweight | ||||
| - (Optionally) bring your own crop UI | ||||
|  |  | |||
|  | @ -6,7 +6,7 @@ | |||
| // | ||||
| 
 | ||||
| import SwiftUI | ||||
| #if !os(macOS) | ||||
| #if os(iOS) | ||||
| import UIKit | ||||
| #endif | ||||
| 
 | ||||
|  | @ -19,7 +19,7 @@ public struct CropImageView<Controls: View, CutHole: View>: View { | |||
|     ///   - scale: The scale binding of the image. | ||||
|     ///   - rotation: The rotation binding of the image. | ||||
|     ///   - crop: An async function to trigger crop action. Result will be delivered via ``onCrop``. | ||||
|     public typealias ControlClosure = ( | ||||
|     public typealias ControlClosure<Controls> = ( | ||||
|         _ offset: Binding<CGSize>, | ||||
|         _ scale: Binding<CGFloat>, | ||||
|         _ rotation: Binding<Angle>, | ||||
|  | @ -30,7 +30,7 @@ public struct CropImageView<Controls: View, CutHole: View>: View { | |||
|     /// | ||||
|     /// - Parameters: | ||||
|     ///   - targetSize: The size of the cut hole. | ||||
|     public typealias CutHoleClosure = (_ targetSize: CGSize) -> CutHole | ||||
|     public typealias CutHoleClosure<CutHole> = (_ targetSize: CGSize) -> CutHole | ||||
| 
 | ||||
|     /// Errors that could happen during the cropping process. | ||||
|     public enum CropError: Error { | ||||
|  | @ -66,8 +66,8 @@ public struct CropImageView<Controls: View, CutHole: View>: View { | |||
|     /// | ||||
|     /// The error should be a ``CropError``. | ||||
|     public var onCrop: (Result<PlatformImage, Error>) -> Void | ||||
|     var controls: ControlClosure | ||||
|     var cutHole: CutHoleClosure | ||||
|     var controls: ControlClosure<Controls> | ||||
|     var cutHole: CutHoleClosure<CutHole> | ||||
|     /// Create a ``CropImageView`` with a custom controls view and a custom cut hole. | ||||
|     public init( | ||||
|         image: PlatformImage, | ||||
|  | @ -75,8 +75,8 @@ public struct CropImageView<Controls: View, CutHole: View>: View { | |||
|         targetScale: CGFloat = 1, | ||||
|         fulfillTargetFrame: Bool = true, | ||||
|         onCrop: @escaping (Result<PlatformImage, Error>) -> Void, | ||||
|         @ViewBuilder controls: @escaping ControlClosure, | ||||
|         @ViewBuilder cutHole: @escaping CutHoleClosure | ||||
|         @ViewBuilder controls: @escaping ControlClosure<Controls>, | ||||
|         @ViewBuilder cutHole: @escaping CutHoleClosure<CutHole> | ||||
|     ) { | ||||
|         self.image = image | ||||
|         self.targetSize = targetSize | ||||
|  | @ -92,7 +92,7 @@ public struct CropImageView<Controls: View, CutHole: View>: View { | |||
|         targetScale: CGFloat = 1, | ||||
|         fulfillTargetFrame: Bool = true, | ||||
|         onCrop: @escaping (Result<PlatformImage, Error>) -> Void, | ||||
|         @ViewBuilder controls: @escaping ControlClosure | ||||
|         @ViewBuilder controls: @escaping ControlClosure<Controls> | ||||
|     ) where CutHole == DefaultCutHoleView { | ||||
|         self.image = image | ||||
|         self.targetSize = targetSize | ||||
|  | @ -141,16 +141,16 @@ public struct CropImageView<Controls: View, CutHole: View>: View { | |||
|             fulfillTargetFrame: fulfillTargetFrame | ||||
|         ) | ||||
|         .frame(width: targetSize.width, height: targetSize.height) | ||||
|         if #available(iOS 16.0, macOS 13.0, visionOS 1.0, *) { | ||||
|         if #available(iOS 16.0, macOS 13.0, *) { | ||||
|             let renderer = ImageRenderer(content: snapshotView) | ||||
|             renderer.scale = targetScale | ||||
| #if !os(macOS) | ||||
| #if os(iOS) | ||||
|             if let image = renderer.uiImage { | ||||
|                 return image | ||||
|             } else { | ||||
|                 throw CropError.imageRendererReturnedNil | ||||
|             } | ||||
| #else | ||||
| #elseif os(macOS) | ||||
|             if let image = renderer.nsImage { | ||||
|                 return image | ||||
|             } else { | ||||
|  | @ -225,7 +225,7 @@ public struct CropImageView<Controls: View, CutHole: View>: View { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| #Preview { | ||||
| struct CropImageView_Previews: PreviewProvider { | ||||
|     struct PreviewView: View { | ||||
|         @State private var targetSize: CGSize = .init(width: 100, height: 100) | ||||
|         @State private var result: Result<PlatformImage, Error>? = nil | ||||
|  | @ -233,7 +233,7 @@ public struct CropImageView<Controls: View, CutHole: View>: View { | |||
|         var body: some View { | ||||
|             VStack { | ||||
|                 CropImageView( | ||||
|                     image: .previewImage, | ||||
|                     image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!, | ||||
|                     targetSize: targetSize | ||||
|                 ) { | ||||
|                     result = $0 | ||||
|  | @ -250,7 +250,7 @@ public struct CropImageView<Controls: View, CutHole: View>: View { | |||
|                             case let .success(croppedImage): | ||||
| #if os(macOS) | ||||
|                                 Image(nsImage: croppedImage) | ||||
| #else | ||||
| #elseif os(iOS) | ||||
|                                 Image(uiImage: croppedImage) | ||||
| #endif | ||||
|                             case let .failure(error): | ||||
|  | @ -262,16 +262,18 @@ public struct CropImageView<Controls: View, CutHole: View>: View { | |||
|                         } | ||||
|                     } header: { Text("Result") } | ||||
|                 } | ||||
| #if os(macOS) | ||||
|                 #if os(macOS) | ||||
|                 .formStyle(.grouped) | ||||
| #endif | ||||
|                 #endif | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     return PreviewView() | ||||
| #if os(macOS) | ||||
|     static var previews: some View { | ||||
|         PreviewView() | ||||
|         #if os(macOS) | ||||
|             .frame(width: 500) | ||||
|             .frame(minHeight: 600) | ||||
| #endif | ||||
|         #endif | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -25,22 +25,16 @@ public struct DefaultControlsView: View { | |||
|         } label: { | ||||
|             Label("Rotate", systemImage: "rotate.right") | ||||
|                 .font(.title2) | ||||
|                 #if !os(visionOS) | ||||
|                 .foregroundColor(.accentColor) | ||||
|                 #endif | ||||
|                 .labelStyle(.iconOnly) | ||||
|                 .padding(.horizontal, 6) | ||||
|                 .padding(.vertical, 3) | ||||
|                 #if !os(visionOS) | ||||
|                 .background( | ||||
|                     RoundedRectangle(cornerRadius: 5, style: .continuous) | ||||
|                         .fill(.background) | ||||
|                 ) | ||||
|                 #endif | ||||
|         } | ||||
|         #if !os(visionOS) | ||||
|         .buttonStyle(.plain) | ||||
|         #endif | ||||
|         .padding() | ||||
|     } | ||||
| 
 | ||||
|  | @ -60,20 +54,14 @@ public struct DefaultControlsView: View { | |||
|         } } label: { | ||||
|             Label("Crop", systemImage: "checkmark.circle.fill") | ||||
|                 .font(.title2) | ||||
|                 #if !os(visionOS) | ||||
|                 .foregroundColor(.accentColor) | ||||
|                 #endif | ||||
|                 .labelStyle(.iconOnly) | ||||
|                 .padding(1) | ||||
|                 #if !os(visionOS) | ||||
|                 .background( | ||||
|                     Circle().fill(.background) | ||||
|                 ) | ||||
|                 #endif | ||||
|         } | ||||
|         #if !os(visionOS) | ||||
|         .buttonStyle(.plain) | ||||
|         #endif | ||||
|         .padding() | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -44,18 +44,20 @@ struct DefaultCutHoleShape: Shape { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| #Preview("Default") { | ||||
| struct DefaultCutHoleShape_Previews: PreviewProvider { | ||||
|     static var previews: some View { | ||||
|         VStack { | ||||
|             DefaultCutHoleShape(size: .init(width: 100, height: 100)) | ||||
|                 .fill(style: FillStyle(eoFill: true)) | ||||
|                 .foregroundColor(.black.opacity(0.6)) | ||||
|         } | ||||
| } | ||||
| 
 | ||||
| #Preview("Circular") { | ||||
|         .previewDisplayName("Default") | ||||
|         VStack { | ||||
|             DefaultCutHoleShape(size: .init(width: 100, height: 100), isCircular: true) | ||||
|                 .fill(style: FillStyle(eoFill: true)) | ||||
|                 .foregroundColor(.black.opacity(0.6)) | ||||
|         } | ||||
|         .previewDisplayName("Circular") | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -28,24 +28,14 @@ public struct DefaultCutHoleView: View { | |||
|     } | ||||
| 
 | ||||
|     var background: some View { | ||||
|         DefaultCutHoleShape(size: targetSize, isCircular: isCircular) | ||||
|         DefaultCutHoleShape(size: targetSize) | ||||
|             .fill(style: FillStyle(eoFill: true)) | ||||
|             .foregroundColor(maskColor) | ||||
|     } | ||||
| 
 | ||||
|     @ViewBuilder | ||||
|     var strokeShape: some View { | ||||
|         if isCircular { | ||||
|             Circle() | ||||
|                 .strokeBorder(style: .init(lineWidth: strokeWidth)) | ||||
|         } else { | ||||
|     var stroke: some View { | ||||
|         Rectangle() | ||||
|             .strokeBorder(style: .init(lineWidth: strokeWidth)) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     var stroke: some View { | ||||
|         strokeShape | ||||
|             .frame( | ||||
|                 width: targetSize.width + strokeWidth * 2, | ||||
|                 height: targetSize.height + strokeWidth * 2 | ||||
|  | @ -61,10 +51,8 @@ public struct DefaultCutHoleView: View { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| #Preview("Default") { | ||||
| struct DefaultCutHoleView_Previews: PreviewProvider { | ||||
|     static var previews: some View { | ||||
|         DefaultCutHoleView(targetSize: .init(width: 100, height: 100)) | ||||
| } | ||||
| 
 | ||||
| #Preview("Circular") { | ||||
|     DefaultCutHoleView(targetSize: .init(width: 100, height: 100), isCircular: true) | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -2,9 +2,9 @@ | |||
| 
 | ||||
| A simple SwiftUI view where user can move and resize an image to a pre-defined size. | ||||
| 
 | ||||
| Supports iOS 14.0 and above, visionOS 1.0 and above or macOS Ventura 13.0 and above. | ||||
| Supports iOS 14.0 and above, or macOS Ventura 13.0 and above. | ||||
| 
 | ||||
| - Supports iOS, visionOS and macOS | ||||
| - Supports both iOS and macOS | ||||
| - Use `ImageRenderer` to render the cropped image, when possible | ||||
| - Very lightweight | ||||
| - (Optionally) bring your own crop UI | ||||
|  |  | |||
|  | @ -11,19 +11,12 @@ import Foundation | |||
| import AppKit | ||||
| /// The image object type, aliased to each platform. | ||||
| /// | ||||
| /// On macOS, it's `NSImage` and on iOS/visionOS it's `UIImage`. | ||||
| /// On macOS, it's `NSImage` and on iOS it's `UIImage`. | ||||
| public typealias PlatformImage = NSImage | ||||
| extension PlatformImage { | ||||
|   @MainActor static let previewImage: PlatformImage = .init(contentsOf: URL(string: "file:///System/Library/Desktop%20Pictures/Hello%20Metallic%20Blue.heic")!)! | ||||
| } | ||||
| #else | ||||
| #elseif os(iOS) | ||||
| import UIKit | ||||
| /// The image object type, aliased to each platform. | ||||
| /// | ||||
| /// On macOS, it's `NSImage` and on iOS/visionOS it's `UIImage`. | ||||
| /// On macOS, it's `NSImage` and on iOS it's `UIImage`. | ||||
| public typealias PlatformImage = UIImage | ||||
| extension PlatformImage { | ||||
|     // This doesn't really work, but at least passes build. | ||||
|     static let previewImage: PlatformImage = .init(contentsOfFile: "/System/Library/Desktop Pictures/Hello Metallic Blue.heic")! | ||||
| } | ||||
| #endif | ||||
|  |  | |||
|  | @ -25,11 +25,6 @@ struct UnderlyingImageView: View { | |||
|     @State private var tempOffset: CGSize = .zero | ||||
|     @State private var tempScale: CGFloat = 1 | ||||
|     @State private var tempRotation: Angle = .zero | ||||
|     @State private var scrolling: Bool = false | ||||
| #if os(macOS) | ||||
|     @State private var hovering: Bool = false | ||||
|     @State private var scrollMonitor: Any? | ||||
| #endif | ||||
| 
 | ||||
|     // When rotated odd multiples of 90 degrees, we need to switch width and height of the image in calculations. | ||||
|     var isRotatedOddMultiplesOf90Deg: Bool { | ||||
|  | @ -71,18 +66,12 @@ struct UnderlyingImageView: View { | |||
|         clampedOffset.height = clampedOffset.height.clamped(to: yOffsetBounds(at: clampedScale)) | ||||
| 
 | ||||
|         if clampedScale != scale || clampedOffset != offset { | ||||
|             if scrolling { | ||||
|                 scale = clampedScale | ||||
|                 offset = clampedOffset | ||||
|                 scrolling = false | ||||
|             } else { | ||||
|             withAnimation(.interactiveSpring()) { | ||||
|                 scale = clampedScale | ||||
|                 offset = clampedOffset | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     } | ||||
| 
 | ||||
|     func setInitialScale(basedOn viewSize: CGSize) { | ||||
|         guard viewSize != .zero else { return } | ||||
|  | @ -92,28 +81,10 @@ struct UnderlyingImageView: View { | |||
|         scale = min(widthScale, heightScale) | ||||
|     } | ||||
| 
 | ||||
| #if os(macOS) | ||||
|     private func setupScrollMonitor() { | ||||
|       scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in | ||||
|             if hovering { | ||||
|                 scrolling = true | ||||
|                 scale = scale + event.scrollingDeltaY / 1000 | ||||
|             } | ||||
|             return event | ||||
|         } | ||||
|     } | ||||
|    | ||||
|     private func removeScrollMonitor() { | ||||
|         if let scrollMonitor { | ||||
|             NSEvent.removeMonitor(scrollMonitor) | ||||
|         } | ||||
|     } | ||||
| #endif | ||||
| 
 | ||||
|     var imageView: Image { | ||||
| #if os(macOS) | ||||
|         Image(nsImage: image) | ||||
| #else | ||||
| #elseif os(iOS) | ||||
|         Image(uiImage: image) | ||||
| #endif | ||||
|     } | ||||
|  | @ -123,14 +94,6 @@ struct UnderlyingImageView: View { | |||
|             .gesture(dragGesture) | ||||
|             .gesture(magnificationgesture) | ||||
|             .gesture(rotationGesture) | ||||
| #if os(macOS) | ||||
|             .onAppear { | ||||
|                 setupScrollMonitor() | ||||
|             } | ||||
|             .onDisappear { | ||||
|                 removeScrollMonitor() | ||||
|             } | ||||
| #endif | ||||
|     } | ||||
| 
 | ||||
|     var dragGesture: some Gesture { | ||||
|  | @ -185,13 +148,10 @@ struct UnderlyingImageView: View { | |||
|             .onChange(of: rotation) { _ in | ||||
|                 adjustToFulfillTargetFrame() | ||||
|             } | ||||
| #if os(macOS) | ||||
|             .onHover { hovering = $0 } | ||||
| #endif | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #Preview { | ||||
| struct MoveAndScalableImageView_Previews: PreviewProvider { | ||||
|     struct PreviewView: View { | ||||
|         @State private var offset: CGSize = .zero | ||||
|         @State private var scale: CGFloat = 1 | ||||
|  | @ -202,7 +162,7 @@ struct UnderlyingImageView: View { | |||
|                 offset: $offset, | ||||
|                 scale: $scale, | ||||
|                 rotation: $rotation, | ||||
|                 image: .previewImage, | ||||
|                 image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!, | ||||
|                 viewSize: .init(width: 200, height: 100), | ||||
|                 targetSize: .init(width: 100, height: 100), | ||||
|                 fulfillTargetFrame: true | ||||
|  | @ -211,5 +171,7 @@ struct UnderlyingImageView: View { | |||
|         } | ||||
|     } | ||||
| 
 | ||||
|     return PreviewView() | ||||
|     static var previews: some View { | ||||
|         PreviewView() | ||||
|     } | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue