Compare commits

..

No commits in common. "main" and "0.5.0" have entirely different histories.
main ... 0.5.0

10 changed files with 85 additions and 220 deletions

2
.github/FUNDING.yml vendored
View file

@ -1,2 +0,0 @@
github: [laosb]
buy_me_a_coffee: laosb

View file

@ -1,4 +1,4 @@
// swift-tools-version: 5.10 // swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -7,8 +7,7 @@ let package = Package(
name: "CropImage", name: "CropImage",
platforms: [ platforms: [
.iOS(.v14), .iOS(.v14),
.macOS(.v13), .macOS(.v13)
.visionOS(.v1)
], ],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // Products define the executables and libraries a package produces, and make them visible to other packages.
@ -26,6 +25,5 @@ let package = Package(
.target( .target(
name: "CropImage", name: "CropImage",
dependencies: []) dependencies: [])
], ]
swiftLanguageVersions: [.version("6"), .v5]
) )

View file

@ -5,15 +5,13 @@
A simple SwiftUI view where user can move and resize an image to a pre-defined size. A simple SwiftUI view where user can move and resize an image to a pre-defined size.
Supports iOS 14.0 and above, visionOS 1.0 and above or macOS Ventura 13.0 and above. Supports iOS 14.0 and above, or macOS Ventura 13.0 and above.
- Supports iOS, visionOS and macOS - Supports both iOS and macOS
- Use `ImageRenderer` to render the cropped image, when possible - Use `ImageRenderer` to render the cropped image, when possible
- Very lightweight - Very lightweight
- (Optionally) bring your own crop UI - (Optionally) bring your own crop UI
Full documentation is available on [Swift Package Index](https://swiftpackageindex.com/laosb/CropImage/main/documentation/cropimage). Be sure to choose the correct version.
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="./Sources/CropImage/Documentation.docc/Resources/macos~dark.png"> <source media="(prefers-color-scheme: dark)" srcset="./Sources/CropImage/Documentation.docc/Resources/macos~dark.png">
<img alt="Preview on macOS" src="./Sources/CropImage/Documentation.docc/Resources/macos.png"> <img alt="Preview on macOS" src="./Sources/CropImage/Documentation.docc/Resources/macos.png">

View file

@ -6,32 +6,19 @@
// //
import SwiftUI import SwiftUI
#if !os(macOS) #if os(iOS)
import UIKit import UIKit
#endif #endif
/// 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, CutHole: View>: View { public struct CropImageView<Controls: View>: View {
/// Defines a custom view overlaid on the image cropper. public typealias ControlClosure<Controls> = (
///
/// - Parameters:
/// - offset: The offset binding of the image.
/// - scale: The scale 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``.
public typealias ControlClosure = (
_ offset: Binding<CGSize>, _ offset: Binding<CGSize>,
_ scale: Binding<CGFloat>, _ scale: Binding<CGFloat>,
_ rotation: Binding<Angle>, _ rotation: Binding<Angle>,
_ crop: @escaping () async -> () _ crop: @escaping () async -> ()
) -> Controls ) -> Controls
/// Defines custom view that indicates the cut hole to users.
///
/// - Parameters:
/// - targetSize: The size of the cut hole.
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 {
/// SwiftUI `ImageRenderer` returned nil when calling `nsImage` or `uiImage`. /// SwiftUI `ImageRenderer` returned nil when calling `nsImage` or `uiImage`.
@ -66,51 +53,37 @@ 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 /// A custom view overlaid on the image cropper.
var cutHole: CutHoleClosure ///
/// Create a ``CropImageView`` with a custom controls view and a custom cut hole. /// - Parameters:
/// - crop: An async function to trigger crop action. Result will be delivered via ``onCrop``.
public var controls: ControlClosure<Controls>
/// Create a ``CropImageView`` with a custom ``controls`` view.
public init( public init(
image: PlatformImage, image: PlatformImage,
targetSize: CGSize, targetSize: CGSize,
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, @ViewBuilder controls: @escaping ControlClosure<Controls>
@ViewBuilder cutHole: @escaping CutHoleClosure
) { ) {
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 = controls self.controls = controls
self.cutHole = cutHole
} }
/// Create a ``CropImageView`` with a custom controls view and default cut hole. /// Create a ``CropImageView`` with the default ``controls`` view.
public init( ///
image: PlatformImage, /// The default ``controls`` view is a simple overlay with a checkmark icon on the bottom-trailing corner to trigger crop action.
targetSize: CGSize,
targetScale: CGFloat = 1,
fulfillTargetFrame: Bool = true,
onCrop: @escaping (Result<PlatformImage, Error>) -> Void,
@ViewBuilder controls: @escaping ControlClosure
) where CutHole == DefaultCutHoleView {
self.image = image
self.targetSize = targetSize
self.targetScale = targetScale
self.onCrop = onCrop
self.controls = controls
self.cutHole = { targetSize in
DefaultCutHoleView(targetSize: targetSize)
}
}
/// Create a ``CropImageView`` with default UI elements.
public init( public init(
image: PlatformImage, image: PlatformImage,
targetSize: CGSize, targetSize: CGSize,
targetScale: CGFloat = 1, targetScale: CGFloat = 1,
fulfillTargetFrame: Bool = true, fulfillTargetFrame: Bool = true,
onCrop: @escaping (Result<PlatformImage, Error>) -> Void onCrop: @escaping (Result<PlatformImage, Error>) -> Void
) where Controls == DefaultControlsView, CutHole == DefaultCutHoleView { ) where Controls == DefaultControlsView {
self.image = image self.image = image
self.targetSize = targetSize self.targetSize = targetSize
self.targetScale = targetScale self.targetScale = targetScale
@ -118,9 +91,6 @@ public struct CropImageView<Controls: View, CutHole: View>: View {
self.controls = { $offset, $scale, $rotation, crop in self.controls = { $offset, $scale, $rotation, crop in
DefaultControlsView(offset: $offset, scale: $scale, rotation: $rotation, crop: crop) DefaultControlsView(offset: $offset, scale: $scale, rotation: $rotation, crop: crop)
} }
self.cutHole = { targetSize in
DefaultCutHoleView(targetSize: targetSize)
}
} }
@State private var offset: CGSize = .zero @State private var offset: CGSize = .zero
@ -141,16 +111,16 @@ public struct CropImageView<Controls: View, CutHole: View>: View {
fulfillTargetFrame: fulfillTargetFrame fulfillTargetFrame: fulfillTargetFrame
) )
.frame(width: targetSize.width, height: targetSize.height) .frame(width: targetSize.width, height: targetSize.height)
if #available(iOS 16.0, macOS 13.0, visionOS 1.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
#if !os(macOS) #if os(iOS)
if let image = renderer.uiImage { if let image = renderer.uiImage {
return image return image
} else { } else {
throw CropError.imageRendererReturnedNil throw CropError.imageRendererReturnedNil
} }
#else #elseif os(macOS)
if let image = renderer.nsImage { if let image = renderer.nsImage {
return image return image
} else { } else {
@ -194,6 +164,10 @@ public struct CropImageView<Controls: View, CutHole: View>: View {
.clipped() .clipped()
} }
var cutHole: some View {
DefaultCutHoleView(targetSize: targetSize)
}
var viewSizeReadingView: some View { var viewSizeReadingView: some View {
GeometryReader { geo in GeometryReader { geo in
Rectangle() Rectangle()
@ -218,14 +192,14 @@ public struct CropImageView<Controls: View, CutHole: View>: View {
} }
public var body: some View { public var body: some View {
cutHole(targetSize) cutHole
.background(underlyingImage) .background(underlyingImage)
.background(viewSizeReadingView) .background(viewSizeReadingView)
.overlay(control) .overlay(control)
} }
} }
#Preview { struct CropImageView_Previews: PreviewProvider {
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 +207,7 @@ public struct CropImageView<Controls: View, CutHole: View>: View {
var body: some View { var body: some View {
VStack { VStack {
CropImageView( CropImageView(
image: .previewImage, image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!,
targetSize: targetSize targetSize: targetSize
) { ) {
result = $0 result = $0
@ -250,7 +224,7 @@ public struct CropImageView<Controls: View, CutHole: View>: View {
case let .success(croppedImage): case let .success(croppedImage):
#if os(macOS) #if os(macOS)
Image(nsImage: croppedImage) Image(nsImage: croppedImage)
#else #elseif os(iOS)
Image(uiImage: croppedImage) Image(uiImage: croppedImage)
#endif #endif
case let .failure(error): case let .failure(error):
@ -262,16 +236,18 @@ public struct CropImageView<Controls: View, CutHole: View>: View {
} }
} header: { Text("Result") } } header: { Text("Result") }
} }
#if os(macOS) #if os(macOS)
.formStyle(.grouped) .formStyle(.grouped)
#endif #endif
} }
} }
} }
return PreviewView() static var previews: some View {
#if os(macOS) PreviewView()
#if os(macOS)
.frame(width: 500) .frame(width: 500)
.frame(minHeight: 600) .frame(minHeight: 600)
#endif #endif
}
} }

View file

@ -19,28 +19,22 @@ public struct DefaultControlsView: View {
var rotateButton: some View { var rotateButton: some View {
Button { Button {
let roundedAngle = Angle.degrees((rotation.degrees / 90).rounded() * 90) let roundedAngle = Angle.degrees((rotation.degrees / 90).rounded() * 90)
withAnimation(.interactiveSpring()) { withAnimation {
rotation = roundedAngle + .degrees(90) rotation = roundedAngle + .degrees(90)
} }
} label: { } label: {
Label("Rotate", systemImage: "rotate.right") Label("Rotate", systemImage: "rotate.right")
.font(.title2) .font(.title2)
#if !os(visionOS)
.foregroundColor(.accentColor) .foregroundColor(.accentColor)
#endif
.labelStyle(.iconOnly) .labelStyle(.iconOnly)
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 3) .padding(.vertical, 3)
#if !os(visionOS)
.background( .background(
RoundedRectangle(cornerRadius: 5, style: .continuous) RoundedRectangle(cornerRadius: 5, style: .continuous)
.fill(.background) .fill(.background)
) )
#endif
} }
#if !os(visionOS)
.buttonStyle(.plain) .buttonStyle(.plain)
#endif
.padding() .padding()
} }
@ -60,20 +54,14 @@ public struct DefaultControlsView: View {
} } label: { } } label: {
Label("Crop", systemImage: "checkmark.circle.fill") Label("Crop", systemImage: "checkmark.circle.fill")
.font(.title2) .font(.title2)
#if !os(visionOS)
.foregroundColor(.accentColor) .foregroundColor(.accentColor)
#endif
.labelStyle(.iconOnly) .labelStyle(.iconOnly)
.padding(1) .padding(1)
#if !os(visionOS)
.background( .background(
Circle().fill(.background) Circle().fill(.background)
) )
#endif
} }
#if !os(visionOS)
.buttonStyle(.plain) .buttonStyle(.plain)
#endif
.padding() .padding()
} }

View file

@ -9,7 +9,6 @@ import SwiftUI
struct DefaultCutHoleShape: Shape { struct DefaultCutHoleShape: Shape {
var size: CGSize var size: CGSize
var isCircular = false
var animatableData: AnimatablePair<CGFloat, CGFloat> { var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { .init(size.width, size.height) } get { .init(size.width, size.height) }
@ -31,31 +30,22 @@ struct DefaultCutHoleShape: Shape {
), size: size) ), size: size)
path.move(to: newRect.origin) path.move(to: newRect.origin)
if isCircular {
path.addEllipse(in: newRect)
} else {
path.addLine(to: .init(x: newRect.maxX, y: newRect.minY)) path.addLine(to: .init(x: newRect.maxX, y: newRect.minY))
path.addLine(to: .init(x: newRect.maxX, y: newRect.maxY)) path.addLine(to: .init(x: newRect.maxX, y: newRect.maxY))
path.addLine(to: .init(x: newRect.minX, y: newRect.maxY)) path.addLine(to: .init(x: newRect.minX, y: newRect.maxY))
path.addLine(to: newRect.origin) path.addLine(to: newRect.origin)
}
path.closeSubpath() path.closeSubpath()
return Path(path) return Path(path)
} }
} }
#Preview("Default") { struct DefaultCutHoleShape_Previews: PreviewProvider {
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))
} }
}
#Preview("Circular") {
VStack {
DefaultCutHoleShape(size: .init(width: 100, height: 100), isCircular: true)
.fill(style: FillStyle(eoFill: true))
.foregroundColor(.black.opacity(0.6))
} }
} }

View file

@ -7,64 +7,33 @@
import SwiftUI import SwiftUI
/// The default cut hole view. Stroke and mask color can be adjusted. struct DefaultCutHoleView: View {
public struct DefaultCutHoleView: View {
var targetSize: CGSize var targetSize: CGSize
var strokeWidth: CGFloat var showStroke = true
var maskColor: Color
var isCircular: Bool
/// Initialize a default rectangular or circular cut hole view with specified target size, stroke width and mask color.
public init(
targetSize: CGSize,
isCircular: Bool = false,
strokeWidth: CGFloat = 1,
maskColor: Color = .black.opacity(0.6)
) {
self.targetSize = targetSize
self.strokeWidth = strokeWidth
self.maskColor = maskColor
self.isCircular = isCircular
}
var background: some View { var background: some View {
DefaultCutHoleShape(size: targetSize, isCircular: isCircular) DefaultCutHoleShape(size: targetSize)
.fill(style: FillStyle(eoFill: true)) .fill(style: FillStyle(eoFill: true))
.foregroundColor(maskColor) .foregroundColor(.black.opacity(0.6))
}
@ViewBuilder
var strokeShape: some View {
if isCircular {
Circle()
.strokeBorder(style: .init(lineWidth: strokeWidth))
} else {
Rectangle()
.strokeBorder(style: .init(lineWidth: strokeWidth))
}
} }
var stroke: some View { var stroke: some View {
strokeShape Rectangle()
.frame( .strokeBorder(style: .init(lineWidth: 1))
width: targetSize.width + strokeWidth * 2, .frame(width: targetSize.width + 2, height: targetSize.height + 2)
height: targetSize.height + strokeWidth * 2
)
.foregroundColor(.white) .foregroundColor(.white)
} }
public var body: some View { var body: some View {
background background
.allowsHitTesting(false) .allowsHitTesting(false)
.overlay(strokeWidth > 0 ? stroke : nil) .overlay(showStroke ? stroke : nil)
.animation(.default, value: targetSize) .animation(.default, value: targetSize)
} }
} }
#Preview("Default") { struct DefaultCutHoleView_Previews: PreviewProvider {
static var previews: some View {
DefaultCutHoleView(targetSize: .init(width: 100, height: 100)) DefaultCutHoleView(targetSize: .init(width: 100, height: 100))
} }
#Preview("Circular") {
DefaultCutHoleView(targetSize: .init(width: 100, height: 100), isCircular: true)
} }

View file

@ -2,9 +2,9 @@
A simple SwiftUI view where user can move and resize an image to a pre-defined size. A simple SwiftUI view where user can move and resize an image to a pre-defined size.
Supports iOS 14.0 and above, visionOS 1.0 and above or macOS Ventura 13.0 and above. Supports iOS 14.0 and above, or macOS Ventura 13.0 and above.
- Supports iOS, visionOS and macOS - Supports both iOS and macOS
- Use `ImageRenderer` to render the cropped image, when possible - Use `ImageRenderer` to render the cropped image, when possible
- Very lightweight - Very lightweight
- (Optionally) bring your own crop UI - (Optionally) bring your own crop UI

View file

@ -11,19 +11,12 @@ import Foundation
import AppKit import AppKit
/// 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 it's `UIImage`.
public typealias PlatformImage = NSImage public typealias PlatformImage = NSImage
extension PlatformImage { #elseif os(iOS)
@MainActor static let previewImage: PlatformImage = .init(contentsOf: URL(string: "file:///System/Library/Desktop%20Pictures/Hello%20Metallic%20Blue.heic")!)!
}
#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 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

View file

@ -25,11 +25,6 @@ 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 {
@ -71,15 +66,9 @@ 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 { withAnimation {
scale = clampedScale scale = clampedScale
offset = clampedOffset offset = clampedOffset
scrolling = false
} else {
withAnimation(.interactiveSpring()) {
scale = clampedScale
offset = clampedOffset
}
} }
} }
} }
@ -92,28 +81,10 @@ 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)
#else #elseif os(iOS)
Image(uiImage: image) Image(uiImage: image)
#endif #endif
} }
@ -123,14 +94,6 @@ 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 {
@ -141,6 +104,7 @@ struct UnderlyingImageView: View {
.onEnded { value in .onEnded { value in
offset = offset + tempOffset offset = offset + tempOffset
tempOffset = .zero tempOffset = .zero
adjustToFulfillTargetFrame()
} }
} }
@ -152,6 +116,7 @@ struct UnderlyingImageView: View {
.onEnded { value in .onEnded { value in
scale = scale * tempScale scale = scale * tempScale
tempScale = 1 tempScale = 1
adjustToFulfillTargetFrame()
} }
} }
@ -168,30 +133,18 @@ struct UnderlyingImageView: View {
var body: some View { var body: some View {
imageView imageView
.rotationEffect(rotation + tempRotation)
.scaleEffect(scale * tempScale) .scaleEffect(scale * tempScale)
.offset(offset + tempOffset) .offset(offset + tempOffset)
.rotationEffect(rotation + tempRotation)
.overlay(interactionView) .overlay(interactionView)
.clipped()
.onChange(of: viewSize) { newValue in .onChange(of: viewSize) { newValue in
setInitialScale(basedOn: newValue) setInitialScale(basedOn: newValue)
} }
.onChange(of: scale) { _ in .clipped()
adjustToFulfillTargetFrame()
}
.onChange(of: offset) { _ in
adjustToFulfillTargetFrame()
}
.onChange(of: rotation) { _ in
adjustToFulfillTargetFrame()
}
#if os(macOS)
.onHover { hovering = $0 }
#endif
} }
} }
#Preview { 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
@ -202,7 +155,7 @@ struct UnderlyingImageView: View {
offset: $offset, offset: $offset,
scale: $scale, scale: $scale,
rotation: $rotation, rotation: $rotation,
image: .previewImage, image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!,
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
@ -211,5 +164,7 @@ struct UnderlyingImageView: View {
} }
} }
return PreviewView() static var previews: some View {
PreviewView()
}
} }