mirror of
https://github.com/laosb/CropImage.git
synced 2025-04-30 23:51:08 +00:00
feat: Rotation.
This commit is contained in:
parent
bbfe1e4636
commit
3f9e8fab8e
3 changed files with 130 additions and 42 deletions
|
@ -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 {
|
||||||
|
|
70
Sources/CropImage/DefaultControlsView.swift
Normal file
70
Sources/CropImage/DefaultControlsView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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")!
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue