diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..b1de7a2
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,2 @@
+github: [laosb]
+buy_me_a_coffee: laosb
diff --git a/Package.swift b/Package.swift
index 948f859..5fa902a 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,4 +1,4 @@
-// swift-tools-version: 5.8
+// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
@@ -7,7 +7,8 @@ let package = Package(
name: "CropImage",
platforms: [
.iOS(.v14),
- .macOS(.v13)
+ .macOS(.v13),
+ .visionOS(.v1)
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
@@ -25,5 +26,6 @@ let package = Package(
.target(
name: "CropImage",
dependencies: [])
- ]
+ ],
+ swiftLanguageVersions: [.version("6"), .v5]
)
diff --git a/README.md b/README.md
index a408017..dd57d6c 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,20 @@
A simple SwiftUI view where user can move and resize an image to a pre-defined size.
-Supports iOS 14.0 and above, or macOS Ventura 13.0 and above.
+Supports iOS 14.0 and above, visionOS 1.0 and above or macOS Ventura 13.0 and above.
-- Supports both iOS and macOS
+- Supports iOS, visionOS and macOS
- Use `ImageRenderer` to render the cropped image, when possible
- Very lightweight
+- (Optionally) bring your own crop UI
+
+Full documentation is available on [Swift Package Index](https://swiftpackageindex.com/laosb/CropImage/main/documentation/cropimage). Be sure to choose the correct version.
+
+
+
+
+
+
+## License
+
+[MIT](./LICENSE)
diff --git a/Sources/CropImage/Comparable+clamped.swift b/Sources/CropImage/Comparable+clamped.swift
new file mode 100644
index 0000000..1bcacb4
--- /dev/null
+++ b/Sources/CropImage/Comparable+clamped.swift
@@ -0,0 +1,15 @@
+//
+// Comparable+clamped.swift
+//
+//
+// Created by Shibo Lyu on 2023/8/16.
+//
+
+import Foundation
+
+// https://stackoverflow.com/a/40868784
+extension Comparable {
+ func clamped(to limits: ClosedRange) -> Self {
+ return min(max(self, limits.lowerBound), limits.upperBound)
+ }
+}
diff --git a/Sources/CropImage/CropImageView.swift b/Sources/CropImage/CropImageView.swift
index 35cbc31..9cd2389 100644
--- a/Sources/CropImage/CropImageView.swift
+++ b/Sources/CropImage/CropImageView.swift
@@ -6,12 +6,32 @@
//
import SwiftUI
-#if os(iOS)
+#if !os(macOS)
import UIKit
#endif
/// A view that allows the user to crop an image.
-public struct CropImageView: View {
+public struct CropImageView: View {
+ /// Defines a custom view overlaid on the image cropper.
+ ///
+ /// - Parameters:
+ /// - offset: The offset binding of the image.
+ /// - 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 = (
+ _ offset: Binding,
+ _ scale: Binding,
+ _ rotation: Binding,
+ _ crop: @escaping () async -> ()
+ ) -> Controls
+
+ /// Defines custom view that indicates the cut hole to users.
+ ///
+ /// - Parameters:
+ /// - targetSize: The size of the cut hole.
+ public typealias CutHoleClosure = (_ targetSize: CGSize) -> CutHole
+
/// Errors that could happen during the cropping process.
public enum CropError: Error {
/// SwiftUI `ImageRenderer` returned nil when calling `nsImage` or `uiImage`.
@@ -28,94 +48,109 @@ public struct CropImageView: View {
case failedToGetImageFromCurrentUIGraphicsImageContext
}
- private static func defaultControlsView(crop: @escaping () async -> ()) -> AnyView { AnyView(
- VStack {
- Spacer()
- HStack {
- Spacer()
- Button { Task {
- await crop()
- } } label: {
- Label("Crop", systemImage: "checkmark.circle.fill")
- .font(.title2)
- .foregroundColor(.accentColor)
- .labelStyle(.iconOnly)
- .padding(1)
- .background(
- Circle().fill(.white)
- )
- }
- .buttonStyle(.plain)
- .padding()
- }
- }
- ) }
-
/// The image to crop.
public var image: PlatformImage
- /// 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``.
public var onCrop: (Result) -> Void
- /// A custom view overlaid on the image cropper.
- ///
- /// - Parameters:
- /// - crop: An async function to trigger crop action. Result will be delivered via ``onCrop``.
- public var controls: (_ crop: @escaping () async -> ()) -> Controls
-
- /// Create a ``CropImageView`` with a custom ``controls`` view.
+ var controls: ControlClosure
+ var cutHole: CutHoleClosure
+ /// Create a ``CropImageView`` with a custom controls view and a custom cut hole.
public init(
image: PlatformImage,
targetSize: CGSize,
targetScale: CGFloat = 1,
+ fulfillTargetFrame: Bool = true,
onCrop: @escaping (Result) -> Void,
- @ViewBuilder controls: @escaping (_ crop: () async -> ()) -> Controls
+ @ViewBuilder controls: @escaping ControlClosure,
+ @ViewBuilder cutHole: @escaping CutHoleClosure
) {
self.image = image
self.targetSize = targetSize
self.targetScale = targetScale
self.onCrop = onCrop
self.controls = controls
+ self.cutHole = cutHole
}
- /// Create a ``CropImageView`` with the default ``controls`` view.
- ///
- /// The default ``controls`` view is a simple overlay with a checkmark icon on the bottom-trailing corner to trigger crop action.
+ /// Create a ``CropImageView`` with a custom controls view and default cut hole.
public init(
image: PlatformImage,
targetSize: CGSize,
targetScale: CGFloat = 1,
- onCrop: @escaping (Result) -> Void
- ) where Controls == AnyView {
+ fulfillTargetFrame: Bool = true,
+ onCrop: @escaping (Result) -> Void,
+ @ViewBuilder controls: @escaping ControlClosure
+ ) where CutHole == DefaultCutHoleView {
self.image = image
self.targetSize = targetSize
self.targetScale = targetScale
self.onCrop = onCrop
- self.controls = Self.defaultControlsView
+ self.controls = controls
+ self.cutHole = { targetSize in
+ DefaultCutHoleView(targetSize: targetSize)
+ }
+ }
+ /// Create a ``CropImageView`` with default UI elements.
+ public init(
+ image: PlatformImage,
+ targetSize: CGSize,
+ targetScale: CGFloat = 1,
+ fulfillTargetFrame: Bool = true,
+ onCrop: @escaping (Result) -> Void
+ ) where Controls == DefaultControlsView, CutHole == DefaultCutHoleView {
+ self.image = image
+ self.targetSize = targetSize
+ self.targetScale = targetScale
+ self.onCrop = onCrop
+ self.controls = { $offset, $scale, $rotation, crop in
+ DefaultControlsView(offset: $offset, scale: $scale, rotation: $rotation, crop: crop)
+ }
+ self.cutHole = { targetSize in
+ DefaultCutHoleView(targetSize: targetSize)
+ }
}
@State private var offset: CGSize = .zero
@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 = MoveAndScalableImageView(offset: $offset, scale: $scale, image: image)
- .frame(width: targetSize.width, height: targetSize.height)
- if #available(iOS 16.0, macOS 13.0, *) {
+ let snapshotView = UnderlyingImageView(
+ offset: $offset,
+ scale: $scale,
+ rotation: $rotation,
+ image: image,
+ viewSize: viewSize,
+ targetSize: targetSize,
+ fulfillTargetFrame: fulfillTargetFrame
+ )
+ .frame(width: targetSize.width, height: targetSize.height)
+ if #available(iOS 16.0, macOS 13.0, visionOS 1.0, *) {
let renderer = ImageRenderer(content: snapshotView)
renderer.scale = targetScale
-#if os(iOS)
+#if !os(macOS)
if let image = renderer.uiImage {
return image
} else {
throw CropError.imageRendererReturnedNil
}
-#elseif os(macOS)
+#else
if let image = renderer.nsImage {
return image
} else {
@@ -124,7 +159,7 @@ public struct CropImageView: View {
#endif
} else {
#if os(macOS)
- fatalError("Cropping is not supported on macOS versions before Ventrura 13.0.")
+ fatalError("Cropping is not supported on macOS versions before Ventura 13.0.")
#elseif os(iOS)
let window = UIWindow(frame: CGRect(origin: .zero, size: targetSize))
let hosting = UIHostingController(rootView: snapshotView)
@@ -145,35 +180,65 @@ public struct CropImageView: View {
}
}
- public var body: some View {
- ZStack {
- MoveAndScalableImageView(offset: $offset, scale: $scale, image: image)
- RectHoleShape(size: targetSize)
- .fill(style: FillStyle(eoFill: true))
- .foregroundColor(.black.opacity(0.6))
- .allowsHitTesting(false)
- controls {
- do {
- onCrop(.success(try crop()))
- } catch {
- onCrop(.failure(error))
+ var underlyingImage: some View {
+ UnderlyingImageView(
+ offset: $offset,
+ scale: $scale,
+ rotation: $rotation,
+ image: image,
+ viewSize: viewSize,
+ targetSize: targetSize,
+ fulfillTargetFrame: fulfillTargetFrame
+ )
+ .frame(width: viewSize.width, height: viewSize.height)
+ .clipped()
+ }
+
+ 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 {
+ onCrop(.success(try crop()))
+ } catch {
+ onCrop(.failure(error))
}
}
}
+
+ public var body: some View {
+ cutHole(targetSize)
+ .background(underlyingImage)
+ .background(viewSizeReadingView)
+ .overlay(control)
+ }
}
-struct CropImageView_Previews: PreviewProvider {
+#Preview {
struct PreviewView: View {
@State private var targetSize: CGSize = .init(width: 100, height: 100)
@State private var result: Result? = nil
-
+
var body: some View {
VStack {
CropImageView(
- image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!,
+ image: .previewImage,
targetSize: targetSize
- ) { result = $0 }
+ ) {
+ result = $0
+ }
+ .frame(height: 300)
Form {
Section {
TextField("Width", value: $targetSize.width, formatter: NumberFormatter())
@@ -185,7 +250,7 @@ struct CropImageView_Previews: PreviewProvider {
case let .success(croppedImage):
#if os(macOS)
Image(nsImage: croppedImage)
-#elseif os(iOS)
+#else
Image(uiImage: croppedImage)
#endif
case let .failure(error):
@@ -197,17 +262,16 @@ struct CropImageView_Previews: PreviewProvider {
}
} header: { Text("Result") }
}
- #if os(macOS)
+#if os(macOS)
.formStyle(.grouped)
- #endif
+#endif
}
}
}
-
- static var previews: some View {
- PreviewView()
- #if os(macOS)
- .frame(minHeight: 750)
- #endif
- }
+
+ return PreviewView()
+#if os(macOS)
+ .frame(width: 500)
+ .frame(minHeight: 600)
+#endif
}
diff --git a/Sources/CropImage/DefaultControlsView.swift b/Sources/CropImage/DefaultControlsView.swift
new file mode 100644
index 0000000..a49da93
--- /dev/null
+++ b/Sources/CropImage/DefaultControlsView.swift
@@ -0,0 +1,98 @@
+//
+// DefaultControlsView.swift
+//
+//
+// Created by Shibo Lyu on 2023/8/10.
+//
+
+import SwiftUI
+
+/// 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 {
+ @Binding var offset: CGSize
+ @Binding var scale: CGFloat
+ @Binding var rotation: Angle
+ var crop: () async -> Void
+
+ var rotateButton: some View {
+ Button {
+ let roundedAngle = Angle.degrees((rotation.degrees / 90).rounded() * 90)
+ withAnimation(.interactiveSpring()) {
+ rotation = roundedAngle + .degrees(90)
+ }
+ } 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()
+ }
+
+ var resetButton: some View {
+ Button("Reset") {
+ withAnimation {
+ offset = .zero
+ scale = 1
+ rotation = .zero
+ }
+ }
+ }
+
+ var cropButton: some View {
+ Button { Task {
+ await crop()
+ } } 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()
+ }
+
+ public var body: some View {
+ VStack {
+ Spacer()
+ HStack {
+ rotateButton
+ Spacer()
+ if #available(iOS 15.0, macOS 13.0, *) {
+ resetButton
+ .buttonStyle(.bordered)
+ .buttonBorderShape(.roundedRectangle)
+ } else {
+ resetButton
+ }
+ Spacer()
+ cropButton
+ }
+ }
+ }
+}
diff --git a/Sources/CropImage/DefaultCutHoleShape.swift b/Sources/CropImage/DefaultCutHoleShape.swift
new file mode 100644
index 0000000..9b55369
--- /dev/null
+++ b/Sources/CropImage/DefaultCutHoleShape.swift
@@ -0,0 +1,61 @@
+//
+// DefaultCutHoleShape.swift
+//
+//
+// Created by Shibo Lyu on 2023/7/21.
+//
+
+import SwiftUI
+
+struct DefaultCutHoleShape: Shape {
+ var size: CGSize
+ var isCircular = false
+
+ var animatableData: AnimatablePair {
+ get { .init(size.width, size.height) }
+ set { size = .init(width: newValue.first, height: newValue.second) }
+ }
+
+ func path(in rect: CGRect) -> Path {
+ let path = CGMutablePath()
+ path.move(to: rect.origin)
+ path.addLine(to: .init(x: rect.maxX, y: rect.minY))
+ path.addLine(to: .init(x: rect.maxX, y: rect.maxY))
+ path.addLine(to: .init(x: rect.minX, y: rect.maxY))
+ path.addLine(to: rect.origin)
+ path.closeSubpath()
+
+ let newRect = CGRect(origin: .init(
+ x: rect.midX - size.width / 2.0,
+ y: rect.midY - size.height / 2.0
+ ), size: size)
+
+ path.move(to: newRect.origin)
+ if isCircular {
+ path.addEllipse(in: newRect)
+ } else {
+ path.addLine(to: .init(x: newRect.maxX, y: newRect.minY))
+ path.addLine(to: .init(x: newRect.maxX, y: newRect.maxY))
+ path.addLine(to: .init(x: newRect.minX, y: newRect.maxY))
+ path.addLine(to: newRect.origin)
+ }
+ path.closeSubpath()
+ return Path(path)
+ }
+}
+
+#Preview("Default") {
+ VStack {
+ DefaultCutHoleShape(size: .init(width: 100, height: 100))
+ .fill(style: FillStyle(eoFill: true))
+ .foregroundColor(.black.opacity(0.6))
+ }
+}
+
+#Preview("Circular") {
+ VStack {
+ DefaultCutHoleShape(size: .init(width: 100, height: 100), isCircular: true)
+ .fill(style: FillStyle(eoFill: true))
+ .foregroundColor(.black.opacity(0.6))
+ }
+}
diff --git a/Sources/CropImage/DefaultCutHoleView.swift b/Sources/CropImage/DefaultCutHoleView.swift
new file mode 100644
index 0000000..e9e8664
--- /dev/null
+++ b/Sources/CropImage/DefaultCutHoleView.swift
@@ -0,0 +1,70 @@
+//
+// SwiftUIView.swift
+//
+//
+// Created by Shibo Lyu on 2023/8/15.
+//
+
+import SwiftUI
+
+/// The default cut hole view. Stroke and mask color can be adjusted.
+public struct DefaultCutHoleView: View {
+ var targetSize: CGSize
+ var strokeWidth: CGFloat
+ var maskColor: Color
+ var isCircular: Bool
+
+ /// Initialize a default rectangular or circular cut hole view with specified target size, stroke width and mask color.
+ public init(
+ targetSize: CGSize,
+ isCircular: Bool = false,
+ strokeWidth: CGFloat = 1,
+ maskColor: Color = .black.opacity(0.6)
+ ) {
+ self.targetSize = targetSize
+ self.strokeWidth = strokeWidth
+ self.maskColor = maskColor
+ self.isCircular = isCircular
+ }
+
+ var background: some View {
+ DefaultCutHoleShape(size: targetSize, isCircular: isCircular)
+ .fill(style: FillStyle(eoFill: true))
+ .foregroundColor(maskColor)
+ }
+
+ @ViewBuilder
+ var strokeShape: some View {
+ if isCircular {
+ Circle()
+ .strokeBorder(style: .init(lineWidth: strokeWidth))
+ } else {
+ Rectangle()
+ .strokeBorder(style: .init(lineWidth: strokeWidth))
+ }
+ }
+
+ var stroke: some View {
+ strokeShape
+ .frame(
+ width: targetSize.width + strokeWidth * 2,
+ height: targetSize.height + strokeWidth * 2
+ )
+ .foregroundColor(.white)
+ }
+
+ public var body: some View {
+ background
+ .allowsHitTesting(false)
+ .overlay(strokeWidth > 0 ? stroke : nil)
+ .animation(.default, value: targetSize)
+ }
+}
+
+#Preview("Default") {
+ DefaultCutHoleView(targetSize: .init(width: 100, height: 100))
+}
+
+#Preview("Circular") {
+ DefaultCutHoleView(targetSize: .init(width: 100, height: 100), isCircular: true)
+}
diff --git a/Sources/CropImage/Documentation.docc/Documentation.md b/Sources/CropImage/Documentation.docc/Documentation.md
index 9feee64..f6a8eea 100644
--- a/Sources/CropImage/Documentation.docc/Documentation.md
+++ b/Sources/CropImage/Documentation.docc/Documentation.md
@@ -2,13 +2,16 @@
A simple SwiftUI view where user can move and resize an image to a pre-defined size.
-Supports iOS 14.0 and above, or macOS Ventura 13.0 and above.
+Supports iOS 14.0 and above, visionOS 1.0 and above or macOS Ventura 13.0 and above.
-## Overview
-
-- Supports both iOS and macOS
+- Supports iOS, visionOS and macOS
- Use `ImageRenderer` to render the cropped image, when possible
- Very lightweight
+- (Optionally) bring your own crop UI
+
+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``.
+
+
## Topics
diff --git a/Sources/CropImage/Documentation.docc/Resources/macos.png b/Sources/CropImage/Documentation.docc/Resources/macos.png
new file mode 100644
index 0000000..50f515e
Binary files /dev/null and b/Sources/CropImage/Documentation.docc/Resources/macos.png differ
diff --git a/Sources/CropImage/Documentation.docc/Resources/macos~dark.png b/Sources/CropImage/Documentation.docc/Resources/macos~dark.png
new file mode 100644
index 0000000..4672643
Binary files /dev/null and b/Sources/CropImage/Documentation.docc/Resources/macos~dark.png differ
diff --git a/Sources/CropImage/MoveAndScalableImageView.swift b/Sources/CropImage/MoveAndScalableImageView.swift
deleted file mode 100644
index c622518..0000000
--- a/Sources/CropImage/MoveAndScalableImageView.swift
+++ /dev/null
@@ -1,73 +0,0 @@
-//
-// MoveAndScalableImageView.swift
-//
-//
-// Created by Shibo Lyu on 2023/7/21.
-//
-
-import SwiftUI
-
-private extension CGSize {
- static func + (lhs: CGSize, rhs: CGSize) -> CGSize {
- .init(width: lhs.width + rhs.width, height: lhs.height + rhs.height)
- }
-}
-
-struct MoveAndScalableImageView: View {
- @Binding var offset: CGSize
- @Binding var scale: CGFloat
- var image: PlatformImage
-
- @State private var tempOffset: CGSize = .zero
- @State private var tempScale: CGFloat = 1
-
- var body: some View {
- ZStack {
- #if os(macOS)
- Image(nsImage: image)
- .scaleEffect(scale * tempScale)
- .offset(offset + tempOffset)
- #elseif os(iOS)
- Image(uiImage: image)
- .scaleEffect(scale * tempScale)
- .offset(offset + tempOffset)
- #endif
- 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.magnitude
- }
- .onEnded { value in
- scale = scale * tempScale
- tempScale = 1
- }
- )
- }
- }
-}
-
-struct MoveAndScalableImageView_Previews: PreviewProvider {
- struct PreviewView: View {
- @State private var offset: CGSize = .zero
- @State private var scale: CGFloat = 1
-
- var body: some View {
- MoveAndScalableImageView(offset: $offset, scale: $scale, image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!)
- }
- }
-
- static var previews: some View {
- PreviewView()
- }
-}
diff --git a/Sources/CropImage/PlatformImage.swift b/Sources/CropImage/PlatformImage.swift
index 617ea5f..ab243b5 100644
--- a/Sources/CropImage/PlatformImage.swift
+++ b/Sources/CropImage/PlatformImage.swift
@@ -11,12 +11,19 @@ import Foundation
import AppKit
/// The image object type, aliased to each platform.
///
-/// On macOS, it's `NSImage` and on iOS it's `UIImage`.
+/// On macOS, it's `NSImage` and on iOS/visionOS it's `UIImage`.
public typealias PlatformImage = NSImage
-#elseif os(iOS)
+extension PlatformImage {
+ @MainActor static let previewImage: PlatformImage = .init(contentsOf: URL(string: "file:///System/Library/Desktop%20Pictures/Hello%20Metallic%20Blue.heic")!)!
+}
+#else
import UIKit
/// The image object type, aliased to each platform.
///
-/// On macOS, it's `NSImage` and on iOS it's `UIImage`.
+/// On macOS, it's `NSImage` and on iOS/visionOS 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
diff --git a/Sources/CropImage/RectHoleShape.swift b/Sources/CropImage/RectHoleShape.swift
deleted file mode 100644
index d77db99..0000000
--- a/Sources/CropImage/RectHoleShape.swift
+++ /dev/null
@@ -1,45 +0,0 @@
-//
-// RectHoleShape.swift
-//
-//
-// Created by Shibo Lyu on 2023/7/21.
-//
-
-import SwiftUI
-
-struct RectHoleShape: Shape {
- let size: CGSize
- func path(in rect: CGRect) -> Path {
- let path = CGMutablePath()
- path.move(to: rect.origin)
- path.addLine(to: .init(x: rect.maxX, y: rect.minY))
- path.addLine(to: .init(x: rect.maxX, y: rect.maxY))
- path.addLine(to: .init(x: rect.minX, y: rect.maxY))
- path.addLine(to: rect.origin)
- path.closeSubpath()
-
- let newRect = CGRect(origin: .init(
- x: rect.midX - size.width / 2.0,
- y: rect.midY - size.height / 2.0
- ), size: size)
-
- path.move(to: newRect.origin)
- path.addLine(to: .init(x: newRect.maxX, y: newRect.minY))
- path.addLine(to: .init(x: newRect.maxX, y: newRect.maxY))
- path.addLine(to: .init(x: newRect.minX, y: newRect.maxY))
- path.addLine(to: newRect.origin)
- path.closeSubpath()
- return Path(path)
- }
-}
-
-struct RectHoleShape_Previews: PreviewProvider {
- static var previews: some View {
- VStack {
- RectHoleShape(size: .init(width: 100, height: 100))
- .fill(style: FillStyle(eoFill: true))
- .foregroundColor(.black.opacity(0.6))
- }
- }
-}
-
diff --git a/Sources/CropImage/UnderlyingImageView.swift b/Sources/CropImage/UnderlyingImageView.swift
new file mode 100644
index 0000000..882ade4
--- /dev/null
+++ b/Sources/CropImage/UnderlyingImageView.swift
@@ -0,0 +1,215 @@
+//
+// UnderlyingImageView.swift
+//
+//
+// Created by Shibo Lyu on 2023/7/21.
+//
+
+import SwiftUI
+
+private extension CGSize {
+ static func + (lhs: CGSize, rhs: CGSize) -> CGSize {
+ .init(width: lhs.width + rhs.width, height: lhs.height + rhs.height)
+ }
+}
+
+struct UnderlyingImageView: View {
+ @Binding var offset: CGSize
+ @Binding var scale: CGFloat
+ @Binding var rotation: Angle
+ var image: PlatformImage
+ 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
+ @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 {
+ rotation != .zero
+ && rotation.degrees.truncatingRemainder(dividingBy: 90) == 0
+ && rotation.degrees.truncatingRemainder(dividingBy: 180) != 0
+ }
+
+ var imageWidth: CGFloat {
+ isRotatedOddMultiplesOf90Deg ? image.size.height : image.size.width
+ }
+ var imageHeight: CGFloat {
+ isRotatedOddMultiplesOf90Deg ? image.size.width : image.size.height
+ }
+
+ var minimumScale: CGFloat {
+ let widthScale = targetSize.width / imageWidth
+ let heightScale = targetSize.height / imageHeight
+ return max(widthScale, heightScale)
+ }
+
+ func xOffsetBounds(at scale: CGFloat) -> ClosedRange {
+ let width = imageWidth * scale
+ let range = (targetSize.width - width) / 2
+ return range > 0 ? -range ... range : range ... -range
+ }
+ func yOffsetBounds(at scale: CGFloat) -> ClosedRange {
+ let height = imageHeight * scale
+ let range = (targetSize.height - height) / 2
+ return range > 0 ? -range ... range : range ... -range
+ }
+
+ func adjustToFulfillTargetFrame() {
+ guard fulfillTargetFrame else { return }
+
+ let clampedScale = max(minimumScale, scale)
+ var clampedOffset = offset
+ clampedOffset.width = clampedOffset.width.clamped(to: xOffsetBounds(at: clampedScale))
+ clampedOffset.height = clampedOffset.height.clamped(to: yOffsetBounds(at: clampedScale))
+
+ if clampedScale != scale || clampedOffset != offset {
+ 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 }
+ let widthScale = viewSize.width / imageWidth
+ let heightScale = viewSize.height / imageHeight
+ print("setInitialScale: widthScale: \(widthScale), heightScale: \(heightScale)")
+ 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
+ Image(uiImage: image)
+#endif
+ }
+
+ var interactionView: some View {
+ Color.white.opacity(0.0001)
+ .gesture(dragGesture)
+ .gesture(magnificationgesture)
+ .gesture(rotationGesture)
+#if os(macOS)
+ .onAppear {
+ setupScrollMonitor()
+ }
+ .onDisappear {
+ removeScrollMonitor()
+ }
+#endif
+ }
+
+ var dragGesture: some Gesture {
+ DragGesture()
+ .onChanged { value in
+ tempOffset = value.translation
+ }
+ .onEnded { value in
+ offset = offset + tempOffset
+ tempOffset = .zero
+ }
+ }
+
+ var magnificationgesture: some Gesture {
+ MagnificationGesture()
+ .onChanged { value in
+ tempScale = value
+ }
+ .onEnded { value in
+ scale = scale * tempScale
+ tempScale = 1
+ }
+ }
+
+ var rotationGesture: some Gesture {
+ RotationGesture()
+ .onChanged { value in
+ tempRotation = value
+ }
+ .onEnded { value in
+ rotation = rotation + tempRotation
+ tempRotation = .zero
+ }
+ }
+
+ var body: some View {
+ imageView
+ .rotationEffect(rotation + tempRotation)
+ .scaleEffect(scale * tempScale)
+ .offset(offset + tempOffset)
+ .overlay(interactionView)
+ .clipped()
+ .onChange(of: viewSize) { newValue in
+ setInitialScale(basedOn: newValue)
+ }
+ .onChange(of: scale) { _ in
+ adjustToFulfillTargetFrame()
+ }
+ .onChange(of: offset) { _ in
+ adjustToFulfillTargetFrame()
+ }
+ .onChange(of: rotation) { _ in
+ adjustToFulfillTargetFrame()
+ }
+#if os(macOS)
+ .onHover { hovering = $0 }
+#endif
+ }
+}
+
+#Preview {
+ struct PreviewView: View {
+ @State private var offset: CGSize = .zero
+ @State private var scale: CGFloat = 1
+ @State private var rotation: Angle = .zero
+
+ var body: some View {
+ UnderlyingImageView(
+ offset: $offset,
+ scale: $scale,
+ rotation: $rotation,
+ image: .previewImage,
+ viewSize: .init(width: 200, height: 100),
+ targetSize: .init(width: 100, height: 100),
+ fulfillTargetFrame: true
+ )
+ .frame(width: 200, height: 100)
+ }
+ }
+
+ return PreviewView()
+}