├── .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 | 
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://swiftpackageindex.com/laosb/CropImage)
4 | [](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 |
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 |
--------------------------------------------------------------------------------