diff --git a/Sources/CropImage/CropImageView.swift b/Sources/CropImage/CropImageView.swift index 53190ad..e0eb368 100644 --- a/Sources/CropImage/CropImageView.swift +++ b/Sources/CropImage/CropImageView.swift @@ -12,6 +12,13 @@ import UIKit /// A view that allows the user to crop an image. public struct CropImageView: View { + public typealias ControlClosure = ( + _ offset: Binding, + _ scale: Binding, + _ rotation: Binding, + _ crop: @escaping () async -> () + ) -> Controls + /// Errors that could happen during the cropping process. public enum CropError: Error { /// SwiftUI `ImageRenderer` returned nil when calling `nsImage` or `uiImage`. @@ -28,29 +35,6 @@ 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. @@ -67,7 +51,7 @@ public struct CropImageView: View { /// /// - Parameters: /// - crop: An async function to trigger crop action. Result will be delivered via ``onCrop``. - public var controls: (_ crop: @escaping () async -> ()) -> Controls + public var controls: ControlClosure /// Create a ``CropImageView`` with a custom ``controls`` view. public init( @@ -75,7 +59,7 @@ public struct CropImageView: View { targetSize: CGSize, targetScale: CGFloat = 1, onCrop: @escaping (Result) -> Void, - @ViewBuilder controls: @escaping (_ crop: () async -> ()) -> Controls + @ViewBuilder controls: @escaping ControlClosure ) { self.image = image self.targetSize = targetSize @@ -91,21 +75,29 @@ public struct CropImageView: View { targetSize: CGSize, targetScale: CGFloat = 1, onCrop: @escaping (Result) -> Void - ) where Controls == AnyView { + ) where Controls == DefaultControlsView { self.image = image self.targetSize = targetSize self.targetScale = targetScale self.onCrop = onCrop - self.controls = Self.defaultControlsView + self.controls = { $offset, $scale, $rotation, crop in + DefaultControlsView(offset: $offset, scale: $scale, rotation: $rotation, crop: crop) + } } @State private var offset: CGSize = .zero @State private var scale: CGFloat = 1 + @State private var rotation: Angle = .zero @MainActor func crop() throws -> PlatformImage { - let snapshotView = MoveAndScalableImageView(offset: $offset, scale: $scale, image: image) - .frame(width: targetSize.width, height: targetSize.height) + let snapshotView = UnderlyingImageView( + offset: $offset, + scale: $scale, + rotation: $rotation, + image: image + ) + .frame(width: targetSize.width, height: targetSize.height) if #available(iOS 16.0, macOS 13.0, *) { let renderer = ImageRenderer(content: snapshotView) renderer.scale = targetScale @@ -147,13 +139,18 @@ public struct CropImageView: View { public var body: some View { ZStack { - MoveAndScalableImageView(offset: $offset, scale: $scale, image: image) + UnderlyingImageView( + offset: $offset, + scale: $scale, + rotation: $rotation, + image: image + ) RectHoleShape(size: targetSize) .fill(style: FillStyle(eoFill: true)) .foregroundColor(.black.opacity(0.6)) .animation(.default, value: targetSize) .allowsHitTesting(false) - controls { + controls($offset, $scale, $rotation) { do { onCrop(.success(try crop())) } catch { diff --git a/Sources/CropImage/DefaultControlsView.swift b/Sources/CropImage/DefaultControlsView.swift new file mode 100644 index 0000000..1e2d0a6 --- /dev/null +++ b/Sources/CropImage/DefaultControlsView.swift @@ -0,0 +1,70 @@ +// +// 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: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 + + public var body: some View { + VStack { + Spacer() + HStack { + Button { + let roundedAngle = Angle.degrees((rotation.degrees / 90).rounded() * 90) + withAnimation { + rotation = roundedAngle + .degrees(90) + } + } label: { + Label("Rotate", systemImage: "rotate.right") + .font(.title2) + .foregroundColor(.accentColor) + .labelStyle(.iconOnly) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(.white) + ) + } + .buttonStyle(.plain) + .padding() + Spacer() + Button("Reset") { + withAnimation { + offset = .zero + scale = 1 + rotation = .zero + } + } + .buttonStyle(.bordered) + .buttonBorderShape(.roundedRectangle) + 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() + } + } + } +} diff --git a/Sources/CropImage/MoveAndScalableImageView.swift b/Sources/CropImage/UnderlyingImageView.swift similarity index 60% rename from Sources/CropImage/MoveAndScalableImageView.swift rename to Sources/CropImage/UnderlyingImageView.swift index c622518..1d30b96 100644 --- a/Sources/CropImage/MoveAndScalableImageView.swift +++ b/Sources/CropImage/UnderlyingImageView.swift @@ -1,5 +1,5 @@ // -// MoveAndScalableImageView.swift +// UnderlyingImageView.swift // // // Created by Shibo Lyu on 2023/7/21. @@ -13,25 +13,30 @@ private extension CGSize { } } -struct MoveAndScalableImageView: View { +struct UnderlyingImageView: View { @Binding var offset: CGSize @Binding var scale: CGFloat + @Binding var rotation: Angle var image: PlatformImage @State private var tempOffset: CGSize = .zero @State private var tempScale: CGFloat = 1 + @State private var tempRotation: Angle = .zero + + var imageView: Image { +#if os(macOS) + Image(nsImage: image) +#elseif os(iOS) + Image(uiImage: image) +#endif + } var body: some View { ZStack { - #if os(macOS) - Image(nsImage: image) + imageView .scaleEffect(scale * tempScale) .offset(offset + tempOffset) - #elseif os(iOS) - Image(uiImage: image) - .scaleEffect(scale * tempScale) - .offset(offset + tempOffset) - #endif + .rotationEffect(rotation + tempRotation) Color.white.opacity(0.0001) .gesture( DragGesture() @@ -46,13 +51,23 @@ struct MoveAndScalableImageView: View { .gesture( MagnificationGesture() .onChanged { value in - tempScale = value.magnitude + tempScale = value } .onEnded { value in scale = scale * tempScale tempScale = 1 } ) + .gesture( + RotationGesture() + .onChanged { value in + tempRotation = value + } + .onEnded { value in + rotation = rotation + tempRotation + tempRotation = .zero + } + ) } } } @@ -61,9 +76,15 @@ 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 { - MoveAndScalableImageView(offset: $offset, scale: $scale, image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!) + UnderlyingImageView( + offset: $offset, + scale: $scale, + rotation: $rotation, + image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")! + ) } }