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. + + + + Preview on macOS + + +## 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``. + +![Preview on macOS](macos) ## 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() +}