feat: fulfillTargetFrame.

This commit is contained in:
Shibo Lyu 2023-08-16 16:36:33 +08:00
parent 428b3eb5e8
commit 03549e2fa6
5 changed files with 172 additions and 70 deletions

View file

@ -0,0 +1,15 @@
//
// 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>) -> Self {
return min(max(self, limits.lowerBound), limits.upperBound)
}
}

View file

@ -37,14 +37,18 @@ public struct CropImageView<Controls: View>: View {
/// The image to crop. /// The image to crop.
public var image: PlatformImage public var image: PlatformImage
/// The region in which the image is initially fitted in, in points. /// The expected size of the cropped image, in points.
public var initialImageSize: CGSize
/// The intended size of the cropped image, in points.
public var targetSize: CGSize public var targetSize: CGSize
/// The intended scale of the cropped image. /// The expected scale of the cropped image.
/// ///
/// This defines the point to pixel ratio for the output image. Defaults to `1`. /// This defines the point to pixel ratio for the output image. Defaults to `1`.
public var targetScale: CGFloat = 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. /// A closure that will be called when the user finishes cropping.
/// ///
/// The error should be a ``CropError``. /// The error should be a ``CropError``.
@ -58,14 +62,13 @@ public struct CropImageView<Controls: View>: View {
/// Create a ``CropImageView`` with a custom ``controls`` view. /// Create a ``CropImageView`` with a custom ``controls`` view.
public init( public init(
image: PlatformImage, image: PlatformImage,
initialImageSize: CGSize,
targetSize: CGSize, targetSize: CGSize,
targetScale: CGFloat = 1, targetScale: CGFloat = 1,
fulfillTargetFrame: Bool = true,
onCrop: @escaping (Result<PlatformImage, Error>) -> Void, onCrop: @escaping (Result<PlatformImage, Error>) -> Void,
@ViewBuilder controls: @escaping ControlClosure<Controls> @ViewBuilder controls: @escaping ControlClosure<Controls>
) { ) {
self.image = image self.image = image
self.initialImageSize = initialImageSize
self.targetSize = targetSize self.targetSize = targetSize
self.targetScale = targetScale self.targetScale = targetScale
self.onCrop = onCrop self.onCrop = onCrop
@ -76,13 +79,12 @@ public struct CropImageView<Controls: View>: View {
/// The default ``controls`` view is a simple overlay with a checkmark icon on the bottom-trailing corner to trigger crop action. /// The default ``controls`` view is a simple overlay with a checkmark icon on the bottom-trailing corner to trigger crop action.
public init( public init(
image: PlatformImage, image: PlatformImage,
initialImageSize: CGSize,
targetSize: CGSize, targetSize: CGSize,
targetScale: CGFloat = 1, targetScale: CGFloat = 1,
fulfillTargetFrame: Bool = true,
onCrop: @escaping (Result<PlatformImage, Error>) -> Void onCrop: @escaping (Result<PlatformImage, Error>) -> Void
) where Controls == DefaultControlsView { ) where Controls == DefaultControlsView {
self.image = image self.image = image
self.initialImageSize = initialImageSize
self.targetSize = targetSize self.targetSize = targetSize
self.targetScale = targetScale self.targetScale = targetScale
self.onCrop = onCrop self.onCrop = onCrop
@ -95,6 +97,8 @@ public struct CropImageView<Controls: View>: View {
@State private var scale: CGFloat = 1 @State private var scale: CGFloat = 1
@State private var rotation: Angle = .zero @State private var rotation: Angle = .zero
@State private var viewSize: CGSize = .zero
@MainActor @MainActor
func crop() throws -> PlatformImage { func crop() throws -> PlatformImage {
let snapshotView = UnderlyingImageView( let snapshotView = UnderlyingImageView(
@ -102,7 +106,9 @@ public struct CropImageView<Controls: View>: View {
scale: $scale, scale: $scale,
rotation: $rotation, rotation: $rotation,
image: image, image: image,
initialImageSize: initialImageSize viewSize: viewSize,
targetSize: targetSize,
fulfillTargetFrame: fulfillTargetFrame
) )
.frame(width: targetSize.width, height: targetSize.height) .frame(width: targetSize.width, height: targetSize.height)
if #available(iOS 16.0, macOS 13.0, *) { if #available(iOS 16.0, macOS 13.0, *) {
@ -150,14 +156,31 @@ public struct CropImageView<Controls: View>: View {
scale: $scale, scale: $scale,
rotation: $rotation, rotation: $rotation,
image: image, image: image,
initialImageSize: initialImageSize viewSize: viewSize,
targetSize: targetSize,
fulfillTargetFrame: fulfillTargetFrame
) )
.frame(width: viewSize.width, height: viewSize.height)
.clipped()
} }
var cutHole: some View { var cutHole: some View {
DefaultCutHoleView(targetSize: targetSize) DefaultCutHoleView(targetSize: targetSize)
} }
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
}
}
}
@MainActor var control: some View { @MainActor var control: some View {
controls($offset, $scale, $rotation) { controls($offset, $scale, $rotation) {
do { do {
@ -169,16 +192,15 @@ public struct CropImageView<Controls: View>: View {
} }
public var body: some View { public var body: some View {
underlyingImage cutHole
.clipped() .background(underlyingImage)
.overlay(cutHole) .background(viewSizeReadingView)
.overlay(control) .overlay(control)
} }
} }
struct CropImageView_Previews: PreviewProvider { struct CropImageView_Previews: PreviewProvider {
struct PreviewView: View { 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 targetSize: CGSize = .init(width: 100, height: 100)
@State private var result: Result<PlatformImage, Error>? = nil @State private var result: Result<PlatformImage, Error>? = nil
@ -186,17 +208,12 @@ struct CropImageView_Previews: PreviewProvider {
VStack { VStack {
CropImageView( CropImageView(
image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!, image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!,
initialImageSize: initialImageSize,
targetSize: targetSize targetSize: targetSize
) { result = $0 } ) {
Form { result = $0
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.")
} }
.frame(height: 300)
Form {
Section { Section {
TextField("Width", value: $targetSize.width, formatter: NumberFormatter()) TextField("Width", value: $targetSize.width, formatter: NumberFormatter())
TextField("Height", value: $targetSize.height, formatter: NumberFormatter()) TextField("Height", value: $targetSize.height, formatter: NumberFormatter())
@ -230,7 +247,7 @@ struct CropImageView_Previews: PreviewProvider {
PreviewView() PreviewView()
#if os(macOS) #if os(macOS)
.frame(width: 500) .frame(width: 500)
.frame(minHeight: 770) .frame(minHeight: 600)
#endif #endif
} }
} }

View file

@ -7,7 +7,7 @@
import SwiftUI import SwiftUI
/// The default controls view used when creating ``CropImageView`` using ``CropImageView/init(image:targetSize:targetScale:onCrop:)``. /// The default controls view used when creating ``CropImageView`` using ``CropImageView/init(image:targetSize:targetScale:fulfillTargetFrame:onCrop:)``.
/// ///
/// It provides basic controls to crop, reset to default cropping & rotation, and rotate the image. /// It provides basic controls to crop, reset to default cropping & rotation, and rotate the image.
public struct DefaultControlsView: View { public struct DefaultControlsView: View {

View file

@ -19,8 +19,8 @@ struct DefaultCutHoleView: View {
var stroke: some View { var stroke: some View {
Rectangle() Rectangle()
.strokeBorder(style: .init(lineWidth: 2)) .strokeBorder(style: .init(lineWidth: 1))
.frame(width: targetSize.width + 4, height: targetSize.height + 4) .frame(width: targetSize.width + 2, height: targetSize.height + 2)
.foregroundColor(.white) .foregroundColor(.white)
} }

View file

@ -18,12 +18,69 @@ struct UnderlyingImageView: View {
@Binding var scale: CGFloat @Binding var scale: CGFloat
@Binding var rotation: Angle @Binding var rotation: Angle
var image: PlatformImage var image: PlatformImage
var initialImageSize: CGSize var viewSize: CGSize
var targetSize: CGSize
var fulfillTargetFrame: Bool
@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 @State private var tempRotation: Angle = .zero
// 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<CGFloat> {
let width = imageWidth * scale
let range = (targetSize.width - width) / 2
return range > 0 ? -range ... range : range ... -range
}
func yOffsetBounds(at scale: CGFloat) -> ClosedRange<CGFloat> {
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 {
withAnimation {
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)
}
var imageView: Image { var imageView: Image {
#if os(macOS) #if os(macOS)
Image(nsImage: image) Image(nsImage: image)
@ -32,18 +89,14 @@ struct UnderlyingImageView: View {
#endif #endif
} }
var body: some View { var interactionView: some View {
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) Color.white.opacity(0.0001)
.gesture( .gesture(dragGesture)
.gesture(magnificationgesture)
.gesture(rotationGesture)
}
var dragGesture: some Gesture {
DragGesture() DragGesture()
.onChanged { value in .onChanged { value in
tempOffset = value.translation tempOffset = value.translation
@ -51,19 +104,23 @@ struct UnderlyingImageView: View {
.onEnded { value in .onEnded { value in
offset = offset + tempOffset offset = offset + tempOffset
tempOffset = .zero tempOffset = .zero
adjustToFulfillTargetFrame()
} }
) }
.gesture(
var magnificationgesture: some Gesture {
MagnificationGesture() MagnificationGesture()
.onChanged { value in .onChanged { value in
tempScale = value tempScale = value
} }
.onEnded { value in .onEnded { value in
scale = max(scale * tempScale, 0.01) scale = scale * tempScale
tempScale = 1 tempScale = 1
adjustToFulfillTargetFrame()
} }
) }
.gesture(
var rotationGesture: some Gesture {
RotationGesture() RotationGesture()
.onChanged { value in .onChanged { value in
tempRotation = value tempRotation = value
@ -72,8 +129,18 @@ struct UnderlyingImageView: View {
rotation = rotation + tempRotation rotation = rotation + tempRotation
tempRotation = .zero tempRotation = .zero
} }
)
} }
var body: some View {
imageView
.scaleEffect(scale * tempScale)
.offset(offset + tempOffset)
.rotationEffect(rotation + tempRotation)
.overlay(interactionView)
.onChange(of: viewSize) { newValue in
setInitialScale(basedOn: newValue)
}
.clipped()
} }
} }
@ -89,8 +156,11 @@ struct MoveAndScalableImageView_Previews: PreviewProvider {
scale: $scale, scale: $scale,
rotation: $rotation, rotation: $rotation,
image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!, image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!,
initialImageSize: .init(width: 200, height: 200) viewSize: .init(width: 200, height: 100),
targetSize: .init(width: 100, height: 100),
fulfillTargetFrame: true
) )
.frame(width: 200, height: 100)
} }
} }