From 663b34bfecff11d8937b964f14e7a752a8ebd307 Mon Sep 17 00:00:00 2001 From: Festanny <60522731+Festanny@users.noreply.github.com> Date: Wed, 14 Aug 2024 14:01:57 +0400 Subject: [PATCH 1/3] Adding Rectangle for cropping Added a new Rectangle shape with a resolution of 4:3 --- README.md | 1 + Sources/SwiftyCrop/Models/CropViewModel.swift | 87 ++++++++++++++++++- Sources/SwiftyCrop/Models/MaskShape.swift | 2 +- Sources/SwiftyCrop/View/CropView.swift | 11 ++- 4 files changed, 96 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c39b59d..9eb1323 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ If you want to display `SwiftyCrop` inside a sheet, use `NavigationView` instead SwiftyCrop supports two different mask shapes for cropping: - `circle` - `square` +- `rectangle` This is only the shape of the mask the user will see when cropping the image. The resulting, cropped image will always be a square by default. You can override this using a configuration. diff --git a/Sources/SwiftyCrop/Models/CropViewModel.swift b/Sources/SwiftyCrop/Models/CropViewModel.swift index 869d4f8..080f84e 100644 --- a/Sources/SwiftyCrop/Models/CropViewModel.swift +++ b/Sources/SwiftyCrop/Models/CropViewModel.swift @@ -5,9 +5,16 @@ class CropViewModel: ObservableObject { private let maxMagnificationScale: CGFloat var imageSizeInView: CGSize = .zero { didSet { - maskRadius = min(maskRadius, min(imageSizeInView.width, imageSizeInView.height) / 2) + if maskShape == .rectangle { + let maxWidthForAspectRatio = (imageSizeInView.height * 4) / 3 + maskRadius = min(maskRadius, min(maxWidthForAspectRatio, imageSizeInView.width) / 2) + } else { + maskRadius = min(maskRadius, min(imageSizeInView.width, imageSizeInView.height) / 2) + } } } + private let maskShape: MaskShape + @Published var maskRadius: CGFloat @Published var scale: CGFloat = 1.0 @@ -19,10 +26,12 @@ class CropViewModel: ObservableObject { init( maskRadius: CGFloat, - maxMagnificationScale: CGFloat + maxMagnificationScale: CGFloat, + maskShape: MaskShape ) { self.maskRadius = maskRadius self.maxMagnificationScale = maxMagnificationScale + self.maskShape = maskShape } /** @@ -34,6 +43,17 @@ class CropViewModel: ObservableObject { let xLimit = ((imageSizeInView.width / 2) * scale) - maskRadius return CGPoint(x: xLimit, y: yLimit) } + func calculateDragGestureMaxRectangle() -> CGPoint { + // Calculate the width and height limits for 4:3 aspect ratio + let aspectRatio: CGFloat = 4 / 3 + + // Calculate the limits based on the imageSizeInView + let yLimit = ((imageSizeInView.height / 2) * scale) - (maskRadius * aspectRatio) + let xLimit = ((imageSizeInView.width / 2) * scale) - maskRadius + + return CGPoint(x: xLimit, y: yLimit) + } + /** Calculates the maximum magnification values that are applied when zooming the image, @@ -46,6 +66,27 @@ class CropViewModel: ObservableObject { let minScale = (maskRadius * 2) / min(imageSizeInView.width, imageSizeInView.height) return (minScale, maxMagnificationScale) } + + /** + Crops the image to the part that is dragged/zoomed inside the view. Cropped image will be a square. + - Parameters: + - image: The UIImage to crop + - Returns: A cropped UIImage if the cropping operation is successful; otherwise nil. + */ + func cropToRectangle(_ image: UIImage) -> UIImage? { + guard let orientedImage = image.correctlyOriented else { + return nil + } + + let cropRect = calculateCropRectRectangle(orientedImage) + + guard let cgImage = orientedImage.cgImage, + let result = cgImage.cropping(to: cropRect) else { + return nil + } + + return UIImage(cgImage: result) + } /** Crops the image to the part that is dragged/zoomed inside the view. Cropped image will be a square. @@ -166,6 +207,48 @@ class CropViewModel: ObservableObject { return UIImage(cgImage: result) } + + /** + Calculates the rectangle to crop. + - Parameters: + - image: The UIImage to calculate the rectangle to crop for + - Returns: A CGRect representing the rectangle to crop. + */ + private func calculateCropRectRectangle(_ orientedImage: UIImage) -> CGRect { + // Aspect ratio 4:3 + let aspectRatio: CGFloat = 4 / 3 + // The ratio factor of the original image to the displayed one + let factor = min( + (orientedImage.size.width / imageSizeInView.width), + (orientedImage.size.height / imageSizeInView.height) + ) + let centerInOriginalImage = CGPoint( + x: orientedImage.size.width / 2, + y: orientedImage.size.height / 2 + ) + // Calculating the cropping radius for the width, taking into account the aspect ratio + let cropWidthRadiusInOriginalImage = (maskRadius * factor) / scale + let cropHeightRadiusInOriginalImage = cropWidthRadiusInOriginalImage * aspectRatio + // Image offsets along the x and y axes when dragging + let offsetX = offset.width * factor + let offsetY = offset.height * factor + // Calculating the coordinates of the cropping rectangle inside the original image + let cropRectX = (centerInOriginalImage.x - cropWidthRadiusInOriginalImage) - (offsetX / scale) + let cropRectY = (centerInOriginalImage.y - cropHeightRadiusInOriginalImage) - (offsetY / scale) + let cropRectCoordinate = CGPoint(x: cropRectX, y: cropRectY) + // Dimensions of the cropping rectangle, taking into account the aspect ratio + let cropRectWidth = cropWidthRadiusInOriginalImage * 2 + let cropRectHeight = cropHeightRadiusInOriginalImage * 2 + + let cropRect = CGRect( + x: cropRectCoordinate.x, + y: cropRectCoordinate.y, + width: cropRectWidth, + height: cropRectHeight + ) + + return cropRect + } /** Calculates the rectangle to crop. diff --git a/Sources/SwiftyCrop/Models/MaskShape.swift b/Sources/SwiftyCrop/Models/MaskShape.swift index 38e5f20..15836b5 100644 --- a/Sources/SwiftyCrop/Models/MaskShape.swift +++ b/Sources/SwiftyCrop/Models/MaskShape.swift @@ -1,3 +1,3 @@ public enum MaskShape: CaseIterable { - case circle, square + case circle, square, rectangle } diff --git a/Sources/SwiftyCrop/View/CropView.swift b/Sources/SwiftyCrop/View/CropView.swift index b8ca961..2271b80 100644 --- a/Sources/SwiftyCrop/View/CropView.swift +++ b/Sources/SwiftyCrop/View/CropView.swift @@ -23,7 +23,8 @@ struct CropView: View { _viewModel = StateObject( wrappedValue: CropViewModel( maskRadius: configuration.maskRadius, - maxMagnificationScale: configuration.maxMagnificationScale + maxMagnificationScale: configuration.maxMagnificationScale, + maskShape: maskShape ) ) localizableTableName = "Localizable" @@ -50,7 +51,7 @@ struct CropView: View { let dragGesture = DragGesture() .onChanged { value in - let maxOffsetPoint = viewModel.calculateDragGestureMax() + let maxOffsetPoint = maskShape == .rectangle ? viewModel.calculateDragGestureMaxRectangle() : viewModel.calculateDragGestureMax() let newX = min( max(value.translation.width + viewModel.lastOffset.width, -maxOffsetPoint.x), maxOffsetPoint.x @@ -149,6 +150,8 @@ struct CropView: View { } if configuration.cropImageCircular && maskShape == .circle { return viewModel.cropToCircle(editedImage) + } else if maskShape == .rectangle { + return viewModel.cropToRectangle(editedImage) } else { return viewModel.cropToSquare(editedImage) } @@ -165,6 +168,10 @@ struct CropView: View { case .square: Rectangle() + + case .rectangle: + Rectangle() + .aspectRatio(3/4, contentMode: .fill) } } } From 59a9cb7af8132ba07356a6eed17585987eb88deb Mon Sep 17 00:00:00 2001 From: Festanny <60522731+Festanny@users.noreply.github.com> Date: Thu, 15 Aug 2024 09:24:19 +0400 Subject: [PATCH 2/3] fix model --- Sources/SwiftyCrop/Models/CropViewModel.swift | 18 ++++++++---------- Sources/SwiftyCrop/View/CropView.swift | 2 +- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/Sources/SwiftyCrop/Models/CropViewModel.swift b/Sources/SwiftyCrop/Models/CropViewModel.swift index 080f84e..48bea81 100644 --- a/Sources/SwiftyCrop/Models/CropViewModel.swift +++ b/Sources/SwiftyCrop/Models/CropViewModel.swift @@ -39,18 +39,16 @@ class CropViewModel: ObservableObject { - Returns: A CGPoint representing the maximum points to which the image can be dragged. */ func calculateDragGestureMax() -> CGPoint { - let yLimit = ((imageSizeInView.height / 2) * scale) - maskRadius let xLimit = ((imageSizeInView.width / 2) * scale) - maskRadius - return CGPoint(x: xLimit, y: yLimit) - } - func calculateDragGestureMaxRectangle() -> CGPoint { - // Calculate the width and height limits for 4:3 aspect ratio - let aspectRatio: CGFloat = 4 / 3 + let yLimit: CGFloat + + if maskShape == .rectangle { + let aspectRatio: CGFloat = 4 / 3 + yLimit = ((imageSizeInView.height / 2) * scale) - (maskRadius * aspectRatio) + } else { + yLimit = ((imageSizeInView.height / 2) * scale) - maskRadius + } - // Calculate the limits based on the imageSizeInView - let yLimit = ((imageSizeInView.height / 2) * scale) - (maskRadius * aspectRatio) - let xLimit = ((imageSizeInView.width / 2) * scale) - maskRadius - return CGPoint(x: xLimit, y: yLimit) } diff --git a/Sources/SwiftyCrop/View/CropView.swift b/Sources/SwiftyCrop/View/CropView.swift index 2271b80..138cad6 100644 --- a/Sources/SwiftyCrop/View/CropView.swift +++ b/Sources/SwiftyCrop/View/CropView.swift @@ -51,7 +51,7 @@ struct CropView: View { let dragGesture = DragGesture() .onChanged { value in - let maxOffsetPoint = maskShape == .rectangle ? viewModel.calculateDragGestureMaxRectangle() : viewModel.calculateDragGestureMax() + let maxOffsetPoint = viewModel.calculateDragGestureMax() let newX = min( max(value.translation.width + viewModel.lastOffset.width, -maxOffsetPoint.x), maxOffsetPoint.x From 186ad2692c9517a6e4914ac68b66672d0c9a4ca8 Mon Sep 17 00:00:00 2001 From: benedom <31181527+benedom@users.noreply.github.com> Date: Thu, 15 Aug 2024 11:34:55 +0200 Subject: [PATCH 3/3] Reworked rectangular cropping --- Demo/SwiftyCropDemo/ContentView.swift | 36 +- README.md | 10 +- Sources/SwiftyCrop/Models/CropViewModel.swift | 335 +++++++----------- .../Models/SwiftyCropConfiguration.swift | 11 +- Sources/SwiftyCrop/View/CropView.swift | 61 ++-- Tests/SwiftyCropTests/SwiftyCropTests.swift | 5 +- 6 files changed, 205 insertions(+), 253 deletions(-) diff --git a/Demo/SwiftyCropDemo/ContentView.swift b/Demo/SwiftyCropDemo/ContentView.swift index 08b1060..489d35f 100644 --- a/Demo/SwiftyCropDemo/ContentView.swift +++ b/Demo/SwiftyCropDemo/ContentView.swift @@ -5,6 +5,7 @@ struct ContentView: View { @State private var showImageCropper: Bool = false @State private var selectedImage: UIImage? @State private var selectedShape: MaskShape = .square + @State private var rectAspectRatio: PresetAspectRatios = .fourToThree @State private var cropImageCircular: Bool @State private var rotateImage: Bool @State private var maxMagnificationScale: CGFloat @@ -12,6 +13,21 @@ struct ContentView: View { @State private var zoomSensitivity: CGFloat @FocusState private var textFieldFocused: Bool + enum PresetAspectRatios: String, CaseIterable { + case fourToThree = "4:3" + case sixteenToNine = "16:9" + + func getValue() -> CGFloat { + switch self { + case .fourToThree: + 4/3 + + case .sixteenToNine: + 16/9 + } + } + } + init() { let defaultConfiguration = SwiftyCropConfiguration() _cropImageCircular = State(initialValue: defaultConfiguration.cropImageCircular) @@ -59,7 +75,7 @@ struct ContentView: View { Text("Mask shape") .frame(maxWidth: .infinity, alignment: .leading) - Picker("maskShape", selection: $selectedShape) { + Picker("maskShape", selection: $selectedShape.animation()) { ForEach(MaskShape.allCases, id: \.self) { mask in Text(String(describing: mask)) } @@ -67,6 +83,21 @@ struct ContentView: View { .pickerStyle(.segmented) } + if selectedShape == .rectangle { + HStack { + Text("Rect aspect ratio") + .frame(maxWidth: .infinity, alignment: .leading) + + Picker("rectAspectRatio", selection: $rectAspectRatio) { + ForEach(PresetAspectRatios.allCases, id: \.self) { aspectRatio in + Text(aspectRatio.rawValue) + } + + } + .pickerStyle(.segmented) + } + } + Toggle("Crop image to circle", isOn: $cropImageCircular) Toggle("Rotate image", isOn: $rotateImage) @@ -129,7 +160,8 @@ struct ContentView: View { maskRadius: maskRadius, cropImageCircular: cropImageCircular, rotateImage: rotateImage, - zoomSensitivity: zoomSensitivity + zoomSensitivity: zoomSensitivity, + rectAspectRatio: rectAspectRatio.getValue() ) ) { croppedImage in // Do something with the returned, cropped image diff --git a/README.md b/README.md index 9eb1323..d26ac2b 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ SwiftyCrop supports two different mask shapes for cropping: - `square` - `rectangle` -This is only the shape of the mask the user will see when cropping the image. The resulting, cropped image will always be a square by default. You can override this using a configuration. +This is only the shape of the mask the user will see when cropping the image. The resulting, cropped image will always be a square by default when using `circle` or `square`. To get a circular cropped image, you can override this using a configuration. You can also configure `SwiftyCropView` by passing a `SwiftyCropConfiguration`. A configuration has the following properties: @@ -149,8 +149,9 @@ You can also configure `SwiftyCropView` by passing a `SwiftyCropConfiguration`. | `maxMagnificationScale` | `CGFloat`: The maximum scale factor that the image can be magnified while cropping. Defaults to `4.0`. | | `maskRadius` | `CGFloat`: The radius of the mask used for cropping. Defaults to `130`. A good way is to make it dependend on the screens size. | | `cropImageCircular` | `Bool`: When using the cropping mask `circle`, whether the resulting image should also be masked as circle. Defaults to `false`. | -| `rotateImage` | `Bool`: Whether the image can be rotated when cropping using pinch gestures. Defaults to `true`. | +| `rotateImage` | `Bool`: Whether the image can be rotated when cropping using pinch gestures. Defaults to `false`. | | `zoomSensitivity` | `CGFloat`: Zoom sensitivity when cropping. Increase to make zoom faster / less sensitive. Defaults to `1.0`. | +| `rectAspectRatio` | `CGFloat`: The aspect ratio to use when a rectangular mask shape is used. Defaults to `4:3`. | Create a configuration like this: ```swift @@ -159,7 +160,8 @@ let configuration = SwiftyCropConfiguration( maskRadius: 130, cropImageCircular: false, rotateImage: true, - zoomSensitivity = 1.0 + zoomSensitivity = 1.0, + rectAspectRatio = 4/3 ) ``` and use it like this: @@ -186,6 +188,8 @@ Thanks to [@leoz](https://github.com/leoz) for adding the circular crop mode, th Thanks to [@kevin-hv](https://github.com/kevin-hv) for adding the hungarian localization 🇭🇺 +Thanks to [@Festanny](https://github.com/Festanny) for helping with the recangular cropping functionality 🎉 + ## ✍️ Author Benedikt Betz & CHECK24 diff --git a/Sources/SwiftyCrop/Models/CropViewModel.swift b/Sources/SwiftyCrop/Models/CropViewModel.swift index 48bea81..687e396 100644 --- a/Sources/SwiftyCrop/Models/CropViewModel.swift +++ b/Sources/SwiftyCrop/Models/CropViewModel.swift @@ -2,220 +2,171 @@ import SwiftUI import UIKit class CropViewModel: ObservableObject { - private let maxMagnificationScale: CGFloat - var imageSizeInView: CGSize = .zero { - didSet { - if maskShape == .rectangle { - let maxWidthForAspectRatio = (imageSizeInView.height * 4) / 3 - maskRadius = min(maskRadius, min(maxWidthForAspectRatio, imageSizeInView.width) / 2) - } else { - maskRadius = min(maskRadius, min(imageSizeInView.width, imageSizeInView.height) / 2) - } - } - } - private let maskShape: MaskShape + private let maskRadius: CGFloat + private let maxMagnificationScale: CGFloat // The maximum allowed scale factor for image magnification. + private let maskShape: MaskShape // The shape of the mask used for cropping. + private let rectAspectRatio: CGFloat // The aspect ratio for rectangular masks. + + var imageSizeInView: CGSize = .zero // The size of the image as displayed in the view. + @Published var maskSize: CGSize = .zero // The size of the mask used for cropping. This is updated based on the mask shape and available space. + @Published var scale: CGFloat = 1.0 // The current scale factor of the image. + @Published var lastScale: CGFloat = 1.0 // The previous scale factor of the image. + @Published var offset: CGSize = .zero // The current offset of the image. + @Published var lastOffset: CGSize = .zero // The previous offset of the image. + @Published var angle: Angle = Angle(degrees: 0) // The current rotation angle of the image. + @Published var lastAngle: Angle = Angle(degrees: 0) // The previous rotation angle of the image. - @Published var maskRadius: CGFloat - - @Published var scale: CGFloat = 1.0 - @Published var lastScale: CGFloat = 1.0 - @Published var offset: CGSize = .zero - @Published var lastOffset: CGSize = .zero - @Published var angle: Angle = Angle(degrees: 0) - @Published var lastAngle: Angle = Angle(degrees: 0) - init( maskRadius: CGFloat, maxMagnificationScale: CGFloat, - maskShape: MaskShape + maskShape: MaskShape, + rectAspectRatio: CGFloat ) { self.maskRadius = maskRadius self.maxMagnificationScale = maxMagnificationScale self.maskShape = maskShape + self.rectAspectRatio = rectAspectRatio } - + /** - Calculates the max points that the image can be dragged to. - - Returns: A CGPoint representing the maximum points to which the image can be dragged. + Updates the mask size based on the given size and mask shape. + - Parameter size: The size to base the mask size calculations on. */ - func calculateDragGestureMax() -> CGPoint { - let xLimit = ((imageSizeInView.width / 2) * scale) - maskRadius - let yLimit: CGFloat - - if maskShape == .rectangle { - let aspectRatio: CGFloat = 4 / 3 - yLimit = ((imageSizeInView.height / 2) * scale) - (maskRadius * aspectRatio) - } else { - yLimit = ((imageSizeInView.height / 2) * scale) - maskRadius + private func updateMaskSize(for size: CGSize) { + switch maskShape { + case .circle, .square: + let diameter = min(maskRadius * 2, min(size.width, size.height)) + maskSize = CGSize(width: diameter, height: diameter) + case .rectangle: + let maxWidth = min(size.width, maskRadius * 2) + let maxHeight = min(size.height, maskRadius * 2) + if maxWidth / maxHeight > rectAspectRatio { + maskSize = CGSize(width: maxHeight * rectAspectRatio, height: maxHeight) + } else { + maskSize = CGSize(width: maxWidth, height: maxWidth / rectAspectRatio) + } } - + } + + /** + Updates the mask dimensions based on the size of the image in the view. + - Parameter imageSizeInView: The size of the image as displayed in the view. + */ + func updateMaskDimensions(for imageSizeInView: CGSize) { + self.imageSizeInView = imageSizeInView + updateMaskSize(for: imageSizeInView) + } + + /** + Calculates the maximum allowed offset for dragging the image. + - Returns: A CGPoint representing the maximum x and y offsets. + */ + func calculateDragGestureMax() -> CGPoint { + let xLimit = max(0, ((imageSizeInView.width / 2) * scale) - (maskSize.width / 2)) + let yLimit = max(0, ((imageSizeInView.height / 2) * scale) - (maskSize.height / 2)) return CGPoint(x: xLimit, y: yLimit) } - - + /** - Calculates the maximum magnification values that are applied when zooming the image, - so that the image can not be zoomed out of its own size. - - Returns: A tuple (CGFloat, CGFloat) representing the minimum and maximum magnification scale values. - The first value is the minimum scale at which the image can be displayed without being smaller than its own size. - The second value is the preset maximum magnification scale. + Calculates the minimum and maximum allowed scale values for image magnification. + - Returns: A tuple containing the minimum and maximum scale values. */ func calculateMagnificationGestureMaxValues() -> (CGFloat, CGFloat) { - let minScale = (maskRadius * 2) / min(imageSizeInView.width, imageSizeInView.height) + let minScale = max(maskSize.width / imageSizeInView.width, maskSize.height / imageSizeInView.height) return (minScale, maxMagnificationScale) } /** - Crops the image to the part that is dragged/zoomed inside the view. Cropped image will be a square. - - Parameters: - - image: The UIImage to crop - - Returns: A cropped UIImage if the cropping operation is successful; otherwise nil. + Crops the given image to a rectangle based on the current mask size and position. + - Parameter image: The UIImage to crop. + - Returns: A cropped UIImage, or nil if cropping fails. */ func cropToRectangle(_ image: UIImage) -> UIImage? { - guard let orientedImage = image.correctlyOriented else { - return nil - } - - let cropRect = calculateCropRectRectangle(orientedImage) - + guard let orientedImage = image.correctlyOriented else { return nil } + + let cropRect = calculateCropRect(orientedImage) + guard let cgImage = orientedImage.cgImage, let result = cgImage.cropping(to: cropRect) else { return nil } - + return UIImage(cgImage: result) } - + /** - Crops the image to the part that is dragged/zoomed inside the view. Cropped image will be a square. - - Parameters: - - image: The UIImage to crop - - Returns: A cropped UIImage if the cropping operation is successful; otherwise nil. + Crops the given image to a square based on the current mask size and position. + - Parameter image: The UIImage to crop. + - Returns: A cropped UIImage, or nil if cropping fails. */ func cropToSquare(_ image: UIImage) -> UIImage? { - guard let orientedImage = image.correctlyOriented else { - return nil - } - + guard let orientedImage = image.correctlyOriented else { return nil } + let cropRect = calculateCropRect(orientedImage) - + guard let cgImage = orientedImage.cgImage, let result = cgImage.cropping(to: cropRect) else { return nil } - + return UIImage(cgImage: result) } - + /** - Crops the image to the part that is dragged/zoomed inside the view. Cropped image will be a circle. - - Parameters: - - image: The UIImage to crop - - Returns: A cropped UIImage if the cropping operation is successful; otherwise nil. + Crops the given image to a circle based on the current mask size and position. + - Parameter image: The UIImage to crop. + - Returns: A cropped UIImage, or nil if cropping fails. */ func cropToCircle(_ image: UIImage) -> UIImage? { - guard let orientedImage = image.correctlyOriented else { - return nil - } - + guard let orientedImage = image.correctlyOriented else { return nil } + let cropRect = calculateCropRect(orientedImage) - - // A circular crop results in some transparency in the - // cropped image, so set opaque to false to ensure the - // cropped image does not include a background fill + let imageRendererFormat = orientedImage.imageRendererFormat imageRendererFormat.opaque = false - - // UIGraphicsImageRenderer().image provides a block - // interface to draw into in a new UIImage + let circleCroppedImage = UIGraphicsImageRenderer( - // The cropRect.size is the size of - // the resulting circleCroppedImage size: cropRect.size, format: imageRendererFormat).image { _ in - - // The drawRect is the cropRect starting at (0,0) - let drawRect = CGRect( - origin: .zero, - size: cropRect.size - ) - - // addClip on a UIBezierPath will clip all contents - // outside of the UIBezierPath drawn after addClip - // is called, in this case, drawRect is a circle so - // the UIBezierPath clips drawing to the circle - UIBezierPath(ovalIn: drawRect).addClip() - - // The drawImageRect is offsets the image’s bounds - // such that the circular clip is at the center of - // the image - let drawImageRect = CGRect( - origin: CGPoint( - x: -cropRect.origin.x, - y: -cropRect.origin.y - ), - size: orientedImage.size - ) - - // Draws the orientedImage inside of the - // circular clip - orientedImage.draw(in: drawImageRect) - } - + let drawRect = CGRect(origin: .zero, size: cropRect.size) + UIBezierPath(ovalIn: drawRect).addClip() + let drawImageRect = CGRect( + origin: CGPoint(x: -cropRect.origin.x, y: -cropRect.origin.y), + size: orientedImage.size + ) + orientedImage.draw(in: drawImageRect) + } + return circleCroppedImage } - + /** - Rotates the image to the angle that is rotated inside the view. - - Parameters: - - image: The UIImage to rotate - - angle: The Angle to rotate to - - Returns: A rotated UIImage if the rotating operation is successful; otherwise nil. + Rotates the given image by the specified angle. + - Parameter image: The UIImage to rotate. + - Parameter angle: The Angle to rotate the image by. + - Returns: A rotated UIImage, or nil if rotation fails. */ func rotate(_ image: UIImage, _ angle: Angle) -> UIImage? { - guard let orientedImage = image.correctlyOriented else { - return nil - } - - guard let cgImage = orientedImage.cgImage else { - return nil - } - + guard let orientedImage = image.correctlyOriented, + let cgImage = orientedImage.cgImage else { return nil } + let ciImage = CIImage(cgImage: cgImage) - - // Prepare filter - let filter = CIFilter.straightenFilter( - image: ciImage, - radians: angle.radians - ) - - // Get output image - guard let output = filter?.outputImage else { - return nil - } - - // Create resulting image + + guard let filter = CIFilter.straightenFilter(image: ciImage, radians: angle.radians), + let output = filter.outputImage else { return nil } + let context = CIContext() - guard let result = context.createCGImage( - output, - from: output.extent - ) else { - return nil - } - + guard let result = context.createCGImage(output, from: output.extent) else { return nil } + return UIImage(cgImage: result) } /** - Calculates the rectangle to crop. - - Parameters: - - image: The UIImage to calculate the rectangle to crop for - - Returns: A CGRect representing the rectangle to crop. + Calculates the rectangle to use for cropping the image based on the current mask size, scale, and offset. + - Parameter orientedImage: The correctly oriented UIImage to calculate the crop rect for. + - Returns: A CGRect representing the area to crop from the original image. */ - private func calculateCropRectRectangle(_ orientedImage: UIImage) -> CGRect { - // Aspect ratio 4:3 - let aspectRatio: CGFloat = 4 / 3 - // The ratio factor of the original image to the displayed one + private func calculateCropRect(_ orientedImage: UIImage) -> CGRect { let factor = min( (orientedImage.size.width / imageSizeInView.width), (orientedImage.size.height / imageSizeInView.height) @@ -224,66 +175,22 @@ class CropViewModel: ObservableObject { x: orientedImage.size.width / 2, y: orientedImage.size.height / 2 ) - // Calculating the cropping radius for the width, taking into account the aspect ratio - let cropWidthRadiusInOriginalImage = (maskRadius * factor) / scale - let cropHeightRadiusInOriginalImage = cropWidthRadiusInOriginalImage * aspectRatio - // Image offsets along the x and y axes when dragging - let offsetX = offset.width * factor - let offsetY = offset.height * factor - // Calculating the coordinates of the cropping rectangle inside the original image - let cropRectX = (centerInOriginalImage.x - cropWidthRadiusInOriginalImage) - (offsetX / scale) - let cropRectY = (centerInOriginalImage.y - cropHeightRadiusInOriginalImage) - (offsetY / scale) - let cropRectCoordinate = CGPoint(x: cropRectX, y: cropRectY) - // Dimensions of the cropping rectangle, taking into account the aspect ratio - let cropRectWidth = cropWidthRadiusInOriginalImage * 2 - let cropRectHeight = cropHeightRadiusInOriginalImage * 2 - let cropRect = CGRect( - x: cropRectCoordinate.x, - y: cropRectCoordinate.y, - width: cropRectWidth, - height: cropRectHeight + let cropSizeInOriginalImage = CGSize( + width: (maskSize.width * factor) / scale, + height: (maskSize.height * factor) / scale ) - return cropRect - } - - /** - Calculates the rectangle to crop. - - Parameters: - - image: The UIImage to calculate the rectangle to crop for - - Returns: A CGRect representing the rectangle to crop. - */ - private func calculateCropRect(_ orientedImage: UIImage) -> CGRect { - // The relation factor of the originals image width/height - // and the width/height of the image displayed in the view (initial) - let factor = min( - (orientedImage.size.width / imageSizeInView.width), (orientedImage.size.height / imageSizeInView.height) - ) - let centerInOriginalImage = CGPoint(x: orientedImage.size.width / 2, y: orientedImage.size.height / 2) - // Calculate the crop radius inside the original image which based on the mask radius - let cropRadiusInOriginalImage = (maskRadius * factor) / scale - // The x offset the image has by dragging - let offsetX = offset.width * factor - // The y offset the image has by dragging - let offsetY = offset.height * factor - // Calculates the x coordinate of the crop rectangle inside the original image - let cropRectX = (centerInOriginalImage.x - cropRadiusInOriginalImage) - (offsetX / scale) - // Calculates the y coordinate of the crop rectangle inside the original image - let cropRectY = (centerInOriginalImage.y - cropRadiusInOriginalImage) - (offsetY / scale) - let cropRectCoordinate = CGPoint(x: cropRectX, y: cropRectY) - // Cropped rects dimension is twice its radius (diameter), - // since it's always a square it's used both for width and height - let cropRectDimension = cropRadiusInOriginalImage * 2 - - let cropRect = CGRect( - x: cropRectCoordinate.x, - y: cropRectCoordinate.y, - width: cropRectDimension, - height: cropRectDimension + let offsetX = offset.width * factor / scale + let offsetY = offset.height * factor / scale + + let cropRectX = (centerInOriginalImage.x - cropSizeInOriginalImage.width / 2) - offsetX + let cropRectY = (centerInOriginalImage.y - cropSizeInOriginalImage.height / 2) - offsetY + + return CGRect( + origin: CGPoint(x: cropRectX, y: cropRectY), + size: cropSizeInOriginalImage ) - - return cropRect } } @@ -295,12 +202,12 @@ private extension UIImage { */ var correctlyOriented: UIImage? { if imageOrientation == .up { return self } - + UIGraphicsBeginImageContextWithOptions(size, false, scale) draw(in: CGRect(origin: .zero, size: size)) let normalizedImage = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() - + return normalizedImage } } @@ -309,8 +216,8 @@ private extension CIFilter { /** Creates the straighten filter. - Parameters: - - inputImage: The CIImage to use as an input image - - radians: An angle in radians + - inputImage: The CIImage to use as an input image + - radians: An angle in radians - Returns: A generated CIFilter. */ static func straightenFilter(image: CIImage, radians: Double) -> CIFilter? { diff --git a/Sources/SwiftyCrop/Models/SwiftyCropConfiguration.swift b/Sources/SwiftyCrop/Models/SwiftyCropConfiguration.swift index 3a3e06a..587e767 100644 --- a/Sources/SwiftyCrop/Models/SwiftyCropConfiguration.swift +++ b/Sources/SwiftyCrop/Models/SwiftyCropConfiguration.swift @@ -7,6 +7,7 @@ public struct SwiftyCropConfiguration { public let cropImageCircular: Bool public let rotateImage: Bool public let zoomSensitivity: CGFloat + public let rectAspectRatio: CGFloat /// Creates a new instance of `SwiftyCropConfiguration`. /// @@ -18,19 +19,23 @@ public struct SwiftyCropConfiguration { /// - cropImageCircular: Option to enable circular crop. /// Defaults to `false`. /// - rotateImage: Option to rotate image. - /// Defaults to `true`. + /// Defaults to `false`. /// - zoomSensitivity: Sensitivity when zooming. Default is `1.0`. Decrease to increase sensitivity. + /// + /// - rectAspectRatio: The aspect ratio to use when a `.rectangle` mask shape is used. Defaults to `4:3`. public init( maxMagnificationScale: CGFloat = 4.0, maskRadius: CGFloat = 130, cropImageCircular: Bool = false, - rotateImage: Bool = true, - zoomSensitivity: CGFloat = 1 + rotateImage: Bool = false, + zoomSensitivity: CGFloat = 1, + rectAspectRatio: CGFloat = 4/3 ) { self.maxMagnificationScale = maxMagnificationScale self.maskRadius = maskRadius self.cropImageCircular = cropImageCircular self.rotateImage = rotateImage self.zoomSensitivity = zoomSensitivity + self.rectAspectRatio = rectAspectRatio } } diff --git a/Sources/SwiftyCrop/View/CropView.swift b/Sources/SwiftyCrop/View/CropView.swift index 138cad6..c2349f6 100644 --- a/Sources/SwiftyCrop/View/CropView.swift +++ b/Sources/SwiftyCrop/View/CropView.swift @@ -3,13 +3,13 @@ import SwiftUI struct CropView: View { @Environment(\.dismiss) private var dismiss @StateObject private var viewModel: CropViewModel - + private let image: UIImage private let maskShape: MaskShape private let configuration: SwiftyCropConfiguration private let onComplete: (UIImage?) -> Void private let localizableTableName: String - + init( image: UIImage, maskShape: MaskShape, @@ -24,31 +24,29 @@ struct CropView: View { wrappedValue: CropViewModel( maskRadius: configuration.maskRadius, maxMagnificationScale: configuration.maxMagnificationScale, - maskShape: maskShape + maskShape: maskShape, + rectAspectRatio: configuration.rectAspectRatio ) ) localizableTableName = "Localizable" } - + var body: some View { let magnificationGesture = MagnificationGesture() .onChanged { value in let sensitivity: CGFloat = 0.1 * configuration.zoomSensitivity let scaledValue = (value.magnitude - 1) * sensitivity + 1 - + let maxScaleValues = viewModel.calculateMagnificationGestureMaxValues() - viewModel.scale = min(max(scaledValue * viewModel.scale, maxScaleValues.0), maxScaleValues.1) - - let maxOffsetPoint = viewModel.calculateDragGestureMax() - let newX = min(max(viewModel.lastOffset.width, -maxOffsetPoint.x), maxOffsetPoint.x) - let newY = min(max(viewModel.lastOffset.height, -maxOffsetPoint.y), maxOffsetPoint.y) - viewModel.offset = CGSize(width: newX, height: newY) + viewModel.scale = min(max(scaledValue * viewModel.lastScale, maxScaleValues.0), maxScaleValues.1) + + updateOffset() } .onEnded { _ in viewModel.lastScale = viewModel.scale viewModel.lastOffset = viewModel.offset } - + let dragGesture = DragGesture() .onChanged { value in let maxOffsetPoint = viewModel.calculateDragGestureMax() @@ -65,7 +63,7 @@ struct CropView: View { .onEnded { _ in viewModel.lastOffset = viewModel.offset } - + let rotationGesture = RotationGesture() .onChanged { value in viewModel.angle = value @@ -73,14 +71,14 @@ struct CropView: View { .onEnded { _ in viewModel.lastAngle = viewModel.angle } - + VStack { Text("interaction_instructions", tableName: localizableTableName, bundle: .module) .font(.system(size: 16, weight: .regular)) .foregroundColor(.white) .padding(.top, 30) .zIndex(1) - + ZStack { Image(uiImage: image) .resizable() @@ -93,11 +91,11 @@ struct CropView: View { GeometryReader { geometry in Color.clear .onAppear { - viewModel.imageSizeInView = geometry.size + viewModel.updateMaskDimensions(for: geometry.size) } } ) - + Image(uiImage: image) .resizable() .scaledToFit() @@ -106,14 +104,14 @@ struct CropView: View { .offset(viewModel.offset) .mask( MaskShapeView(maskShape: maskShape) - .frame(width: viewModel.maskRadius * 2, height: viewModel.maskRadius * 2) + .frame(width: viewModel.maskSize.width, height: viewModel.maskSize.height) ) } .frame(maxWidth: .infinity, maxHeight: .infinity) .simultaneousGesture(magnificationGesture) .simultaneousGesture(dragGesture) .simultaneousGesture(configuration.rotateImage ? rotationGesture : nil) - + HStack { Button { dismiss() @@ -121,9 +119,9 @@ struct CropView: View { Text("cancel_button", tableName: localizableTableName, bundle: .module) } .foregroundColor(.white) - + Spacer() - + Button { onComplete(cropImage()) dismiss() @@ -137,7 +135,15 @@ struct CropView: View { } .background(.black) } - + + private func updateOffset() { + let maxOffsetPoint = viewModel.calculateDragGestureMax() + let newX = min(max(viewModel.offset.width, -maxOffsetPoint.x), maxOffsetPoint.x) + let newY = min(max(viewModel.offset.height, -maxOffsetPoint.y), maxOffsetPoint.y) + viewModel.offset = CGSize(width: newX, height: newY) + viewModel.lastOffset = viewModel.offset + } + private func cropImage() -> UIImage? { var editedImage: UIImage = image if configuration.rotateImage { @@ -156,22 +162,17 @@ struct CropView: View { return viewModel.cropToSquare(editedImage) } } - + private struct MaskShapeView: View { let maskShape: MaskShape - + var body: some View { Group { switch maskShape { case .circle: Circle() - - case .square: - Rectangle() - - case .rectangle: + case .square, .rectangle: Rectangle() - .aspectRatio(3/4, contentMode: .fill) } } } diff --git a/Tests/SwiftyCropTests/SwiftyCropTests.swift b/Tests/SwiftyCropTests/SwiftyCropTests.swift index d79f68c..a9c7f41 100644 --- a/Tests/SwiftyCropTests/SwiftyCropTests.swift +++ b/Tests/SwiftyCropTests/SwiftyCropTests.swift @@ -6,10 +6,13 @@ final class SwiftyCropTests: XCTestCase { let configuration = SwiftyCropConfiguration( maxMagnificationScale: 1.0, maskRadius: 1.0, - cropImageCircular: true + cropImageCircular: true, + rectAspectRatio: 4/3 ) + XCTAssertEqual(configuration.maxMagnificationScale, 1.0) XCTAssertEqual(configuration.maskRadius, 1.0) XCTAssertEqual(configuration.cropImageCircular, true) + XCTAssertEqual(configuration.rectAspectRatio, 4/3) } }