feat: Custom cut hole.

This commit is contained in:
Shibo Lyu 2023-09-07 15:02:12 +08:00
parent e703e25200
commit e4d096dafb
3 changed files with 86 additions and 29 deletions

View file

@ -11,7 +11,14 @@ 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<Controls> = ( public typealias ControlClosure<Controls> = (
_ offset: Binding<CGSize>, _ offset: Binding<CGSize>,
_ scale: Binding<CGFloat>, _ scale: Binding<CGFloat>,
@ -19,6 +26,12 @@ public struct CropImageView<Controls: View>: View {
_ 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<CutHole> = (_ 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`.
@ -53,13 +66,26 @@ public struct CropImageView<Controls: 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
/// A custom view overlaid on the image cropper. var controls: ControlClosure<Controls>
/// var cutHole: CutHoleClosure<CutHole>
/// - 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 init(
public var controls: ControlClosure<Controls> image: PlatformImage,
targetSize: CGSize,
/// Create a ``CropImageView`` with a custom ``controls`` view. targetScale: CGFloat = 1,
fulfillTargetFrame: Bool = true,
onCrop: @escaping (Result<PlatformImage, Error>) -> Void,
@ViewBuilder controls: @escaping ControlClosure<Controls>,
@ViewBuilder cutHole: @escaping CutHoleClosure<CutHole>
) {
self.image = image
self.targetSize = targetSize
self.targetScale = targetScale
self.onCrop = onCrop
self.controls = controls
self.cutHole = cutHole
}
/// Create a ``CropImageView`` with a custom controls view and default cut hole.
public init( public init(
image: PlatformImage, image: PlatformImage,
targetSize: CGSize, targetSize: CGSize,
@ -67,23 +93,24 @@ public struct CropImageView<Controls: View>: View {
fulfillTargetFrame: Bool = true, fulfillTargetFrame: Bool = true,
onCrop: @escaping (Result<PlatformImage, Error>) -> Void, onCrop: @escaping (Result<PlatformImage, Error>) -> Void,
@ViewBuilder controls: @escaping ControlClosure<Controls> @ViewBuilder controls: @escaping ControlClosure<Controls>
) { ) 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 = controls self.controls = controls
self.cutHole = { targetSize in
DefaultCutHoleView(targetSize: targetSize)
}
} }
/// Create a ``CropImageView`` with the default ``controls`` view. /// Create a ``CropImageView`` with default UI elements.
///
/// 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,
fulfillTargetFrame: Bool = true, fulfillTargetFrame: Bool = true,
onCrop: @escaping (Result<PlatformImage, Error>) -> Void onCrop: @escaping (Result<PlatformImage, Error>) -> Void
) where Controls == DefaultControlsView { ) where Controls == DefaultControlsView, CutHole == DefaultCutHoleView {
self.image = image self.image = image
self.targetSize = targetSize self.targetSize = targetSize
self.targetScale = targetScale self.targetScale = targetScale
@ -91,6 +118,9 @@ public struct CropImageView<Controls: 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
@ -164,10 +194,6 @@ public struct CropImageView<Controls: 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()
@ -192,7 +218,7 @@ public struct CropImageView<Controls: View>: View {
} }
public var body: some View { public var body: some View {
cutHole cutHole(targetSize)
.background(underlyingImage) .background(underlyingImage)
.background(viewSizeReadingView) .background(viewSizeReadingView)
.overlay(control) .overlay(control)

View file

@ -9,6 +9,7 @@ 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) }
@ -30,10 +31,14 @@ struct DefaultCutHoleShape: Shape {
), size: size) ), size: size)
path.move(to: newRect.origin) path.move(to: newRect.origin)
path.addLine(to: .init(x: newRect.maxX, y: newRect.minY)) if isCircular {
path.addLine(to: .init(x: newRect.maxX, y: newRect.maxY)) path.addEllipse(in: newRect)
path.addLine(to: .init(x: newRect.minX, y: newRect.maxY)) } else {
path.addLine(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() path.closeSubpath()
return Path(path) return Path(path)
} }
@ -46,6 +51,13 @@ struct DefaultCutHoleShape_Previews: PreviewProvider {
.fill(style: FillStyle(eoFill: true)) .fill(style: FillStyle(eoFill: true))
.foregroundColor(.black.opacity(0.6)) .foregroundColor(.black.opacity(0.6))
} }
.previewDisplayName("Default")
VStack {
DefaultCutHoleShape(size: .init(width: 100, height: 100), isCircular: true)
.fill(style: FillStyle(eoFill: true))
.foregroundColor(.black.opacity(0.6))
}
.previewDisplayName("Circular")
} }
} }

View file

@ -7,27 +7,46 @@
import SwiftUI 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 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 { var background: some View {
DefaultCutHoleShape(size: targetSize) DefaultCutHoleShape(size: targetSize)
.fill(style: FillStyle(eoFill: true)) .fill(style: FillStyle(eoFill: true))
.foregroundColor(.black.opacity(0.6)) .foregroundColor(maskColor)
} }
var stroke: some View { var stroke: some View {
Rectangle() Rectangle()
.strokeBorder(style: .init(lineWidth: 1)) .strokeBorder(style: .init(lineWidth: strokeWidth))
.frame(width: targetSize.width + 2, height: targetSize.height + 2) .frame(
width: targetSize.width + strokeWidth * 2,
height: targetSize.height + strokeWidth * 2
)
.foregroundColor(.white) .foregroundColor(.white)
} }
var body: some View { public var body: some View {
background background
.allowsHitTesting(false) .allowsHitTesting(false)
.overlay(showStroke ? stroke : nil) .overlay(strokeWidth > 0 ? stroke : nil)
.animation(.default, value: targetSize) .animation(.default, value: targetSize)
} }
} }