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 c9f0fe7..dd57d6c 100644 --- a/README.md +++ b/README.md @@ -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, 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 diff --git a/Sources/CropImage/CropImageView.swift b/Sources/CropImage/CropImageView.swift index adc0777..9cd2389 100644 --- a/Sources/CropImage/CropImageView.swift +++ b/Sources/CropImage/CropImageView.swift @@ -6,7 +6,7 @@ // import SwiftUI -#if os(iOS) +#if !os(macOS) import UIKit #endif @@ -19,7 +19,7 @@ public struct CropImageView: 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 = ( _ offset: Binding, _ scale: Binding, _ rotation: Binding, @@ -30,7 +30,7 @@ public struct CropImageView: View { /// /// - Parameters: /// - targetSize: The size of the cut hole. - public typealias CutHoleClosure = (_ targetSize: CGSize) -> CutHole + public typealias CutHoleClosure = (_ targetSize: CGSize) -> CutHole /// Errors that could happen during the cropping process. public enum CropError: Error { @@ -66,8 +66,8 @@ public struct CropImageView: View { /// /// The error should be a ``CropError``. public var onCrop: (Result) -> Void - var controls: ControlClosure - var cutHole: CutHoleClosure + var controls: ControlClosure + var cutHole: CutHoleClosure /// Create a ``CropImageView`` with a custom controls view and a custom cut hole. public init( image: PlatformImage, @@ -75,8 +75,8 @@ public struct CropImageView: View { targetScale: CGFloat = 1, fulfillTargetFrame: Bool = true, onCrop: @escaping (Result) -> Void, - @ViewBuilder controls: @escaping ControlClosure, - @ViewBuilder cutHole: @escaping CutHoleClosure + @ViewBuilder controls: @escaping ControlClosure, + @ViewBuilder cutHole: @escaping CutHoleClosure ) { self.image = image self.targetSize = targetSize @@ -92,7 +92,7 @@ public struct CropImageView: View { targetScale: CGFloat = 1, fulfillTargetFrame: Bool = true, onCrop: @escaping (Result) -> Void, - @ViewBuilder controls: @escaping ControlClosure + @ViewBuilder controls: @escaping ControlClosure ) where CutHole == DefaultCutHoleView { self.image = image self.targetSize = targetSize @@ -141,16 +141,16 @@ public struct CropImageView: View { fulfillTargetFrame: fulfillTargetFrame ) .frame(width: targetSize.width, height: targetSize.height) - if #available(iOS 16.0, macOS 13.0, *) { + 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 { @@ -225,15 +225,15 @@ public struct CropImageView: View { } } -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 @@ -250,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): @@ -262,18 +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(width: 500) - .frame(minHeight: 600) - #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 index 4b69e89..a49da93 100644 --- a/Sources/CropImage/DefaultControlsView.swift +++ b/Sources/CropImage/DefaultControlsView.swift @@ -25,16 +25,22 @@ 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() } @@ -54,14 +60,20 @@ 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() } diff --git a/Sources/CropImage/DefaultCutHoleShape.swift b/Sources/CropImage/DefaultCutHoleShape.swift index 3a8c93e..9b55369 100644 --- a/Sources/CropImage/DefaultCutHoleShape.swift +++ b/Sources/CropImage/DefaultCutHoleShape.swift @@ -44,20 +44,18 @@ struct DefaultCutHoleShape: Shape { } } -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)) - } - .previewDisplayName("Default") - VStack { - DefaultCutHoleShape(size: .init(width: 100, height: 100), isCircular: true) - .fill(style: FillStyle(eoFill: true)) - .foregroundColor(.black.opacity(0.6)) - } - .previewDisplayName("Circular") +#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 index e7a756f..e9e8664 100644 --- a/Sources/CropImage/DefaultCutHoleView.swift +++ b/Sources/CropImage/DefaultCutHoleView.swift @@ -28,14 +28,24 @@ public struct DefaultCutHoleView: View { } var background: some View { - DefaultCutHoleShape(size: targetSize) + 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 { - Rectangle() - .strokeBorder(style: .init(lineWidth: strokeWidth)) + strokeShape .frame( width: targetSize.width + strokeWidth * 2, height: targetSize.height + strokeWidth * 2 @@ -51,8 +61,10 @@ public struct DefaultCutHoleView: View { } } -struct DefaultCutHoleView_Previews: PreviewProvider { - static var previews: some View { - DefaultCutHoleView(targetSize: .init(width: 100, height: 100)) - } +#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 4197e07..f6a8eea 100644 --- a/Sources/CropImage/Documentation.docc/Documentation.md +++ b/Sources/CropImage/Documentation.docc/Documentation.md @@ -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, 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 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/UnderlyingImageView.swift b/Sources/CropImage/UnderlyingImageView.swift index 7166bf4..882ade4 100644 --- a/Sources/CropImage/UnderlyingImageView.swift +++ b/Sources/CropImage/UnderlyingImageView.swift @@ -25,6 +25,11 @@ 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 { @@ -66,9 +71,15 @@ struct UnderlyingImageView: View { clampedOffset.height = clampedOffset.height.clamped(to: yOffsetBounds(at: clampedScale)) if clampedScale != scale || clampedOffset != offset { - withAnimation(.interactiveSpring()) { + if scrolling { scale = clampedScale offset = clampedOffset + scrolling = false + } else { + withAnimation(.interactiveSpring()) { + scale = clampedScale + offset = clampedOffset + } } } } @@ -81,10 +92,28 @@ 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) -#elseif os(iOS) +#else Image(uiImage: image) #endif } @@ -94,6 +123,14 @@ struct UnderlyingImageView: View { .gesture(dragGesture) .gesture(magnificationgesture) .gesture(rotationGesture) +#if os(macOS) + .onAppear { + setupScrollMonitor() + } + .onDisappear { + removeScrollMonitor() + } +#endif } var dragGesture: some Gesture { @@ -148,21 +185,24 @@ struct UnderlyingImageView: View { .onChange(of: rotation) { _ in adjustToFulfillTargetFrame() } +#if os(macOS) + .onHover { hovering = $0 } +#endif } } -struct MoveAndScalableImageView_Previews: PreviewProvider { +#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: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!, + image: .previewImage, viewSize: .init(width: 200, height: 100), targetSize: .init(width: 100, height: 100), fulfillTargetFrame: true @@ -170,8 +210,6 @@ struct MoveAndScalableImageView_Previews: PreviewProvider { .frame(width: 200, height: 100) } } - - static var previews: some View { - PreviewView() - } + + return PreviewView() }