mirror of
https://github.com/laosb/CropImage.git
synced 2025-04-30 23:51:08 +00:00
feat: fulfillTargetFrame
.
This commit is contained in:
parent
428b3eb5e8
commit
03549e2fa6
5 changed files with 172 additions and 70 deletions
15
Sources/CropImage/Comparable+clamped.swift
Normal file
15
Sources/CropImage/Comparable+clamped.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue