diff --git a/Sources/CropImage/CropImageView.swift b/Sources/CropImage/CropImageView.swift index 8a6376e..adc0777 100644 --- a/Sources/CropImage/CropImageView.swift +++ b/Sources/CropImage/CropImageView.swift @@ -11,7 +11,14 @@ import UIKit #endif /// A view that allows the user to crop an image. -public struct CropImageView: View { +public struct CropImageView: 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, _ scale: Binding, @@ -19,6 +26,12 @@ public struct CropImageView: View { _ 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`. @@ -53,13 +66,26 @@ public struct CropImageView: View { /// /// The error should be a ``CropError``. public var onCrop: (Result) -> 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 - - /// 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, + targetSize: CGSize, + targetScale: CGFloat = 1, + fulfillTargetFrame: Bool = true, + onCrop: @escaping (Result) -> Void, + @ViewBuilder controls: @escaping ControlClosure, + @ViewBuilder cutHole: @escaping CutHoleClosure + ) { + 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( image: PlatformImage, targetSize: CGSize, @@ -67,23 +93,24 @@ public struct CropImageView: View { fulfillTargetFrame: Bool = true, onCrop: @escaping (Result) -> 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 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 default UI elements. public init( image: PlatformImage, targetSize: CGSize, targetScale: CGFloat = 1, fulfillTargetFrame: Bool = true, onCrop: @escaping (Result) -> Void - ) where Controls == DefaultControlsView { + ) where Controls == DefaultControlsView, CutHole == DefaultCutHoleView { self.image = image self.targetSize = targetSize self.targetScale = targetScale @@ -91,6 +118,9 @@ public struct CropImageView: View { 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 @@ -164,10 +194,6 @@ public struct CropImageView: View { .clipped() } - var cutHole: some View { - DefaultCutHoleView(targetSize: targetSize) - } - var viewSizeReadingView: some View { GeometryReader { geo in Rectangle() @@ -192,7 +218,7 @@ public struct CropImageView: View { } public var body: some View { - cutHole + cutHole(targetSize) .background(underlyingImage) .background(viewSizeReadingView) .overlay(control) diff --git a/Sources/CropImage/DefaultCutHoleShape.swift b/Sources/CropImage/DefaultCutHoleShape.swift index 818324c..3a8c93e 100644 --- a/Sources/CropImage/DefaultCutHoleShape.swift +++ b/Sources/CropImage/DefaultCutHoleShape.swift @@ -9,6 +9,7 @@ import SwiftUI struct DefaultCutHoleShape: Shape { var size: CGSize + var isCircular = false var animatableData: AnimatablePair { get { .init(size.width, size.height) } @@ -30,10 +31,14 @@ struct DefaultCutHoleShape: Shape { ), 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) + 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) } @@ -46,6 +51,13 @@ struct DefaultCutHoleShape_Previews: PreviewProvider { .fill(style: FillStyle(eoFill: true)) .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") } } diff --git a/Sources/CropImage/DefaultCutHoleView.swift b/Sources/CropImage/DefaultCutHoleView.swift index 86742a8..e7a756f 100644 --- a/Sources/CropImage/DefaultCutHoleView.swift +++ b/Sources/CropImage/DefaultCutHoleView.swift @@ -7,27 +7,46 @@ 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) .fill(style: FillStyle(eoFill: true)) - .foregroundColor(.black.opacity(0.6)) + .foregroundColor(maskColor) } var stroke: some View { Rectangle() - .strokeBorder(style: .init(lineWidth: 1)) - .frame(width: targetSize.width + 2, height: targetSize.height + 2) + .strokeBorder(style: .init(lineWidth: strokeWidth)) + .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) } }