├── .github └── FUNDING.yml ├── .spi.yml ├── Sources └── CropImage │ ├── Documentation.docc │ ├── Resources │ │ ├── macos.png │ │ └── macos~dark.png │ └── Documentation.md │ ├── Comparable+clamped.swift │ ├── PlatformImage.swift │ ├── DefaultCutHoleShape.swift │ ├── DefaultCutHoleView.swift │ ├── DefaultControlsView.swift │ ├── UnderlyingImageView.swift │ └── CropImageView.swift ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.swift └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [laosb] 2 | buy_me_a_coffee: laosb 3 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [CropImage] 5 | 6 | -------------------------------------------------------------------------------- /Sources/CropImage/Documentation.docc/Resources/macos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laosb/CropImage/HEAD/Sources/CropImage/Documentation.docc/Resources/macos.png -------------------------------------------------------------------------------- /Sources/CropImage/Documentation.docc/Resources/macos~dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laosb/CropImage/HEAD/Sources/CropImage/Documentation.docc/Resources/macos~dark.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/CropImage/Comparable+clamped.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Comparable+clamped.swift 3 | // 4 | // 5 | // Created by Shibo Lyu on 2023/8/16. 6 | // 7 | 8 | import Foundation 9 | 10 | // https://stackoverflow.com/a/40868784 11 | extension Comparable { 12 | func clamped(to limits: ClosedRange) -> Self { 13 | return min(max(self, limits.lowerBound), limits.upperBound) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/CropImage/Documentation.docc/Documentation.md: -------------------------------------------------------------------------------- 1 | # ``CropImage`` 2 | 3 | A simple SwiftUI view where user can move and resize an image to a pre-defined size. 4 | 5 | Supports iOS 14.0 and above, visionOS 1.0 and above or macOS Ventura 13.0 and above. 6 | 7 | - Supports iOS, visionOS and macOS 8 | - Use `ImageRenderer` to render the cropped image, when possible 9 | - Very lightweight 10 | - (Optionally) bring your own crop UI 11 | 12 | Configure and present ``CropImageView`` to the user, optionally specifying a ``CropImageView/ControlClosure`` to use your own UI controls to transform the image in the canvas, and cancel or finish the crop process, and receive cropped image from ``CropImageView/onCrop``. 13 | 14 | ![Preview on macOS](macos) 15 | 16 | ## Topics 17 | 18 | ### Views 19 | 20 | - ``CropImageView`` 21 | 22 | ### Supporting Types 23 | 24 | - ``PlatformImage`` 25 | -------------------------------------------------------------------------------- /Sources/CropImage/PlatformImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlatformImage.swift 3 | // 4 | // 5 | // Created by Shibo Lyu on 2023/7/21. 6 | // 7 | 8 | import Foundation 9 | 10 | #if os(macOS) 11 | import AppKit 12 | /// The image object type, aliased to each platform. 13 | /// 14 | /// On macOS, it's `NSImage` and on iOS/visionOS it's `UIImage`. 15 | public typealias PlatformImage = NSImage 16 | extension PlatformImage { 17 | @MainActor static let previewImage: PlatformImage = .init(contentsOf: URL(string: "file:///System/Library/Desktop%20Pictures/Hello%20Metallic%20Blue.heic")!)! 18 | } 19 | #else 20 | import UIKit 21 | /// The image object type, aliased to each platform. 22 | /// 23 | /// On macOS, it's `NSImage` and on iOS/visionOS it's `UIImage`. 24 | public typealias PlatformImage = UIImage 25 | extension PlatformImage { 26 | // This doesn't really work, but at least passes build. 27 | static let previewImage: PlatformImage = .init(contentsOfFile: "/System/Library/Desktop Pictures/Hello Metallic Blue.heic")! 28 | } 29 | #endif 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Shibo Lyu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CropImage", 8 | platforms: [ 9 | .iOS(.v14), 10 | .macOS(.v13), 11 | .visionOS(.v1) 12 | ], 13 | products: [ 14 | // Products define the executables and libraries a package produces, and make them visible to other packages. 15 | .library( 16 | name: "CropImage", 17 | targets: ["CropImage"]), 18 | ], 19 | dependencies: [ 20 | // Dependencies declare other packages that this package depends on. 21 | // .package(url: /* package url */, from: "1.0.0"), 22 | ], 23 | targets: [ 24 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 25 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 26 | .target( 27 | name: "CropImage", 28 | dependencies: []) 29 | ], 30 | swiftLanguageVersions: [.version("6"), .v5] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CropImage 2 | 3 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Flaosb%2FCropImage%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/laosb/CropImage) 4 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Flaosb%2FCropImage%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/laosb/CropImage) 5 | 6 | A simple SwiftUI view where user can move and resize an image to a pre-defined size. 7 | 8 | Supports iOS 14.0 and above, visionOS 1.0 and above or macOS Ventura 13.0 and above. 9 | 10 | - Supports iOS, visionOS and macOS 11 | - Use `ImageRenderer` to render the cropped image, when possible 12 | - Very lightweight 13 | - (Optionally) bring your own crop UI 14 | 15 | Full documentation is available on [Swift Package Index](https://swiftpackageindex.com/laosb/CropImage/main/documentation/cropimage). Be sure to choose the correct version. 16 | 17 | 18 | 19 | Preview on macOS 20 | 21 | 22 | ## License 23 | 24 | [MIT](./LICENSE) 25 | -------------------------------------------------------------------------------- /Sources/CropImage/DefaultCutHoleShape.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultCutHoleShape.swift 3 | // 4 | // 5 | // Created by Shibo Lyu on 2023/7/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DefaultCutHoleShape: Shape { 11 | var size: CGSize 12 | var isCircular = false 13 | 14 | var animatableData: AnimatablePair { 15 | get { .init(size.width, size.height) } 16 | set { size = .init(width: newValue.first, height: newValue.second) } 17 | } 18 | 19 | func path(in rect: CGRect) -> Path { 20 | let path = CGMutablePath() 21 | path.move(to: rect.origin) 22 | path.addLine(to: .init(x: rect.maxX, y: rect.minY)) 23 | path.addLine(to: .init(x: rect.maxX, y: rect.maxY)) 24 | path.addLine(to: .init(x: rect.minX, y: rect.maxY)) 25 | path.addLine(to: rect.origin) 26 | path.closeSubpath() 27 | 28 | let newRect = CGRect(origin: .init( 29 | x: rect.midX - size.width / 2.0, 30 | y: rect.midY - size.height / 2.0 31 | ), size: size) 32 | 33 | path.move(to: newRect.origin) 34 | if isCircular { 35 | path.addEllipse(in: newRect) 36 | } else { 37 | path.addLine(to: .init(x: newRect.maxX, y: newRect.minY)) 38 | path.addLine(to: .init(x: newRect.maxX, y: newRect.maxY)) 39 | path.addLine(to: .init(x: newRect.minX, y: newRect.maxY)) 40 | path.addLine(to: newRect.origin) 41 | } 42 | path.closeSubpath() 43 | return Path(path) 44 | } 45 | } 46 | 47 | #Preview("Default") { 48 | VStack { 49 | DefaultCutHoleShape(size: .init(width: 100, height: 100)) 50 | .fill(style: FillStyle(eoFill: true)) 51 | .foregroundColor(.black.opacity(0.6)) 52 | } 53 | } 54 | 55 | #Preview("Circular") { 56 | VStack { 57 | DefaultCutHoleShape(size: .init(width: 100, height: 100), isCircular: true) 58 | .fill(style: FillStyle(eoFill: true)) 59 | .foregroundColor(.black.opacity(0.6)) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/CropImage/DefaultCutHoleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by Shibo Lyu on 2023/8/15. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// The default cut hole view. Stroke and mask color can be adjusted. 11 | public struct DefaultCutHoleView: View { 12 | var targetSize: CGSize 13 | var strokeWidth: CGFloat 14 | var maskColor: Color 15 | var isCircular: Bool 16 | 17 | /// Initialize a default rectangular or circular cut hole view with specified target size, stroke width and mask color. 18 | public init( 19 | targetSize: CGSize, 20 | isCircular: Bool = false, 21 | strokeWidth: CGFloat = 1, 22 | maskColor: Color = .black.opacity(0.6) 23 | ) { 24 | self.targetSize = targetSize 25 | self.strokeWidth = strokeWidth 26 | self.maskColor = maskColor 27 | self.isCircular = isCircular 28 | } 29 | 30 | var background: some View { 31 | DefaultCutHoleShape(size: targetSize, isCircular: isCircular) 32 | .fill(style: FillStyle(eoFill: true)) 33 | .foregroundColor(maskColor) 34 | } 35 | 36 | @ViewBuilder 37 | var strokeShape: some View { 38 | if isCircular { 39 | Circle() 40 | .strokeBorder(style: .init(lineWidth: strokeWidth)) 41 | } else { 42 | Rectangle() 43 | .strokeBorder(style: .init(lineWidth: strokeWidth)) 44 | } 45 | } 46 | 47 | var stroke: some View { 48 | strokeShape 49 | .frame( 50 | width: targetSize.width + strokeWidth * 2, 51 | height: targetSize.height + strokeWidth * 2 52 | ) 53 | .foregroundColor(.white) 54 | } 55 | 56 | public var body: some View { 57 | background 58 | .allowsHitTesting(false) 59 | .overlay(strokeWidth > 0 ? stroke : nil) 60 | .animation(.default, value: targetSize) 61 | } 62 | } 63 | 64 | #Preview("Default") { 65 | DefaultCutHoleView(targetSize: .init(width: 100, height: 100)) 66 | } 67 | 68 | #Preview("Circular") { 69 | DefaultCutHoleView(targetSize: .init(width: 100, height: 100), isCircular: true) 70 | } 71 | -------------------------------------------------------------------------------- /Sources/CropImage/DefaultControlsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultControlsView.swift 3 | // 4 | // 5 | // Created by Shibo Lyu on 2023/8/10. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// The default controls view used when creating ``CropImageView`` using ``CropImageView/init(image:targetSize:targetScale:fulfillTargetFrame:onCrop:)``. 11 | /// 12 | /// It provides basic controls to crop, reset to default cropping & rotation, and rotate the image. 13 | public struct DefaultControlsView: View { 14 | @Binding var offset: CGSize 15 | @Binding var scale: CGFloat 16 | @Binding var rotation: Angle 17 | var crop: () async -> Void 18 | 19 | var rotateButton: some View { 20 | Button { 21 | let roundedAngle = Angle.degrees((rotation.degrees / 90).rounded() * 90) 22 | withAnimation(.interactiveSpring()) { 23 | rotation = roundedAngle + .degrees(90) 24 | } 25 | } label: { 26 | Label("Rotate", systemImage: "rotate.right") 27 | .font(.title2) 28 | #if !os(visionOS) 29 | .foregroundColor(.accentColor) 30 | #endif 31 | .labelStyle(.iconOnly) 32 | .padding(.horizontal, 6) 33 | .padding(.vertical, 3) 34 | #if !os(visionOS) 35 | .background( 36 | RoundedRectangle(cornerRadius: 5, style: .continuous) 37 | .fill(.background) 38 | ) 39 | #endif 40 | } 41 | #if !os(visionOS) 42 | .buttonStyle(.plain) 43 | #endif 44 | .padding() 45 | } 46 | 47 | var resetButton: some View { 48 | Button("Reset") { 49 | withAnimation { 50 | offset = .zero 51 | scale = 1 52 | rotation = .zero 53 | } 54 | } 55 | } 56 | 57 | var cropButton: some View { 58 | Button { Task { 59 | await crop() 60 | } } label: { 61 | Label("Crop", systemImage: "checkmark.circle.fill") 62 | .font(.title2) 63 | #if !os(visionOS) 64 | .foregroundColor(.accentColor) 65 | #endif 66 | .labelStyle(.iconOnly) 67 | .padding(1) 68 | #if !os(visionOS) 69 | .background( 70 | Circle().fill(.background) 71 | ) 72 | #endif 73 | } 74 | #if !os(visionOS) 75 | .buttonStyle(.plain) 76 | #endif 77 | .padding() 78 | } 79 | 80 | public var body: some View { 81 | VStack { 82 | Spacer() 83 | HStack { 84 | rotateButton 85 | Spacer() 86 | if #available(iOS 15.0, macOS 13.0, *) { 87 | resetButton 88 | .buttonStyle(.bordered) 89 | .buttonBorderShape(.roundedRectangle) 90 | } else { 91 | resetButton 92 | } 93 | Spacer() 94 | cropButton 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/CropImage/UnderlyingImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UnderlyingImageView.swift 3 | // 4 | // 5 | // Created by Shibo Lyu on 2023/7/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | private extension CGSize { 11 | static func + (lhs: CGSize, rhs: CGSize) -> CGSize { 12 | .init(width: lhs.width + rhs.width, height: lhs.height + rhs.height) 13 | } 14 | } 15 | 16 | struct UnderlyingImageView: View { 17 | @Binding var offset: CGSize 18 | @Binding var scale: CGFloat 19 | @Binding var rotation: Angle 20 | var image: PlatformImage 21 | var viewSize: CGSize 22 | var targetSize: CGSize 23 | var fulfillTargetFrame: Bool 24 | 25 | @State private var tempOffset: CGSize = .zero 26 | @State private var tempScale: CGFloat = 1 27 | @State private var tempRotation: Angle = .zero 28 | @State private var scrolling: Bool = false 29 | #if os(macOS) 30 | @State private var hovering: Bool = false 31 | @State private var scrollMonitor: Any? 32 | #endif 33 | 34 | // When rotated odd multiples of 90 degrees, we need to switch width and height of the image in calculations. 35 | var isRotatedOddMultiplesOf90Deg: Bool { 36 | rotation != .zero 37 | && rotation.degrees.truncatingRemainder(dividingBy: 90) == 0 38 | && rotation.degrees.truncatingRemainder(dividingBy: 180) != 0 39 | } 40 | 41 | var imageWidth: CGFloat { 42 | isRotatedOddMultiplesOf90Deg ? image.size.height : image.size.width 43 | } 44 | var imageHeight: CGFloat { 45 | isRotatedOddMultiplesOf90Deg ? image.size.width : image.size.height 46 | } 47 | 48 | var minimumScale: CGFloat { 49 | let widthScale = targetSize.width / imageWidth 50 | let heightScale = targetSize.height / imageHeight 51 | return max(widthScale, heightScale) 52 | } 53 | 54 | func xOffsetBounds(at scale: CGFloat) -> ClosedRange { 55 | let width = imageWidth * scale 56 | let range = (targetSize.width - width) / 2 57 | return range > 0 ? -range ... range : range ... -range 58 | } 59 | func yOffsetBounds(at scale: CGFloat) -> ClosedRange { 60 | let height = imageHeight * scale 61 | let range = (targetSize.height - height) / 2 62 | return range > 0 ? -range ... range : range ... -range 63 | } 64 | 65 | func adjustToFulfillTargetFrame() { 66 | guard fulfillTargetFrame else { return } 67 | 68 | let clampedScale = max(minimumScale, scale) 69 | var clampedOffset = offset 70 | clampedOffset.width = clampedOffset.width.clamped(to: xOffsetBounds(at: clampedScale)) 71 | clampedOffset.height = clampedOffset.height.clamped(to: yOffsetBounds(at: clampedScale)) 72 | 73 | if clampedScale != scale || clampedOffset != offset { 74 | if scrolling { 75 | scale = clampedScale 76 | offset = clampedOffset 77 | scrolling = false 78 | } else { 79 | withAnimation(.interactiveSpring()) { 80 | scale = clampedScale 81 | offset = clampedOffset 82 | } 83 | } 84 | } 85 | } 86 | 87 | func setInitialScale(basedOn viewSize: CGSize) { 88 | guard viewSize != .zero else { return } 89 | let widthScale = viewSize.width / imageWidth 90 | let heightScale = viewSize.height / imageHeight 91 | print("setInitialScale: widthScale: \(widthScale), heightScale: \(heightScale)") 92 | scale = min(widthScale, heightScale) 93 | } 94 | 95 | #if os(macOS) 96 | private func setupScrollMonitor() { 97 | scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in 98 | if hovering { 99 | scrolling = true 100 | scale = scale + event.scrollingDeltaY / 1000 101 | } 102 | return event 103 | } 104 | } 105 | 106 | private func removeScrollMonitor() { 107 | if let scrollMonitor { 108 | NSEvent.removeMonitor(scrollMonitor) 109 | } 110 | } 111 | #endif 112 | 113 | var imageView: Image { 114 | #if os(macOS) 115 | Image(nsImage: image) 116 | #else 117 | Image(uiImage: image) 118 | #endif 119 | } 120 | 121 | var interactionView: some View { 122 | Color.white.opacity(0.0001) 123 | .gesture(dragGesture) 124 | .gesture(magnificationgesture) 125 | .gesture(rotationGesture) 126 | #if os(macOS) 127 | .onAppear { 128 | setupScrollMonitor() 129 | } 130 | .onDisappear { 131 | removeScrollMonitor() 132 | } 133 | #endif 134 | } 135 | 136 | var dragGesture: some Gesture { 137 | DragGesture() 138 | .onChanged { value in 139 | tempOffset = value.translation 140 | } 141 | .onEnded { value in 142 | offset = offset + tempOffset 143 | tempOffset = .zero 144 | } 145 | } 146 | 147 | var magnificationgesture: some Gesture { 148 | MagnificationGesture() 149 | .onChanged { value in 150 | tempScale = value 151 | } 152 | .onEnded { value in 153 | scale = scale * tempScale 154 | tempScale = 1 155 | } 156 | } 157 | 158 | var rotationGesture: some Gesture { 159 | RotationGesture() 160 | .onChanged { value in 161 | tempRotation = value 162 | } 163 | .onEnded { value in 164 | rotation = rotation + tempRotation 165 | tempRotation = .zero 166 | } 167 | } 168 | 169 | var body: some View { 170 | imageView 171 | .rotationEffect(rotation + tempRotation) 172 | .scaleEffect(scale * tempScale) 173 | .offset(offset + tempOffset) 174 | .overlay(interactionView) 175 | .clipped() 176 | .onChange(of: viewSize) { newValue in 177 | setInitialScale(basedOn: newValue) 178 | } 179 | .onChange(of: scale) { _ in 180 | adjustToFulfillTargetFrame() 181 | } 182 | .onChange(of: offset) { _ in 183 | adjustToFulfillTargetFrame() 184 | } 185 | .onChange(of: rotation) { _ in 186 | adjustToFulfillTargetFrame() 187 | } 188 | #if os(macOS) 189 | .onHover { hovering = $0 } 190 | #endif 191 | } 192 | } 193 | 194 | #Preview { 195 | struct PreviewView: View { 196 | @State private var offset: CGSize = .zero 197 | @State private var scale: CGFloat = 1 198 | @State private var rotation: Angle = .zero 199 | 200 | var body: some View { 201 | UnderlyingImageView( 202 | offset: $offset, 203 | scale: $scale, 204 | rotation: $rotation, 205 | image: .previewImage, 206 | viewSize: .init(width: 200, height: 100), 207 | targetSize: .init(width: 100, height: 100), 208 | fulfillTargetFrame: true 209 | ) 210 | .frame(width: 200, height: 100) 211 | } 212 | } 213 | 214 | return PreviewView() 215 | } 216 | -------------------------------------------------------------------------------- /Sources/CropImage/CropImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CropImageView.swift 3 | // 4 | // 5 | // Created by Shibo Lyu on 2023/7/21. 6 | // 7 | 8 | import SwiftUI 9 | #if !os(macOS) 10 | import UIKit 11 | #endif 12 | 13 | /// A view that allows the user to crop an image. 14 | public struct CropImageView: View { 15 | /// Defines a custom view overlaid on the image cropper. 16 | /// 17 | /// - Parameters: 18 | /// - offset: The offset binding of the image. 19 | /// - scale: The scale binding of the image. 20 | /// - rotation: The rotation binding of the image. 21 | /// - crop: An async function to trigger crop action. Result will be delivered via ``onCrop``. 22 | public typealias ControlClosure = ( 23 | _ offset: Binding, 24 | _ scale: Binding, 25 | _ rotation: Binding, 26 | _ crop: @escaping () async -> () 27 | ) -> Controls 28 | 29 | /// Defines custom view that indicates the cut hole to users. 30 | /// 31 | /// - Parameters: 32 | /// - targetSize: The size of the cut hole. 33 | public typealias CutHoleClosure = (_ targetSize: CGSize) -> CutHole 34 | 35 | /// Errors that could happen during the cropping process. 36 | public enum CropError: Error { 37 | /// SwiftUI `ImageRenderer` returned nil when calling `nsImage` or `uiImage`. 38 | /// 39 | /// See [SwiftUI - ImageRenderer](https://developer.apple.com/documentation/swiftui/imagerenderer) for more information. 40 | case imageRendererReturnedNil 41 | /// `UIGraphicsGetCurrentContext()` call returned `nil`. 42 | /// 43 | /// It shouldn't happen, but if it does it will only be on iOS versions prior to 16.0. 44 | case failedToGetCurrentUIGraphicsContext 45 | /// `UIGraphicsGetImageFromCurrentImageContext()` call returned `nil`. 46 | /// 47 | /// It shouldn't happen, but if it does it will only be on iOS versions prior to 16.0. 48 | case failedToGetImageFromCurrentUIGraphicsImageContext 49 | } 50 | 51 | /// The image to crop. 52 | public var image: PlatformImage 53 | /// The expected size of the cropped image, in points. 54 | public var targetSize: CGSize 55 | /// The expected scale of the cropped image. 56 | /// 57 | /// This defines the point to pixel ratio for the output image. Defaults to `1`. 58 | public var targetScale: CGFloat = 1 59 | /// Limit movement and scaling to make sure the image fills the target frame. 60 | /// 61 | /// Defaults to `true`. 62 | /// 63 | /// > 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. 64 | public var fulfillTargetFrame: Bool = true 65 | /// A closure that will be called when the user finishes cropping. 66 | /// 67 | /// The error should be a ``CropError``. 68 | public var onCrop: (Result) -> Void 69 | var controls: ControlClosure 70 | var cutHole: CutHoleClosure 71 | /// Create a ``CropImageView`` with a custom controls view and a custom cut hole. 72 | public init( 73 | image: PlatformImage, 74 | targetSize: CGSize, 75 | targetScale: CGFloat = 1, 76 | fulfillTargetFrame: Bool = true, 77 | onCrop: @escaping (Result) -> Void, 78 | @ViewBuilder controls: @escaping ControlClosure, 79 | @ViewBuilder cutHole: @escaping CutHoleClosure 80 | ) { 81 | self.image = image 82 | self.targetSize = targetSize 83 | self.targetScale = targetScale 84 | self.onCrop = onCrop 85 | self.controls = controls 86 | self.cutHole = cutHole 87 | } 88 | /// Create a ``CropImageView`` with a custom controls view and default cut hole. 89 | public init( 90 | image: PlatformImage, 91 | targetSize: CGSize, 92 | targetScale: CGFloat = 1, 93 | fulfillTargetFrame: Bool = true, 94 | onCrop: @escaping (Result) -> Void, 95 | @ViewBuilder controls: @escaping ControlClosure 96 | ) where CutHole == DefaultCutHoleView { 97 | self.image = image 98 | self.targetSize = targetSize 99 | self.targetScale = targetScale 100 | self.onCrop = onCrop 101 | self.controls = controls 102 | self.cutHole = { targetSize in 103 | DefaultCutHoleView(targetSize: targetSize) 104 | } 105 | } 106 | /// Create a ``CropImageView`` with default UI elements. 107 | public init( 108 | image: PlatformImage, 109 | targetSize: CGSize, 110 | targetScale: CGFloat = 1, 111 | fulfillTargetFrame: Bool = true, 112 | onCrop: @escaping (Result) -> Void 113 | ) where Controls == DefaultControlsView, CutHole == DefaultCutHoleView { 114 | self.image = image 115 | self.targetSize = targetSize 116 | self.targetScale = targetScale 117 | self.onCrop = onCrop 118 | self.controls = { $offset, $scale, $rotation, crop in 119 | DefaultControlsView(offset: $offset, scale: $scale, rotation: $rotation, crop: crop) 120 | } 121 | self.cutHole = { targetSize in 122 | DefaultCutHoleView(targetSize: targetSize) 123 | } 124 | } 125 | 126 | @State private var offset: CGSize = .zero 127 | @State private var scale: CGFloat = 1 128 | @State private var rotation: Angle = .zero 129 | 130 | @State private var viewSize: CGSize = .zero 131 | 132 | @MainActor 133 | func crop() throws -> PlatformImage { 134 | let snapshotView = UnderlyingImageView( 135 | offset: $offset, 136 | scale: $scale, 137 | rotation: $rotation, 138 | image: image, 139 | viewSize: viewSize, 140 | targetSize: targetSize, 141 | fulfillTargetFrame: fulfillTargetFrame 142 | ) 143 | .frame(width: targetSize.width, height: targetSize.height) 144 | if #available(iOS 16.0, macOS 13.0, visionOS 1.0, *) { 145 | let renderer = ImageRenderer(content: snapshotView) 146 | renderer.scale = targetScale 147 | #if !os(macOS) 148 | if let image = renderer.uiImage { 149 | return image 150 | } else { 151 | throw CropError.imageRendererReturnedNil 152 | } 153 | #else 154 | if let image = renderer.nsImage { 155 | return image 156 | } else { 157 | throw CropError.imageRendererReturnedNil 158 | } 159 | #endif 160 | } else { 161 | #if os(macOS) 162 | fatalError("Cropping is not supported on macOS versions before Ventura 13.0.") 163 | #elseif os(iOS) 164 | let window = UIWindow(frame: CGRect(origin: .zero, size: targetSize)) 165 | let hosting = UIHostingController(rootView: snapshotView) 166 | hosting.view.frame = window.frame 167 | window.addSubview(hosting.view) 168 | window.makeKeyAndVisible() 169 | UIGraphicsBeginImageContextWithOptions(hosting.view.bounds.size, false, targetScale) 170 | guard let context = UIGraphicsGetCurrentContext() else { 171 | throw CropError.failedToGetCurrentUIGraphicsContext 172 | } 173 | hosting.view.layer.render(in: context) 174 | guard let image = UIGraphicsGetImageFromCurrentImageContext() else { 175 | throw CropError.failedToGetImageFromCurrentUIGraphicsImageContext 176 | } 177 | UIGraphicsEndImageContext() 178 | return image 179 | #endif 180 | } 181 | } 182 | 183 | var underlyingImage: some View { 184 | UnderlyingImageView( 185 | offset: $offset, 186 | scale: $scale, 187 | rotation: $rotation, 188 | image: image, 189 | viewSize: viewSize, 190 | targetSize: targetSize, 191 | fulfillTargetFrame: fulfillTargetFrame 192 | ) 193 | .frame(width: viewSize.width, height: viewSize.height) 194 | .clipped() 195 | } 196 | 197 | var viewSizeReadingView: some View { 198 | GeometryReader { geo in 199 | Rectangle() 200 | .fill(.white.opacity(0.0001)) 201 | .onChange(of: geo.size) { newValue in 202 | viewSize = newValue 203 | } 204 | .onAppear { 205 | viewSize = geo.size 206 | } 207 | } 208 | } 209 | 210 | @MainActor var control: some View { 211 | controls($offset, $scale, $rotation) { 212 | do { 213 | onCrop(.success(try crop())) 214 | } catch { 215 | onCrop(.failure(error)) 216 | } 217 | } 218 | } 219 | 220 | public var body: some View { 221 | cutHole(targetSize) 222 | .background(underlyingImage) 223 | .background(viewSizeReadingView) 224 | .overlay(control) 225 | } 226 | } 227 | 228 | #Preview { 229 | struct PreviewView: View { 230 | @State private var targetSize: CGSize = .init(width: 100, height: 100) 231 | @State private var result: Result? = nil 232 | 233 | var body: some View { 234 | VStack { 235 | CropImageView( 236 | image: .previewImage, 237 | targetSize: targetSize 238 | ) { 239 | result = $0 240 | } 241 | .frame(height: 300) 242 | Form { 243 | Section { 244 | TextField("Width", value: $targetSize.width, formatter: NumberFormatter()) 245 | TextField("Height", value: $targetSize.height, formatter: NumberFormatter()) 246 | } header: { Text("Crop Target Size") } 247 | Section { 248 | if let result { 249 | switch result { 250 | case let .success(croppedImage): 251 | #if os(macOS) 252 | Image(nsImage: croppedImage) 253 | #else 254 | Image(uiImage: croppedImage) 255 | #endif 256 | case let .failure(error): 257 | Text(error.localizedDescription) 258 | .foregroundColor(.red) 259 | } 260 | } else { 261 | Text("Press \(Image(systemName: "checkmark.circle.fill")) to crop.") 262 | } 263 | } header: { Text("Result") } 264 | } 265 | #if os(macOS) 266 | .formStyle(.grouped) 267 | #endif 268 | } 269 | } 270 | } 271 | 272 | return PreviewView() 273 | #if os(macOS) 274 | .frame(width: 500) 275 | .frame(minHeight: 600) 276 | #endif 277 | } 278 | --------------------------------------------------------------------------------