mirror of
https://github.com/laosb/CropImage.git
synced 2025-05-02 08:21:09 +00:00
Compare commits
22 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
9a8b486f55 | ||
![]() |
481f59cf41 | ||
![]() |
7042102108 | ||
![]() |
a9672b8a33 | ||
![]() |
54861801e6 | ||
![]() |
6b33bcdbe4 | ||
![]() |
246b20d079 | ||
![]() |
672ec51d3e | ||
![]() |
ea5a5354fb | ||
![]() |
304a4c8e7e | ||
![]() |
517fb0c003 | ||
![]() |
4e0f333e2f | ||
![]() |
57ff1a7b86 | ||
![]() |
a74e54dd00 | ||
![]() |
1d66f10bff | ||
![]() |
c296f3e6e5 | ||
![]() |
030ac8cbde | ||
![]() |
e4d096dafb | ||
![]() |
e703e25200 | ||
![]() |
63a60f802a | ||
![]() |
fc22f01d78 | ||
![]() |
03549e2fa6 |
13 changed files with 384 additions and 147 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
|
|
@ -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.
|
||||
|
||||
import PackageDescription
|
||||
|
@ -7,7 +7,8 @@ let package = Package(
|
|||
name: "CropImage",
|
||||
platforms: [
|
||||
.iOS(.v14),
|
||||
.macOS(.v13)
|
||||
.macOS(.v13),
|
||||
.visionOS(.v1)
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
|
@ -25,5 +26,6 @@ let package = Package(
|
|||
.target(
|
||||
name: "CropImage",
|
||||
dependencies: [])
|
||||
]
|
||||
],
|
||||
swiftLanguageVersions: [.version("6"), .v5]
|
||||
)
|
||||
|
|
|
@ -5,13 +5,15 @@
|
|||
|
||||
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
|
||||
- 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">
|
||||
|
|
15
Sources/CropImage/Comparable+clamped.swift
Normal file
15
Sources/CropImage/Comparable+clamped.swift
Normal file
|
@ -0,0 +1,15 @@
|
|||
//
|
||||
// Comparable+clamped.swift
|
||||
//
|
||||
//
|
||||
// Created by Shibo Lyu on 2023/8/16.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// https://stackoverflow.com/a/40868784
|
||||
extension Comparable {
|
||||
func clamped(to limits: ClosedRange<Self>) -> Self {
|
||||
return min(max(self, limits.lowerBound), limits.upperBound)
|
||||
}
|
||||
}
|
|
@ -6,19 +6,32 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
#if !os(macOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// A view that allows the user to crop an image.
|
||||
public struct CropImageView<Controls: View>: View {
|
||||
public typealias ControlClosure<Controls> = (
|
||||
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.
|
||||
public enum CropError: Error {
|
||||
/// SwiftUI `ImageRenderer` returned nil when calling `nsImage` or `uiImage`.
|
||||
|
@ -37,64 +50,85 @@ public struct CropImageView<Controls: View>: View {
|
|||
|
||||
/// The image to crop.
|
||||
public var image: PlatformImage
|
||||
/// The region in which the image is initially fitted in, in points.
|
||||
public var initialImageSize: CGSize
|
||||
/// The intended size of the cropped image, in points.
|
||||
/// The expected size of the cropped image, in points.
|
||||
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`.
|
||||
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.
|
||||
///
|
||||
/// The error should be a ``CropError``.
|
||||
public var onCrop: (Result<PlatformImage, Error>) -> Void
|
||||
/// A custom view overlaid on the image cropper.
|
||||
///
|
||||
/// - 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.
|
||||
var controls: ControlClosure
|
||||
var cutHole: CutHoleClosure
|
||||
/// Create a ``CropImageView`` with a custom controls view and a custom cut hole.
|
||||
public init(
|
||||
image: PlatformImage,
|
||||
initialImageSize: CGSize,
|
||||
targetSize: CGSize,
|
||||
targetScale: CGFloat = 1,
|
||||
fulfillTargetFrame: Bool = true,
|
||||
onCrop: @escaping (Result<PlatformImage, Error>) -> Void,
|
||||
@ViewBuilder controls: @escaping ControlClosure<Controls>
|
||||
@ViewBuilder controls: @escaping ControlClosure,
|
||||
@ViewBuilder cutHole: @escaping CutHoleClosure
|
||||
) {
|
||||
self.image = image
|
||||
self.initialImageSize = initialImageSize
|
||||
self.targetSize = targetSize
|
||||
self.targetScale = targetScale
|
||||
self.onCrop = onCrop
|
||||
self.controls = controls
|
||||
self.cutHole = cutHole
|
||||
}
|
||||
/// Create a ``CropImageView`` with the default ``controls`` view.
|
||||
///
|
||||
/// The default ``controls`` view is a simple overlay with a checkmark icon on the bottom-trailing corner to trigger crop action.
|
||||
/// Create a ``CropImageView`` with a custom controls view and default cut hole.
|
||||
public init(
|
||||
image: PlatformImage,
|
||||
initialImageSize: CGSize,
|
||||
targetSize: CGSize,
|
||||
targetScale: CGFloat = 1,
|
||||
onCrop: @escaping (Result<PlatformImage, Error>) -> Void
|
||||
) where Controls == DefaultControlsView {
|
||||
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(
|
||||
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.initialImageSize = initialImageSize
|
||||
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 scale: CGFloat = 1
|
||||
@State private var rotation: Angle = .zero
|
||||
|
||||
@State private var viewSize: CGSize = .zero
|
||||
|
||||
@MainActor
|
||||
func crop() throws -> PlatformImage {
|
||||
let snapshotView = UnderlyingImageView(
|
||||
|
@ -102,19 +136,21 @@ public struct CropImageView<Controls: View>: View {
|
|||
scale: $scale,
|
||||
rotation: $rotation,
|
||||
image: image,
|
||||
initialImageSize: initialImageSize
|
||||
viewSize: viewSize,
|
||||
targetSize: targetSize,
|
||||
fulfillTargetFrame: fulfillTargetFrame
|
||||
)
|
||||
.frame(width: targetSize.width, height: targetSize.height)
|
||||
if #available(iOS 16.0, macOS 13.0, *) {
|
||||
if #available(iOS 16.0, macOS 13.0, visionOS 1.0, *) {
|
||||
let renderer = ImageRenderer(content: snapshotView)
|
||||
renderer.scale = targetScale
|
||||
#if os(iOS)
|
||||
#if !os(macOS)
|
||||
if let image = renderer.uiImage {
|
||||
return image
|
||||
} else {
|
||||
throw CropError.imageRendererReturnedNil
|
||||
}
|
||||
#elseif os(macOS)
|
||||
#else
|
||||
if let image = renderer.nsImage {
|
||||
return image
|
||||
} else {
|
||||
|
@ -150,12 +186,25 @@ public struct CropImageView<Controls: View>: View {
|
|||
scale: $scale,
|
||||
rotation: $rotation,
|
||||
image: image,
|
||||
initialImageSize: initialImageSize
|
||||
viewSize: viewSize,
|
||||
targetSize: targetSize,
|
||||
fulfillTargetFrame: fulfillTargetFrame
|
||||
)
|
||||
.frame(width: viewSize.width, height: viewSize.height)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
var cutHole: some View {
|
||||
DefaultCutHoleView(targetSize: targetSize)
|
||||
var viewSizeReadingView: some View {
|
||||
GeometryReader { geo in
|
||||
Rectangle()
|
||||
.fill(.white.opacity(0.0001))
|
||||
.onChange(of: geo.size) { newValue in
|
||||
viewSize = newValue
|
||||
}
|
||||
.onAppear {
|
||||
viewSize = geo.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor var control: some View {
|
||||
|
@ -169,34 +218,28 @@ public struct CropImageView<Controls: View>: View {
|
|||
}
|
||||
|
||||
public var body: some View {
|
||||
underlyingImage
|
||||
.clipped()
|
||||
.overlay(cutHole)
|
||||
cutHole(targetSize)
|
||||
.background(underlyingImage)
|
||||
.background(viewSizeReadingView)
|
||||
.overlay(control)
|
||||
}
|
||||
}
|
||||
|
||||
struct CropImageView_Previews: PreviewProvider {
|
||||
#Preview {
|
||||
struct PreviewView: View {
|
||||
@State private var initialImageSize: CGSize = .init(width: 200, height: 200)
|
||||
@State private var targetSize: CGSize = .init(width: 100, height: 100)
|
||||
@State private var result: Result<PlatformImage, Error>? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
CropImageView(
|
||||
image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!,
|
||||
initialImageSize: initialImageSize,
|
||||
image: .previewImage,
|
||||
targetSize: targetSize
|
||||
) { result = $0 }
|
||||
Form {
|
||||
Section {
|
||||
TextField("Width", value: $initialImageSize.width, formatter: NumberFormatter())
|
||||
TextField("Height", value: $initialImageSize.height, formatter: NumberFormatter())
|
||||
} header: {
|
||||
Text("Initial Image Size")
|
||||
Text("The image will be fitted into this region.")
|
||||
) {
|
||||
result = $0
|
||||
}
|
||||
.frame(height: 300)
|
||||
Form {
|
||||
Section {
|
||||
TextField("Width", value: $targetSize.width, formatter: NumberFormatter())
|
||||
TextField("Height", value: $targetSize.height, formatter: NumberFormatter())
|
||||
|
@ -207,7 +250,7 @@ struct CropImageView_Previews: PreviewProvider {
|
|||
case let .success(croppedImage):
|
||||
#if os(macOS)
|
||||
Image(nsImage: croppedImage)
|
||||
#elseif os(iOS)
|
||||
#else
|
||||
Image(uiImage: croppedImage)
|
||||
#endif
|
||||
case let .failure(error):
|
||||
|
@ -226,11 +269,9 @@ struct CropImageView_Previews: PreviewProvider {
|
|||
}
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
PreviewView()
|
||||
return PreviewView()
|
||||
#if os(macOS)
|
||||
.frame(width: 500)
|
||||
.frame(minHeight: 770)
|
||||
.frame(minHeight: 600)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
/// The default controls view used when creating ``CropImageView`` using ``CropImageView/init(image:targetSize:targetScale:onCrop:)``.
|
||||
/// The default controls view used when creating ``CropImageView`` using ``CropImageView/init(image:targetSize:targetScale:fulfillTargetFrame:onCrop:)``.
|
||||
///
|
||||
/// It provides basic controls to crop, reset to default cropping & rotation, and rotate the image.
|
||||
public struct DefaultControlsView: View {
|
||||
|
@ -19,22 +19,28 @@ public struct DefaultControlsView: View {
|
|||
var rotateButton: some View {
|
||||
Button {
|
||||
let roundedAngle = Angle.degrees((rotation.degrees / 90).rounded() * 90)
|
||||
withAnimation {
|
||||
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()
|
||||
}
|
||||
|
||||
|
@ -54,14 +60,20 @@ public struct DefaultControlsView: View {
|
|||
} } 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()
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import SwiftUI
|
|||
|
||||
struct DefaultCutHoleShape: Shape {
|
||||
var size: CGSize
|
||||
var isCircular = false
|
||||
|
||||
var animatableData: AnimatablePair<CGFloat, CGFloat> {
|
||||
get { .init(size.width, size.height) }
|
||||
|
@ -30,22 +31,31 @@ struct DefaultCutHoleShape: Shape {
|
|||
), 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)
|
||||
}
|
||||
}
|
||||
|
||||
struct DefaultCutHoleShape_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
#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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,33 +7,64 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
struct DefaultCutHoleView: View {
|
||||
/// The default cut hole view. Stroke and mask color can be adjusted.
|
||||
public struct DefaultCutHoleView: View {
|
||||
var targetSize: CGSize
|
||||
var showStroke = true
|
||||
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)
|
||||
DefaultCutHoleShape(size: targetSize, isCircular: isCircular)
|
||||
.fill(style: FillStyle(eoFill: true))
|
||||
.foregroundColor(.black.opacity(0.6))
|
||||
.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 {
|
||||
Rectangle()
|
||||
.strokeBorder(style: .init(lineWidth: 2))
|
||||
.frame(width: targetSize.width + 4, height: targetSize.height + 4)
|
||||
strokeShape
|
||||
.frame(
|
||||
width: targetSize.width + strokeWidth * 2,
|
||||
height: targetSize.height + strokeWidth * 2
|
||||
)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
public var body: some View {
|
||||
background
|
||||
.allowsHitTesting(false)
|
||||
.overlay(showStroke ? stroke : nil)
|
||||
.overlay(strokeWidth > 0 ? stroke : nil)
|
||||
.animation(.default, value: targetSize)
|
||||
}
|
||||
}
|
||||
|
||||
struct DefaultCutHoleView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
#Preview("Default") {
|
||||
DefaultCutHoleView(targetSize: .init(width: 100, height: 100))
|
||||
}
|
||||
|
||||
#Preview("Circular") {
|
||||
DefaultCutHoleView(targetSize: .init(width: 100, height: 100), isCircular: true)
|
||||
}
|
||||
|
|
|
@ -2,15 +2,15 @@
|
|||
|
||||
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 both iOS and macOS
|
||||
- Supports iOS, visionOS and macOS
|
||||
- Use `ImageRenderer` to render the cropped image, when possible
|
||||
- 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``.
|
||||
|
||||

|
||||
|
||||
## Topics
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 95 KiB |
Binary file not shown.
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 97 KiB |
|
@ -11,12 +11,19 @@ import Foundation
|
|||
import AppKit
|
||||
/// 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
|
||||
#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
|
||||
/// 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
|
||||
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
|
||||
|
|
|
@ -18,32 +18,122 @@ struct UnderlyingImageView: View {
|
|||
@Binding var scale: CGFloat
|
||||
@Binding var rotation: Angle
|
||||
var image: PlatformImage
|
||||
var initialImageSize: CGSize
|
||||
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)
|
||||
#elseif os(iOS)
|
||||
#else
|
||||
Image(uiImage: image)
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
imageView
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: initialImageSize.width, height: initialImageSize.height)
|
||||
.animation(.default, value: initialImageSize)
|
||||
.scaleEffect(scale * tempScale)
|
||||
.offset(offset + tempOffset)
|
||||
.rotationEffect(rotation + tempRotation)
|
||||
var interactionView: some View {
|
||||
Color.white.opacity(0.0001)
|
||||
.gesture(
|
||||
.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
|
||||
|
@ -52,18 +142,20 @@ struct UnderlyingImageView: View {
|
|||
offset = offset + tempOffset
|
||||
tempOffset = .zero
|
||||
}
|
||||
)
|
||||
.gesture(
|
||||
}
|
||||
|
||||
var magnificationgesture: some Gesture {
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
tempScale = value
|
||||
}
|
||||
.onEnded { value in
|
||||
scale = max(scale * tempScale, 0.01)
|
||||
scale = scale * tempScale
|
||||
tempScale = 1
|
||||
}
|
||||
)
|
||||
.gesture(
|
||||
}
|
||||
|
||||
var rotationGesture: some Gesture {
|
||||
RotationGesture()
|
||||
.onChanged { value in
|
||||
tempRotation = value
|
||||
|
@ -72,12 +164,34 @@ struct UnderlyingImageView: View {
|
|||
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
|
||||
}
|
||||
}
|
||||
|
||||
struct MoveAndScalableImageView_Previews: PreviewProvider {
|
||||
#Preview {
|
||||
struct PreviewView: View {
|
||||
@State private var offset: CGSize = .zero
|
||||
@State private var scale: CGFloat = 1
|
||||
|
@ -88,13 +202,14 @@ struct MoveAndScalableImageView_Previews: PreviewProvider {
|
|||
offset: $offset,
|
||||
scale: $scale,
|
||||
rotation: $rotation,
|
||||
image: .init(contentsOfFile: "/Users/laosb/Downloads/png.png")!,
|
||||
initialImageSize: .init(width: 200, height: 200)
|
||||
image: .previewImage,
|
||||
viewSize: .init(width: 200, height: 100),
|
||||
targetSize: .init(width: 100, height: 100),
|
||||
fulfillTargetFrame: true
|
||||
)
|
||||
.frame(width: 200, height: 100)
|
||||
}
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
PreviewView()
|
||||
}
|
||||
return PreviewView()
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue