diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index b1de7a2..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,2 +0,0 @@ -github: [laosb] -buy_me_a_coffee: laosb diff --git a/Package.swift b/Package.swift index 5fa902a..948f859 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.10 +// swift-tools-version: 5.8 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -7,8 +7,7 @@ let package = Package( name: "CropImage", platforms: [ .iOS(.v14), - .macOS(.v13), - .visionOS(.v1) + .macOS(.v13) ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. @@ -26,6 +25,5 @@ let package = Package( .target( name: "CropImage", dependencies: []) - ], - swiftLanguageVersions: [.version("6"), .v5] + ] ) diff --git a/README.md b/README.md index dd57d6c..821c04f 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,13 @@ A simple SwiftUI view where user can move and resize an image to a pre-defined size. -Supports iOS 14.0 and above, visionOS 1.0 and above or macOS Ventura 13.0 and above. +Supports iOS 14.0 and above, or macOS Ventura 13.0 and above. -- Supports iOS, visionOS and macOS +- Supports both iOS 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 diff --git a/Sources/CropImage/Comparable+clamped.swift b/Sources/CropImage/Comparable+clamped.swift deleted file mode 100644 index 1bcacb4..0000000 --- a/Sources/CropImage/Comparable+clamped.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// 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 9cd2389..6ead4c8 100644 --- a/Sources/CropImage/CropImageView.swift +++ b/Sources/CropImage/CropImageView.swift @@ -6,32 +6,19 @@ // import SwiftUI -#if !os(macOS) +#if os(iOS) import UIKit #endif /// A view that allows the user to crop an image. -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 = ( +public struct CropImageView: View { + 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`. @@ -50,85 +37,64 @@ public struct CropImageView: View { /// The image to crop. public var image: PlatformImage - /// The expected size of the cropped image, in points. + /// The region in which the image is initially fitted in, in points. + public var initialImageSize: CGSize + /// The intended size of the cropped image, in points. public var targetSize: CGSize - /// The expected scale of the cropped image. + /// The intended 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 - var controls: ControlClosure - var cutHole: CutHoleClosure - /// Create a ``CropImageView`` with a custom controls view and a custom cut hole. + /// 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: ControlClosure + + /// Create a ``CropImageView`` with a custom ``controls`` view. public init( image: PlatformImage, + initialImageSize: CGSize, targetSize: CGSize, targetScale: CGFloat = 1, - fulfillTargetFrame: Bool = true, onCrop: @escaping (Result) -> Void, - @ViewBuilder controls: @escaping ControlClosure, - @ViewBuilder cutHole: @escaping CutHoleClosure + @ViewBuilder controls: @escaping ControlClosure ) { self.image = image + self.initialImageSize = initialImageSize self.targetSize = targetSize self.targetScale = targetScale self.onCrop = onCrop self.controls = controls - self.cutHole = cutHole } - /// Create a ``CropImageView`` with a custom controls view and default cut hole. + /// 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. public init( image: PlatformImage, + initialImageSize: CGSize, targetSize: CGSize, targetScale: CGFloat = 1, - 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 = 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 { + ) where Controls == DefaultControlsView { self.image = image + self.initialImageSize = initialImageSize 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 = UnderlyingImageView( @@ -136,21 +102,19 @@ public struct CropImageView: View { scale: $scale, rotation: $rotation, image: image, - viewSize: viewSize, - targetSize: targetSize, - fulfillTargetFrame: fulfillTargetFrame + initialImageSize: initialImageSize ) .frame(width: targetSize.width, height: targetSize.height) - if #available(iOS 16.0, macOS 13.0, visionOS 1.0, *) { + if #available(iOS 16.0, macOS 13.0, *) { let renderer = ImageRenderer(content: snapshotView) renderer.scale = targetScale -#if !os(macOS) +#if os(iOS) if let image = renderer.uiImage { return image } else { throw CropError.imageRendererReturnedNil } -#else +#elseif os(macOS) if let image = renderer.nsImage { return image } else { @@ -186,25 +150,12 @@ public struct CropImageView: View { scale: $scale, rotation: $rotation, image: image, - viewSize: viewSize, - targetSize: targetSize, - fulfillTargetFrame: fulfillTargetFrame + initialImageSize: initialImageSize ) - .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 - } - } + var cutHole: some View { + DefaultCutHoleView(targetSize: targetSize) } @MainActor var control: some View { @@ -218,28 +169,34 @@ public struct CropImageView: View { } public var body: some View { - cutHole(targetSize) - .background(underlyingImage) - .background(viewSizeReadingView) + underlyingImage + .clipped() + .overlay(cutHole) .overlay(control) } } -#Preview { +struct CropImageView_Previews: PreviewProvider { struct PreviewView: View { + @State private var initialImageSize: CGSize = .init(width: 200, height: 200) @State private var targetSize: CGSize = .init(width: 100, height: 100) @State private var result: Result? = nil - + var body: some View { VStack { CropImageView( - image: .previewImage, + image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!, + initialImageSize: initialImageSize, targetSize: targetSize - ) { - result = $0 - } - .frame(height: 300) + ) { result = $0 } Form { + Section { + TextField("Width", value: $initialImageSize.width, formatter: NumberFormatter()) + TextField("Height", value: $initialImageSize.height, formatter: NumberFormatter()) + } header: { + Text("Initial Image Size") + Text("The image will be fitted into this region.") + } Section { TextField("Width", value: $targetSize.width, formatter: NumberFormatter()) TextField("Height", value: $targetSize.height, formatter: NumberFormatter()) @@ -250,7 +207,7 @@ public struct CropImageView: View { case let .success(croppedImage): #if os(macOS) Image(nsImage: croppedImage) -#else +#elseif os(iOS) Image(uiImage: croppedImage) #endif case let .failure(error): @@ -262,16 +219,18 @@ public struct CropImageView: View { } } header: { Text("Result") } } -#if os(macOS) + #if os(macOS) .formStyle(.grouped) -#endif + #endif } } } - - return PreviewView() -#if os(macOS) - .frame(width: 500) - .frame(minHeight: 600) -#endif + + static var previews: some View { + PreviewView() + #if os(macOS) + .frame(width: 500) + .frame(minHeight: 770) + #endif + } } diff --git a/Sources/CropImage/DefaultControlsView.swift b/Sources/CropImage/DefaultControlsView.swift index a49da93..678e0f1 100644 --- a/Sources/CropImage/DefaultControlsView.swift +++ b/Sources/CropImage/DefaultControlsView.swift @@ -7,7 +7,7 @@ import SwiftUI -/// The default controls view used when creating ``CropImageView`` using ``CropImageView/init(image:targetSize:targetScale:fulfillTargetFrame:onCrop:)``. +/// The default controls view used when creating ``CropImageView`` using ``CropImageView/init(image:targetSize:targetScale:onCrop:)``. /// /// It provides basic controls to crop, reset to default cropping & rotation, and rotate the image. public struct DefaultControlsView: View { @@ -19,28 +19,22 @@ public struct DefaultControlsView: View { var rotateButton: some View { Button { let roundedAngle = Angle.degrees((rotation.degrees / 90).rounded() * 90) - withAnimation(.interactiveSpring()) { + withAnimation { 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() } @@ -60,20 +54,14 @@ 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 9b55369..818324c 100644 --- a/Sources/CropImage/DefaultCutHoleShape.swift +++ b/Sources/CropImage/DefaultCutHoleShape.swift @@ -9,7 +9,6 @@ import SwiftUI struct DefaultCutHoleShape: Shape { var size: CGSize - var isCircular = false var animatableData: AnimatablePair { get { .init(size.width, size.height) } @@ -31,31 +30,22 @@ struct DefaultCutHoleShape: Shape { ), 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.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)) +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)) + } } } -#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 e9e8664..9b021aa 100644 --- a/Sources/CropImage/DefaultCutHoleView.swift +++ b/Sources/CropImage/DefaultCutHoleView.swift @@ -7,64 +7,33 @@ import SwiftUI -/// The default cut hole view. Stroke and mask color can be adjusted. -public struct DefaultCutHoleView: View { +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 showStroke = true var background: some View { - DefaultCutHoleShape(size: targetSize, isCircular: isCircular) + DefaultCutHoleShape(size: targetSize) .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)) - } + .foregroundColor(.black.opacity(0.6)) } var stroke: some View { - strokeShape - .frame( - width: targetSize.width + strokeWidth * 2, - height: targetSize.height + strokeWidth * 2 - ) + Rectangle() + .strokeBorder(style: .init(lineWidth: 2)) + .frame(width: targetSize.width + 4, height: targetSize.height + 4) .foregroundColor(.white) } - public var body: some View { + var body: some View { background .allowsHitTesting(false) - .overlay(strokeWidth > 0 ? stroke : nil) + .overlay(showStroke ? 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) +struct DefaultCutHoleView_Previews: PreviewProvider { + static var previews: some View { + DefaultCutHoleView(targetSize: .init(width: 100, height: 100)) + } } diff --git a/Sources/CropImage/Documentation.docc/Documentation.md b/Sources/CropImage/Documentation.docc/Documentation.md index f6a8eea..09e0574 100644 --- a/Sources/CropImage/Documentation.docc/Documentation.md +++ b/Sources/CropImage/Documentation.docc/Documentation.md @@ -2,15 +2,15 @@ A simple SwiftUI view where user can move and resize an image to a pre-defined size. -Supports iOS 14.0 and above, visionOS 1.0 and above or macOS Ventura 13.0 and above. +Supports iOS 14.0 and above, or macOS Ventura 13.0 and above. -- Supports iOS, visionOS and macOS +## Overview + +- Supports both iOS 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 index 50f515e..a399204 100644 Binary files a/Sources/CropImage/Documentation.docc/Resources/macos.png 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 index 4672643..d8e337f 100644 Binary files a/Sources/CropImage/Documentation.docc/Resources/macos~dark.png and b/Sources/CropImage/Documentation.docc/Resources/macos~dark.png differ diff --git a/Sources/CropImage/PlatformImage.swift b/Sources/CropImage/PlatformImage.swift index ab243b5..617ea5f 100644 --- a/Sources/CropImage/PlatformImage.swift +++ b/Sources/CropImage/PlatformImage.swift @@ -11,19 +11,12 @@ import Foundation import AppKit /// The image object type, aliased to each platform. /// -/// On macOS, it's `NSImage` and on iOS/visionOS it's `UIImage`. +/// On macOS, it's `NSImage` and on iOS it's `UIImage`. public typealias PlatformImage = NSImage -extension PlatformImage { - @MainActor static let previewImage: PlatformImage = .init(contentsOf: URL(string: "file:///System/Library/Desktop%20Pictures/Hello%20Metallic%20Blue.heic")!)! -} -#else +#elseif os(iOS) import UIKit /// The image object type, aliased to each platform. /// -/// On macOS, it's `NSImage` and on iOS/visionOS it's `UIImage`. +/// On macOS, it's `NSImage` and on iOS 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 882ade4..4d0a4be 100644 --- a/Sources/CropImage/UnderlyingImageView.swift +++ b/Sources/CropImage/UnderlyingImageView.swift @@ -18,198 +18,83 @@ struct UnderlyingImageView: View { @Binding var scale: CGFloat @Binding var rotation: Angle var image: PlatformImage - var viewSize: CGSize - var targetSize: CGSize - var fulfillTargetFrame: Bool + var initialImageSize: CGSize @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 +#elseif os(iOS) 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 + ZStack { + imageView + .resizable() + .scaledToFit() + .frame(width: initialImageSize.width, height: initialImageSize.height) + .animation(.default, value: initialImageSize) + .scaleEffect(scale * tempScale) + .offset(offset + tempOffset) + .rotationEffect(rotation + tempRotation) + 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 + } + .onEnded { value in + scale = max(scale * tempScale, 0.01) + tempScale = 1 + } + ) + .gesture( + RotationGesture() + .onChanged { value in + tempRotation = value + } + .onEnded { value in + rotation = rotation + tempRotation + tempRotation = .zero + } + ) + } } } -#Preview { +struct MoveAndScalableImageView_Previews: PreviewProvider { 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 + image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!, + initialImageSize: .init(width: 200, height: 200) ) - .frame(width: 200, height: 100) } } - - return PreviewView() + + static var previews: some View { + PreviewView() + } }