mirror of
https://github.com/laosb/CropImage.git
synced 2025-05-01 16:11:07 +00:00
Compare commits
No commits in common. "main" and "0.5.0" have entirely different histories.
10 changed files with 85 additions and 220 deletions
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
|
@ -1,2 +0,0 @@
|
||||||
github: [laosb]
|
|
||||||
buy_me_a_coffee: laosb
|
|
|
@ -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]
|
|
||||||
)
|
)
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue