Compare commits

...

33 commits
0.2.0 ... main

Author SHA1 Message Date
Shibo Lyu
9a8b486f55
Create FUNDING.yml 2024-12-24 14:18:08 +08:00
Shibo Lyu
481f59cf41 fix: macos build
fixes #4
2024-12-20 14:58:23 +08:00
Shibo Lyu
7042102108 fix: build on non-macOS. 2024-12-18 16:49:24 +08:00
Shibo Lyu
a9672b8a33 feat: supports Swift 6 2024-12-18 16:11:02 +08:00
Shibo Lyu
54861801e6 chore: convert to preview macro 2024-12-18 16:06:40 +08:00
Shibo Lyu
6b33bcdbe4 fix(macos): remove monitor after use 2024-12-18 15:49:02 +08:00
infinitepower18
246b20d079 Disable spring animation for scroll zoom due to performance issue 2024-08-14 22:24:08 +01:00
infinitepower18
672ec51d3e only allow when hovered 2024-08-13 21:02:07 +01:00
Ahnaf Mahmud
ea5a5354fb
Allow zoom using scroll wheel 2024-08-13 20:30:21 +01:00
infinitepower18
304a4c8e7e Remove back compat code from visionOS 2024-03-28 15:25:13 +08:00
Ahnaf Mahmud
517fb0c003 Update Sources/CropImage/CropImageView.swift
Co-authored-by: Shibo Lyu <github@shibolyu.com>
2024-03-28 15:25:13 +08:00
infinitepower18
4e0f333e2f Don't use accent colour on visionOS 2024-03-28 15:25:13 +08:00
infinitepower18
57ff1a7b86 Adjust button style for visionOS 2024-03-28 15:25:13 +08:00
infinitepower18
a74e54dd00 update docs 2024-03-28 15:25:13 +08:00
infinitepower18
1d66f10bff add visionos support 2024-03-28 15:25:13 +08:00
Shibo Lyu
c296f3e6e5 fix: Circular cut hole. 2023-09-07 16:01:28 +08:00
Shibo Lyu
030ac8cbde fix: Pass isCircular to DefaultCutHoleShape. 2023-09-07 15:52:24 +08:00
Shibo Lyu
e4d096dafb feat: Custom cut hole. 2023-09-07 15:02:12 +08:00
Shibo Lyu
e703e25200 fix: Dragging after rotation(#1), adjust to fill after rotation, animation. 2023-09-01 11:32:29 +08:00
Shibo Lyu
63a60f802a doc: Mention DocC doc in README. 2023-08-16 17:03:44 +08:00
Shibo Lyu
fc22f01d78 doc: Update documentation to reflect recent changes. 2023-08-16 16:46:14 +08:00
Shibo Lyu
03549e2fa6 feat: fulfillTargetFrame. 2023-08-16 16:36:33 +08:00
Shibo Lyu
428b3eb5e8 feat: Cut hole stroke. 2023-08-15 18:49:04 +08:00
Shibo Lyu
8a4b71757c fix: Overflow. 2023-08-10 18:04:13 +08:00
Shibo Lyu
1f3cf71c83 doc: Update preview image. 2023-08-10 17:28:13 +08:00
Shibo Lyu
edc6a5c17b feat: Initial image size. 2023-08-10 17:20:38 +08:00
Shibo Lyu
b42b532ddb fix: iOS 14 compatibility. 2023-08-10 16:47:52 +08:00
Shibo Lyu
9ff52995b7 doc: Fix README image. 2023-08-10 16:14:54 +08:00
Shibo Lyu
a07d26a65b docs: Update README. 2023-08-10 16:12:55 +08:00
Shibo Lyu
bb70cb3266 doc: Add preview image on doc. 2023-08-10 16:06:10 +08:00
Shibo Lyu
3f9e8fab8e feat: Rotation. 2023-08-10 16:02:07 +08:00
Shibo Lyu
bbfe1e4636 feat: Animation for targetSize change. 2023-08-10 14:47:57 +08:00
Shibo Lyu
f34449d10a fix: Typo in fatalError. 2023-08-09 18:32:25 +08:00
15 changed files with 634 additions and 203 deletions

2
.github/FUNDING.yml vendored Normal file
View file

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

View file

@ -1,4 +1,4 @@
// swift-tools-version: 5.8 // swift-tools-version: 5.10
// 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,7 +7,8 @@ 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.
@ -25,5 +26,6 @@ let package = Package(
.target( .target(
name: "CropImage", name: "CropImage",
dependencies: []) dependencies: [])
] ],
swiftLanguageVersions: [.version("6"), .v5]
) )

View file

@ -5,8 +5,20 @@
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, or macOS Ventura 13.0 and above. Supports iOS 14.0 and above, visionOS 1.0 and above or macOS Ventura 13.0 and above.
- Supports both iOS and macOS - Supports iOS, visionOS 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
Full documentation is available on [Swift Package Index](https://swiftpackageindex.com/laosb/CropImage/main/documentation/cropimage). Be sure to choose the correct version.
<picture>
<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">
</picture>
## License
[MIT](./LICENSE)

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

@ -6,12 +6,32 @@
// //
import SwiftUI import SwiftUI
#if os(iOS) #if !os(macOS)
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>: View { public struct CropImageView<Controls: View, CutHole: View>: View {
/// Defines a custom view overlaid on the image cropper.
///
/// - 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>,
_ scale: Binding<CGFloat>,
_ rotation: Binding<Angle>,
_ crop: @escaping () async -> ()
) -> 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`.
@ -28,94 +48,109 @@ public struct CropImageView<Controls: View>: View {
case failedToGetImageFromCurrentUIGraphicsImageContext case failedToGetImageFromCurrentUIGraphicsImageContext
} }
private static func defaultControlsView(crop: @escaping () async -> ()) -> AnyView { AnyView(
VStack {
Spacer()
HStack {
Spacer()
Button { Task {
await crop()
} } label: {
Label("Crop", systemImage: "checkmark.circle.fill")
.font(.title2)
.foregroundColor(.accentColor)
.labelStyle(.iconOnly)
.padding(1)
.background(
Circle().fill(.white)
)
}
.buttonStyle(.plain)
.padding()
}
}
) }
/// The image to crop. /// The image to crop.
public var image: PlatformImage public var image: PlatformImage
/// The intended size of the cropped image, in points. /// The expected 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``.
public var onCrop: (Result<PlatformImage, Error>) -> Void public var onCrop: (Result<PlatformImage, Error>) -> Void
/// A custom view overlaid on the image cropper. var controls: ControlClosure
/// var cutHole: CutHoleClosure
/// - Parameters: /// Create a ``CropImageView`` with a custom controls view and a custom cut hole.
/// - crop: An async function to trigger crop action. Result will be delivered via ``onCrop``.
public var controls: (_ crop: @escaping () async -> ()) -> 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,
onCrop: @escaping (Result<PlatformImage, Error>) -> Void, onCrop: @escaping (Result<PlatformImage, Error>) -> Void,
@ViewBuilder controls: @escaping (_ crop: () async -> ()) -> Controls @ViewBuilder controls: @escaping ControlClosure,
@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 the default ``controls`` view. /// Create a ``CropImageView`` with a custom controls view and default cut hole.
///
/// 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,
targetSize: CGSize, targetSize: CGSize,
targetScale: CGFloat = 1, targetScale: CGFloat = 1,
onCrop: @escaping (Result<PlatformImage, Error>) -> Void fulfillTargetFrame: Bool = true,
) where Controls == AnyView { onCrop: @escaping (Result<PlatformImage, Error>) -> Void,
@ViewBuilder controls: @escaping ControlClosure
) where CutHole == DefaultCutHoleView {
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 = Self.defaultControlsView self.controls = controls
self.cutHole = { targetSize in
DefaultCutHoleView(targetSize: targetSize)
}
}
/// Create a ``CropImageView`` with default UI elements.
public init(
image: PlatformImage,
targetSize: CGSize,
targetScale: CGFloat = 1,
fulfillTargetFrame: Bool = true,
onCrop: @escaping (Result<PlatformImage, Error>) -> Void
) where Controls == DefaultControlsView, CutHole == DefaultCutHoleView {
self.image = image
self.targetSize = targetSize
self.targetScale = targetScale
self.onCrop = onCrop
self.controls = { $offset, $scale, $rotation, crop in
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
@State private var scale: CGFloat = 1 @State private var scale: CGFloat = 1
@State private var rotation: Angle = .zero
@State private var viewSize: CGSize = .zero
@MainActor @MainActor
func crop() throws -> PlatformImage { func crop() throws -> PlatformImage {
let snapshotView = MoveAndScalableImageView(offset: $offset, scale: $scale, image: image) let snapshotView = UnderlyingImageView(
.frame(width: targetSize.width, height: targetSize.height) offset: $offset,
if #available(iOS 16.0, macOS 13.0, *) { scale: $scale,
rotation: $rotation,
image: image,
viewSize: viewSize,
targetSize: targetSize,
fulfillTargetFrame: fulfillTargetFrame
)
.frame(width: targetSize.width, height: targetSize.height)
if #available(iOS 16.0, macOS 13.0, visionOS 1.0, *) {
let renderer = ImageRenderer(content: snapshotView) let renderer = ImageRenderer(content: snapshotView)
renderer.scale = targetScale renderer.scale = targetScale
#if os(iOS) #if !os(macOS)
if let image = renderer.uiImage { if let image = renderer.uiImage {
return image return image
} else { } else {
throw CropError.imageRendererReturnedNil throw CropError.imageRendererReturnedNil
} }
#elseif os(macOS) #else
if let image = renderer.nsImage { if let image = renderer.nsImage {
return image return image
} else { } else {
@ -124,7 +159,7 @@ public struct CropImageView<Controls: View>: View {
#endif #endif
} else { } else {
#if os(macOS) #if os(macOS)
fatalError("Cropping is not supported on macOS versions before Ventrura 13.0.") fatalError("Cropping is not supported on macOS versions before Ventura 13.0.")
#elseif os(iOS) #elseif os(iOS)
let window = UIWindow(frame: CGRect(origin: .zero, size: targetSize)) let window = UIWindow(frame: CGRect(origin: .zero, size: targetSize))
let hosting = UIHostingController(rootView: snapshotView) let hosting = UIHostingController(rootView: snapshotView)
@ -145,35 +180,65 @@ public struct CropImageView<Controls: View>: View {
} }
} }
public var body: some View { var underlyingImage: some View {
ZStack { UnderlyingImageView(
MoveAndScalableImageView(offset: $offset, scale: $scale, image: image) offset: $offset,
RectHoleShape(size: targetSize) scale: $scale,
.fill(style: FillStyle(eoFill: true)) rotation: $rotation,
.foregroundColor(.black.opacity(0.6)) image: image,
.allowsHitTesting(false) viewSize: viewSize,
controls { targetSize: targetSize,
do { fulfillTargetFrame: fulfillTargetFrame
onCrop(.success(try crop())) )
} catch { .frame(width: viewSize.width, height: viewSize.height)
onCrop(.failure(error)) .clipped()
}
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 {
controls($offset, $scale, $rotation) {
do {
onCrop(.success(try crop()))
} catch {
onCrop(.failure(error))
} }
} }
} }
public var body: some View {
cutHole(targetSize)
.background(underlyingImage)
.background(viewSizeReadingView)
.overlay(control)
}
} }
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
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
}
.frame(height: 300)
Form { Form {
Section { Section {
TextField("Width", value: $targetSize.width, formatter: NumberFormatter()) TextField("Width", value: $targetSize.width, formatter: NumberFormatter())
@ -185,7 +250,7 @@ struct CropImageView_Previews: PreviewProvider {
case let .success(croppedImage): case let .success(croppedImage):
#if os(macOS) #if os(macOS)
Image(nsImage: croppedImage) Image(nsImage: croppedImage)
#elseif os(iOS) #else
Image(uiImage: croppedImage) Image(uiImage: croppedImage)
#endif #endif
case let .failure(error): case let .failure(error):
@ -197,17 +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(minHeight: 750) .frame(minHeight: 600)
#endif #endif
}
} }

View file

@ -0,0 +1,98 @@
//
// DefaultControlsView.swift
//
//
// Created by Shibo Lyu on 2023/8/10.
//
import SwiftUI
/// 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.
public struct DefaultControlsView: View {
@Binding var offset: CGSize
@Binding var scale: CGFloat
@Binding var rotation: Angle
var crop: () async -> Void
var rotateButton: some View {
Button {
let roundedAngle = Angle.degrees((rotation.degrees / 90).rounded() * 90)
withAnimation(.interactiveSpring()) {
rotation = roundedAngle + .degrees(90)
}
} label: {
Label("Rotate", systemImage: "rotate.right")
.font(.title2)
#if !os(visionOS)
.foregroundColor(.accentColor)
#endif
.labelStyle(.iconOnly)
.padding(.horizontal, 6)
.padding(.vertical, 3)
#if !os(visionOS)
.background(
RoundedRectangle(cornerRadius: 5, style: .continuous)
.fill(.background)
)
#endif
}
#if !os(visionOS)
.buttonStyle(.plain)
#endif
.padding()
}
var resetButton: some View {
Button("Reset") {
withAnimation {
offset = .zero
scale = 1
rotation = .zero
}
}
}
var cropButton: some View {
Button { Task {
await crop()
} } label: {
Label("Crop", systemImage: "checkmark.circle.fill")
.font(.title2)
#if !os(visionOS)
.foregroundColor(.accentColor)
#endif
.labelStyle(.iconOnly)
.padding(1)
#if !os(visionOS)
.background(
Circle().fill(.background)
)
#endif
}
#if !os(visionOS)
.buttonStyle(.plain)
#endif
.padding()
}
public var body: some View {
VStack {
Spacer()
HStack {
rotateButton
Spacer()
if #available(iOS 15.0, macOS 13.0, *) {
resetButton
.buttonStyle(.bordered)
.buttonBorderShape(.roundedRectangle)
} else {
resetButton
}
Spacer()
cropButton
}
}
}
}

View file

@ -0,0 +1,61 @@
//
// DefaultCutHoleShape.swift
//
//
// Created by Shibo Lyu on 2023/7/21.
//
import SwiftUI
struct DefaultCutHoleShape: Shape {
var size: CGSize
var isCircular = false
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { .init(size.width, size.height) }
set { size = .init(width: newValue.first, height: newValue.second) }
}
func path(in rect: CGRect) -> Path {
let path = CGMutablePath()
path.move(to: rect.origin)
path.addLine(to: .init(x: rect.maxX, y: rect.minY))
path.addLine(to: .init(x: rect.maxX, y: rect.maxY))
path.addLine(to: .init(x: rect.minX, y: rect.maxY))
path.addLine(to: rect.origin)
path.closeSubpath()
let newRect = CGRect(origin: .init(
x: rect.midX - size.width / 2.0,
y: rect.midY - size.height / 2.0
), size: size)
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.maxY))
path.addLine(to: .init(x: newRect.minX, y: newRect.maxY))
path.addLine(to: newRect.origin)
}
path.closeSubpath()
return Path(path)
}
}
#Preview("Default") {
VStack {
DefaultCutHoleShape(size: .init(width: 100, height: 100))
.fill(style: FillStyle(eoFill: true))
.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

@ -0,0 +1,70 @@
//
// SwiftUIView.swift
//
//
// Created by Shibo Lyu on 2023/8/15.
//
import SwiftUI
/// The default cut hole view. Stroke and mask color can be adjusted.
public struct DefaultCutHoleView: View {
var targetSize: CGSize
var strokeWidth: CGFloat
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 {
DefaultCutHoleShape(size: targetSize, isCircular: isCircular)
.fill(style: FillStyle(eoFill: true))
.foregroundColor(maskColor)
}
@ViewBuilder
var strokeShape: some View {
if isCircular {
Circle()
.strokeBorder(style: .init(lineWidth: strokeWidth))
} else {
Rectangle()
.strokeBorder(style: .init(lineWidth: strokeWidth))
}
}
var stroke: some View {
strokeShape
.frame(
width: targetSize.width + strokeWidth * 2,
height: targetSize.height + strokeWidth * 2
)
.foregroundColor(.white)
}
public var body: some View {
background
.allowsHitTesting(false)
.overlay(strokeWidth > 0 ? stroke : nil)
.animation(.default, value: targetSize)
}
}
#Preview("Default") {
DefaultCutHoleView(targetSize: .init(width: 100, height: 100))
}
#Preview("Circular") {
DefaultCutHoleView(targetSize: .init(width: 100, height: 100), isCircular: true)
}

View file

@ -2,13 +2,16 @@
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, or macOS Ventura 13.0 and above. Supports iOS 14.0 and above, visionOS 1.0 and above or macOS Ventura 13.0 and above.
## Overview - 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
Configure and present ``CropImageView`` to the user, optionally specifying a ``CropImageView/ControlClosure`` to use your own UI controls to transform the image in the canvas, and cancel or finish the crop process, and receive cropped image from ``CropImageView/onCrop``.
![Preview on macOS](macos)
## Topics ## Topics

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View file

@ -1,73 +0,0 @@
//
// MoveAndScalableImageView.swift
//
//
// Created by Shibo Lyu on 2023/7/21.
//
import SwiftUI
private extension CGSize {
static func + (lhs: CGSize, rhs: CGSize) -> CGSize {
.init(width: lhs.width + rhs.width, height: lhs.height + rhs.height)
}
}
struct MoveAndScalableImageView: View {
@Binding var offset: CGSize
@Binding var scale: CGFloat
var image: PlatformImage
@State private var tempOffset: CGSize = .zero
@State private var tempScale: CGFloat = 1
var body: some View {
ZStack {
#if os(macOS)
Image(nsImage: image)
.scaleEffect(scale * tempScale)
.offset(offset + tempOffset)
#elseif os(iOS)
Image(uiImage: image)
.scaleEffect(scale * tempScale)
.offset(offset + tempOffset)
#endif
Color.white.opacity(0.0001)
.gesture(
DragGesture()
.onChanged { value in
tempOffset = value.translation
}
.onEnded { value in
offset = offset + tempOffset
tempOffset = .zero
}
)
.gesture(
MagnificationGesture()
.onChanged { value in
tempScale = value.magnitude
}
.onEnded { value in
scale = scale * tempScale
tempScale = 1
}
)
}
}
}
struct MoveAndScalableImageView_Previews: PreviewProvider {
struct PreviewView: View {
@State private var offset: CGSize = .zero
@State private var scale: CGFloat = 1
var body: some View {
MoveAndScalableImageView(offset: $offset, scale: $scale, image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!)
}
}
static var previews: some View {
PreviewView()
}
}

View file

@ -11,12 +11,19 @@ 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 it's `UIImage`. /// On macOS, it's `NSImage` and on iOS/visionOS it's `UIImage`.
public typealias PlatformImage = NSImage public typealias PlatformImage = NSImage
#elseif os(iOS) extension PlatformImage {
@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 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

View file

@ -1,45 +0,0 @@
//
// RectHoleShape.swift
//
//
// Created by Shibo Lyu on 2023/7/21.
//
import SwiftUI
struct RectHoleShape: Shape {
let size: CGSize
func path(in rect: CGRect) -> Path {
let path = CGMutablePath()
path.move(to: rect.origin)
path.addLine(to: .init(x: rect.maxX, y: rect.minY))
path.addLine(to: .init(x: rect.maxX, y: rect.maxY))
path.addLine(to: .init(x: rect.minX, y: rect.maxY))
path.addLine(to: rect.origin)
path.closeSubpath()
let newRect = CGRect(origin: .init(
x: rect.midX - size.width / 2.0,
y: rect.midY - size.height / 2.0
), size: size)
path.move(to: newRect.origin)
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.minX, y: newRect.maxY))
path.addLine(to: newRect.origin)
path.closeSubpath()
return Path(path)
}
}
struct RectHoleShape_Previews: PreviewProvider {
static var previews: some View {
VStack {
RectHoleShape(size: .init(width: 100, height: 100))
.fill(style: FillStyle(eoFill: true))
.foregroundColor(.black.opacity(0.6))
}
}
}

View file

@ -0,0 +1,215 @@
//
// UnderlyingImageView.swift
//
//
// Created by Shibo Lyu on 2023/7/21.
//
import SwiftUI
private extension CGSize {
static func + (lhs: CGSize, rhs: CGSize) -> CGSize {
.init(width: lhs.width + rhs.width, height: lhs.height + rhs.height)
}
}
struct UnderlyingImageView: View {
@Binding var offset: CGSize
@Binding var scale: CGFloat
@Binding var rotation: Angle
var image: PlatformImage
var viewSize: CGSize
var targetSize: CGSize
var fulfillTargetFrame: Bool
@State private var tempOffset: CGSize = .zero
@State private var tempScale: CGFloat = 1
@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.
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 {
if scrolling {
scale = clampedScale
offset = clampedOffset
scrolling = false
} else {
withAnimation(.interactiveSpring()) {
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)
}
#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 {
#if os(macOS)
Image(nsImage: image)
#else
Image(uiImage: image)
#endif
}
var interactionView: some View {
Color.white.opacity(0.0001)
.gesture(dragGesture)
.gesture(magnificationgesture)
.gesture(rotationGesture)
#if os(macOS)
.onAppear {
setupScrollMonitor()
}
.onDisappear {
removeScrollMonitor()
}
#endif
}
var dragGesture: some Gesture {
DragGesture()
.onChanged { value in
tempOffset = value.translation
}
.onEnded { value in
offset = offset + tempOffset
tempOffset = .zero
}
}
var magnificationgesture: some Gesture {
MagnificationGesture()
.onChanged { value in
tempScale = value
}
.onEnded { value in
scale = scale * tempScale
tempScale = 1
}
}
var rotationGesture: some Gesture {
RotationGesture()
.onChanged { value in
tempRotation = value
}
.onEnded { value in
rotation = rotation + tempRotation
tempRotation = .zero
}
}
var body: some View {
imageView
.rotationEffect(rotation + tempRotation)
.scaleEffect(scale * tempScale)
.offset(offset + tempOffset)
.overlay(interactionView)
.clipped()
.onChange(of: viewSize) { newValue in
setInitialScale(basedOn: newValue)
}
.onChange(of: scale) { _ in
adjustToFulfillTargetFrame()
}
.onChange(of: offset) { _ in
adjustToFulfillTargetFrame()
}
.onChange(of: rotation) { _ in
adjustToFulfillTargetFrame()
}
#if os(macOS)
.onHover { hovering = $0 }
#endif
}
}
#Preview {
struct PreviewView: View {
@State private var offset: CGSize = .zero
@State private var scale: CGFloat = 1
@State private var rotation: Angle = .zero
var body: some View {
UnderlyingImageView(
offset: $offset,
scale: $scale,
rotation: $rotation,
image: .previewImage,
viewSize: .init(width: 200, height: 100),
targetSize: .init(width: 100, height: 100),
fulfillTargetFrame: true
)
.frame(width: 200, height: 100)
}
}
return PreviewView()
}