mirror of
https://github.com/laosb/CropImage.git
synced 2025-05-02 08:21:09 +00:00
Compare commits
9 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
9a8b486f55 | ||
![]() |
481f59cf41 | ||
![]() |
7042102108 | ||
![]() |
a9672b8a33 | ||
![]() |
54861801e6 | ||
![]() |
6b33bcdbe4 | ||
![]() |
246b20d079 | ||
![]() |
672ec51d3e | ||
![]() |
ea5a5354fb |
7 changed files with 93 additions and 50 deletions
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
github: [laosb]
|
||||||
|
buy_me_a_coffee: laosb
|
|
@ -26,5 +26,6 @@ let package = Package(
|
||||||
.target(
|
.target(
|
||||||
name: "CropImage",
|
name: "CropImage",
|
||||||
dependencies: [])
|
dependencies: [])
|
||||||
]
|
],
|
||||||
|
swiftLanguageVersions: [.version("6"), .v5]
|
||||||
)
|
)
|
||||||
|
|
|
@ -19,7 +19,7 @@ public struct CropImageView<Controls: View, CutHole: View>: View {
|
||||||
/// - scale: The scale binding of the image.
|
/// - scale: The scale binding of the image.
|
||||||
/// - rotation: The rotation binding of the image.
|
/// - rotation: The rotation binding of the image.
|
||||||
/// - 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 typealias ControlClosure<Controls> = (
|
public typealias ControlClosure = (
|
||||||
_ offset: Binding<CGSize>,
|
_ offset: Binding<CGSize>,
|
||||||
_ scale: Binding<CGFloat>,
|
_ scale: Binding<CGFloat>,
|
||||||
_ rotation: Binding<Angle>,
|
_ rotation: Binding<Angle>,
|
||||||
|
@ -30,7 +30,7 @@ public struct CropImageView<Controls: View, CutHole: View>: View {
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - targetSize: The size of the cut hole.
|
/// - targetSize: The size of the cut hole.
|
||||||
public typealias CutHoleClosure<CutHole> = (_ targetSize: CGSize) -> CutHole
|
public typealias CutHoleClosure = (_ targetSize: CGSize) -> CutHole
|
||||||
|
|
||||||
/// Errors that could happen during the cropping process.
|
/// Errors that could happen during the cropping process.
|
||||||
public enum CropError: Error {
|
public enum CropError: Error {
|
||||||
|
@ -66,8 +66,8 @@ public struct CropImageView<Controls: View, CutHole: View>: View {
|
||||||
///
|
///
|
||||||
/// The error should be a ``CropError``.
|
/// The error should be a ``CropError``.
|
||||||
public var onCrop: (Result<PlatformImage, Error>) -> Void
|
public var onCrop: (Result<PlatformImage, Error>) -> Void
|
||||||
var controls: ControlClosure<Controls>
|
var controls: ControlClosure
|
||||||
var cutHole: CutHoleClosure<CutHole>
|
var cutHole: CutHoleClosure
|
||||||
/// Create a ``CropImageView`` with a custom controls view and a custom cut hole.
|
/// Create a ``CropImageView`` with a custom controls view and a custom cut hole.
|
||||||
public init(
|
public init(
|
||||||
image: PlatformImage,
|
image: PlatformImage,
|
||||||
|
@ -75,8 +75,8 @@ public struct CropImageView<Controls: View, CutHole: View>: View {
|
||||||
targetScale: CGFloat = 1,
|
targetScale: CGFloat = 1,
|
||||||
fulfillTargetFrame: Bool = true,
|
fulfillTargetFrame: Bool = true,
|
||||||
onCrop: @escaping (Result<PlatformImage, Error>) -> Void,
|
onCrop: @escaping (Result<PlatformImage, Error>) -> Void,
|
||||||
@ViewBuilder controls: @escaping ControlClosure<Controls>,
|
@ViewBuilder controls: @escaping ControlClosure,
|
||||||
@ViewBuilder cutHole: @escaping CutHoleClosure<CutHole>
|
@ViewBuilder cutHole: @escaping CutHoleClosure
|
||||||
) {
|
) {
|
||||||
self.image = image
|
self.image = image
|
||||||
self.targetSize = targetSize
|
self.targetSize = targetSize
|
||||||
|
@ -92,7 +92,7 @@ public struct CropImageView<Controls: View, CutHole: View>: View {
|
||||||
targetScale: CGFloat = 1,
|
targetScale: CGFloat = 1,
|
||||||
fulfillTargetFrame: Bool = true,
|
fulfillTargetFrame: Bool = true,
|
||||||
onCrop: @escaping (Result<PlatformImage, Error>) -> Void,
|
onCrop: @escaping (Result<PlatformImage, Error>) -> Void,
|
||||||
@ViewBuilder controls: @escaping ControlClosure<Controls>
|
@ViewBuilder controls: @escaping ControlClosure
|
||||||
) where CutHole == DefaultCutHoleView {
|
) where CutHole == DefaultCutHoleView {
|
||||||
self.image = image
|
self.image = image
|
||||||
self.targetSize = targetSize
|
self.targetSize = targetSize
|
||||||
|
@ -225,7 +225,7 @@ public struct CropImageView<Controls: View, CutHole: View>: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CropImageView_Previews: PreviewProvider {
|
#Preview {
|
||||||
struct PreviewView: View {
|
struct PreviewView: View {
|
||||||
@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
|
||||||
|
@ -233,7 +233,7 @@ struct CropImageView_Previews: PreviewProvider {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
CropImageView(
|
CropImageView(
|
||||||
image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!,
|
image: .previewImage,
|
||||||
targetSize: targetSize
|
targetSize: targetSize
|
||||||
) {
|
) {
|
||||||
result = $0
|
result = $0
|
||||||
|
@ -262,18 +262,16 @@ struct CropImageView_Previews: PreviewProvider {
|
||||||
}
|
}
|
||||||
} header: { Text("Result") }
|
} header: { Text("Result") }
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static var previews: some View {
|
return PreviewView()
|
||||||
PreviewView()
|
#if os(macOS)
|
||||||
#if os(macOS)
|
|
||||||
.frame(width: 500)
|
.frame(width: 500)
|
||||||
.frame(minHeight: 600)
|
.frame(minHeight: 600)
|
||||||
#endif
|
#endif
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,20 +44,18 @@ struct DefaultCutHoleShape: Shape {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DefaultCutHoleShape_Previews: PreviewProvider {
|
#Preview("Default") {
|
||||||
static var previews: some View {
|
|
||||||
VStack {
|
VStack {
|
||||||
DefaultCutHoleShape(size: .init(width: 100, height: 100))
|
DefaultCutHoleShape(size: .init(width: 100, height: 100))
|
||||||
.fill(style: FillStyle(eoFill: true))
|
.fill(style: FillStyle(eoFill: true))
|
||||||
.foregroundColor(.black.opacity(0.6))
|
.foregroundColor(.black.opacity(0.6))
|
||||||
}
|
}
|
||||||
.previewDisplayName("Default")
|
}
|
||||||
|
|
||||||
|
#Preview("Circular") {
|
||||||
VStack {
|
VStack {
|
||||||
DefaultCutHoleShape(size: .init(width: 100, height: 100), isCircular: true)
|
DefaultCutHoleShape(size: .init(width: 100, height: 100), isCircular: true)
|
||||||
.fill(style: FillStyle(eoFill: true))
|
.fill(style: FillStyle(eoFill: true))
|
||||||
.foregroundColor(.black.opacity(0.6))
|
.foregroundColor(.black.opacity(0.6))
|
||||||
}
|
}
|
||||||
.previewDisplayName("Circular")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -61,11 +61,10 @@ public struct DefaultCutHoleView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DefaultCutHoleView_Previews: PreviewProvider {
|
#Preview("Default") {
|
||||||
static var previews: some View {
|
|
||||||
DefaultCutHoleView(targetSize: .init(width: 100, height: 100))
|
DefaultCutHoleView(targetSize: .init(width: 100, height: 100))
|
||||||
.previewDisplayName("Default")
|
}
|
||||||
DefaultCutHoleView(targetSize: .init(width: 100, height: 100), isCircular: true)
|
|
||||||
.previewDisplayName("Circular")
|
#Preview("Circular") {
|
||||||
}
|
DefaultCutHoleView(targetSize: .init(width: 100, height: 100), isCircular: true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,10 +13,17 @@ import AppKit
|
||||||
///
|
///
|
||||||
/// On macOS, it's `NSImage` and on iOS/visionOS it's `UIImage`.
|
/// On macOS, it's `NSImage` and on iOS/visionOS it's `UIImage`.
|
||||||
public typealias PlatformImage = NSImage
|
public typealias PlatformImage = NSImage
|
||||||
|
extension PlatformImage {
|
||||||
|
@MainActor static let previewImage: PlatformImage = .init(contentsOf: URL(string: "file:///System/Library/Desktop%20Pictures/Hello%20Metallic%20Blue.heic")!)!
|
||||||
|
}
|
||||||
#else
|
#else
|
||||||
import UIKit
|
import UIKit
|
||||||
/// The image object type, aliased to each platform.
|
/// The image object type, aliased to each platform.
|
||||||
///
|
///
|
||||||
/// On macOS, it's `NSImage` and on iOS/visionOS it's `UIImage`.
|
/// On macOS, it's `NSImage` and on iOS/visionOS it's `UIImage`.
|
||||||
public typealias PlatformImage = UIImage
|
public typealias PlatformImage = UIImage
|
||||||
|
extension PlatformImage {
|
||||||
|
// This doesn't really work, but at least passes build.
|
||||||
|
static let previewImage: PlatformImage = .init(contentsOfFile: "/System/Library/Desktop Pictures/Hello Metallic Blue.heic")!
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
|
@ -25,6 +25,11 @@ struct UnderlyingImageView: View {
|
||||||
@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
|
||||||
|
@State private var scrolling: Bool = false
|
||||||
|
#if os(macOS)
|
||||||
|
@State private var hovering: Bool = false
|
||||||
|
@State private var scrollMonitor: Any?
|
||||||
|
#endif
|
||||||
|
|
||||||
// When rotated odd multiples of 90 degrees, we need to switch width and height of the image in calculations.
|
// When rotated odd multiples of 90 degrees, we need to switch width and height of the image in calculations.
|
||||||
var isRotatedOddMultiplesOf90Deg: Bool {
|
var isRotatedOddMultiplesOf90Deg: Bool {
|
||||||
|
@ -66,12 +71,18 @@ struct UnderlyingImageView: View {
|
||||||
clampedOffset.height = clampedOffset.height.clamped(to: yOffsetBounds(at: clampedScale))
|
clampedOffset.height = clampedOffset.height.clamped(to: yOffsetBounds(at: clampedScale))
|
||||||
|
|
||||||
if clampedScale != scale || clampedOffset != offset {
|
if clampedScale != scale || clampedOffset != offset {
|
||||||
|
if scrolling {
|
||||||
|
scale = clampedScale
|
||||||
|
offset = clampedOffset
|
||||||
|
scrolling = false
|
||||||
|
} else {
|
||||||
withAnimation(.interactiveSpring()) {
|
withAnimation(.interactiveSpring()) {
|
||||||
scale = clampedScale
|
scale = clampedScale
|
||||||
offset = clampedOffset
|
offset = clampedOffset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setInitialScale(basedOn viewSize: CGSize) {
|
func setInitialScale(basedOn viewSize: CGSize) {
|
||||||
guard viewSize != .zero else { return }
|
guard viewSize != .zero else { return }
|
||||||
|
@ -81,6 +92,24 @@ struct UnderlyingImageView: View {
|
||||||
scale = min(widthScale, heightScale)
|
scale = min(widthScale, heightScale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
private func setupScrollMonitor() {
|
||||||
|
scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in
|
||||||
|
if hovering {
|
||||||
|
scrolling = true
|
||||||
|
scale = scale + event.scrollingDeltaY / 1000
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeScrollMonitor() {
|
||||||
|
if let scrollMonitor {
|
||||||
|
NSEvent.removeMonitor(scrollMonitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
var imageView: Image {
|
var imageView: Image {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Image(nsImage: image)
|
Image(nsImage: image)
|
||||||
|
@ -94,6 +123,14 @@ struct UnderlyingImageView: View {
|
||||||
.gesture(dragGesture)
|
.gesture(dragGesture)
|
||||||
.gesture(magnificationgesture)
|
.gesture(magnificationgesture)
|
||||||
.gesture(rotationGesture)
|
.gesture(rotationGesture)
|
||||||
|
#if os(macOS)
|
||||||
|
.onAppear {
|
||||||
|
setupScrollMonitor()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
removeScrollMonitor()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
var dragGesture: some Gesture {
|
var dragGesture: some Gesture {
|
||||||
|
@ -148,10 +185,13 @@ struct UnderlyingImageView: View {
|
||||||
.onChange(of: rotation) { _ in
|
.onChange(of: rotation) { _ in
|
||||||
adjustToFulfillTargetFrame()
|
adjustToFulfillTargetFrame()
|
||||||
}
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.onHover { hovering = $0 }
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MoveAndScalableImageView_Previews: PreviewProvider {
|
#Preview {
|
||||||
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
|
||||||
|
@ -162,7 +202,7 @@ struct MoveAndScalableImageView_Previews: PreviewProvider {
|
||||||
offset: $offset,
|
offset: $offset,
|
||||||
scale: $scale,
|
scale: $scale,
|
||||||
rotation: $rotation,
|
rotation: $rotation,
|
||||||
image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!,
|
image: .previewImage,
|
||||||
viewSize: .init(width: 200, height: 100),
|
viewSize: .init(width: 200, height: 100),
|
||||||
targetSize: .init(width: 100, height: 100),
|
targetSize: .init(width: 100, height: 100),
|
||||||
fulfillTargetFrame: true
|
fulfillTargetFrame: true
|
||||||
|
@ -171,7 +211,5 @@ struct MoveAndScalableImageView_Previews: PreviewProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static var previews: some View {
|
return PreviewView()
|
||||||
PreviewView()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue