diff options
author | Benedikt Betz <benedikt.betz@check24.de> | 2023-10-18 15:43:59 +0200 |
---|---|---|
committer | Benedikt Betz <benedikt.betz@check24.de> | 2023-10-18 15:43:59 +0200 |
commit | 485fabdd5372b2d35d58b5ef7e05d4ec25157f1e (patch) | |
tree | b93a5f84e7d7134adf2eef5880302dd9e28cd3b0 | |
parent | 3a7fa6210258d73ab6851a9f067a3150243b27ef (diff) |
First commit
-rw-r--r-- | .DS_Store | bin | 0 -> 6148 bytes | |||
-rw-r--r-- | LICENSE | 21 | ||||
-rw-r--r-- | LICENSE.md | 9 | ||||
-rw-r--r-- | Package.swift | 24 | ||||
-rw-r--r-- | README.md | 134 | ||||
-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 | ||||
-rw-r--r-- | Tests/SwiftyCropTests/SwiftyCropTests.swift | 12 |
13 files changed, 612 insertions, 21 deletions
diff --git a/.DS_Store b/.DS_Store Binary files differnew file mode 100644 index 0000000..ee843fc --- /dev/null +++ b/.DS_Store diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 3c8cd84..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2023 benedom - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..b554044 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2023 Benedikt Betz & Check24 + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..388d74a --- /dev/null +++ b/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "SwiftyCrop", + platforms: [.iOS(.v16)], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "SwiftyCrop", + targets: ["SwiftyCrop"]), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "SwiftyCrop"), + .testTarget( + name: "SwiftyCropTests", + dependencies: ["SwiftyCrop"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..32c4134 --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# SwiftyCrop + +// TODO: Screenshots, GIFs + +## 🔭 Overview +SwiftyCrop allows users to seamlessly crop images within their SwiftUI applications. It provides a user-friendly interface that makes cropping an image as simple as selecting the desired area. + +With SwiftyCrop, you can easily adjust the cropping area, maintain aspect ratio, zoom in and out for precise cropping. + +The following languages are supported & localized: +- 🇬🇧 English +- 🇩🇪 German +- 🇫🇷 French +- 🇮🇹 Italian +- 🇷🇺 Russian +- 🇪🇸 Spanish +- 🇹🇷 Turkish +- 🇺🇦 Ukrainian + +The localization file can be found in `Sources/SwiftyCrop/Resources`. + +## 📕 Contents + +- [Requirements](#🧳-requirements) +- [Installation](#💻-installation) +- [Usage](#🛠️-usage) +- [Contributors](#👨💻-contributors) +- [Author](#✍️-author) +- [License](#📃-license) + +## 🧳 Requirements + +- iOS 16.0 or later +- Xcode 14.3 or later +- Swift 5.0 or later + + +## 💻 Installation +There are two ways to use SwiftyCrop in your project: +- using Swift Package Manager +- manual install (embed Xcode Project) + +### Swift Package Manager + +The [Swift Package Manager](https://swift.org/package-manager/) is a tool for managing the distribution of Swift code. It’s integrated with the Swift build system to automate the process of downloading, compiling, and linking dependencies. + +To integrate `SwiftyCrop` into your Xcode project using Xcode 14.3 or later, specify it in `File > Swift Packages > Add Package Dependency...`: + +```ogdl +https://github.com/elai950/AlertToast.git, :branch="master" // TODO: Adjust URL +``` + +### Manually + +If you prefer not to use any of dependency managers, you can integrate `SwiftyCrop` into your project manually. Put `Sources/SwiftyCrop` folder in your Xcode project. Make sure to enable `Copy items if needed` and `Create groups`. + +## 🛠️ Usage + +### Quick Start +This example shows how to display `SwiftyCropView` in a full screen cover after an image has been set. +```swift +import SwiftUI +import SwiftyCrop + +struct ExampleView: View { + @State private var showImageCropper: Bool = false + @State private var selectedImage: UIImage? + + var body: some View { + VStack { + /* + Your view implementation here. + + Update `selectedImage` with the image you want to crop, + e.g. after picking it from the library or downloading it. + + As soon as you have done this, toggle `showImageCropper`. + + Below is a sample implementation: + */ + + Button("Show cropper") { + selectedImage = UIImage(named: "") // TODO: Test + showImageCropper.toggle() + } + + } + .fullScreenCover(isPresented: $showImageCropper) { + if let selectedImage = selectedImage { + SwiftyCropView( + imageToCrop: selectedImage, + maskShape: .square + ) { croppedImage in + // Do something with the returned cropped image + } + } + } + } +} +``` + +You can also configure `SwiftyCropView` by passing a `SwiftyCropConfiguration`: +```swift +let configuration = SwiftyCropConfiguration( + maxMagnificationScale = 4.0, + maskRadius: 130 +) +``` + +```swift +.fullScreenCover(isPresented: $showImageCropper) { + if let selectedImage = selectedImage { + SwiftyCropView( + imageToCrop: selectedImage, + maskShape: .square, + configuration: configuration // Use the configuration + ) { croppedImage in + // Do something with the returned cropped image + } + } + } +``` + +## 👨💻 Contributors + +All issue reports, feature requests, pull requests and GitHub stars are welcomed and much appreciated. + +## ✍️ Author + +Benedikt Betz + +## 📃 License + +`SwiftyCrop` is available under the MIT license. See the [LICENSE](https://github.com/elai950/AlertToast/blob/master/LICENSE.md) file for more info. // TODO
\ No newline at end of file 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() + } + } + } + } +} diff --git a/Tests/SwiftyCropTests/SwiftyCropTests.swift b/Tests/SwiftyCropTests/SwiftyCropTests.swift new file mode 100644 index 0000000..e029923 --- /dev/null +++ b/Tests/SwiftyCropTests/SwiftyCropTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import SwiftyCrop + +final class SwiftyCropTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +} |