aboutsummaryrefslogtreecommitdiff
path: root/Sources
diff options
context:
space:
mode:
authorBenedikt Betz <benedikt.betz@check24.de>2023-10-18 15:43:59 +0200
committerBenedikt Betz <benedikt.betz@check24.de>2023-10-18 15:43:59 +0200
commit485fabdd5372b2d35d58b5ef7e05d4ec25157f1e (patch)
treeb93a5f84e7d7134adf2eef5880302dd9e28cd3b0 /Sources
parent3a7fa6210258d73ab6851a9f067a3150243b27ef (diff)
First commit
Diffstat (limited to 'Sources')
-rw-r--r--Sources/SwiftyCrop/.DS_Storebin0 -> 6148 bytes
-rw-r--r--Sources/SwiftyCrop/Models/Configuration.swift17
-rw-r--r--Sources/SwiftyCrop/Models/CropViewModel.swift84
-rw-r--r--Sources/SwiftyCrop/Models/MaskShape.swift3
-rw-r--r--Sources/SwiftyCrop/Resources/Localizable.xcstrings165
-rw-r--r--Sources/SwiftyCrop/SwiftyCrop.swift26
-rw-r--r--Sources/SwiftyCrop/View/CropView.swift138
7 files changed, 433 insertions, 0 deletions
diff --git a/Sources/SwiftyCrop/.DS_Store b/Sources/SwiftyCrop/.DS_Store
new file mode 100644
index 0000000..4de4f47
--- /dev/null
+++ b/Sources/SwiftyCrop/.DS_Store
Binary files differ
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()
+ }
+ }
+ }
+ }
+}