diff options
Diffstat (limited to 'Sources')
-rw-r--r-- | Sources/SwiftyCrop/.DS_Store | bin | 0 -> 6148 bytes | |||
-rw-r--r-- | Sources/SwiftyCrop/Models/Configuration.swift | 17 | ||||
-rw-r--r-- | Sources/SwiftyCrop/Models/CropViewModel.swift | 84 | ||||
-rw-r--r-- | Sources/SwiftyCrop/Models/MaskShape.swift | 3 | ||||
-rw-r--r-- | Sources/SwiftyCrop/Resources/Localizable.xcstrings | 165 | ||||
-rw-r--r-- | Sources/SwiftyCrop/SwiftyCrop.swift | 26 | ||||
-rw-r--r-- | Sources/SwiftyCrop/View/CropView.swift | 138 |
7 files changed, 433 insertions, 0 deletions
diff --git a/Sources/SwiftyCrop/.DS_Store b/Sources/SwiftyCrop/.DS_Store Binary files differnew file mode 100644 index 0000000..4de4f47 --- /dev/null +++ b/Sources/SwiftyCrop/.DS_Store diff --git a/Sources/SwiftyCrop/Models/Configuration.swift b/Sources/SwiftyCrop/Models/Configuration.swift new file mode 100644 index 0000000..bacdab0 --- /dev/null +++ b/Sources/SwiftyCrop/Models/Configuration.swift @@ -0,0 +1,17 @@ +import CoreGraphics + +/// `SwiftyCropConfiguration` is a struct that defines the configuration for cropping behavior. +struct SwiftyCropConfiguration { + let maxMagnificationScale: CGFloat + let maskRadius: CGFloat + + /// Creates a new instance of `SwiftyCropConfiguration`. + /// + /// - Parameters: + /// - maxMagnificationScale: The maximum scale factor that the image can be magnified while cropping. Defaults to `4.0`. + /// - maskRadius: The radius of the mask used for cropping. Defaults to `130`. + init(maxMagnificationScale: CGFloat = 4.0, maskRadius: CGFloat = 130) { + self.maxMagnificationScale = maxMagnificationScale + self.maskRadius = maskRadius + } +} diff --git a/Sources/SwiftyCrop/Models/CropViewModel.swift b/Sources/SwiftyCrop/Models/CropViewModel.swift new file mode 100644 index 0000000..b2eb929 --- /dev/null +++ b/Sources/SwiftyCrop/Models/CropViewModel.swift @@ -0,0 +1,84 @@ +import SwiftUI +import UIKit + +class CropViewModel: ObservableObject { + private let maxMagnificationScale: CGFloat + var imageSizeInView: CGSize = .zero + 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 circleSize: CGSize = .zero + + init( + maskRadius: CGFloat, + maxMagnificationScale: CGFloat + ) { + self.maskRadius = maskRadius + self.maxMagnificationScale = maxMagnificationScale + } + + /** + Calculates the max points that the image can be dragged to. + */ + func calculateDragGestureMax() -> CGPoint { + let yLimit = ((imageSizeInView.height / 2) * scale) - maskRadius + 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, so that the image can not be zoomed out of its own size. + */ + func calculateMagnificationGestureMaxValues() -> (CGFloat, CGFloat) { + 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 **always** be a square, no matter what mask shape is used. + */ + func crop(_ image: UIImage) -> UIImage? { + guard let orientedImage = image.correctlyOriented else { + return nil + } + let factor = min((orientedImage.size.width / imageSizeInView.width), (orientedImage.size.height / imageSizeInView.height)) /// The relation factor of the originals image width/height and the width/height of the image displayed in the view (initial) + let centerInOriginalImage = CGPoint(x: orientedImage.size.width / 2, y: orientedImage.size.height / 2) + let cropRadiusInOriginalImage = (maskRadius * factor) / scale /// Calculate the crop radius inside the original image which based on the mask radius + let offsetX = offset.width * factor /// The x offset the image has by dragging + let offsetY = offset.height * factor /// The y offset the image has by dragging + let cropRectX = (centerInOriginalImage.x - cropRadiusInOriginalImage) - (offsetX / scale) /// Calculates the x coordinate of the crop rectangle inside the original image + let cropRectY = (centerInOriginalImage.y - cropRadiusInOriginalImage) - (offsetY / scale) /// Calculates the y coordinate of the crop rectangle inside the original image + let cropRectCoordinate = CGPoint(x: cropRectX, y: cropRectY) + let cropRectDimension = cropRadiusInOriginalImage * 2 /// Cropped rects dimension is twice its radius (diameter), since it's always a square it's used both for width and height + + let cropRect = CGRect( + x: cropRectCoordinate.x, + y: cropRectCoordinate.y, + width: cropRectDimension, + height: cropRectDimension + ) + + guard let cgImage = orientedImage.cgImage, + let result = cgImage.cropping(to: cropRect) else { + return nil + } + + return UIImage(cgImage: result) + } +} + +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 + } +} diff --git a/Sources/SwiftyCrop/Models/MaskShape.swift b/Sources/SwiftyCrop/Models/MaskShape.swift new file mode 100644 index 0000000..c4b1538 --- /dev/null +++ b/Sources/SwiftyCrop/Models/MaskShape.swift @@ -0,0 +1,3 @@ +enum MaskShape { + case circle, square +} diff --git a/Sources/SwiftyCrop/Resources/Localizable.xcstrings b/Sources/SwiftyCrop/Resources/Localizable.xcstrings new file mode 100644 index 0000000..8c5c203 --- /dev/null +++ b/Sources/SwiftyCrop/Resources/Localizable.xcstrings @@ -0,0 +1,165 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "cancel_button" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuler" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annullamento" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İptal" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скасувати" + } + } + } + }, + "interaction_instructions" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bewegen und skalieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move and scale" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mover y escalar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déplacer et dimensionner" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Muoversi e scalare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перемещение и масштабирование" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Taşıyın ve ölçeklendirin" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переміщення та масштабування" + } + } + } + }, + "save_button" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speichern" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guardar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Risparmiare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kaydet" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зберегти" + } + } + } + } + }, + "version" : "1.0" +}
\ No newline at end of file diff --git a/Sources/SwiftyCrop/SwiftyCrop.swift b/Sources/SwiftyCrop/SwiftyCrop.swift new file mode 100644 index 0000000..ab252e6 --- /dev/null +++ b/Sources/SwiftyCrop/SwiftyCrop.swift @@ -0,0 +1,26 @@ +import SwiftUI + +/// `SwiftyCropView` is a SwiftUI view for cropping images. +/// +/// You can customize the cropping behavior using a `SwiftyCropConfiguration` instance and a completion handler. +/// +/// - Parameters: +/// - imageToCrop: The image to be cropped. +/// - maskShape: The shape of the mask used for cropping. +/// - configuration: The configuration for the cropping behavior. If nothing is specified, the default is used. +/// - onComplete: A closure that's called when the cropping is complete. This closure returns the cropped `UIImage?`. If an error occurs the return value is nil. +struct SwiftyCropView: View { + let imageToCrop: UIImage + let maskShape: MaskShape + let configuration: SwiftyCropConfiguration = SwiftyCropConfiguration() + let onComplete: (UIImage?) -> Void + + var body: some View { + CropView( + image: imageToCrop, + maskShape: maskShape, + configuration: configuration, + onComplete: onComplete + ) + } +} diff --git a/Sources/SwiftyCrop/View/CropView.swift b/Sources/SwiftyCrop/View/CropView.swift new file mode 100644 index 0000000..29cfb8a --- /dev/null +++ b/Sources/SwiftyCrop/View/CropView.swift @@ -0,0 +1,138 @@ +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, + configuration: SwiftyCropConfiguration, + onComplete: @escaping (UIImage?) -> Void + ) { + self.image = image + self.maskShape = maskShape + self.configuration = configuration + self.onComplete = onComplete + _viewModel = StateObject( + wrappedValue: CropViewModel( + maskRadius: configuration.maskRadius, + maxMagnificationScale: configuration.maxMagnificationScale + ) + ) + localizableTableName = "Localizable" + } + + var body: some View { + VStack { + Text("interaction_instructions", tableName: localizableTableName) + .font(.system(size: 16, weight: .regular)) + .foregroundColor(.white) + .padding(.top, 30) + .zIndex(1) + + ZStack { + Image(uiImage: image) + .resizable() + .scaledToFit() + .scaleEffect(viewModel.scale) + .offset(viewModel.offset) + .opacity(0.5) + .overlay( + GeometryReader { geometry in + Color.clear + .onAppear { + viewModel.imageSizeInView = geometry.size + } + } + ) + + Image(uiImage: image) + .resizable() + .scaledToFit() + .scaleEffect(viewModel.scale) + .offset(viewModel.offset) + .mask( + MaskShapeView(maskShape: maskShape) + .frame(width: viewModel.maskRadius * 2, height: viewModel.maskRadius * 2) + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .gesture( + MagnificationGesture() + .onChanged { value in + let sensitivity: CGFloat = 0.2 + 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) + } + .onEnded { _ in + viewModel.lastScale = viewModel.scale + viewModel.lastOffset = viewModel.offset + } + .simultaneously( + with: DragGesture() + .onChanged { value in + let maxOffsetPoint = viewModel.calculateDragGestureMax() + let newX = min(max(value.translation.width + viewModel.lastOffset.width, -maxOffsetPoint.x), maxOffsetPoint.x) + let newY = min(max(value.translation.height + viewModel.lastOffset.height, -maxOffsetPoint.y), maxOffsetPoint.y) + viewModel.offset = CGSize(width: newX, height: newY) + } + .onEnded { _ in + viewModel.lastOffset = viewModel.offset + } + ) + ) + + HStack { + Button { + dismiss() + } label: { + Text("cancel_button", tableName: localizableTableName) + } + .foregroundColor(.white) + + Spacer() + + Button { + onComplete(viewModel.crop(image)) + dismiss() + } label: { + Text("save_button", tableName: localizableTableName) + } + .foregroundColor(.white) + } + .frame(maxWidth: .infinity, alignment: .bottom) + .padding() + } + .background(.black) + } + + private struct MaskShapeView: View { + let maskShape: MaskShape + + var body: some View { + Group { + switch maskShape { + case .circle: + Circle() + + case .square: + Rectangle() + } + } + } + } +} |