feat: Rotation.

This commit is contained in:
Shibo Lyu 2023-08-10 16:02:07 +08:00
parent bbfe1e4636
commit 3f9e8fab8e
3 changed files with 130 additions and 42 deletions

View file

@ -12,6 +12,13 @@ import UIKit
/// A view that allows the user to crop an image. /// A view that allows the user to crop an image.
public struct CropImageView<Controls: View>: View { public struct CropImageView<Controls: View>: View {
public typealias ControlClosure<Controls> = (
_ offset: Binding<CGSize>,
_ scale: Binding<CGFloat>,
_ rotation: Binding<Angle>,
_ crop: @escaping () async -> ()
) -> Controls
/// Errors that could happen during the cropping process. /// Errors that could happen during the cropping process.
public enum CropError: Error { public enum CropError: Error {
/// SwiftUI `ImageRenderer` returned nil when calling `nsImage` or `uiImage`. /// SwiftUI `ImageRenderer` returned nil when calling `nsImage` or `uiImage`.
@ -28,29 +35,6 @@ public struct CropImageView<Controls: View>: View {
case failedToGetImageFromCurrentUIGraphicsImageContext 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. /// The image to crop.
public var image: PlatformImage public var image: PlatformImage
/// The intended size of the cropped image, in points. /// The intended size of the cropped image, in points.
@ -67,7 +51,7 @@ public struct CropImageView<Controls: View>: View {
/// ///
/// - Parameters: /// - Parameters:
/// - crop: An async function to trigger crop action. Result will be delivered via ``onCrop``. /// - 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<Controls>
/// Create a ``CropImageView`` with a custom ``controls`` view. /// Create a ``CropImageView`` with a custom ``controls`` view.
public init( public init(
@ -75,7 +59,7 @@ public struct CropImageView<Controls: View>: View {
targetSize: CGSize, targetSize: CGSize,
targetScale: CGFloat = 1, targetScale: CGFloat = 1,
onCrop: @escaping (Result<PlatformImage, Error>) -> Void, onCrop: @escaping (Result<PlatformImage, Error>) -> Void,
@ViewBuilder controls: @escaping (_ crop: () async -> ()) -> Controls @ViewBuilder controls: @escaping ControlClosure<Controls>
) { ) {
self.image = image self.image = image
self.targetSize = targetSize self.targetSize = targetSize
@ -91,21 +75,29 @@ public struct CropImageView<Controls: View>: View {
targetSize: CGSize, targetSize: CGSize,
targetScale: CGFloat = 1, targetScale: CGFloat = 1,
onCrop: @escaping (Result<PlatformImage, Error>) -> Void onCrop: @escaping (Result<PlatformImage, Error>) -> Void
) where Controls == AnyView { ) where Controls == DefaultControlsView {
self.image = image self.image = image
self.targetSize = targetSize self.targetSize = targetSize
self.targetScale = targetScale self.targetScale = targetScale
self.onCrop = onCrop 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 offset: CGSize = .zero
@State private var scale: CGFloat = 1 @State private var scale: CGFloat = 1
@State private var rotation: Angle = .zero
@MainActor @MainActor
func crop() throws -> PlatformImage { func crop() throws -> PlatformImage {
let snapshotView = MoveAndScalableImageView(offset: $offset, scale: $scale, image: image) let snapshotView = UnderlyingImageView(
.frame(width: targetSize.width, height: targetSize.height) offset: $offset,
scale: $scale,
rotation: $rotation,
image: image
)
.frame(width: targetSize.width, height: targetSize.height)
if #available(iOS 16.0, macOS 13.0, *) { if #available(iOS 16.0, macOS 13.0, *) {
let renderer = ImageRenderer(content: snapshotView) let renderer = ImageRenderer(content: snapshotView)
renderer.scale = targetScale renderer.scale = targetScale
@ -147,13 +139,18 @@ public struct CropImageView<Controls: View>: View {
public var body: some View { public var body: some View {
ZStack { ZStack {
MoveAndScalableImageView(offset: $offset, scale: $scale, image: image) UnderlyingImageView(
offset: $offset,
scale: $scale,
rotation: $rotation,
image: image
)
RectHoleShape(size: targetSize) RectHoleShape(size: targetSize)
.fill(style: FillStyle(eoFill: true)) .fill(style: FillStyle(eoFill: true))
.foregroundColor(.black.opacity(0.6)) .foregroundColor(.black.opacity(0.6))
.animation(.default, value: targetSize) .animation(.default, value: targetSize)
.allowsHitTesting(false) .allowsHitTesting(false)
controls { controls($offset, $scale, $rotation) {
do { do {
onCrop(.success(try crop())) onCrop(.success(try crop()))
} catch { } catch {

View file

@ -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()
}
}
}
}

View file

@ -1,5 +1,5 @@
// //
// MoveAndScalableImageView.swift // UnderlyingImageView.swift
// //
// //
// Created by Shibo Lyu on 2023/7/21. // 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 offset: CGSize
@Binding var scale: CGFloat @Binding var scale: CGFloat
@Binding var rotation: Angle
var image: PlatformImage var image: PlatformImage
@State private var tempOffset: CGSize = .zero @State private var tempOffset: CGSize = .zero
@State private var tempScale: CGFloat = 1 @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 { var body: some View {
ZStack { ZStack {
#if os(macOS) imageView
Image(nsImage: image)
.scaleEffect(scale * tempScale) .scaleEffect(scale * tempScale)
.offset(offset + tempOffset) .offset(offset + tempOffset)
#elseif os(iOS) .rotationEffect(rotation + tempRotation)
Image(uiImage: image)
.scaleEffect(scale * tempScale)
.offset(offset + tempOffset)
#endif
Color.white.opacity(0.0001) Color.white.opacity(0.0001)
.gesture( .gesture(
DragGesture() DragGesture()
@ -46,13 +51,23 @@ struct MoveAndScalableImageView: View {
.gesture( .gesture(
MagnificationGesture() MagnificationGesture()
.onChanged { value in .onChanged { value in
tempScale = value.magnitude tempScale = value
} }
.onEnded { value in .onEnded { value in
scale = scale * tempScale scale = scale * tempScale
tempScale = 1 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 { struct PreviewView: View {
@State private var offset: CGSize = .zero @State private var offset: CGSize = .zero
@State private var scale: CGFloat = 1 @State private var scale: CGFloat = 1
@State private var rotation: Angle = .zero
var body: some View { 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")!
)
} }
} }