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.
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.
public enum CropError: Error {
/// SwiftUI `ImageRenderer` returned nil when calling `nsImage` or `uiImage`.
@ -28,29 +35,6 @@ public struct CropImageView<Controls: View>: 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<Controls: View>: 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<Controls>
/// Create a ``CropImageView`` with a custom ``controls`` view.
public init(
@ -75,7 +59,7 @@ public struct CropImageView<Controls: View>: View {
targetSize: CGSize,
targetScale: CGFloat = 1,
onCrop: @escaping (Result<PlatformImage, Error>) -> Void,
@ViewBuilder controls: @escaping (_ crop: () async -> ()) -> Controls
@ViewBuilder controls: @escaping ControlClosure<Controls>
) {
self.image = image
self.targetSize = targetSize
@ -91,21 +75,29 @@ public struct CropImageView<Controls: View>: View {
targetSize: CGSize,
targetScale: CGFloat = 1,
onCrop: @escaping (Result<PlatformImage, Error>) -> 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<Controls: View>: 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 {

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.
@ -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")!
)
}
}