├── Sources └── DGCropImage │ ├── Resources │ ├── ko.lproj │ │ └── Localizable.strings │ └── en.lproj │ │ └── Localizable.strings │ ├── definitions.swift │ ├── CropView │ ├── CropViewStatus.swift │ ├── CropView+UIScrollViewDelegate.swift │ ├── ImageContainer.swift │ ├── CropView+Touches.swift │ ├── CropMaskViewManager.swift │ ├── CropScrollView.swift │ ├── CropBoxFreeAspectFrameUpdater.swift │ ├── CropBoxLockedAspectFrameUpdater.swift │ ├── CropViewModel.swift │ ├── CropOverlayView.swift │ └── CropView.swift │ ├── RatioOptions.swift │ ├── MaskBackground │ ├── CropDimmingView.swift │ ├── CropVisualEffectView.swift │ └── CropMaskProtocol.swift │ ├── RotationDial │ ├── RotationDialViewModel.swift │ ├── RotationDial+Touches.swift │ ├── CGAngel.swift │ ├── DialConfig.swift │ ├── RotationCalculator.swift │ ├── RotationDialPlate.swift │ └── RotationDial.swift │ ├── ToolbarButtonOptions.swift │ ├── LocalizedHelper.swift │ ├── CropViewController │ ├── CropToolbarProtocol.swift │ ├── RatioItemView.swift │ ├── RatioPresenter.swift │ ├── RatioSelector.swift │ ├── FixedRatioManager.swift │ ├── CropToolbar.swift │ ├── ToolBarButtonImageBuilder.swift │ └── CropViewController.swift │ ├── Helpers │ └── GeometryHelper.swift │ ├── Extensions │ ├── CGImageExtensions.swift │ ├── CoreGraphicsExtensions.swift │ └── UIImageExtensions.swift │ └── DGCropImage.swift ├── Tests └── DGCropImageTests │ └── DGCropImageTests.swift ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ └── DGCropImage.xcscheme ├── .github └── workflows │ └── swift.yml ├── Package.swift ├── DGCropImage.podspec ├── README.md ├── LICENSE └── .gitignore /Sources/DGCropImage/Resources/ko.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "cancel" = "취소"; 2 | "done" = "완료"; 3 | -------------------------------------------------------------------------------- /Sources/DGCropImage/Resources/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "cancel" = "Cancel"; 2 | "done" = "Done"; 3 | -------------------------------------------------------------------------------- /Tests/DGCropImageTests/DGCropImageTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import DGCropImage 3 | 4 | final class DGCropImageTests: XCTestCase { 5 | 6 | } 7 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Sources/DGCropImage/definitions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | typealias OverlayEdgeType = (xDelta: CGFloat, yDelta: CGFloat) 12 | typealias TappedEdgeCropFrameUpdateRule = [CropViewOverlayEdge: OverlayEdgeType] 13 | 14 | -------------------------------------------------------------------------------- /Sources/DGCropImage/CropView/CropViewStatus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import Foundation 9 | 10 | enum CropViewStatus { 11 | case initial 12 | case rotating(angle: CGAngle) 13 | case degree90Rotating 14 | case touchImage 15 | case touchRotationBoard 16 | case touchCropboxHandle(tappedEdge: CropViewOverlayEdge = .none) 17 | case betweenOperation 18 | } 19 | 20 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: 4 | push: 5 | branches: [main, develop, feature/*] 6 | pull_request: 7 | branches: [main, develop, feature/*] 8 | 9 | jobs: 10 | build: 11 | runs-on: macos-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Build and test ( iOS 15.2) 16 | run: xcodebuild test -scheme DGCropImage -destination 'platform=iOS Simulator,OS=15.2,name=iPhone 13 Pro' CODE_SIGNING_ALLOWED=NO IPHONEOS_DEPLOYMENT_TARGET=12.0 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "DGCropImage", 8 | defaultLocalization: "en", 9 | platforms: [.iOS(.v12)], 10 | products: [ 11 | .library( 12 | name: "DGCropImage", 13 | targets: ["DGCropImage"]), 14 | ], 15 | targets: [ 16 | .target( 17 | name: "DGCropImage", 18 | resources: [.process("Resources")] 19 | ), 20 | .testTarget( 21 | name: "DGCropImageTests", 22 | dependencies: ["DGCropImage"]), 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /Sources/DGCropImage/RatioOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct RatioOptions: OptionSet { 11 | public let rawValue: Int 12 | public init(rawValue: Int) { 13 | self.rawValue = rawValue 14 | } 15 | 16 | static public let original = RatioOptions(rawValue: 1 << 0) 17 | static public let square = RatioOptions(rawValue: 1 << 1) 18 | static public let extraDefaultRatios = RatioOptions(rawValue: 1 << 2) 19 | static public let custom = RatioOptions(rawValue: 1 << 3) 20 | 21 | static public let all: RatioOptions = [original, square, extraDefaultRatios, custom] 22 | } 23 | 24 | -------------------------------------------------------------------------------- /Sources/DGCropImage/MaskBackground/CropDimmingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | class CropDimmingView: UIView, CropMaskProtocol { 11 | var innerLayer: CALayer? 12 | 13 | var cropShapeType: CropShapeType = .rect 14 | var imageRatio: CGFloat = 1.0 15 | 16 | convenience init(cropShapeType: CropShapeType = .rect, cropRatio: CGFloat = 1.0) { 17 | self.init(frame: CGRect.zero) 18 | self.cropShapeType = cropShapeType 19 | initialize(cropRatio: cropRatio) 20 | } 21 | 22 | func setMask(cropRatio: CGFloat) { 23 | let layer = createOverLayer(opacity: 0.5, cropRatio: cropRatio) 24 | self.layer.addSublayer(layer) 25 | innerLayer = layer 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /DGCropImage.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'DGCropImage' 3 | s.version = '1.0.0' 4 | s.summary = 'A photo cropping tool which mimics Photo.app written by Swift.' 5 | s.homepage = 'https://github.com/donggyushin/DGCropImage' 6 | s.license = { :type => 'MIT', :file => 'LICENSE.md' } 7 | s.author = { 'donggyushin' => 'donggyu9410@gmail.com' } 8 | s.source = { :git => 'https://github.com/donggyushin/DGCropImage.git', :tag => s.version.to_s } 9 | s.ios.deployment_target = '12.0' 10 | s.swift_version = '5.5' 11 | s.source_files = 'Sources/DGCropImage/**/*' 12 | s.resource_bundles = { 13 | "DGCropImageResources" => ["Sources/**/*.lproj/*.strings"] 14 | } 15 | s.pod_target_xcconfig = { 16 | "PRODUCT_BUNDLE_IDENTIFIER": "com.donggyushin.DGCropImage" 17 | } 18 | end -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DGCropImage 2 | A photo cropping tool which mimics Photo.app written by Swift.
3 | This library supports localized string for english and korean. If there is other languages whenever just let me know or feel free to open a new pull request. 4 |
5 | 6 |
7 | 8 | ## Requirements 9 | - iOS 12.0+ 10 | - Swift 5.5+ 11 | - Xcode 10.0+ 12 | 13 | 14 | ## Installation 15 | 16 | ### SPM 17 | ``` 18 | File > Add Packages > https://github.com/donggyushin/DGCropImage 19 | ``` 20 | 21 | ### CocoaPod 22 | ``` 23 | pod 'DGCropImage', :git => 'https://github.com/donggyushin/DGCropImage.git' 24 | ``` 25 | 26 | ## Usage 27 | ``` 28 | let crop = DGCropImage.crop(image: image) 29 | self.present(crop, animated: true, completion: nil) 30 | 31 | // Don't forget 32 | crop.delegate = self 33 | ``` 34 | -------------------------------------------------------------------------------- /Sources/DGCropImage/RotationDial/RotationDialViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | public class RotationDialViewModel: NSObject { 11 | fileprivate var rotationCal: RotationCalculator? 12 | @objc dynamic var rotationAngle = CGAngle(degrees: 0) 13 | 14 | var touchPoint: CGPoint? { 15 | didSet { 16 | guard let oldValue = oldValue, 17 | let newValue = self.touchPoint, 18 | let rotationCal = rotationCal else { 19 | return 20 | } 21 | 22 | let radians = rotationCal.getRotationRadians(byOldPoint: oldValue, andNewPoint: newValue) 23 | rotationAngle = CGAngle(radians: radians) 24 | } 25 | } 26 | 27 | public override init() { 28 | 29 | } 30 | 31 | func makeRotationCalculator(by midPoint: CGPoint) { 32 | rotationCal = RotationCalculator(midPoint: midPoint) 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 donggyu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/DGCropImage/ToolbarButtonOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct ToolbarButtonOptions: OptionSet { 11 | public let rawValue: Int 12 | public init(rawValue: Int) { 13 | self.rawValue = rawValue 14 | } 15 | 16 | static public let counterclockwiseRotate = ToolbarButtonOptions(rawValue: 1 << 0) 17 | static public let clockwiseRotate = ToolbarButtonOptions(rawValue: 1 << 1) 18 | static public let reset = ToolbarButtonOptions(rawValue: 1 << 2) 19 | static public let ratio = ToolbarButtonOptions(rawValue: 1 << 3) 20 | static public let alterCropper90Degree = ToolbarButtonOptions(rawValue: 1 << 4) 21 | 22 | static public let `default`: ToolbarButtonOptions = [counterclockwiseRotate, 23 | reset, 24 | ratio] 25 | 26 | static public let all: ToolbarButtonOptions = [counterclockwiseRotate, 27 | clockwiseRotate, 28 | reset, 29 | ratio] 30 | } 31 | 32 | -------------------------------------------------------------------------------- /Sources/DGCropImage/RotationDial/RotationDial+Touches.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | extension RotationDial { 11 | public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { 12 | let newPoint = convert(point, to: self) 13 | if bounds.contains(newPoint) { 14 | return self 15 | } 16 | 17 | return nil 18 | } 19 | 20 | private func handle(_ touches: Set) { 21 | guard touches.count == 1, 22 | let touch = touches.first else { 23 | return 24 | } 25 | 26 | viewModel.touchPoint = touch.location(in: self) 27 | } 28 | 29 | public override func touchesBegan(_ touches: Set, with event: UIEvent?) { 30 | super.touchesBegan(touches, with: event) 31 | handle(touches) 32 | } 33 | 34 | public override func touchesMoved(_ touches: Set, with event: UIEvent?) { 35 | super.touchesMoved(touches, with: event) 36 | handle(touches) 37 | } 38 | 39 | public override func touchesEnded(_ touches: Set, with event: UIEvent?) { 40 | super.touchesEnded(touches, with: event) 41 | didFinishedRotate() 42 | viewModel.touchPoint = nil 43 | } 44 | } 45 | 46 | 47 | -------------------------------------------------------------------------------- /Sources/DGCropImage/CropView/CropView+UIScrollViewDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | extension CropView: UIScrollViewDelegate { 12 | func viewForZooming(in scrollView: UIScrollView) -> UIView? { 13 | return imageContainer 14 | } 15 | 16 | func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { 17 | viewModel.setTouchImageStatus() 18 | } 19 | 20 | func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { 21 | // A resize event has begun via gesture on the photo (scrollview), so notify delegate 22 | viewModel.setTouchImageStatus() 23 | } 24 | 25 | func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { 26 | viewModel.setBetweenOperationStatus() 27 | } 28 | 29 | func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { 30 | makeSureImageContainsCropOverlay() 31 | 32 | manualZoomed = true 33 | viewModel.setBetweenOperationStatus() 34 | } 35 | 36 | func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { 37 | if !decelerate { 38 | viewModel.setBetweenOperationStatus() 39 | } 40 | } 41 | } 42 | 43 | 44 | -------------------------------------------------------------------------------- /Sources/DGCropImage/CropView/ImageContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | class ImageContainer: UIView { 11 | 12 | lazy private var imageView: UIImageView = { 13 | let imageView = UIImageView(frame: bounds) 14 | imageView.layer.minificationFilter = .trilinear 15 | imageView.accessibilityIgnoresInvertColors = true 16 | imageView.contentMode = .scaleAspectFit 17 | 18 | addSubview(imageView) 19 | 20 | return imageView 21 | } () 22 | 23 | var image: UIImage? { 24 | didSet { 25 | imageView.frame = bounds 26 | imageView.image = image 27 | 28 | imageView.isUserInteractionEnabled = true 29 | } 30 | } 31 | 32 | override func layoutSubviews() { 33 | super.layoutSubviews() 34 | imageView.frame = bounds 35 | } 36 | 37 | func contains(rect: CGRect, fromView view: UIView, tolerance: CGFloat = 1e-6) -> Bool { 38 | let newRect = view.convert(rect, to: self) 39 | 40 | let p1 = newRect.origin 41 | let p2 = CGPoint(x: newRect.maxX, y: newRect.maxY) 42 | 43 | let refBounds = bounds.insetBy(dx: -tolerance, dy: -tolerance) 44 | 45 | return refBounds.contains(p1) && refBounds.contains(p2) 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /Sources/DGCropImage/LocalizedHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import Foundation 9 | 10 | struct LocalizedHelper { 11 | private static var bundle: Bundle? 12 | 13 | static func setBundle(_ bundle: Bundle) { 14 | guard let resourceBundleURL = bundle.url( 15 | forResource: "DGCropImageResources", withExtension: "bundle") 16 | else { return } 17 | LocalizedHelper.bundle = Bundle(url: resourceBundleURL) 18 | } 19 | 20 | static func getString( 21 | _ key: String, 22 | localizationConfig: LocalizationConfig = DGCropImage.localizationConfig, 23 | value: String? = nil 24 | ) -> String { 25 | let value = value ?? key 26 | 27 | #if SWIFT_PACKAGE 28 | let bundle = localizationConfig.bundle ?? Bundle.module 29 | 30 | return NSLocalizedString( 31 | key, 32 | tableName: localizationConfig.tableName, 33 | bundle: bundle, 34 | value: value, 35 | comment: "" 36 | ) 37 | #else 38 | guard let bundle = LocalizedHelper.bundle ?? (localizationConfig.bundle ?? DGCropImage.bundle) else { 39 | return value 40 | } 41 | 42 | return NSLocalizedString( 43 | key, 44 | tableName: localizationConfig.tableName, 45 | bundle: bundle, 46 | value: value, 47 | comment: "" 48 | ) 49 | #endif 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/DGCropImage/MaskBackground/CropVisualEffectView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | class CropVisualEffectView: UIVisualEffectView, CropMaskProtocol { 11 | var innerLayer: CALayer? 12 | 13 | var cropShapeType: CropShapeType = .rect 14 | var imageRatio: CGFloat = 1.0 15 | 16 | fileprivate var translucencyEffect: UIVisualEffect? 17 | 18 | convenience init(cropShapeType: CropShapeType = .rect, 19 | effectType: CropVisualEffectType = .blurDark, 20 | cropRatio: CGFloat = 1.0) { 21 | 22 | let (translucencyEffect, backgroundColor) = CropVisualEffectView.getEffect(byType: effectType) 23 | 24 | self.init(effect: translucencyEffect) 25 | self.cropShapeType = cropShapeType 26 | self.translucencyEffect = translucencyEffect 27 | self.backgroundColor = backgroundColor 28 | 29 | initialize(cropRatio: cropRatio) 30 | } 31 | 32 | func setMask(cropRatio: CGFloat) { 33 | let layer = createOverLayer(opacity: 0.98, cropRatio: cropRatio) 34 | 35 | let maskView = UIView(frame: self.bounds) 36 | maskView.clipsToBounds = true 37 | maskView.layer.addSublayer(layer) 38 | 39 | innerLayer = layer 40 | 41 | self.mask = maskView 42 | } 43 | 44 | static func getEffect(byType type: CropVisualEffectType) -> (UIVisualEffect?, UIColor) { 45 | switch type { 46 | case .blurDark: return (UIBlurEffect(style: .dark), .clear) 47 | case .dark: return (nil, UIColor.black.withAlphaComponent(0.75)) 48 | case .light: return (nil, UIColor.black.withAlphaComponent(0.35)) 49 | case .none: return (nil, .black) 50 | } 51 | } 52 | 53 | } 54 | 55 | -------------------------------------------------------------------------------- /Sources/DGCropImage/RotationDial/CGAngel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | /// Use this class to make angle calculation to be simpler 11 | public class CGAngle: NSObject, Comparable { 12 | public static func < (lhs: CGAngle, rhs: CGAngle) -> Bool { 13 | return lhs.radians < rhs.radians 14 | } 15 | 16 | public var radians: CGFloat = 0.0 17 | 18 | @inlinable public var degrees: CGFloat { 19 | get { 20 | return radians / CGFloat.pi * 180.0 21 | } 22 | set { 23 | radians = newValue / 180.0 * CGFloat.pi 24 | } 25 | } 26 | 27 | public init(radians: CGFloat) { 28 | self.radians = radians 29 | } 30 | 31 | public init(degrees: CGFloat) { 32 | self.radians = degrees / 180.0 * CGFloat.pi 33 | } 34 | 35 | 36 | override public var description: String { 37 | return String(format: "%0.2f°", degrees) 38 | } 39 | 40 | static public func +(lhs: CGAngle, rhs: CGAngle) -> CGAngle { 41 | return CGAngle(radians: lhs.radians + rhs.radians) 42 | } 43 | 44 | static public func *(lhs: CGAngle, rhs: CGAngle) -> CGAngle { 45 | return CGAngle(radians: lhs.radians * rhs.radians) 46 | } 47 | 48 | static public func -(lhs: CGAngle, rhs: CGAngle) -> CGAngle { 49 | return CGAngle(radians: lhs.radians - rhs.radians) 50 | } 51 | 52 | static public prefix func -(rhs: CGAngle) -> CGAngle { 53 | return CGAngle(radians: -rhs.radians) 54 | } 55 | 56 | static public func /(lhs: CGAngle, rhs: CGAngle) -> CGAngle { 57 | guard rhs.radians != 0 else { 58 | if lhs.radians == 0 { return CGAngle(radians: 0)} 59 | if lhs.radians > 0 { return CGAngle(radians: CGFloat.infinity)} 60 | return CGAngle(radians: -CGFloat.infinity) 61 | } 62 | return CGAngle(radians: lhs.radians / rhs.radians) 63 | } 64 | 65 | } 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /Sources/DGCropImage/RotationDial/DialConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | // MARK: - DialConfig 11 | public struct DialConfig { 12 | public init() {} 13 | 14 | public var margin: Double = 10 15 | public var interactable = false 16 | public var rotationLimitType: RotationLimitType = .limit(angle: CGAngle(degrees: 45)) 17 | public var angleShowLimitType: AngleShowLimitType = .limit(angle: CGAngle(degrees: 40)) 18 | public var rotationCenterType: RotationCenterType = .useDefault 19 | public var numberShowSpan = 1 20 | public var orientation: Orientation = .normal 21 | 22 | public var backgroundColor: UIColor = .clear 23 | public var bigScaleColor: UIColor = .lightGray 24 | public var smallScaleColor: UIColor = .lightGray 25 | public var indicatorColor: UIColor = .lightGray 26 | public var numberColor: UIColor = .lightGray 27 | public var centerAxisColor: UIColor = .lightGray 28 | 29 | public var theme: Theme = .dark { 30 | didSet { 31 | switch theme { 32 | case .dark: 33 | backgroundColor = .clear 34 | bigScaleColor = .lightGray 35 | smallScaleColor = .lightGray 36 | indicatorColor = .lightGray 37 | numberColor = .lightGray 38 | centerAxisColor = .lightGray 39 | case .light: 40 | backgroundColor = .clear 41 | bigScaleColor = .darkGray 42 | smallScaleColor = .darkGray 43 | indicatorColor = .darkGray 44 | numberColor = .darkGray 45 | centerAxisColor = .darkGray 46 | } 47 | } 48 | } 49 | 50 | public enum RotationCenterType { 51 | case useDefault 52 | case custom(CGPoint) 53 | } 54 | 55 | public enum AngleShowLimitType { 56 | case noLimit 57 | case limit(angle: CGAngle) 58 | } 59 | 60 | public enum RotationLimitType { 61 | case noLimit 62 | case limit(angle: CGAngle) 63 | } 64 | 65 | public enum Orientation { 66 | case normal 67 | case right 68 | case left 69 | case upsideDown 70 | } 71 | 72 | public enum Theme { 73 | case dark 74 | case light 75 | } 76 | } 77 | 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /Sources/DGCropImage/CropView/CropView+Touches.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | extension CropView { 12 | override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { 13 | let newPoint = self.convert(point, to: self) 14 | 15 | // if let rotationDial = rotationDial, rotationDial.frame.contains(newPoint) { 16 | // return rotationDial 17 | // } 18 | 19 | if (gridOverlayView.frame.insetBy(dx: -hotAreaUnit/2, dy: -hotAreaUnit/2).contains(newPoint) && 20 | !gridOverlayView.frame.insetBy(dx: hotAreaUnit/2, dy: hotAreaUnit/2).contains(newPoint)) 21 | { 22 | return self 23 | } 24 | 25 | if self.bounds.contains(newPoint) { 26 | return self.scrollView 27 | } 28 | 29 | return nil 30 | } 31 | 32 | override func touchesBegan(_ touches: Set, with event: UIEvent?) { 33 | super.touchesBegan(touches, with: event) 34 | 35 | guard touches.count == 1, let touch = touches.first else { 36 | return 37 | } 38 | 39 | // A resize event has begun by grabbing the crop UI, so notify delegate 40 | 41 | if touch.view is RotationDial { 42 | viewModel.setTouchRotationBoardStatus() 43 | return 44 | } 45 | 46 | let point = touch.location(in: self) 47 | viewModel.prepareForCrop(byTouchPoint: point) 48 | } 49 | 50 | override func touchesMoved(_ touches: Set, with event: UIEvent?) { 51 | super.touchesMoved(touches, with: event) 52 | 53 | guard touches.count == 1, let touch = touches.first else { 54 | return 55 | } 56 | 57 | if touch.view is RotationDial { 58 | return 59 | } 60 | 61 | let point = touch.location(in: self) 62 | updateCropBoxFrame(with: point) 63 | } 64 | 65 | override func touchesEnded(_ touches: Set, with event: UIEvent?) { 66 | super.touchesEnded(touches, with: event) 67 | 68 | if viewModel.needCrop() { 69 | gridOverlayView.handleEdgeUntouched() 70 | let contentRect = getContentBounds() 71 | adjustUIForNewCrop(contentRect: contentRect) {[weak self] in 72 | self?.viewModel.setBetweenOperationStatus() 73 | self?.scrollView.updateMinZoomScale() 74 | } 75 | } else { 76 | viewModel.setBetweenOperationStatus() 77 | } 78 | } 79 | } 80 | 81 | 82 | -------------------------------------------------------------------------------- /Sources/DGCropImage/RotationDial/RotationCalculator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | class RotationCalculator { 11 | 12 | // midpoint for gesture recognizer 13 | var midPoint = CGPoint.zero 14 | 15 | // minimal distance from midpoint 16 | var innerRadius: CGFloat? 17 | 18 | // maximal distance to midpoint 19 | var outerRadius: CGFloat? 20 | 21 | // relative rotation for current gesture (in radians) 22 | var rotation: CGFloat? { 23 | guard let currentPoint = self.currentPoint, 24 | let previousPoint = self.previousPoint else { 25 | return nil 26 | } 27 | 28 | var rotation = angleBetween(pointA: currentPoint, andPointB: previousPoint) 29 | 30 | if (rotation > CGFloat.pi) { 31 | rotation -= CGFloat.pi * 2 32 | } else if (rotation < -CGFloat.pi) { 33 | rotation += CGFloat.pi * 2 34 | } 35 | 36 | return rotation 37 | } 38 | 39 | // absolute angle for current gesture (in radians) 40 | var angle: CGFloat? { 41 | if let nowPoint = self.currentPoint { 42 | return self.angleForPoint(point: nowPoint) 43 | } 44 | 45 | return nil 46 | } 47 | 48 | // distance from midpoint 49 | var distance: CGFloat? { 50 | if let nowPoint = self.currentPoint { 51 | return self.distanceBetween(pointA: self.midPoint, andPointB: nowPoint) 52 | } 53 | 54 | return nil 55 | } 56 | 57 | private var currentPoint: CGPoint? 58 | private var previousPoint: CGPoint? 59 | 60 | init(midPoint: CGPoint) { 61 | self.midPoint = midPoint 62 | } 63 | 64 | private func distanceBetween(pointA: CGPoint, andPointB pointB: CGPoint) -> CGFloat { 65 | let dx = Float(pointA.x - pointB.x) 66 | let dy = Float(pointA.y - pointB.y) 67 | return CGFloat(sqrtf(dx*dx + dy*dy)) 68 | } 69 | 70 | private func angleForPoint(point: CGPoint) -> CGFloat { 71 | var angle = CGFloat(-atan2f(Float(point.x - midPoint.x), Float(point.y - midPoint.y))) + CGFloat.pi / 2 72 | 73 | if (angle < 0) { 74 | angle += CGFloat.pi * 2 75 | } 76 | 77 | return angle 78 | } 79 | 80 | private func angleBetween(pointA: CGPoint, andPointB pointB: CGPoint) -> CGFloat { 81 | return angleForPoint(point: pointA) - angleForPoint(point: pointB) 82 | } 83 | 84 | func getRotationRadians(byOldPoint p1: CGPoint, andNewPoint p2: CGPoint) -> CGFloat { 85 | self.previousPoint = p1 86 | self.currentPoint = p2 87 | return rotation ?? 0 88 | } 89 | } 90 | 91 | -------------------------------------------------------------------------------- /Sources/DGCropImage/CropView/CropMaskViewManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | class CropMaskViewManager { 11 | fileprivate var dimmingView: CropDimmingView! 12 | fileprivate var visualEffectView: CropVisualEffectView! 13 | 14 | var cropShapeType: CropShapeType = .rect 15 | var cropVisualEffectType: CropVisualEffectType = .blurDark 16 | 17 | init(with superview: UIView, 18 | cropRatio: CGFloat = 1.0, 19 | cropShapeType: CropShapeType = .rect, 20 | cropVisualEffectType: CropVisualEffectType = .blurDark) { 21 | 22 | setup(in: superview, cropRatio: cropRatio) 23 | self.cropShapeType = cropShapeType 24 | self.cropVisualEffectType = cropVisualEffectType 25 | } 26 | 27 | private func setupOverlayView(in view: UIView, cropRatio: CGFloat = 1.0) { 28 | dimmingView = CropDimmingView(cropShapeType: cropShapeType, cropRatio: cropRatio) 29 | dimmingView.isUserInteractionEnabled = false 30 | dimmingView.alpha = 0 31 | view.addSubview(dimmingView) 32 | } 33 | 34 | private func setupTranslucencyView(in view: UIView, cropRatio: CGFloat = 1.0) { 35 | visualEffectView = CropVisualEffectView(cropShapeType: cropShapeType, 36 | effectType: cropVisualEffectType, 37 | cropRatio: cropRatio) 38 | visualEffectView.isUserInteractionEnabled = false 39 | view.addSubview(visualEffectView) 40 | } 41 | 42 | func setup(in view: UIView, cropRatio: CGFloat = 1.0) { 43 | setupOverlayView(in: view, cropRatio: cropRatio) 44 | setupTranslucencyView(in: view, cropRatio: cropRatio) 45 | } 46 | 47 | func removeMaskViews() { 48 | dimmingView.removeFromSuperview() 49 | visualEffectView.removeFromSuperview() 50 | } 51 | 52 | func bringMaskViewsToFront() { 53 | dimmingView.superview?.bringSubviewToFront(dimmingView) 54 | visualEffectView.superview?.bringSubviewToFront(visualEffectView) 55 | } 56 | 57 | func showDimmingBackground() { 58 | UIView.animate(withDuration: 0.1) { 59 | self.dimmingView.alpha = 1 60 | self.visualEffectView.alpha = 0 61 | } 62 | } 63 | 64 | func showVisualEffectBackground() { 65 | UIView.animate(withDuration: 0.5) { 66 | self.dimmingView.alpha = 0 67 | self.visualEffectView.alpha = 1 68 | } 69 | } 70 | 71 | func adaptMaskTo(match cropRect: CGRect, cropRatio: CGFloat) { 72 | dimmingView.adaptMaskTo(match: cropRect, cropRatio: cropRatio) 73 | visualEffectView.adaptMaskTo(match: cropRect, cropRatio: cropRatio) 74 | } 75 | } 76 | 77 | -------------------------------------------------------------------------------- /Sources/DGCropImage/CropViewController/CropToolbarProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | public protocol CropToolbarDelegate: AnyObject { 11 | func didSelectCancel() 12 | func didSelectCrop() 13 | func didSelectCounterClockwiseRotate() 14 | func didSelectClockwiseRotate() 15 | func didSelectReset() 16 | func didSelectSetRatio() 17 | func didSelectRatio(ratio: Double) 18 | func didSelectAlterCropper90Degree() 19 | } 20 | 21 | public protocol CropToolbarProtocol: UIView { 22 | var heightForVerticalOrientationConstraint: NSLayoutConstraint? {get set} 23 | var widthForHorizonOrientationConstraint: NSLayoutConstraint? {get set} 24 | var cropToolbarDelegate: CropToolbarDelegate? {get set} 25 | 26 | func createToolbarUI(config: CropToolbarConfig) 27 | func handleFixedRatioSetted(ratio: Double) 28 | func handleFixedRatioUnSetted() 29 | 30 | // MARK: - The following functions have default implementations 31 | func getRatioListPresentSourceView() -> UIView? 32 | 33 | func initConstraints(heightForVerticalOrientation: CGFloat, 34 | widthForHorizonOrientation: CGFloat) 35 | 36 | func respondToOrientationChange() 37 | func adjustLayoutConstraintsWhenOrientationChange() 38 | func adjustUIWhenOrientationChange() 39 | 40 | func handleCropViewDidBecomeResettable() 41 | func handleCropViewDidBecomeUnResettable() 42 | } 43 | 44 | public extension CropToolbarProtocol { 45 | func getRatioListPresentSourceView() -> UIView? { 46 | return nil 47 | } 48 | 49 | func initConstraints(heightForVerticalOrientation: CGFloat, widthForHorizonOrientation: CGFloat) { 50 | heightForVerticalOrientationConstraint = heightAnchor.constraint(equalToConstant: heightForVerticalOrientation) 51 | widthForHorizonOrientationConstraint = widthAnchor.constraint(equalToConstant: widthForHorizonOrientation) 52 | } 53 | 54 | func respondToOrientationChange() { 55 | adjustLayoutConstraintsWhenOrientationChange() 56 | adjustUIWhenOrientationChange() 57 | } 58 | 59 | func adjustLayoutConstraintsWhenOrientationChange() { 60 | 61 | if UIDevice.current.orientation == .portrait { 62 | heightForVerticalOrientationConstraint?.isActive = true 63 | widthForHorizonOrientationConstraint?.isActive = false 64 | } else { 65 | heightForVerticalOrientationConstraint?.isActive = false 66 | widthForHorizonOrientationConstraint?.isActive = true 67 | } 68 | } 69 | 70 | func adjustUIWhenOrientationChange() { 71 | 72 | } 73 | 74 | func handleCropViewDidBecomeResettable() { 75 | 76 | } 77 | 78 | func handleCropViewDidBecomeUnResettable() { 79 | 80 | } 81 | } 82 | 83 | -------------------------------------------------------------------------------- /Sources/DGCropImage/Helpers/GeometryHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | enum CropViewOverlayEdge { 11 | case none 12 | case topLeft 13 | case top 14 | case topRight 15 | case right 16 | case bottomRight 17 | case bottom 18 | case bottomLeft 19 | case left 20 | } 21 | 22 | 23 | struct GeometryHelper { 24 | static func getInscribeRect(fromOutsideRect outsideRect: CGRect, andInsideRect insideRect: CGRect) -> CGRect { 25 | let insideRectRatio = insideRect.width / insideRect.height 26 | let outsideRectRatio = outsideRect.width / outsideRect.height 27 | 28 | var rect = CGRect(origin: .zero, size: insideRect.size) 29 | if outsideRectRatio > insideRectRatio { 30 | rect.size.width *= outsideRect.height / rect.height 31 | rect.size.height = outsideRect.height 32 | } else { 33 | rect.size.height *= outsideRect.width / rect.width 34 | rect.size.width = outsideRect.width 35 | } 36 | 37 | rect.origin.x = outsideRect.midX - rect.width / 2 38 | rect.origin.y = outsideRect.midY - rect.height / 2 39 | return rect 40 | } 41 | 42 | static func getCropEdge(forPoint point: CGPoint, byTouchRect touchRect: CGRect, hotAreaUnit: CGFloat) -> CropViewOverlayEdge { 43 | //Make sure the corners take priority 44 | let touchSize = CGSize(width: hotAreaUnit, height: hotAreaUnit) 45 | 46 | let topLeftRect = CGRect(origin: touchRect.origin, size: touchSize) 47 | if topLeftRect.contains(point) { return .topLeft } 48 | 49 | let topRightRect = topLeftRect.offsetBy(dx: touchRect.width - hotAreaUnit, dy: 0) 50 | if topRightRect.contains(point) { return .topRight } 51 | 52 | let bottomLeftRect = topLeftRect.offsetBy(dx: 0, dy: touchRect.height - hotAreaUnit) 53 | if bottomLeftRect.contains(point) { return .bottomLeft } 54 | 55 | let bottomRightRect = bottomLeftRect.offsetBy(dx: touchRect.width - hotAreaUnit, dy: 0) 56 | if bottomRightRect.contains(point) { return .bottomRight } 57 | 58 | //Check for edges 59 | let topRect = CGRect(origin: touchRect.origin, size: CGSize(width: touchRect.width, height: hotAreaUnit)) 60 | if topRect.contains(point) { return .top } 61 | 62 | let leftRect = CGRect(origin: touchRect.origin, size: CGSize(width: hotAreaUnit, height: touchRect.height)) 63 | if leftRect.contains(point) { return .left } 64 | 65 | let rightRect = CGRect(origin: CGPoint(x: touchRect.maxX - hotAreaUnit, y: touchRect.origin.y), size: CGSize(width: hotAreaUnit, height: touchRect.height)) 66 | if rightRect.contains(point) { return .right } 67 | 68 | let bottomRect = CGRect(origin: CGPoint(x: touchRect.origin.x, y: touchRect.maxY - hotAreaUnit), size: CGSize(width: touchRect.width, height: hotAreaUnit)) 69 | if bottomRect.contains(point) { return .bottom } 70 | 71 | return .none 72 | } 73 | 74 | static func scale(from transform: CGAffineTransform) -> Double { 75 | return sqrt(Double(transform.a * transform.a + transform.c * transform.c)); 76 | } 77 | } 78 | 79 | -------------------------------------------------------------------------------- /Sources/DGCropImage/CropViewController/RatioItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | class RatioItemView: UIView { 11 | var didGetRatio: ((RatioItemType)->Void) = { _ in } 12 | var selected = false { 13 | didSet { 14 | UIView.animate(withDuration: 0.2) { 15 | self.backgroundColor = self.selected ? UIColor.lightGray.withAlphaComponent(0.7) : .black 16 | self.titleLabel.textColor = self.selected ? .white : .gray 17 | } 18 | } 19 | } 20 | 21 | private lazy var titleLabel: PaddingLabel = { 22 | let label = PaddingLabel() 23 | label.textAlignment = .center 24 | let titleSize: CGFloat = (UIDevice.current.userInterfaceIdiom == .pad) ? 20 : 14 25 | label.font = .systemFont(ofSize: titleSize, weight: .medium) 26 | label.textColor = .white 27 | label.translatesAutoresizingMaskIntoConstraints = false 28 | return label 29 | }() 30 | 31 | var ratio: RatioItemType! 32 | 33 | var type: RatioType! { 34 | didSet { 35 | titleLabel.text = type == .vertical ? ratio.nameV : ratio.nameH 36 | } 37 | } 38 | 39 | init(type: RatioType, item: RatioItemType) { 40 | super.init(frame: .zero) 41 | self.ratio = item 42 | self.type = type 43 | setup() 44 | } 45 | 46 | override init(frame: CGRect) { 47 | super.init(frame: frame) 48 | } 49 | 50 | required init?(coder: NSCoder) { 51 | fatalError("init(coder:) has not been implemented") 52 | } 53 | 54 | private func setup() { 55 | titleLabel.text = type == .vertical ? ratio.nameV : ratio.nameH 56 | addSubview(titleLabel) 57 | translatesAutoresizingMaskIntoConstraints = false 58 | titleLabel.topAnchor.constraint(equalTo: topAnchor).isActive = true 59 | titleLabel.leftAnchor.constraint(equalTo: leftAnchor).isActive = true 60 | titleLabel.rightAnchor.constraint(equalTo: rightAnchor).isActive = true 61 | titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true 62 | titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true 63 | let gesture = UITapGestureRecognizer(target: self, action: #selector(tap)) 64 | addGestureRecognizer(gesture) 65 | 66 | layer.cornerRadius = 10 67 | clipsToBounds = true 68 | 69 | } 70 | 71 | @objc private func tap() { 72 | selected = !selected 73 | self.didGetRatio(ratio) 74 | } 75 | } 76 | 77 | fileprivate class PaddingLabel: UILabel { 78 | var topInset: CGFloat = 4.0 79 | var bottomInset: CGFloat = 4.0 80 | var leftInset: CGFloat = 10.0 81 | var rightInset: CGFloat = 10.0 82 | 83 | override func drawText(in rect: CGRect) { 84 | let insets = UIEdgeInsets(top: topInset, left: leftInset, bottom: bottomInset, right: rightInset) 85 | super.drawText(in: rect.inset(by: insets)) 86 | } 87 | 88 | override var intrinsicContentSize: CGSize { 89 | let size = super.intrinsicContentSize 90 | return CGSize(width: size.width + leftInset + rightInset, 91 | height: size.height + topInset + bottomInset) 92 | } 93 | 94 | override var bounds: CGRect { 95 | didSet { 96 | // ensures this works within stack views if multi-line 97 | preferredMaxLayoutWidth = bounds.width - (leftInset + rightInset) 98 | } 99 | } 100 | } 101 | 102 | -------------------------------------------------------------------------------- /Sources/DGCropImage/CropViewController/RatioPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | enum RatioType { 11 | case horizontal 12 | case vertical 13 | } 14 | 15 | class RatioPresenter { 16 | var didGetRatio: ((Double)->Void) = { _ in } 17 | private var type: RatioType = .vertical 18 | private var originalRatioH: Double 19 | private var ratios: [RatioItemType] 20 | private var fixRatiosShowType: FixRatiosShowType = .adaptive 21 | 22 | init(type: RatioType, originalRatioH: Double, ratios: [RatioItemType] = [], fixRatiosShowType: FixRatiosShowType = .adaptive) { 23 | self.type = type 24 | self.originalRatioH = originalRatioH 25 | self.ratios = ratios 26 | self.fixRatiosShowType = fixRatiosShowType 27 | } 28 | 29 | private func getItemTitle(by ratio: RatioItemType) -> String { 30 | switch fixRatiosShowType { 31 | case .adaptive: 32 | return (type == .horizontal) ? ratio.nameH : ratio.nameV 33 | case .horizontal: 34 | return ratio.nameH 35 | case .vetical: 36 | return ratio.nameV 37 | } 38 | } 39 | 40 | private func getItemValue(by ratio: RatioItemType) -> Double { 41 | switch fixRatiosShowType { 42 | case .adaptive: 43 | return (type == .horizontal) ? ratio.ratioH : ratio.ratioV 44 | case .horizontal: 45 | return ratio.ratioH 46 | case .vetical: 47 | return ratio.ratioV 48 | } 49 | } 50 | 51 | func present(by viewController: UIViewController, in sourceView: UIView) { 52 | let actionSheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) 53 | 54 | for ratio in ratios { 55 | let title = getItemTitle(by: ratio) 56 | 57 | let action = UIAlertAction(title: title, style: .default) {[weak self] _ in 58 | guard let self = self else { return } 59 | let ratioValue = self.getItemValue(by: ratio) 60 | self.didGetRatio(ratioValue) 61 | } 62 | actionSheet.addAction(action) 63 | } 64 | 65 | actionSheet.handlePopupInBigScreenIfNeeded(sourceView: sourceView) 66 | 67 | let cancelText = "Cancel" 68 | let cancelAction = UIAlertAction(title: cancelText, style: .cancel) 69 | actionSheet.addAction(cancelAction) 70 | 71 | viewController.present(actionSheet, animated: true) 72 | } 73 | } 74 | 75 | public extension UIAlertController { 76 | func handlePopupInBigScreenIfNeeded(sourceView: UIView, permittedArrowDirections: UIPopoverArrowDirection? = nil) { 77 | func handlePopupInBigScreen(sourceView: UIView, permittedArrowDirections: UIPopoverArrowDirection? = nil) { 78 | // https://stackoverflow.com/a/27823616/288724 79 | popoverPresentationController?.permittedArrowDirections = permittedArrowDirections ?? .any 80 | popoverPresentationController?.sourceView = sourceView 81 | popoverPresentationController?.sourceRect = sourceView.bounds 82 | } 83 | 84 | if #available(macCatalyst 14.0, iOS 14.0, *) { 85 | if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { 86 | handlePopupInBigScreen(sourceView: sourceView, permittedArrowDirections: permittedArrowDirections) 87 | } 88 | } else { 89 | if UIDevice.current.userInterfaceIdiom == .pad { 90 | handlePopupInBigScreen(sourceView: sourceView, permittedArrowDirections: permittedArrowDirections) 91 | } 92 | } 93 | } 94 | } 95 | 96 | -------------------------------------------------------------------------------- /Sources/DGCropImage/Extensions/CGImageExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | extension CGImage { 11 | 12 | func transformedImage(_ transform: CGAffineTransform, zoomScale: CGFloat, sourceSize: CGSize, cropSize: CGSize, imageViewSize: CGSize) -> CGImage? { 13 | guard var colorSpaceRef = self.colorSpace else { 14 | return self 15 | } 16 | // If the color space does not allow output, default to the RGB color space 17 | if (!colorSpaceRef.supportsOutput) { 18 | colorSpaceRef = CGColorSpaceCreateDeviceRGB(); 19 | } 20 | 21 | let expectedWidth = floor(sourceSize.width / imageViewSize.width * cropSize.width) / zoomScale 22 | let expectedHeight = floor(sourceSize.height / imageViewSize.height * cropSize.height) / zoomScale 23 | let outputSize = CGSize(width: expectedWidth, height: expectedHeight) 24 | let bitmapBytesPerRow = 0 25 | 26 | func getBitmapInfo() -> UInt32 { 27 | if colorSpaceRef.model == .rgb { 28 | switch(bitsPerPixel, bitsPerComponent) { 29 | case (16, 5): 30 | return CGImageAlphaInfo.noneSkipFirst.rawValue 31 | case (32, 8): 32 | return CGImageAlphaInfo.premultipliedLast.rawValue 33 | case (32, 10): 34 | if #available(iOS 12, macOS 10.14, *) { 35 | return CGImageAlphaInfo.alphaOnly.rawValue | CGImagePixelFormatInfo.RGBCIF10.rawValue 36 | } else { 37 | return bitmapInfo.rawValue 38 | } 39 | case (64, 16): 40 | return CGImageAlphaInfo.premultipliedLast.rawValue 41 | case (128, 32): 42 | return CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.floatComponents.rawValue 43 | default: 44 | return bitmapInfo.rawValue 45 | } 46 | } 47 | 48 | return bitmapInfo.rawValue 49 | } 50 | 51 | guard let context = CGContext(data: nil, 52 | width: Int(outputSize.width), 53 | height: Int(outputSize.height), 54 | bitsPerComponent: bitsPerComponent, 55 | bytesPerRow: bitmapBytesPerRow, 56 | space: colorSpaceRef, 57 | bitmapInfo: getBitmapInfo()) else { 58 | return self 59 | } 60 | 61 | context.setFillColor(UIColor.clear.cgColor) 62 | context.fill(CGRect(x: 0, 63 | y: 0, 64 | width: outputSize.width, 65 | height: outputSize.height)) 66 | 67 | var uiCoords = CGAffineTransform(scaleX: outputSize.width / cropSize.width, 68 | y: outputSize.height / cropSize.height) 69 | uiCoords = uiCoords.translatedBy(x: cropSize.width / 2, y: cropSize.height / 2) 70 | uiCoords = uiCoords.scaledBy(x: 1.0, y: -1.0) 71 | 72 | context.concatenate(uiCoords) 73 | context.concatenate(transform) 74 | context.scaleBy(x: 1.0, y: -1.0) 75 | context.draw(self, in: CGRect(x: (-imageViewSize.width / 2), 76 | y: (-imageViewSize.height / 2), 77 | width: imageViewSize.width, 78 | height: imageViewSize.height)) 79 | 80 | let result = context.makeImage() 81 | 82 | return result 83 | } 84 | } 85 | 86 | -------------------------------------------------------------------------------- /Sources/DGCropImage/CropView/CropScrollView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | class CropScrollView: UIScrollView { 11 | 12 | weak var imageContainer: ImageContainer? 13 | 14 | var touchesBegan = {} 15 | var touchesCancelled = {} 16 | var touchesEnded = {} 17 | 18 | override init(frame: CGRect) { 19 | super.init(frame: frame) 20 | alwaysBounceHorizontal = true 21 | alwaysBounceVertical = true 22 | showsHorizontalScrollIndicator = false 23 | showsVerticalScrollIndicator = false 24 | contentInsetAdjustmentBehavior = .never 25 | minimumZoomScale = 1.0 26 | maximumZoomScale = 15.0 27 | clipsToBounds = false 28 | contentSize = bounds.size 29 | layer.anchorPoint = CGPoint(x: 0.5, y: 0.5) 30 | } 31 | 32 | required init?(coder aDecoder: NSCoder) { 33 | super.init(coder: aDecoder) 34 | } 35 | 36 | override func touchesBegan(_ touches: Set, with event: UIEvent?) { 37 | touchesBegan() 38 | super.touchesBegan(touches, with: event) 39 | } 40 | 41 | override func touchesCancelled(_ touches: Set, with event: UIEvent?) { 42 | touchesCancelled() 43 | super.touchesBegan(touches, with: event) 44 | } 45 | 46 | override func touchesEnded(_ touches: Set, with event: UIEvent?) { 47 | touchesEnded() 48 | super.touchesBegan(touches, with: event) 49 | } 50 | 51 | func checkContentOffset() { 52 | contentOffset.x = max(contentOffset.x, 0) 53 | contentOffset.y = max(contentOffset.y, 0) 54 | 55 | if contentSize.height - contentOffset.y <= bounds.size.height { 56 | contentOffset.y = contentSize.height - bounds.size.height 57 | } 58 | 59 | if contentSize.width - contentOffset.x <= bounds.size.width { 60 | contentOffset.x = contentSize.width - bounds.size.width 61 | } 62 | } 63 | 64 | private func getBoundZoomScale() -> CGFloat { 65 | guard let imageContainer = imageContainer else { 66 | return 1.0 67 | } 68 | 69 | let scaleW = bounds.width / imageContainer.bounds.width 70 | let scaleH = bounds.height / imageContainer.bounds.height 71 | 72 | return max(scaleW, scaleH) 73 | } 74 | 75 | func updateMinZoomScale() { 76 | minimumZoomScale = getBoundZoomScale() 77 | } 78 | 79 | func zoomScaleToBound(animated: Bool = false) { 80 | let scale = getBoundZoomScale() 81 | 82 | minimumZoomScale = scale 83 | setZoomScale(scale, animated: animated) 84 | } 85 | 86 | func shouldScale() -> Bool { 87 | return contentSize.width / bounds.width <= 1.0 88 | || contentSize.height / bounds.height <= 1.0 89 | } 90 | 91 | func updateLayout(byNewSize newSize: CGSize) { 92 | let oldScrollViewcenter = center 93 | let contentOffsetCenter = CGPoint(x: (contentOffset.x + bounds.width / 2), 94 | y: (contentOffset.y + bounds.height / 2)) 95 | 96 | bounds = CGRect(x: 0, y: 0, width: newSize.width, height: newSize.height) 97 | let newContentOffset = CGPoint(x: (contentOffsetCenter.x - bounds.width / 2), 98 | y: (contentOffsetCenter.y - bounds.height / 2)) 99 | 100 | contentOffset = newContentOffset 101 | center = oldScrollViewcenter 102 | } 103 | 104 | func resetBy(rect: CGRect) { 105 | // Reseting zoom need to be before resetting frame and contentsize 106 | minimumZoomScale = 1.0 107 | zoomScale = 1.0 108 | 109 | frame = rect 110 | contentSize = rect.size 111 | } 112 | } 113 | 114 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/DGCropImage.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 52 | 53 | 58 | 59 | 61 | 67 | 68 | 69 | 70 | 71 | 81 | 82 | 88 | 89 | 95 | 96 | 97 | 98 | 100 | 101 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /Sources/DGCropImage/CropViewController/RatioSelector.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | public class RatioSelector: UIView { 11 | 12 | var didGetRatio: ((Double)->Void) = { _ in } 13 | private var type: RatioType = .vertical 14 | private var originalRatioH: Double = 0.0 15 | private var ratios: [RatioItemType] = [] 16 | 17 | private let scrollView: UIScrollView = { 18 | let scrollView = UIScrollView() 19 | scrollView.showsHorizontalScrollIndicator = false 20 | scrollView.showsVerticalScrollIndicator = false 21 | scrollView.translatesAutoresizingMaskIntoConstraints = false 22 | return scrollView 23 | }() 24 | 25 | private let stackView: UIStackView = { 26 | let view = UIStackView() 27 | view.alignment = .center 28 | view.distribution = .equalSpacing 29 | view.axis = .horizontal 30 | view.spacing = 10 31 | view.isLayoutMarginsRelativeArrangement = true 32 | view.layoutMargins = .init(top: 5, left: 0, bottom: 0, right: 5) 33 | view.translatesAutoresizingMaskIntoConstraints = false 34 | return view 35 | }() 36 | 37 | init(type: RatioType, originalRatioH: Double, ratios: [RatioItemType] = []) { 38 | super.init(frame: .zero) 39 | self.type = type 40 | self.originalRatioH = originalRatioH 41 | self.ratios = ratios 42 | setupViews() 43 | } 44 | 45 | public override init(frame: CGRect) { 46 | super.init(frame: frame) 47 | setupViews() 48 | } 49 | 50 | required init?(coder: NSCoder) { 51 | super.init(coder: coder) 52 | setupViews() 53 | } 54 | 55 | func update(fixedRatioManager: FixedRatioManager?) { 56 | guard let fixedRatioManager = fixedRatioManager else { return } 57 | ratios = fixedRatioManager.ratios 58 | type = fixedRatioManager.type 59 | originalRatioH = fixedRatioManager.originalRatioH 60 | 61 | if let ratioItemViews = stackView.arrangedSubviews as? [RatioItemView] { 62 | for ratioView in ratioItemViews { 63 | ratioView.type = type 64 | } 65 | } 66 | } 67 | 68 | func reset() { 69 | if let ratioItemViews = stackView.arrangedSubviews as? [RatioItemView] { 70 | for ratioView in ratioItemViews { 71 | ratioView.selected = originalRatioH == ratioView.ratio.ratioH ? true : false 72 | } 73 | } 74 | } 75 | 76 | private func addRatioItems() { 77 | for (index, item) in ratios.enumerated() { 78 | let itemView = RatioItemView(type: type, item: item) 79 | itemView.selected = index == 0 80 | stackView.addArrangedSubview(itemView) 81 | 82 | itemView.didGetRatio = {[weak self] ratio in 83 | let ratioValue = (self?.type == .horizontal) ? ratio.ratioH : ratio.ratioV 84 | self?.didGetRatio(ratioValue) 85 | 86 | if let ratioItemViews = self?.stackView.arrangedSubviews as? [RatioItemView] { 87 | for ratioView in ratioItemViews { 88 | ratioView.selected = ratio.nameH == ratioView.ratio.nameH ? true : false 89 | } 90 | } 91 | } 92 | } 93 | } 94 | 95 | private func setupViews() { 96 | translatesAutoresizingMaskIntoConstraints = false 97 | 98 | addSubview(scrollView) 99 | scrollView.addSubview(stackView) 100 | 101 | scrollView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true 102 | scrollView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true 103 | scrollView.topAnchor.constraint(equalTo: topAnchor).isActive = true 104 | scrollView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true 105 | 106 | stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor).isActive = true 107 | stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor).isActive = true 108 | stackView.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true 109 | stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true 110 | stackView.widthAnchor.constraint(greaterThanOrEqualTo: scrollView.widthAnchor).isActive = true 111 | stackView.heightAnchor.constraint(equalTo: scrollView.heightAnchor).isActive = true 112 | 113 | scrollView.contentInset = .init(top: 0, left: 15, bottom: 0, right: 15) 114 | 115 | addRatioItems() 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sources/DGCropImage/CropView/CropBoxFreeAspectFrameUpdater.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | struct CropBoxFreeAspectFrameUpdater { 11 | var minimumAspectRatio = CGFloat(0) 12 | 13 | private var contentFrame = CGRect.zero 14 | private var cropOriginFrame = CGRect.zero 15 | private(set) var cropBoxFrame = CGRect.zero 16 | private var tappedEdge = CropViewOverlayEdge.none 17 | 18 | init(tappedEdge: CropViewOverlayEdge, contentFrame: CGRect, cropOriginFrame: CGRect, cropBoxFrame: CGRect) { 19 | self.tappedEdge = tappedEdge 20 | self.contentFrame = contentFrame 21 | self.cropOriginFrame = cropOriginFrame 22 | self.cropBoxFrame = cropBoxFrame 23 | } 24 | 25 | mutating func updateCropBoxFrame(xDelta: CGFloat, yDelta: CGFloat) { 26 | func newAspectRatioValid(withNewSize newSize: CGSize) -> Bool { 27 | return min(newSize.width, newSize.height) / max(newSize.width, newSize.height) >= minimumAspectRatio 28 | } 29 | 30 | func handleLeftEdgeFrameUpdate(newSize: CGSize) { 31 | if newAspectRatioValid(withNewSize: newSize) { 32 | cropBoxFrame.origin.x = cropOriginFrame.origin.x + xDelta 33 | cropBoxFrame.size.width = newSize.width 34 | } 35 | } 36 | 37 | func handleRightEdgeFrameUpdate(newSize: CGSize) { 38 | if newAspectRatioValid(withNewSize: newSize) { 39 | cropBoxFrame.size.width = newSize.width 40 | } 41 | } 42 | 43 | func handleTopEdgeFrameUpdate(newSize: CGSize) { 44 | if newAspectRatioValid(withNewSize: newSize) { 45 | cropBoxFrame.origin.y = cropOriginFrame.origin.y + yDelta 46 | cropBoxFrame.size.height = newSize.height 47 | } 48 | } 49 | 50 | func handleBottomEdgeFrameUpdate(newSize: CGSize) { 51 | if newAspectRatioValid(withNewSize: newSize) { 52 | cropBoxFrame.size.height = newSize.height 53 | } 54 | } 55 | 56 | func getNewCropFrameSize(byTappedEdge tappedEdge: CropViewOverlayEdge) -> CGSize { 57 | let tappedEdgeCropFrameUpdateRule: TappedEdgeCropFrameUpdateRule = [.left: (-xDelta, 0), 58 | .right: (xDelta, 0), 59 | .top: (0, -yDelta), 60 | .bottom: (0, yDelta), 61 | .topLeft: (-xDelta, -yDelta), 62 | .topRight: (xDelta, -yDelta), 63 | .bottomLeft: (-xDelta, yDelta), 64 | .bottomRight: (xDelta, yDelta)] 65 | 66 | guard let delta = tappedEdgeCropFrameUpdateRule[tappedEdge] else { 67 | return cropOriginFrame.size 68 | } 69 | 70 | return CGSize(width: cropOriginFrame.width + delta.xDelta, height: cropOriginFrame.height + delta.yDelta) 71 | } 72 | 73 | func updateCropBoxFrame() { 74 | let newSize = getNewCropFrameSize(byTappedEdge: tappedEdge) 75 | 76 | switch tappedEdge { 77 | case .left: 78 | handleLeftEdgeFrameUpdate(newSize: newSize) 79 | case .right: 80 | handleRightEdgeFrameUpdate(newSize: newSize) 81 | case .top: 82 | handleTopEdgeFrameUpdate(newSize: newSize) 83 | case .bottom: 84 | handleBottomEdgeFrameUpdate(newSize: newSize) 85 | case .topLeft: 86 | handleTopEdgeFrameUpdate(newSize: newSize) 87 | handleLeftEdgeFrameUpdate(newSize: newSize) 88 | case .topRight: 89 | handleTopEdgeFrameUpdate(newSize: newSize) 90 | handleRightEdgeFrameUpdate(newSize: newSize) 91 | case .bottomLeft: 92 | handleBottomEdgeFrameUpdate(newSize: newSize) 93 | handleLeftEdgeFrameUpdate(newSize: newSize) 94 | case .bottomRight: 95 | handleBottomEdgeFrameUpdate(newSize: newSize) 96 | handleRightEdgeFrameUpdate(newSize: newSize) 97 | default: 98 | return 99 | } 100 | } 101 | 102 | updateCropBoxFrame() 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Sources/DGCropImage/CropViewController/FixedRatioManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import Foundation 9 | 10 | typealias RatioItemType = (nameH: String, ratioH: Double, nameV: String, ratioV: Double) 11 | 12 | class FixedRatioManager { 13 | private (set) var ratios: [RatioItemType] = [] 14 | private var ratioOptions: RatioOptions = .all 15 | private var customRatios: [RatioItemType] = [] 16 | 17 | var type: RatioType = .horizontal 18 | var originalRatioH = 1.0 19 | 20 | init(type: RatioType, originalRatioH: Double, ratioOptions: RatioOptions = .all, customRatios: [RatioItemType] = []) { 21 | 22 | self.type = type 23 | self.originalRatioH = originalRatioH 24 | 25 | if ratioOptions.contains(.original) { 26 | appendToTail(ratioItem: getOriginalRatioItem()) 27 | } 28 | 29 | if ratioOptions.contains(.square) { 30 | let squareText = "Square" 31 | let square = (squareText, 1.0, squareText, 1.0) 32 | appendToTail(ratioItem: square) 33 | } 34 | 35 | if ratioOptions.contains(.extraDefaultRatios) { 36 | addExtraDefaultRatios() 37 | } 38 | 39 | if ratioOptions.contains(.custom) { 40 | appendToTail(ratioItems: customRatios) 41 | } 42 | 43 | sort(isByHorizontal: (type == .horizontal)) 44 | } 45 | 46 | func getOriginalRatioItem() -> RatioItemType { 47 | let originalText = "Original" 48 | return (originalText, originalRatioH, originalText, originalRatioH) 49 | } 50 | } 51 | 52 | // MARK: - Private methods 53 | extension FixedRatioManager { 54 | private func addExtraDefaultRatios() { 55 | let scale3to2 = RatioItemType("3:2", 3.0/2.0, "2:3", 2.0/3.0) 56 | let scale5to3 = RatioItemType("5:3", 5.0/3.0, "3:5", 3.0/5.0) 57 | let scale4to3 = RatioItemType("4:3", 4.0/3.0, "3:4", 3.0/4.0) 58 | let scale5to4 = RatioItemType("5:4", 5.0/4.0, "4:5", 4.0/5.0) 59 | let scale7to5 = RatioItemType("7:5", 7.0/5.0, "5:7", 5.0/7.0) 60 | let scale16to9 = RatioItemType("16:9", 16.0/9.0, "9:16", 9.0/16.0) 61 | 62 | appendToTail(ratioItem: scale3to2) 63 | appendToTail(ratioItem: scale5to3) 64 | appendToTail(ratioItem: scale4to3) 65 | appendToTail(ratioItem: scale5to4) 66 | appendToTail(ratioItem: scale7to5) 67 | appendToTail(ratioItem: scale16to9) 68 | } 69 | 70 | private func contains(ratioItem: RatioItemType) -> Bool { 71 | var contains = false 72 | ratios.forEach { 73 | if $0.nameH == ratioItem.nameH || $0.nameV == ratioItem.nameV { 74 | contains = true 75 | } 76 | } 77 | return contains 78 | } 79 | 80 | private func getWidth(fromNameH nameH: String) -> Int { 81 | let items = nameH.split(separator: ":") 82 | guard items.count == 2 else { 83 | return 0 84 | } 85 | 86 | guard let width = Int(items[0]) else { 87 | return 0 88 | } 89 | 90 | return width 91 | } 92 | 93 | private func getHeight(fromNameH nameH: String) -> Int { 94 | let items = nameH.split(separator: ":") 95 | guard items.count == 2 else { 96 | return 0 97 | } 98 | 99 | guard let width = Int(items[1]) else { 100 | return 0 101 | } 102 | 103 | return width 104 | } 105 | 106 | private func insertToHead(ratioItem: RatioItemType) { 107 | guard contains(ratioItem: ratioItem) == false else { return } 108 | ratios.insert(ratioItem, at: 0) 109 | } 110 | 111 | private func appendToTail(ratioItem: RatioItemType) { 112 | guard contains(ratioItem: ratioItem) == false else { return } 113 | ratios.append(ratioItem) 114 | } 115 | 116 | private func appendToTail(ratioItems: [RatioItemType]) { 117 | ratioItems.forEach{ 118 | appendToTail(ratioItem: $0) 119 | } 120 | } 121 | 122 | private func appendToTail(items: [(width: Int, height: Int)]) { 123 | items.forEach { 124 | let ratioItem = (String("\($0.width):\($0.height)"), Double($0.width)/Double($0.height), String("\($0.height):\($0.width)"), Double($0.height)/Double($0.width)) 125 | appendToTail(ratioItem: ratioItem) 126 | } 127 | } 128 | 129 | private func sort(isByHorizontal: Bool) { 130 | guard ratios.count > 1 else { 131 | return 132 | } 133 | 134 | if isByHorizontal { 135 | ratios = ratios[...1] + ratios[2...].sorted { getHeight(fromNameH: $0.nameH) < getHeight(fromNameH: $1.nameH) } 136 | } else { 137 | ratios = ratios[...1] + ratios[2...].sorted { getWidth(fromNameH: $0.nameH) < getWidth(fromNameH: $1.nameH) } 138 | } 139 | } 140 | } 141 | 142 | -------------------------------------------------------------------------------- /Sources/DGCropImage/RotationDial/RotationDialPlate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | fileprivate let bigDegreeScaleNumber = 36 11 | fileprivate let smallDegreeScaleNumber = bigDegreeScaleNumber * 5 12 | fileprivate let margin: CGFloat = 0 13 | fileprivate let spaceBetweenScaleAndNumber: CGFloat = 10 14 | 15 | class RotationDialPlate: UIView { 16 | 17 | let smallDotLayer:CAReplicatorLayer = { 18 | var layer = CAReplicatorLayer() 19 | layer.instanceCount = smallDegreeScaleNumber 20 | layer.instanceTransform = 21 | CATransform3DMakeRotation( 22 | 2 * CGFloat.pi / CGFloat(layer.instanceCount), 23 | 0,0,1) 24 | 25 | return layer 26 | }() 27 | 28 | let bigDotLayer:CAReplicatorLayer = { 29 | var layer = CAReplicatorLayer() 30 | layer.instanceCount = bigDegreeScaleNumber 31 | layer.instanceTransform = 32 | CATransform3DMakeRotation( 33 | 2 * CGFloat.pi / CGFloat(layer.instanceCount), 34 | 0,0,1) 35 | 36 | return layer 37 | }() 38 | 39 | var dialConfig = DGCropImage.Config().dialConfig 40 | 41 | init(frame: CGRect, dialConfig: DialConfig = DGCropImage.Config().dialConfig) { 42 | super.init(frame: frame) 43 | self.dialConfig = dialConfig 44 | setup() 45 | } 46 | 47 | required init?(coder aDecoder: NSCoder) { 48 | super.init(coder: aDecoder) 49 | } 50 | 51 | private func getSmallScaleMark() -> CALayer { 52 | let mark = CAShapeLayer() 53 | mark.frame = CGRect(x: 0, y: 0, width: 2, height: 2) 54 | mark.path = UIBezierPath(ovalIn: mark.bounds).cgPath 55 | mark.fillColor = dialConfig.smallScaleColor.cgColor 56 | 57 | return mark 58 | } 59 | 60 | private func getBigScaleMark() -> CALayer { 61 | let mark = CAShapeLayer() 62 | mark.frame = CGRect(x: 0, y: 0, width: 4, height: 4) 63 | mark.path = UIBezierPath(ovalIn: mark.bounds).cgPath 64 | mark.fillColor = dialConfig.bigScaleColor.cgColor 65 | 66 | return mark 67 | } 68 | 69 | private func setupAngleNumber() { 70 | let numberFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.caption2) 71 | let cgFont = CTFontCreateUIFontForLanguage(.label, numberFont.pointSize/2, nil) 72 | 73 | let numberPlateLayer = CALayer() 74 | numberPlateLayer.sublayers?.forEach { $0.removeFromSuperlayer() } 75 | 76 | numberPlateLayer.frame = self.bounds 77 | self.layer.addSublayer(numberPlateLayer) 78 | 79 | let origin = CGPoint(x: numberPlateLayer.frame.midX, y: numberPlateLayer.frame.midY) 80 | let startPos = CGPoint(x: numberPlateLayer.bounds.midX, y: numberPlateLayer.bounds.maxY - margin - spaceBetweenScaleAndNumber) 81 | let step = (2 * CGFloat.pi) / CGFloat(bigDegreeScaleNumber) 82 | for index in (0 ..< bigDegreeScaleNumber) { 83 | 84 | guard index % dialConfig.numberShowSpan == 0 else { 85 | continue 86 | } 87 | 88 | let numberLayer = CATextLayer() 89 | numberLayer.bounds.size = CGSize(width: 30, height: 15) 90 | numberLayer.fontSize = numberFont.pointSize 91 | numberLayer.alignmentMode = CATextLayerAlignmentMode.center 92 | numberLayer.contentsScale = UIScreen.main.scale 93 | numberLayer.font = cgFont 94 | let angle = (index > bigDegreeScaleNumber / 2 ? index - bigDegreeScaleNumber : index) * 10 95 | numberLayer.string = "\(angle)" 96 | numberLayer.foregroundColor = dialConfig.numberColor.cgColor 97 | 98 | let stepChange = CGFloat(index) * step 99 | numberLayer.position = CGVector(from:origin, to:startPos).rotate(-stepChange).add(origin.vector).point.checked 100 | 101 | numberLayer.transform = CATransform3DMakeRotation(-stepChange, 0, 0, 1) 102 | numberPlateLayer.addSublayer(numberLayer) 103 | } 104 | } 105 | 106 | private func setupSmallScaleMarks() { 107 | smallDotLayer.frame = self.bounds 108 | smallDotLayer.sublayers?.forEach { $0.removeFromSuperlayer() } 109 | 110 | let smallScaleMark = getSmallScaleMark() 111 | smallScaleMark.position = CGPoint(x: smallDotLayer.bounds.midX, y: margin) 112 | smallDotLayer.addSublayer(smallScaleMark) 113 | 114 | self.layer.addSublayer(smallDotLayer) 115 | } 116 | 117 | private func setupBigScaleMarks() { 118 | bigDotLayer.frame = self.bounds 119 | bigDotLayer.sublayers?.forEach { $0.removeFromSuperlayer() } 120 | 121 | let bigScaleMark = getBigScaleMark() 122 | bigScaleMark.position = CGPoint(x: bigDotLayer.bounds.midX, y: margin) 123 | bigDotLayer.addSublayer(bigScaleMark) 124 | self.layer.addSublayer(bigDotLayer) 125 | } 126 | 127 | private func setCenterPart() { 128 | let layer = CAShapeLayer() 129 | let radius: CGFloat = 4 130 | layer.frame = CGRect(x: (self.layer.bounds.width - radius) / 2 , y: (self.layer.bounds.height - radius) / 2, width: radius, height: radius) 131 | layer.path = UIBezierPath(ovalIn: layer.bounds).cgPath 132 | layer.fillColor = dialConfig.centerAxisColor.cgColor 133 | 134 | self.layer.addSublayer(layer) 135 | } 136 | 137 | private func setup() { 138 | setupSmallScaleMarks() 139 | setupBigScaleMarks() 140 | setupAngleNumber() 141 | setCenterPart() 142 | } 143 | } 144 | 145 | -------------------------------------------------------------------------------- /Sources/DGCropImage/Extensions/CoreGraphicsExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | extension UIColor { 12 | var greyscale: UIColor{ 13 | var (hue, saturation, brightness, alpha) = (CGFloat(0.0), CGFloat(0.0), CGFloat(0.0), CGFloat(0.0)) 14 | 15 | if self.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) { 16 | return UIColor(hue: hue, saturation: 0, brightness: brightness, alpha: alpha / 2) 17 | }else { 18 | return UIColor.gray 19 | } 20 | } 21 | func modified(withAdditionalHue hue: CGFloat, additionalSaturation: CGFloat, additionalBrightness: CGFloat) -> UIColor { 22 | 23 | var currentHue: CGFloat = 0.0 24 | var currentSaturation: CGFloat = 0.0 25 | var currentBrigthness: CGFloat = 0.0 26 | var currentAlpha: CGFloat = 0.0 27 | 28 | if self.getHue(¤tHue, saturation: ¤tSaturation, brightness: ¤tBrigthness, alpha: ¤tAlpha){ 29 | return UIColor(hue: currentHue + hue, 30 | saturation: currentSaturation + additionalSaturation, 31 | brightness: currentBrigthness + additionalBrightness, 32 | alpha: currentAlpha) 33 | } else { 34 | return self 35 | } 36 | } 37 | } 38 | 39 | extension Angle{ 40 | var reverse:Angle{return 2 * CGFloat.pi - self} 41 | } 42 | extension FloatingPoint{ 43 | var isBad:Bool{ return isNaN || isInfinite } 44 | var checked:Self{ 45 | guard !isBad && !isInfinite else { 46 | fatalError("bad number!") 47 | } 48 | return self 49 | } 50 | 51 | } 52 | 53 | typealias Angle = CGFloat 54 | func df() -> CGFloat { 55 | return CGFloat(drand48()).checked 56 | } 57 | 58 | func clockDescretization(_ val: CGFloat) -> CGFloat{ 59 | let min:Double = 0 60 | let max:Double = 2 * Double.pi 61 | let steps:Double = 144 62 | let stepSize = (max - min) / steps 63 | let nsf = floor(Double(val) / stepSize) 64 | let rest = Double(val) - stepSize * nsf 65 | return CGFloat(rest > stepSize / 2 ? stepSize * (nsf + 1) : stepSize * nsf).checked 66 | 67 | } 68 | 69 | extension CALayer { 70 | func doDebug(){ 71 | self.borderColor = UIColor(hue: df() , saturation: df(), brightness: 1, alpha: 1).cgColor 72 | self.borderWidth = 2; 73 | self.sublayers?.forEach({$0.doDebug()}) 74 | } 75 | } 76 | 77 | extension CGSize{ 78 | var hasNaN:Bool{return width.isBad || height.isBad } 79 | var checked:CGSize{ 80 | guard !hasNaN else { 81 | fatalError("bad number!") 82 | } 83 | return self 84 | } 85 | } 86 | 87 | extension CGRect{ 88 | var center:CGPoint { return CGPoint(x:midX, y: midY).checked} 89 | var hasNaN:Bool{return size.hasNaN || origin.hasNaN} 90 | var checked:CGRect{ 91 | guard !hasNaN else { 92 | fatalError("bad number!") 93 | } 94 | return self 95 | } 96 | } 97 | 98 | extension CGPoint{ 99 | var vector:CGVector { return CGVector(dx: x, dy: y).checked} 100 | var checked:CGPoint{ 101 | guard !hasNaN else { 102 | fatalError("bad number!") 103 | } 104 | return self 105 | } 106 | var hasNaN:Bool{return x.isBad || y.isBad } 107 | } 108 | 109 | extension CGVector{ 110 | var hasNaN:Bool{return dx.isBad || dy.isBad} 111 | var checked:CGVector{ 112 | guard !hasNaN else { 113 | fatalError("bad number!") 114 | } 115 | return self 116 | } 117 | 118 | static var root:CGVector{ return CGVector(dx:1, dy:0).checked} 119 | var magnitude:CGFloat { return sqrt(pow(dx, 2) + pow(dy,2)).checked} 120 | var normalized: CGVector { return CGVector(dx:dx / magnitude, dy: dy / magnitude).checked } 121 | var point:CGPoint { return CGPoint(x: dx, y: dy).checked} 122 | func rotate(_ angle:Angle) -> CGVector { return CGVector(dx: dx * cos(angle) - dy * sin(angle), dy: dx * sin(angle) + dy * cos(angle) ).checked} 123 | 124 | func dot(_ vec2:CGVector) -> CGFloat { return (dx * vec2.dx + dy * vec2.dy).checked} 125 | func add(_ vec2:CGVector) -> CGVector { return CGVector(dx:dx + vec2.dx , dy: dy + vec2.dy).checked} 126 | func cross(_ vec2:CGVector) -> CGFloat { return (dx * vec2.dy - dy * vec2.dx).checked} 127 | func scale(_ scale:CGFloat) -> CGVector { return CGVector(dx:dx * scale , dy: dy * scale).checked} 128 | 129 | init(from: CGPoint, to: CGPoint) { 130 | guard !from.hasNaN && !to.hasNaN else { 131 | fatalError("Nan point!") 132 | } 133 | self.init() 134 | dx = to.x - from.x 135 | dy = to.y - from.y 136 | _ = self.checked 137 | } 138 | 139 | init(angle: Angle) { 140 | let compAngle = angle < 0 ? (angle + 2 * CGFloat.pi) : angle 141 | self.init() 142 | dx = cos(compAngle.checked) 143 | dy = sin(compAngle.checked) 144 | _ = self.checked 145 | } 146 | 147 | var theta: Angle { 148 | return atan2(dy, dx)} 149 | 150 | static func theta(_ vec1: CGVector, vec2: CGVector) -> Angle { 151 | var result = vec1.normalized.dot(vec2.normalized) 152 | if result > 1 { 153 | result = 1 154 | } else if result < -1 { 155 | result = -1 156 | } 157 | return acos(result).checked 158 | } 159 | 160 | static func signedTheta(_ vec1: CGVector, vec2: CGVector) -> Angle { 161 | 162 | return (vec1.normalized.cross(vec2.normalized) > 0 ? -1 : 1) * theta(vec1.normalized, vec2: vec2.normalized).checked 163 | } 164 | } 165 | 166 | -------------------------------------------------------------------------------- /Sources/DGCropImage/CropView/CropBoxLockedAspectFrameUpdater.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | struct CropBoxLockedAspectFrameUpdater { 12 | private var contentFrame = CGRect.zero 13 | private var cropOriginFrame = CGRect.zero 14 | private(set) var cropBoxFrame = CGRect.zero 15 | private var tappedEdge = CropViewOverlayEdge.none 16 | 17 | init(tappedEdge: CropViewOverlayEdge, contentFrame: CGRect, cropOriginFrame: CGRect, cropBoxFrame: CGRect) { 18 | self.tappedEdge = tappedEdge 19 | self.contentFrame = contentFrame 20 | self.cropOriginFrame = cropOriginFrame 21 | self.cropBoxFrame = cropBoxFrame 22 | } 23 | 24 | mutating func updateCropBoxFrame(xDelta: CGFloat, yDelta: CGFloat) { 25 | var xDelta = xDelta 26 | var yDelta = yDelta 27 | 28 | //Current aspect ratio of the crop box in case we need to clamp it 29 | let aspectRatio = (cropOriginFrame.size.width / cropOriginFrame.size.height); 30 | 31 | func updateHeightFromBothSides() { 32 | cropBoxFrame.size.height = cropBoxFrame.width / aspectRatio; 33 | cropBoxFrame.origin.y = cropOriginFrame.midY - (cropBoxFrame.height * 0.5); 34 | } 35 | 36 | func updateWidthFromBothSides() { 37 | cropBoxFrame.size.width = cropBoxFrame.height * aspectRatio 38 | cropBoxFrame.origin.x = cropOriginFrame.midX - cropBoxFrame.width * 0.5 39 | } 40 | 41 | func handleLeftEdgeFrameUpdate() { 42 | updateHeightFromBothSides() 43 | xDelta = max(0, xDelta) 44 | cropBoxFrame.origin.x = cropOriginFrame.origin.x + xDelta 45 | cropBoxFrame.size.width = cropOriginFrame.width - xDelta 46 | cropBoxFrame.size.height = cropBoxFrame.size.width / aspectRatio 47 | } 48 | 49 | func handleRightEdgeFrameUpdate() { 50 | updateHeightFromBothSides() 51 | cropBoxFrame.size.width = min(cropOriginFrame.width + xDelta, contentFrame.height * aspectRatio) 52 | cropBoxFrame.size.height = cropBoxFrame.size.width / aspectRatio 53 | } 54 | 55 | func handleTopEdgeFrameUpdate() { 56 | updateWidthFromBothSides() 57 | yDelta = max(0, yDelta) 58 | cropBoxFrame.origin.y = cropOriginFrame.origin.y + yDelta 59 | cropBoxFrame.size.height = cropOriginFrame.height - yDelta 60 | cropBoxFrame.size.width = cropBoxFrame.size.height * aspectRatio 61 | } 62 | 63 | func handleBottomEdgeFrameUpdate() { 64 | updateWidthFromBothSides() 65 | cropBoxFrame.size.height = min(cropOriginFrame.height + yDelta, contentFrame.width / aspectRatio) 66 | cropBoxFrame.size.width = cropBoxFrame.size.height * aspectRatio 67 | } 68 | 69 | let tappedEdgeCropFrameUpdateRule: TappedEdgeCropFrameUpdateRule = [.topLeft: (xDelta, yDelta), 70 | .topRight: (-xDelta, yDelta), 71 | .bottomLeft: (xDelta, -yDelta), 72 | .bottomRight: (-xDelta, -yDelta)] 73 | 74 | func setCropBoxSize() { 75 | guard let delta = tappedEdgeCropFrameUpdateRule[tappedEdge] else { 76 | return 77 | } 78 | 79 | var distance = CGPoint() 80 | distance.x = 1.0 - (delta.xDelta / cropOriginFrame.width) 81 | distance.y = 1.0 - (delta.yDelta / cropOriginFrame.height) 82 | let scale = (distance.x + distance.y) * 0.5 83 | 84 | cropBoxFrame.size.width = ceil(cropOriginFrame.width * scale) 85 | cropBoxFrame.size.height = ceil(cropOriginFrame.height * scale) 86 | } 87 | 88 | func handleTopLeftEdgeFrameUpdate() { 89 | xDelta = max(0, xDelta) 90 | yDelta = max(0, yDelta) 91 | 92 | setCropBoxSize() 93 | cropBoxFrame.origin.x = cropOriginFrame.origin.x + (cropOriginFrame.width - cropBoxFrame.width) 94 | cropBoxFrame.origin.y = cropOriginFrame.origin.y + (cropOriginFrame.height - cropBoxFrame.height) 95 | } 96 | 97 | func handleTopRightEdgeFrameUpdate() { 98 | xDelta = max(0, xDelta) 99 | yDelta = max(0, yDelta) 100 | 101 | setCropBoxSize() 102 | cropBoxFrame.origin.y = cropOriginFrame.origin.y + (cropOriginFrame.height - cropBoxFrame.height) 103 | } 104 | 105 | func handleBottomLeftEdgeFrameUpdate() { 106 | setCropBoxSize() 107 | cropBoxFrame.origin.x = cropOriginFrame.maxX - cropBoxFrame.width; 108 | } 109 | 110 | func handleBottomRightEdgeFrameUpdate() { 111 | setCropBoxSize() 112 | } 113 | 114 | func updateCropBoxFrame() { 115 | switch tappedEdge { 116 | case .left: 117 | handleLeftEdgeFrameUpdate() 118 | case .right: 119 | handleRightEdgeFrameUpdate() 120 | case .top: 121 | handleTopEdgeFrameUpdate() 122 | case .bottom: 123 | handleBottomEdgeFrameUpdate() 124 | case .topLeft: 125 | handleTopLeftEdgeFrameUpdate() 126 | case .topRight: 127 | handleTopRightEdgeFrameUpdate() 128 | case .bottomLeft: 129 | handleBottomLeftEdgeFrameUpdate() 130 | case .bottomRight: 131 | handleBottomRightEdgeFrameUpdate() 132 | default: 133 | print("none") 134 | } 135 | } 136 | 137 | updateCropBoxFrame() 138 | } 139 | } 140 | 141 | -------------------------------------------------------------------------------- /Sources/DGCropImage/DGCropImage.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | private(set) var bundle: Bundle? = { 4 | return DGCropImage.Config.bundle 5 | }() 6 | 7 | internal var localizationConfig = LocalizationConfig() 8 | 9 | // MARK: - APIs 10 | public func crop(image: UIImage, 11 | config: DGCropImage.Config = DGCropImage.Config(), 12 | cropToolbar: CropToolbarProtocol = CropToolbar(frame: CGRect.zero)) -> CropViewController { 13 | return CropViewController(image: image, 14 | config: config, 15 | mode: .normal, 16 | cropToolbar: cropToolbar) 17 | } 18 | 19 | public func locateResourceBundle(by hostClass: AnyClass) { 20 | LocalizedHelper.setBundle(Bundle(for: hostClass)) 21 | } 22 | 23 | @available(*, deprecated, renamed: "crop(image:by:)") 24 | public func getCroppedImage(byCropInfo cropInfo: CropInfo, andImage image: UIImage) -> UIImage? { 25 | return image.crop(by: cropInfo) 26 | } 27 | 28 | public func crop(image: UIImage, by cropInfo: CropInfo) -> UIImage? { 29 | return image.crop(by: cropInfo) 30 | } 31 | 32 | // MARK: - Type Aliases 33 | public typealias Transformation = ( 34 | offset: CGPoint, 35 | rotation: CGFloat, 36 | scale: CGFloat, 37 | manualZoomed: Bool, 38 | intialMaskFrame: CGRect, 39 | maskFrame: CGRect, 40 | scrollBounds: CGRect 41 | ) 42 | 43 | public typealias CropInfo = (translation: CGPoint, rotation: CGFloat, scale: CGFloat, cropSize: CGSize, imageViewSize: CGSize) 44 | 45 | // MARK: - Enums 46 | public enum PresetTransformationType { 47 | case none 48 | case presetInfo(info: Transformation) 49 | case presetNormalizedInfo(normailizedInfo: CGRect) 50 | } 51 | 52 | public enum PresetFixedRatioType { 53 | /** When choose alwaysUsingOnePresetFixedRatio, fixed-ratio setting button does not show. 54 | */ 55 | case alwaysUsingOnePresetFixedRatio(ratio: Double = 0) 56 | case canUseMultiplePresetFixedRatio(defaultRatio: Double = 0) 57 | } 58 | 59 | public enum CropVisualEffectType { 60 | case blurDark 61 | case dark 62 | case light 63 | case none 64 | } 65 | 66 | public enum CropShapeType { 67 | case rect 68 | 69 | /** 70 | The ratio of the crop mask will always be 1:1. 71 | ### Notice 72 | It equals cropShapeType = .rect 73 | and presetFixedRatioType = .alwaysUsingOnePresetFixedRatio(ratio: 1) 74 | */ 75 | case square 76 | 77 | /** 78 | When maskOnly is true, the cropped image is kept rect 79 | */ 80 | case ellipse(maskOnly: Bool = false) 81 | 82 | /** 83 | The ratio of the crop mask will always be 1:1 and when maskOnly is true, the cropped image is kept rect. 84 | ### Notice 85 | It equals cropShapeType = .ellipse and presetFixedRatioType = .alwaysUsingOnePresetFixedRatio(ratio: 1) 86 | */ 87 | case circle(maskOnly: Bool = false) 88 | 89 | /** 90 | When maskOnly is true, the cropped image is kept rect 91 | */ 92 | case roundedRect(radiusToShortSide: CGFloat, maskOnly: Bool = false) 93 | 94 | case diamond(maskOnly: Bool = false) 95 | 96 | case heart(maskOnly: Bool = false) 97 | 98 | case polygon(sides: Int, offset: CGFloat = 0, maskOnly: Bool = false) 99 | 100 | /** 101 | Each point should have normailzed values whose range is 0...1 102 | */ 103 | case path(points: [CGPoint], maskOnly: Bool = false) 104 | } 105 | 106 | public enum RatioCandidatesShowType { 107 | case presentRatioList 108 | case alwaysShowRatioList 109 | } 110 | 111 | public enum FixRatiosShowType { 112 | case adaptive 113 | case horizontal 114 | case vetical 115 | } 116 | 117 | // MARK: - Localization 118 | public class LocalizationConfig { 119 | public var bundle: Bundle? = DGCropImage.Config.bundle 120 | public var tableName = "Localizable" 121 | } 122 | 123 | // MARK: - CropToolbarConfig 124 | public struct CropToolbarConfig { 125 | public var optionButtonFontSize: CGFloat = 14 126 | public var optionButtonFontSizeForPad: CGFloat = 20 127 | public var cropToolbarHeightForVertialOrientation: CGFloat = 44 128 | public var cropToolbarWidthForHorizontalOrientation: CGFloat = 80 129 | public var ratioCandidatesShowType: RatioCandidatesShowType = .presentRatioList 130 | public var fixRatiosShowType: FixRatiosShowType = .adaptive 131 | public var toolbarButtonOptions: ToolbarButtonOptions = .default 132 | public var presetRatiosButtonSelected = false 133 | 134 | var mode: CropToolbarMode = .normal 135 | var includeFixedRatioSettingButton = true 136 | } 137 | 138 | // MARK: - Config 139 | public struct Config { 140 | public var presetTransformationType: PresetTransformationType = .none 141 | public var cropShapeType: CropShapeType = .rect 142 | public var cropVisualEffectType: CropVisualEffectType = .blurDark 143 | public var ratioOptions: RatioOptions = .all 144 | public var presetFixedRatioType: PresetFixedRatioType = .canUseMultiplePresetFixedRatio() 145 | public var showRotationDial = true 146 | public var dialConfig = DialConfig() 147 | public var cropToolbarConfig = CropToolbarConfig() 148 | public private(set) var localizationConfig = DGCropImage.localizationConfig 149 | 150 | var customRatios: [(width: Int, height: Int)] = [] 151 | 152 | static private var bundleIdentifier: String = { 153 | return "com.donggyushin.DGCropImage" 154 | }() 155 | 156 | static private(set) var bundle: Bundle? = { 157 | guard let bundle = Bundle(identifier: bundleIdentifier) else { 158 | return nil 159 | } 160 | 161 | if let url = bundle.url(forResource: "DGCropImageResources", withExtension: "bundle") { 162 | let bundle = Bundle(url: url) 163 | return bundle 164 | } 165 | return nil 166 | }() 167 | 168 | public init() { 169 | } 170 | 171 | mutating public func addCustomRatio(byHorizontalWidth width: Int, andHorizontalHeight height: Int) { 172 | customRatios.append((width, height)) 173 | } 174 | 175 | mutating public func addCustomRatio(byVerticalWidth width: Int, andVerticalHeight height: Int) { 176 | customRatios.append((height, width)) 177 | } 178 | 179 | func hasCustomRatios() -> Bool { 180 | return !customRatios.isEmpty 181 | } 182 | 183 | func getCustomRatioItems() -> [RatioItemType] { 184 | return customRatios.map { 185 | (String("\($0.width):\($0.height)"), Double($0.width)/Double($0.height), String("\($0.height):\($0.width)"), Double($0.height)/Double($0.width)) 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /Sources/DGCropImage/Extensions/UIImageExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIImage { 11 | 12 | func cgImageWithFixedOrientation() -> CGImage? { 13 | 14 | guard let cgImage = self.cgImage, let colorSpace = cgImage.colorSpace else { 15 | return nil 16 | } 17 | 18 | if self.imageOrientation == UIImage.Orientation.up { 19 | return self.cgImage 20 | } 21 | 22 | let width = self.size.width 23 | let height = self.size.height 24 | 25 | var transform = CGAffineTransform.identity 26 | 27 | switch self.imageOrientation { 28 | case .down, .downMirrored: 29 | transform = transform.translatedBy(x: width, y: height) 30 | transform = transform.rotated(by: CGFloat.pi) 31 | 32 | case .left, .leftMirrored: 33 | transform = transform.translatedBy(x: width, y: 0) 34 | transform = transform.rotated(by: 0.5 * CGFloat.pi) 35 | 36 | case .right, .rightMirrored: 37 | transform = transform.translatedBy(x: 0, y: height) 38 | transform = transform.rotated(by: -0.5 * CGFloat.pi) 39 | 40 | case .up, .upMirrored: 41 | break 42 | @unknown default: 43 | break 44 | } 45 | 46 | switch self.imageOrientation { 47 | case .upMirrored, .downMirrored: 48 | transform = transform.translatedBy(x: width, y: 0) 49 | transform = transform.scaledBy(x: -1, y: 1) 50 | 51 | case .leftMirrored, .rightMirrored: 52 | transform = transform.translatedBy(x: height, y: 0) 53 | transform = transform.scaledBy(x: -1, y: 1) 54 | 55 | default: 56 | break 57 | } 58 | 59 | guard let context = CGContext( 60 | data: nil, 61 | width: Int(width), 62 | height: Int(height), 63 | bitsPerComponent: cgImage.bitsPerComponent, 64 | bytesPerRow: 0, 65 | space: colorSpace, 66 | bitmapInfo: UInt32(cgImage.bitmapInfo.rawValue) 67 | ) else { 68 | return nil 69 | } 70 | 71 | context.concatenate(transform) 72 | 73 | switch self.imageOrientation { 74 | case .left, .leftMirrored, .right, .rightMirrored: 75 | context.draw(cgImage, in: CGRect(x: 0, y: 0, width: height, height: width)) 76 | 77 | default: 78 | context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) 79 | } 80 | 81 | // And now we just create a new UIImage from the drawing context 82 | guard let newCGImg = context.makeImage() else { 83 | return nil 84 | } 85 | 86 | return newCGImg 87 | } 88 | 89 | func isHorizontal() -> Bool { 90 | let orientationArray: [UIImage.Orientation] = [.up,.upMirrored,.down,.downMirrored] 91 | 92 | if orientationArray.contains(imageOrientation) { 93 | return size.width > size.height 94 | } else { 95 | return size.height > size.width 96 | } 97 | } 98 | 99 | func ratioH() -> CGFloat { 100 | let orientationArray: [UIImage.Orientation] = [.up,.upMirrored,.down,.downMirrored] 101 | if orientationArray.contains(imageOrientation) { 102 | return size.width / size.height 103 | } else { 104 | return size.height / size.width 105 | } 106 | } 107 | 108 | func crop(by cropInfo: CropInfo) -> UIImage? { 109 | guard let fixedImage = self.cgImageWithFixedOrientation() else { 110 | return nil 111 | } 112 | 113 | var transform = CGAffineTransform.identity 114 | transform = transform.translatedBy(x: cropInfo.translation.x, y: cropInfo.translation.y) 115 | transform = transform.rotated(by: cropInfo.rotation) 116 | transform = transform.scaledBy(x: cropInfo.scale, y: cropInfo.scale) 117 | 118 | guard let imageRef = fixedImage.transformedImage(transform, 119 | zoomScale: cropInfo.scale, 120 | sourceSize: self.size, 121 | cropSize: cropInfo.cropSize, 122 | imageViewSize: cropInfo.imageViewSize) else { 123 | return nil 124 | } 125 | 126 | return UIImage(cgImage: imageRef) 127 | } 128 | 129 | } 130 | 131 | extension UIImage { 132 | func getImageWithTransparentBackground(pathBuilder: (CGRect) -> UIBezierPath) -> UIImage? { 133 | guard let cgImage = cgImage else { return nil } 134 | 135 | // Because imageRendererFormat is a read only property 136 | // Setting imageRendererFormat.opaque = false does not work 137 | // https://stackoverflow.com/a/59805317/288724 138 | let format = imageRendererFormat 139 | format.opaque = false 140 | 141 | let rect = CGRect(origin: .zero, size: size) 142 | 143 | return UIGraphicsImageRenderer(size: size, format: format).image() { 144 | _ in 145 | pathBuilder(rect).addClip() 146 | UIImage(cgImage: cgImage, scale: scale, orientation: imageOrientation) 147 | .draw(in: rect) 148 | } 149 | } 150 | 151 | var ellipseMasked: UIImage? { 152 | return getImageWithTransparentBackground() { 153 | UIBezierPath(ovalIn: $0) 154 | } 155 | } 156 | 157 | func roundRect(_ radius: CGFloat) -> UIImage? { 158 | return getImageWithTransparentBackground() { 159 | UIBezierPath(roundedRect: $0, cornerRadius: radius) 160 | } 161 | } 162 | 163 | var heart: UIImage? { 164 | return getImageWithTransparentBackground() { 165 | UIBezierPath(heartIn: $0) 166 | } 167 | } 168 | 169 | func clipPath(_ points: [CGPoint]) -> UIImage? { 170 | guard points.count >= 3 else { 171 | return nil 172 | } 173 | 174 | return getImageWithTransparentBackground() {rect in 175 | let newPoints = points.map{ CGPoint(x: rect.origin.x + rect.width * $0.x, y: rect.origin.y + rect.height * $0.y)} 176 | 177 | 178 | let path = UIBezierPath() 179 | path.move(to: newPoints[0]) 180 | 181 | for index in 1.. CAShapeLayer { 61 | let coff: CGFloat 62 | switch cropShapeType { 63 | case .roundedRect: 64 | coff = cropRatio 65 | default: 66 | coff = 1 67 | } 68 | 69 | let originX = bounds.midX - minOverLayerUnit * coff / 2 70 | let originY = bounds.midY - minOverLayerUnit / 2 71 | let initialRect = CGRect(x: originX, y: originY, width: minOverLayerUnit * coff, height: minOverLayerUnit) 72 | 73 | let path = UIBezierPath(rect: self.bounds) 74 | 75 | let innerPath: UIBezierPath 76 | 77 | func getInnerPath(by points: [CGPoint]) -> UIBezierPath { 78 | let innerPath = UIBezierPath() 79 | guard points.count >= 3 else { 80 | return innerPath 81 | } 82 | let points0 = CGPoint(x: initialRect.width * points[0].x + initialRect.origin.x, y: initialRect.height * points[0].y + initialRect.origin.y) 83 | innerPath.move(to: points0) 84 | 85 | for index in 1.. CGFloat { 162 | return .pi * (self/180) 163 | } 164 | } 165 | 166 | extension Int { 167 | var degreesToRadians: CGFloat { return CGFloat(self) * .pi / 180 } 168 | } 169 | 170 | func polygonPointArray(sides: Int, 171 | originX: CGFloat, 172 | originY: CGFloat, 173 | radius: CGFloat, 174 | offset: CGFloat) -> [CGPoint] { 175 | let angle = (360/CGFloat(sides)).radians() 176 | 177 | var index = 0 178 | var points = [CGPoint]() 179 | 180 | while index <= sides { 181 | let xpo = originX + radius * cos(angle * CGFloat(index) - offset.radians()) 182 | let ypo = originY + radius * sin(angle * CGFloat(index) - offset.radians()) 183 | points.append(CGPoint(x: xpo, y: ypo)) 184 | index += 1 185 | } 186 | return points 187 | } 188 | 189 | 190 | -------------------------------------------------------------------------------- /Sources/DGCropImage/CropView/CropViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | enum ImageRotationType: CGFloat { 11 | case none = 0 12 | case counterclockwise90 = -90 13 | case counterclockwise180 = -180 14 | case counterclockwise270 = -270 15 | 16 | mutating func counterclockwiseRotate90() { 17 | if self == .counterclockwise270 { 18 | self = .none 19 | } else { 20 | self = ImageRotationType(rawValue: self.rawValue - 90) ?? .none 21 | } 22 | } 23 | 24 | mutating func clockwiseRotate90() { 25 | switch (self) { 26 | case .counterclockwise90: 27 | self = .none 28 | case .counterclockwise180: 29 | self = .counterclockwise90 30 | case .counterclockwise270: 31 | self = .counterclockwise180 32 | case .none: 33 | self = .counterclockwise270 34 | } 35 | } 36 | } 37 | 38 | class CropViewModel: NSObject { 39 | var statusChanged: (_ status: CropViewStatus)->Void = { _ in } 40 | 41 | var viewStatus: CropViewStatus = .initial { 42 | didSet { 43 | self.statusChanged(viewStatus) 44 | } 45 | } 46 | 47 | @objc dynamic var cropBoxFrame = CGRect.zero 48 | var cropOrignFrame = CGRect.zero 49 | 50 | var panOriginPoint = CGPoint.zero 51 | var tappedEdge = CropViewOverlayEdge.none 52 | 53 | var degrees: CGFloat = 0 54 | 55 | var radians: CGFloat { 56 | get { 57 | return degrees * CGFloat.pi / 180 58 | } 59 | } 60 | 61 | var rotationType: ImageRotationType = .none 62 | var aspectRatio: CGFloat = -1 63 | var cropLeftTopOnImage: CGPoint = .zero 64 | var cropRightBottomOnImage: CGPoint = CGPoint(x: 1, y: 1) 65 | 66 | func reset(forceFixedRatio: Bool = false) { 67 | cropBoxFrame = .zero 68 | degrees = 0 69 | rotationType = .none 70 | 71 | if forceFixedRatio == false { 72 | aspectRatio = -1 73 | } 74 | 75 | cropLeftTopOnImage = .zero 76 | cropRightBottomOnImage = CGPoint(x: 1, y: 1) 77 | 78 | setInitialStatus() 79 | } 80 | 81 | func rotateBy90(rotateAngle: CGFloat) { 82 | if (rotateAngle < 0) { 83 | rotationType.counterclockwiseRotate90() 84 | } else { 85 | rotationType.clockwiseRotate90() 86 | } 87 | } 88 | 89 | func counterclockwiseRotateBy90() { 90 | rotationType.counterclockwiseRotate90() 91 | } 92 | 93 | func clockwiseRotateBy90() { 94 | rotationType.clockwiseRotate90() 95 | } 96 | 97 | func getTotalRadias(by radians: CGFloat) -> CGFloat { 98 | return radians + rotationType.rawValue * CGFloat.pi / 180 99 | } 100 | 101 | func getTotalRadians() -> CGFloat { 102 | return getTotalRadias(by: radians) 103 | } 104 | 105 | func getRatioType(byImageIsOriginalHorizontal isHorizontal: Bool) -> RatioType { 106 | if isUpOrUpsideDown() { 107 | return isHorizontal ? .horizontal : .vertical 108 | } else { 109 | return isHorizontal ? .vertical : .horizontal 110 | } 111 | } 112 | 113 | func isUpOrUpsideDown() -> Bool { 114 | return rotationType == .none || rotationType == .counterclockwise180 115 | } 116 | 117 | func prepareForCrop(byTouchPoint point: CGPoint) { 118 | panOriginPoint = point 119 | cropOrignFrame = cropBoxFrame 120 | 121 | tappedEdge = cropEdge(forPoint: point) 122 | 123 | if tappedEdge == .none { 124 | setTouchImageStatus() 125 | } else { 126 | setTouchCropboxHandleStatus() 127 | } 128 | } 129 | 130 | func resetCropFrame(by frame: CGRect) { 131 | cropBoxFrame = frame 132 | cropOrignFrame = frame 133 | } 134 | 135 | func needCrop() -> Bool { 136 | return !cropOrignFrame.equalTo(cropBoxFrame) 137 | } 138 | 139 | func cropEdge(forPoint point: CGPoint) -> CropViewOverlayEdge { 140 | let touchRect = cropBoxFrame.insetBy(dx: -hotAreaUnit / 2, dy: -hotAreaUnit / 2) 141 | return GeometryHelper.getCropEdge(forPoint: point, byTouchRect: touchRect, hotAreaUnit: hotAreaUnit) 142 | } 143 | 144 | func getNewCropBoxFrame(with point: CGPoint, and contentFrame: CGRect, aspectRatioLockEnabled: Bool) -> CGRect { 145 | var point = point 146 | point.x = max(contentFrame.origin.x - cropViewPadding, point.x) 147 | point.y = max(contentFrame.origin.y - cropViewPadding, point.y) 148 | 149 | //The delta between where we first tapped, and where our finger is now 150 | let xDelta = ceil(point.x - panOriginPoint.x) 151 | let yDelta = ceil(point.y - panOriginPoint.y) 152 | 153 | let newCropBoxFrame: CGRect 154 | if aspectRatioLockEnabled { 155 | var cropBoxLockedAspectFrameUpdater = CropBoxLockedAspectFrameUpdater(tappedEdge: tappedEdge, contentFrame: contentFrame, cropOriginFrame: cropOrignFrame, cropBoxFrame: cropBoxFrame) 156 | cropBoxLockedAspectFrameUpdater.updateCropBoxFrame(xDelta: xDelta, yDelta: yDelta) 157 | newCropBoxFrame = cropBoxLockedAspectFrameUpdater.cropBoxFrame 158 | } else { 159 | var cropBoxFreeAspectFrameUpdater = CropBoxFreeAspectFrameUpdater(tappedEdge: tappedEdge, contentFrame: contentFrame, cropOriginFrame: cropOrignFrame, cropBoxFrame: cropBoxFrame) 160 | cropBoxFreeAspectFrameUpdater.updateCropBoxFrame(xDelta: xDelta, yDelta: yDelta) 161 | newCropBoxFrame = cropBoxFreeAspectFrameUpdater.cropBoxFrame 162 | } 163 | 164 | return newCropBoxFrame 165 | } 166 | 167 | func setCropBoxFrame(by refCropBox: CGRect, and imageRationH: Double) { 168 | var cropBoxFrame = refCropBox 169 | let center = cropBoxFrame.center 170 | 171 | if (aspectRatio > CGFloat(imageRationH)) { 172 | cropBoxFrame.size.height = cropBoxFrame.width / aspectRatio 173 | } else { 174 | cropBoxFrame.size.width = cropBoxFrame.height * aspectRatio 175 | } 176 | 177 | cropBoxFrame.origin.x = center.x - cropBoxFrame.width / 2 178 | cropBoxFrame.origin.y = center.y - cropBoxFrame.height / 2 179 | 180 | self.cropBoxFrame = cropBoxFrame 181 | } 182 | } 183 | 184 | // MARK: - Handle view status changes 185 | extension CropViewModel { 186 | func setInitialStatus() { 187 | viewStatus = .initial 188 | } 189 | 190 | func setRotatingStatus(by angle: CGAngle) { 191 | viewStatus = .rotating(angle: angle) 192 | } 193 | 194 | func setDegree90RotatingStatus() { 195 | viewStatus = .degree90Rotating 196 | } 197 | 198 | func setTouchImageStatus() { 199 | viewStatus = .touchImage 200 | } 201 | 202 | func setTouchRotationBoardStatus() { 203 | viewStatus = .touchRotationBoard 204 | } 205 | 206 | func setTouchCropboxHandleStatus() { 207 | viewStatus = .touchCropboxHandle(tappedEdge: tappedEdge) 208 | } 209 | 210 | func setBetweenOperationStatus() { 211 | viewStatus = .betweenOperation 212 | } 213 | } 214 | 215 | -------------------------------------------------------------------------------- /Sources/DGCropImage/RotationDial/RotationDial.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | @IBDesignable 11 | class RotationDial: UIView { 12 | @IBInspectable public var pointerHeight: CGFloat = 8 13 | @IBInspectable public var spanBetweenDialPlateAndPointer: CGFloat = 6 14 | @IBInspectable public var pointerWidth: CGFloat = 8 * sqrt(2) 15 | 16 | var didRotate: (_ angle: CGAngle) -> Void = { _ in } 17 | var didFinishedRotate: () -> Void = { } 18 | 19 | var dialConfig = DGCropImage.Config().dialConfig 20 | 21 | private var angleLimit = CGAngle(radians: .pi) 22 | private var showRadiansLimit: CGFloat = .pi 23 | private var dialPlate: RotationDialPlate? 24 | private var dialPlateHolder: UIView? 25 | private var pointer: CAShapeLayer = CAShapeLayer() 26 | private var rotationKVO: NSKeyValueObservation? 27 | 28 | var viewModel = RotationDialViewModel() 29 | 30 | /** 31 | This one is needed to solve storyboard render problem 32 | https://stackoverflow.com/a/42678873/288724 33 | */ 34 | public override init(frame: CGRect) { 35 | super.init(frame: frame) 36 | setup() 37 | } 38 | 39 | public init(frame: CGRect, dialConfig: DialConfig) { 40 | super.init(frame: frame) 41 | setup(with: dialConfig) 42 | } 43 | 44 | required init?(coder aDecoder: NSCoder) { 45 | super.init(coder: aDecoder) 46 | } 47 | } 48 | 49 | // MARK: - private funtions 50 | extension RotationDial { 51 | private func setupUI() { 52 | clipsToBounds = true 53 | backgroundColor = dialConfig.backgroundColor 54 | 55 | dialPlateHolder?.removeFromSuperview() 56 | dialPlateHolder = getDialPlateHolder(by: dialConfig.orientation) 57 | addSubview(dialPlateHolder!) 58 | createDialPlate(in: dialPlateHolder!) 59 | setupPointer(in: dialPlateHolder!) 60 | setDialPlateHolder(by: dialConfig.orientation) 61 | } 62 | 63 | private func setupViewModel() { 64 | rotationKVO = viewModel.observe(\.rotationAngle, 65 | options: [.old, .new] 66 | ) { [weak self] _, changed in 67 | guard let angle = changed.newValue else { return } 68 | self?.handleRotation(by: angle) 69 | } 70 | 71 | let rotationCenter = getRotationCenter() 72 | viewModel.makeRotationCalculator(by: rotationCenter) 73 | } 74 | 75 | private func handleRotation(by angle: CGAngle) { 76 | if case .limit = dialConfig.rotationLimitType { 77 | guard angle <= angleLimit else { 78 | return 79 | } 80 | } 81 | 82 | if rotateDialPlate(by: angle) { 83 | didRotate(getRotationAngle()) 84 | } 85 | } 86 | 87 | private func getDialPlateHolder(by orientation: DialConfig.Orientation) -> UIView { 88 | let view = UIView(frame: bounds) 89 | 90 | switch orientation { 91 | case .normal, .upsideDown: 92 | () 93 | case .left, .right: 94 | view.frame.size = CGSize(width: view.bounds.height, height: view.bounds.width) 95 | } 96 | 97 | return view 98 | } 99 | 100 | private func setDialPlateHolder(by orientation: DialConfig.Orientation) { 101 | switch orientation { 102 | case .normal: 103 | () 104 | case .left: 105 | dialPlateHolder?.transform = CGAffineTransform(rotationAngle: -CGFloat.pi / 2) 106 | dialPlateHolder?.frame.origin = CGPoint(x: 0, y: 0) 107 | case .right: 108 | dialPlateHolder?.transform = CGAffineTransform(rotationAngle: CGFloat.pi / 2) 109 | dialPlateHolder?.frame.origin = CGPoint(x: 0, y: 0) 110 | case .upsideDown: 111 | dialPlateHolder?.transform = CGAffineTransform(rotationAngle: CGFloat.pi) 112 | dialPlateHolder?.frame.origin = CGPoint(x: 0, y: 0) 113 | } 114 | } 115 | 116 | private func createDialPlate(in container: UIView) { 117 | var margin: CGFloat = CGFloat(dialConfig.margin) 118 | if case .limit(let angle) = dialConfig.angleShowLimitType { 119 | margin = 0 120 | showRadiansLimit = angle.radians 121 | } else { 122 | showRadiansLimit = CGFloat.pi 123 | } 124 | 125 | var dialPlateShowHeight = container.frame.height - margin - pointerHeight - spanBetweenDialPlateAndPointer 126 | var radius = dialPlateShowHeight / (1 - cos(showRadiansLimit)) 127 | 128 | if radius * 2 * sin(showRadiansLimit) > container.frame.width { 129 | radius = (container.frame.width / 2) / sin(showRadiansLimit) 130 | dialPlateShowHeight = radius - radius * cos(showRadiansLimit) 131 | } 132 | 133 | let dialPlateLength = 2 * radius 134 | let dialPlateFrame = CGRect(x: (container.frame.width - dialPlateLength) / 2, y: margin - (dialPlateLength - dialPlateShowHeight), width: dialPlateLength, height: dialPlateLength) 135 | 136 | dialPlate?.removeFromSuperview() 137 | dialPlate = RotationDialPlate(frame: dialPlateFrame, dialConfig: dialConfig) 138 | container.addSubview(dialPlate!) 139 | } 140 | 141 | private func setupPointer(in container: UIView){ 142 | guard let dialPlate = dialPlate else { return } 143 | 144 | let path = CGMutablePath() 145 | let pointerEdgeLength: CGFloat = pointerWidth 146 | 147 | let pointTop = CGPoint(x: container.bounds.width/2, y: dialPlate.frame.maxY + spanBetweenDialPlateAndPointer) 148 | let pointLeft = CGPoint(x: container.bounds.width/2 - pointerEdgeLength / 2, y: pointTop.y + pointerHeight) 149 | let pointRight = CGPoint(x: container.bounds.width/2 + pointerEdgeLength / 2, y: pointLeft.y) 150 | 151 | path.move(to: pointTop) 152 | path.addLine(to: pointLeft) 153 | path.addLine(to: pointRight) 154 | path.addLine(to: pointTop) 155 | pointer.fillColor = dialConfig.indicatorColor.cgColor 156 | pointer.path = path 157 | container.layer.addSublayer(pointer) 158 | } 159 | 160 | private func getRotationCenter() -> CGPoint { 161 | guard let dialPlate = dialPlate else { return .zero } 162 | 163 | if case .custom(let center) = dialConfig.rotationCenterType { 164 | return center 165 | } else { 166 | let point = CGPoint(x: dialPlate.bounds.midX , y: dialPlate.bounds.midY) 167 | return dialPlate.convert(point, to: self) 168 | } 169 | } 170 | } 171 | 172 | // MARK: - public API 173 | extension RotationDial { 174 | /// Setup the dial with your own config 175 | /// 176 | /// - Parameter dialConfig: dail config. If not provided, default config will be used 177 | public func setup(with dialConfig: DialConfig = DGCropImage.Config().dialConfig) { 178 | self.dialConfig = dialConfig 179 | 180 | if case .limit(let angle) = dialConfig.rotationLimitType { 181 | angleLimit = angle 182 | } 183 | 184 | setupUI() 185 | setupViewModel() 186 | } 187 | 188 | @discardableResult 189 | func rotateDialPlate(by angle: CGAngle) -> Bool { 190 | guard let dialPlate = dialPlate else { return false } 191 | 192 | let radians = angle.radians 193 | if case .limit = dialConfig.rotationLimitType { 194 | if (getRotationAngle() * angle).radians > 0 && abs(getRotationAngle().radians + radians) >= angleLimit.radians { 195 | 196 | if radians > 0 { 197 | rotateDialPlate(to: angleLimit) 198 | } else { 199 | rotateDialPlate(to: -angleLimit) 200 | } 201 | 202 | return false 203 | } 204 | } 205 | 206 | dialPlate.transform = dialPlate.transform.rotated(by: radians) 207 | return true 208 | } 209 | 210 | public func rotateDialPlate(to angle: CGAngle, animated: Bool = false) { 211 | let radians = angle.radians 212 | 213 | if case .limit = dialConfig.rotationLimitType { 214 | guard abs(radians) <= angleLimit.radians else { 215 | return 216 | } 217 | } 218 | 219 | func rotate() { 220 | dialPlate?.transform = CGAffineTransform(rotationAngle: radians) 221 | } 222 | 223 | if animated { 224 | UIView.animate(withDuration: 0.5) { 225 | rotate() 226 | } 227 | } else { 228 | rotate() 229 | } 230 | } 231 | 232 | public func resetAngle(animated: Bool) { 233 | rotateDialPlate(to: CGAngle(radians: 0), animated: animated) 234 | } 235 | 236 | public func getRotationAngle() -> CGAngle { 237 | guard let dialPlate = dialPlate else { return CGAngle(degrees: 0) } 238 | 239 | let radians = CGFloat(atan2f(Float(dialPlate.transform.b), Float(dialPlate.transform.a))) 240 | return CGAngle(radians: radians) 241 | } 242 | 243 | public func setRotationCenter(by point: CGPoint, of view: UIView) { 244 | let newPoint = view.convert(point, to: self) 245 | dialConfig.rotationCenterType = .custom(newPoint) 246 | } 247 | } 248 | 249 | -------------------------------------------------------------------------------- /Sources/DGCropImage/CropViewController/CropToolbar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | public enum CropToolbarMode { 11 | case normal 12 | case simple 13 | } 14 | 15 | public class CropToolbar: UIView, CropToolbarProtocol { 16 | public var heightForVerticalOrientationConstraint: NSLayoutConstraint? 17 | public var widthForHorizonOrientationConstraint: NSLayoutConstraint? 18 | 19 | public weak var cropToolbarDelegate: CropToolbarDelegate? 20 | 21 | var fixedRatioSettingButton: UIButton? 22 | 23 | var cancelButton: UIButton? 24 | var resetButton: UIButton? 25 | var counterClockwiseRotationButton: UIButton? 26 | var clockwiseRotationButton: UIButton? 27 | var alterCropper90DegreeButton: UIButton? 28 | var cropButton: UIButton? 29 | 30 | var config: CropToolbarConfig! 31 | 32 | private var optionButtonStackView: UIStackView? 33 | 34 | private func createOptionButton(withTitle title: String?, andAction action: Selector) -> UIButton { 35 | let buttonColor = UIColor.white 36 | let buttonFontSize: CGFloat = (UIDevice.current.userInterfaceIdiom == .pad) ? 37 | config.optionButtonFontSizeForPad : 38 | config.optionButtonFontSize 39 | 40 | let buttonFont = UIFont.systemFont(ofSize: buttonFontSize) 41 | 42 | let button = UIButton(type: .system) 43 | button.tintColor = .white 44 | button.titleLabel?.font = buttonFont 45 | 46 | if let title = title { 47 | button.setTitle(title, for: .normal) 48 | button.setTitleColor(buttonColor, for: .normal) 49 | } 50 | 51 | button.addTarget(self, action: action, for: .touchUpInside) 52 | button.contentEdgeInsets = UIEdgeInsets(top: 4, left: 10, bottom: 4, right: 10) 53 | 54 | return button 55 | } 56 | 57 | private func createCancelButton() { 58 | let cancelText = LocalizedHelper.getString("cancel") 59 | 60 | cancelButton = createOptionButton(withTitle: cancelText, andAction: #selector(cancel)) 61 | } 62 | 63 | private func createCounterClockwiseRotationButton() { 64 | counterClockwiseRotationButton = createOptionButton(withTitle: nil, andAction: #selector(counterClockwiseRotate)) 65 | counterClockwiseRotationButton?.setImage(ToolBarButtonImageBuilder.rotateCCWImage(), for: .normal) 66 | } 67 | 68 | private func createClockwiseRotationButton() { 69 | clockwiseRotationButton = createOptionButton(withTitle: nil, andAction: #selector(clockwiseRotate)) 70 | clockwiseRotationButton?.setImage(ToolBarButtonImageBuilder.rotateCWImage(), for: .normal) 71 | } 72 | 73 | private func createAlterCropper90DegreeButton() { 74 | alterCropper90DegreeButton = createOptionButton(withTitle: nil, andAction: #selector(alterCropper90Degree)) 75 | alterCropper90DegreeButton?.setImage(ToolBarButtonImageBuilder.alterCropper90DegreeImage(), for: .normal) 76 | } 77 | 78 | private func createResetButton(with image: UIImage? = nil) { 79 | if let image = image { 80 | resetButton = createOptionButton(withTitle: nil, andAction: #selector(reset)) 81 | resetButton?.setImage(image, for: .normal) 82 | } else { 83 | let resetText = "Reset" 84 | resetButton = createOptionButton(withTitle: resetText, andAction: #selector(reset)) 85 | } 86 | } 87 | 88 | private func createSetRatioButton() { 89 | fixedRatioSettingButton = createOptionButton(withTitle: nil, andAction: #selector(setRatio)) 90 | fixedRatioSettingButton?.setImage(ToolBarButtonImageBuilder.clampImage(), for: .normal) 91 | } 92 | 93 | private func createCropButton() { 94 | let doneText = LocalizedHelper.getString("done") 95 | cropButton = createOptionButton(withTitle: doneText, andAction: #selector(crop)) 96 | } 97 | 98 | private func createButtonContainer() { 99 | optionButtonStackView = UIStackView() 100 | addSubview(optionButtonStackView!) 101 | 102 | optionButtonStackView?.distribution = .equalCentering 103 | optionButtonStackView?.isLayoutMarginsRelativeArrangement = true 104 | } 105 | 106 | private func setButtonContainerLayout() { 107 | optionButtonStackView?.translatesAutoresizingMaskIntoConstraints = false 108 | optionButtonStackView?.topAnchor.constraint(equalTo: topAnchor).isActive = true 109 | optionButtonStackView?.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true 110 | optionButtonStackView?.leftAnchor.constraint(equalTo: leftAnchor).isActive = true 111 | optionButtonStackView?.rightAnchor.constraint(equalTo: rightAnchor).isActive = true 112 | } 113 | 114 | private func addButtonsToContainer(button: UIButton?) { 115 | if let button = button { 116 | optionButtonStackView?.addArrangedSubview(button) 117 | } 118 | } 119 | 120 | private func addButtonsToContainer(buttons: [UIButton?]) { 121 | buttons.forEach { 122 | if let button = $0 { 123 | optionButtonStackView?.addArrangedSubview(button) 124 | } 125 | } 126 | } 127 | 128 | public func createToolbarUI(config: CropToolbarConfig) { 129 | self.config = config 130 | backgroundColor = .black 131 | 132 | if #available(macCatalyst 14.0, iOS 14.0, *) { 133 | if UIDevice.current.userInterfaceIdiom == .mac { 134 | backgroundColor = .white 135 | } 136 | } 137 | 138 | createButtonContainer() 139 | setButtonContainerLayout() 140 | 141 | if config.mode == .normal { 142 | createCancelButton() 143 | addButtonsToContainer(button: cancelButton) 144 | } 145 | 146 | if config.toolbarButtonOptions.contains(.counterclockwiseRotate) { 147 | createCounterClockwiseRotationButton() 148 | addButtonsToContainer(button: counterClockwiseRotationButton) 149 | } 150 | 151 | if config.toolbarButtonOptions.contains(.clockwiseRotate) { 152 | createClockwiseRotationButton() 153 | addButtonsToContainer(button: clockwiseRotationButton) 154 | } 155 | 156 | if config.toolbarButtonOptions.contains(.alterCropper90Degree) { 157 | createAlterCropper90DegreeButton() 158 | addButtonsToContainer(button: alterCropper90DegreeButton) 159 | } 160 | 161 | if config.toolbarButtonOptions.contains(.reset) { 162 | createResetButton(with: ToolBarButtonImageBuilder.resetImage()) 163 | addButtonsToContainer(button: resetButton) 164 | resetButton?.isHidden = true 165 | } 166 | 167 | if config.toolbarButtonOptions.contains(.ratio) && config.ratioCandidatesShowType == .presentRatioList { 168 | if config.includeFixedRatioSettingButton { 169 | createSetRatioButton() 170 | addButtonsToContainer(button: fixedRatioSettingButton) 171 | 172 | if config.presetRatiosButtonSelected { 173 | handleFixedRatioSetted(ratio: 0) 174 | resetButton?.isHidden = false 175 | } 176 | } 177 | } 178 | 179 | if config.mode == .normal { 180 | createCropButton() 181 | addButtonsToContainer(button: cropButton) 182 | } 183 | } 184 | 185 | public func getRatioListPresentSourceView() -> UIView? { 186 | return fixedRatioSettingButton 187 | } 188 | 189 | public func respondToOrientationChange() { 190 | if UIDevice.current.orientation == .portrait { 191 | optionButtonStackView?.axis = .horizontal 192 | optionButtonStackView?.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) 193 | } else { 194 | optionButtonStackView?.axis = .vertical 195 | optionButtonStackView?.layoutMargins = UIEdgeInsets(top: 20, left: 0, bottom: 20, right: 0) 196 | } 197 | } 198 | 199 | public func handleFixedRatioSetted(ratio: Double) { 200 | fixedRatioSettingButton?.tintColor = nil 201 | } 202 | 203 | public func handleFixedRatioUnSetted() { 204 | fixedRatioSettingButton?.tintColor = .white 205 | } 206 | 207 | public func handleCropViewDidBecomeResettable() { 208 | resetButton?.isHidden = false 209 | } 210 | 211 | public func handleCropViewDidBecomeUnResettable() { 212 | resetButton?.isHidden = true 213 | } 214 | 215 | public func initConstraints(heightForVerticalOrientation: CGFloat, widthForHorizonOrientation: CGFloat) { 216 | 217 | } 218 | 219 | @objc private func cancel() { 220 | cropToolbarDelegate?.didSelectCancel() 221 | } 222 | 223 | @objc private func setRatio() { 224 | cropToolbarDelegate?.didSelectSetRatio() 225 | } 226 | 227 | @objc private func reset(_ sender: Any) { 228 | cropToolbarDelegate?.didSelectReset() 229 | } 230 | 231 | @objc private func counterClockwiseRotate(_ sender: Any) { 232 | cropToolbarDelegate?.didSelectCounterClockwiseRotate() 233 | } 234 | 235 | @objc private func clockwiseRotate(_ sender: Any) { 236 | cropToolbarDelegate?.didSelectClockwiseRotate() 237 | } 238 | 239 | @objc private func alterCropper90Degree(_ sender: Any) { 240 | cropToolbarDelegate?.didSelectAlterCropper90Degree() 241 | } 242 | 243 | @objc private func crop(_ sender: Any) { 244 | cropToolbarDelegate?.didSelectCrop() 245 | } 246 | } 247 | 248 | -------------------------------------------------------------------------------- /Sources/DGCropImage/CropView/CropOverlayView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | class CropOverlayView: UIView { 11 | private var boarderNormalColor = UIColor.white 12 | private var boarderHintColor = UIColor.white 13 | private var hintLine = UIView() 14 | private var tappedEdge: CropViewOverlayEdge = .none 15 | 16 | var gridHidden = true 17 | var gridColor = UIColor(white: 0.8, alpha: 1) 18 | 19 | private let cropOverLayerCornerWidth = CGFloat(20.0) 20 | 21 | var gridLineNumberType: GridLineNumberType = .crop { 22 | didSet { 23 | setupGridLines() 24 | layoutGridLines() 25 | } 26 | } 27 | private var horizontalGridLines: [UIView] = [] 28 | private var verticalGridLines: [UIView] = [] 29 | private var borderLine: UIView = UIView() 30 | private var corners: [UIView] = [] 31 | private let borderThickness = CGFloat(1.0) 32 | private let hineLineThickness = CGFloat(2.0) 33 | 34 | override var frame: CGRect { 35 | didSet { 36 | if !corners.isEmpty { 37 | layoutLines() 38 | handleEdgeTouched(with: tappedEdge) 39 | } 40 | } 41 | } 42 | 43 | override init(frame: CGRect) { 44 | super.init(frame: frame) 45 | clipsToBounds = false 46 | setup() 47 | } 48 | 49 | required init?(coder aDecoder: NSCoder) { 50 | super.init(coder: aDecoder) 51 | } 52 | 53 | private func createNewLine() -> UIView { 54 | let view = UIView() 55 | view.frame = CGRect.zero 56 | view.backgroundColor = .white 57 | addSubview(view) 58 | return view 59 | } 60 | 61 | private func setup() { 62 | borderLine = createNewLine() 63 | borderLine.layer.backgroundColor = UIColor.clear.cgColor 64 | borderLine.layer.borderWidth = borderThickness 65 | borderLine.layer.borderColor = boarderNormalColor.cgColor 66 | 67 | for _ in 0..<8 { 68 | corners.append(createNewLine()) 69 | } 70 | 71 | setupGridLines() 72 | hintLine.backgroundColor = boarderHintColor 73 | } 74 | 75 | override func didMoveToSuperview() { 76 | super.didMoveToSuperview() 77 | 78 | if !corners.isEmpty { 79 | layoutLines() 80 | } 81 | } 82 | 83 | private func layoutLines() { 84 | guard bounds.isEmpty == false else { 85 | return 86 | } 87 | 88 | layoutOuterLines() 89 | layoutCornerLines() 90 | layoutGridLines() 91 | setGridShowStatus() 92 | } 93 | 94 | private func setGridShowStatus() { 95 | horizontalGridLines.forEach{ $0.alpha = gridHidden ? 0 : 1} 96 | verticalGridLines.forEach{ $0.alpha = gridHidden ? 0 : 1} 97 | } 98 | 99 | private func layoutGridLines() { 100 | for index in 0.. UIImage? { 12 | var rotateImage: UIImage? = nil 13 | 14 | UIGraphicsBeginImageContextWithOptions(CGSize(width: 18, height: 21), false, 0.0) 15 | 16 | //// Draw rectangle 17 | let rectanglePath = UIBezierPath(rect: CGRect(x: 0, y: 9, width: 12, height: 12)) 18 | UIColor.white.setFill() 19 | rectanglePath.fill() 20 | 21 | //// Draw triangle 22 | let trianglePath = UIBezierPath() 23 | trianglePath.move(to: CGPoint(x: 5, y: 3)) 24 | trianglePath.addLine(to: CGPoint(x: 10, y: 6)) 25 | trianglePath.addLine(to: CGPoint(x: 10, y: 0)) 26 | trianglePath.addLine(to: CGPoint(x: 5, y: 3)) 27 | trianglePath.close() 28 | UIColor.white.setFill() 29 | trianglePath.fill() 30 | 31 | //// Bezier Drawing 32 | let bezierPath = UIBezierPath() 33 | bezierPath.move(to: CGPoint(x: 10, y: 3)) 34 | 35 | bezierPath.addCurve(to: CGPoint(x: 17.5, y: 11), controlPoint1: CGPoint(x: 15, y: 3), controlPoint2: CGPoint(x: 17.5, y: 5.91)) 36 | UIColor.white.setStroke() 37 | bezierPath.lineWidth = 1 38 | bezierPath.stroke() 39 | rotateImage = UIGraphicsGetImageFromCurrentImageContext() 40 | 41 | UIGraphicsEndImageContext() 42 | 43 | return rotateImage 44 | } 45 | 46 | static func rotateCWImage() -> UIImage? { 47 | guard let rotateCCWImage = self.rotateCCWImage(), let cgImage = rotateCCWImage.cgImage else { return nil } 48 | 49 | UIGraphicsBeginImageContextWithOptions(rotateCCWImage.size, false, rotateCCWImage.scale) 50 | let context = UIGraphicsGetCurrentContext() 51 | context?.translateBy(x: rotateCCWImage.size.width, y: rotateCCWImage.size.height) 52 | context?.rotate(by: .pi) 53 | context?.draw(cgImage, in: CGRect(x: 0, y: 0, width: rotateCCWImage.size.width, height: rotateCCWImage.size.height)) 54 | let rotateCWImage: UIImage? = UIGraphicsGetImageFromCurrentImageContext() 55 | UIGraphicsEndImageContext() 56 | return rotateCWImage 57 | } 58 | 59 | static func flipHorizontally() -> UIImage? { 60 | var flippedImage: UIImage? = nil 61 | 62 | let wholeWidth = 24 63 | let wholeHeight = 24 64 | UIGraphicsBeginImageContextWithOptions(CGSize(width: wholeWidth, height: wholeHeight), false, 0.0) 65 | 66 | let arrowWidth = 5 67 | let arrowHeight = 6 68 | let topbarWidth = wholeWidth - arrowWidth 69 | let topbarY = arrowHeight / 2 - 1 70 | 71 | // topbar 72 | let rectangle2Path = UIBezierPath(rect: CGRect(x: 0, y: topbarY, width: topbarWidth, height: 1)) 73 | UIColor.white.setFill() 74 | rectangle2Path.fill() 75 | 76 | // left arrow 77 | let leftarrowPath = UIBezierPath() 78 | leftarrowPath.move(to: CGPoint(x: 0, y: topbarY)) 79 | leftarrowPath.addLine(to: CGPoint(x: arrowWidth, y: 0)) 80 | leftarrowPath.addLine(to: CGPoint(x: arrowWidth, y: arrowHeight)) 81 | leftarrowPath.addLine(to: CGPoint(x: 0, y: topbarY)) 82 | leftarrowPath.close() 83 | UIColor.white.setFill() 84 | leftarrowPath.fill() 85 | 86 | // right arrow 87 | let rightarrowPath = UIBezierPath() 88 | rightarrowPath.move(to: CGPoint(x: wholeWidth, y: topbarY)) 89 | rightarrowPath.addLine(to: CGPoint(x: wholeWidth - arrowWidth, y: 0)) 90 | rightarrowPath.addLine(to: CGPoint(x: wholeWidth - arrowWidth, y: arrowHeight)) 91 | rightarrowPath.addLine(to: CGPoint(x: wholeWidth, y: topbarY)) 92 | rightarrowPath.close() 93 | UIColor.white.setFill() 94 | rightarrowPath.fill() 95 | 96 | let mirrorWidth = wholeWidth / 2 - 1 97 | let mirrowHeight = wholeHeight - 8 98 | 99 | // left mirror 100 | let leftMirror = UIBezierPath() 101 | leftMirror.move(to: CGPoint(x: 0, y: wholeHeight)) 102 | leftMirror.addLine(to: CGPoint(x: mirrorWidth, y: wholeHeight)) 103 | leftMirror.addLine(to: CGPoint(x: mirrorWidth, y: wholeHeight - mirrowHeight)) 104 | leftMirror.addLine(to: CGPoint(x: 0, y: wholeHeight)) 105 | leftMirror.close() 106 | UIColor.white.setFill() 107 | leftMirror.fill() 108 | 109 | // right mirror 110 | let rightMirror = UIBezierPath() 111 | rightMirror.move(to: CGPoint(x: wholeWidth, y: wholeHeight)) 112 | rightMirror.addLine(to: CGPoint(x: wholeWidth - mirrorWidth, y: wholeHeight)) 113 | rightMirror.addLine(to: CGPoint(x: wholeWidth - mirrorWidth, y: wholeHeight - mirrowHeight)) 114 | rightMirror.addLine(to: CGPoint(x: wholeWidth, y: wholeHeight)) 115 | rightMirror.close() 116 | UIColor.white.setFill() 117 | rightMirror.fill() 118 | 119 | flippedImage = UIGraphicsGetImageFromCurrentImageContext() 120 | UIGraphicsEndImageContext() 121 | 122 | return flippedImage 123 | 124 | } 125 | 126 | static func flipVertically() -> UIImage? { 127 | guard let flippedHorizontallyImage = self.flipHorizontally(), let cgImage = flippedHorizontallyImage.cgImage else { return nil } 128 | 129 | UIGraphicsBeginImageContextWithOptions(flippedHorizontallyImage.size, false, flippedHorizontallyImage.scale) 130 | let context = UIGraphicsGetCurrentContext() 131 | context?.rotate(by: -.pi / 2) 132 | context?.translateBy(x: -flippedHorizontallyImage.size.height, y: 0) 133 | context?.draw(cgImage, in: CGRect(x: 0, y: 0, width: flippedHorizontallyImage.size.height, height: flippedHorizontallyImage.size.width)) 134 | let fippedVerticallyImage: UIImage? = UIGraphicsGetImageFromCurrentImageContext() 135 | UIGraphicsEndImageContext() 136 | return fippedVerticallyImage 137 | } 138 | 139 | static func clampImage() -> UIImage? { 140 | var clampImage: UIImage? = nil 141 | 142 | UIGraphicsBeginImageContextWithOptions(CGSize(width: 22, height: 16), false, 0.0) 143 | 144 | //// Color Declarations 145 | let outerBox = UIColor(red: 1, green: 1, blue: 1, alpha: 0.553) 146 | let innerBox = UIColor(red: 1, green: 1, blue: 1, alpha: 0.773) 147 | 148 | //// Rectangle Drawing 149 | let rectanglePath = UIBezierPath(rect: CGRect(x: 0, y: 3, width: 13, height: 13)) 150 | UIColor.white.setFill() 151 | rectanglePath.fill() 152 | 153 | 154 | //// Outer 155 | //// Top Drawing 156 | let topPath = UIBezierPath(rect: CGRect(x: 0, y: 0, width: 22, height: 2)) 157 | outerBox.setFill() 158 | topPath.fill() 159 | 160 | //// Side Drawing 161 | let sidePath = UIBezierPath(rect: CGRect(x: 19, y: 2, width: 3, height: 14)) 162 | outerBox.setFill() 163 | sidePath.fill() 164 | 165 | //// Rectangle 2 Drawing 166 | let rectangle2Path = UIBezierPath(rect: CGRect(x: 14, y: 3, width: 4, height: 13)) 167 | innerBox.setFill() 168 | rectangle2Path.fill() 169 | 170 | clampImage = UIGraphicsGetImageFromCurrentImageContext() 171 | 172 | UIGraphicsEndImageContext() 173 | return clampImage 174 | } 175 | 176 | static func resetImage() -> UIImage? { 177 | var resetImage: UIImage? = nil 178 | 179 | UIGraphicsBeginImageContextWithOptions(CGSize(width: 22, height: 18), false, 0.0) //// Bezier 2 Drawing 180 | let bezier2Path = UIBezierPath() 181 | bezier2Path.move(to: CGPoint(x: 22, y: 9)) 182 | bezier2Path.addCurve(to: CGPoint(x: 13, y: 18), controlPoint1: CGPoint(x: 22, y: 13.97), controlPoint2: CGPoint(x: 17.97, y: 18)) 183 | bezier2Path.addCurve(to: CGPoint(x: 13, y: 16), controlPoint1: CGPoint(x: 13, y: 17.35), controlPoint2: CGPoint(x: 13, y: 16.68)) 184 | bezier2Path.addCurve(to: CGPoint(x: 20, y: 9), controlPoint1: CGPoint(x: 16.87, y: 16), controlPoint2: CGPoint(x: 20, y: 12.87)) 185 | bezier2Path.addCurve(to: CGPoint(x: 13, y: 2), controlPoint1: CGPoint(x: 20, y: 5.13), controlPoint2: CGPoint(x: 16.87, y: 2)) 186 | bezier2Path.addCurve(to: CGPoint(x: 6.55, y: 6.27), controlPoint1: CGPoint(x: 10.1, y: 2), controlPoint2: CGPoint(x: 7.62, y: 3.76)) 187 | bezier2Path.addCurve(to: CGPoint(x: 6, y: 9), controlPoint1: CGPoint(x: 6.2, y: 7.11), controlPoint2: CGPoint(x: 6, y: 8.03)) 188 | bezier2Path.addLine(to: CGPoint(x: 4, y: 9)) 189 | bezier2Path.addCurve(to: CGPoint(x: 4.65, y: 5.63), controlPoint1: CGPoint(x: 4, y: 7.81), controlPoint2: CGPoint(x: 4.23, y: 6.67)) 190 | bezier2Path.addCurve(to: CGPoint(x: 7.65, y: 1.76), controlPoint1: CGPoint(x: 5.28, y: 4.08), controlPoint2: CGPoint(x: 6.32, y: 2.74)) 191 | bezier2Path.addCurve(to: CGPoint(x: 13, y: 0), controlPoint1: CGPoint(x: 9.15, y: 0.65), controlPoint2: CGPoint(x: 11, y: 0)) 192 | bezier2Path.addCurve(to: CGPoint(x: 22, y: 9), controlPoint1: CGPoint(x: 17.97, y: 0), controlPoint2: CGPoint(x: 22, y: 4.03)) 193 | bezier2Path.close() 194 | UIColor.white.setFill() 195 | bezier2Path.fill() 196 | 197 | //// Polygon Drawing 198 | let polygonPath = UIBezierPath() 199 | polygonPath.move(to: CGPoint(x: 5, y: 15)) 200 | polygonPath.addLine(to: CGPoint(x: 10, y: 9)) 201 | polygonPath.addLine(to: CGPoint(x: 0, y: 9)) 202 | polygonPath.addLine(to: CGPoint(x: 5, y: 15)) 203 | polygonPath.close() 204 | UIColor.white.setFill() 205 | polygonPath.fill() 206 | 207 | resetImage = UIGraphicsGetImageFromCurrentImageContext() 208 | UIGraphicsEndImageContext() 209 | 210 | return resetImage 211 | } 212 | 213 | static func alterCropper90DegreeImage() -> UIImage? { 214 | var rotateCropperImage: UIImage? 215 | 216 | UIGraphicsBeginImageContextWithOptions(CGSize(width: 22, height: 22), false, 0.0) 217 | 218 | //// Draw rectangle 219 | let rectanglePath1 = UIBezierPath(rect: CGRect(x: 1, y: 5, width: 20, height: 11)) 220 | UIColor.white.setStroke() 221 | rectanglePath1.lineWidth = 1 222 | rectanglePath1.stroke() 223 | 224 | let rectanglePath2 = UIBezierPath(rect: CGRect(x: 6, y: 1, width: 10, height: 20)) 225 | UIColor.white.setStroke() 226 | rectanglePath2.lineWidth = 1 227 | rectanglePath2.stroke() 228 | 229 | rotateCropperImage = UIGraphicsGetImageFromCurrentImageContext() 230 | 231 | UIGraphicsEndImageContext() 232 | 233 | return rotateCropperImage 234 | } 235 | 236 | } 237 | 238 | -------------------------------------------------------------------------------- /Sources/DGCropImage/CropViewController/CropViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | public protocol CropViewControllerDelegate: AnyObject { 11 | func cropViewControllerDidCrop(_ cropViewController: CropViewController, 12 | cropped: UIImage, 13 | transformation: Transformation, 14 | cropInfo: CropInfo) 15 | func cropViewControllerDidFailToCrop(_ cropViewController: CropViewController, original: UIImage) 16 | func cropViewControllerDidCancel(_ cropViewController: CropViewController, original: UIImage) 17 | } 18 | 19 | public extension CropViewControllerDelegate where Self: UIViewController { 20 | func cropViewControllerDidFailToCrop(_ cropViewController: CropViewController, original: UIImage) {} 21 | } 22 | 23 | public enum CropViewControllerMode { 24 | case normal 25 | case customizable 26 | } 27 | 28 | public class CropViewController: UIViewController { 29 | /// When a CropViewController is used in a storyboard, 30 | /// passing an image to it is needed after the CropViewController is created. 31 | public var image: UIImage! { 32 | didSet { 33 | cropView.image = image 34 | } 35 | } 36 | 37 | public weak var delegate: CropViewControllerDelegate? 38 | public var mode: CropViewControllerMode = .normal 39 | public var config = DGCropImage.Config() 40 | 41 | private var orientation: UIDeviceOrientation? 42 | private lazy var cropView = CropView(image: image, viewModel: CropViewModel(), dialConfig: config.dialConfig) 43 | private var cropToolbar: CropToolbarProtocol 44 | private var ratioPresenter: RatioPresenter? 45 | private var ratioSelector: RatioSelector? 46 | private var stackView: UIStackView? 47 | private var cropStackView: UIStackView! 48 | private var initialLayout = false 49 | private var disableRotation = false 50 | 51 | deinit { 52 | print("CropViewController deinit.") 53 | } 54 | 55 | init(image: UIImage, 56 | config: DGCropImage.Config = DGCropImage.Config(), 57 | mode: CropViewControllerMode = .normal, 58 | cropToolbar: CropToolbarProtocol = CropToolbar(frame: CGRect.zero)) { 59 | self.image = image 60 | 61 | self.config = config 62 | 63 | switch config.cropShapeType { 64 | case .circle, .square, .heart: 65 | self.config.presetFixedRatioType = .alwaysUsingOnePresetFixedRatio(ratio: 1) 66 | default: 67 | () 68 | } 69 | 70 | self.mode = mode 71 | self.cropToolbar = cropToolbar 72 | 73 | super.init(nibName: nil, bundle: nil) 74 | } 75 | 76 | required init?(coder aDecoder: NSCoder) { 77 | self.cropToolbar = CropToolbar(frame: CGRect.zero) 78 | super.init(coder: aDecoder) 79 | } 80 | 81 | fileprivate func createRatioSelector() { 82 | let fixedRatioManager = getFixedRatioManager() 83 | self.ratioSelector = RatioSelector(type: fixedRatioManager.type, originalRatioH: fixedRatioManager.originalRatioH, ratios: fixedRatioManager.ratios) 84 | self.ratioSelector?.didGetRatio = { [weak self] ratio in 85 | self?.setFixedRatio(ratio) 86 | } 87 | } 88 | 89 | fileprivate func createCropToolbar() { 90 | cropToolbar.cropToolbarDelegate = self 91 | 92 | switch config.presetFixedRatioType { 93 | case .alwaysUsingOnePresetFixedRatio(let ratio): 94 | config.cropToolbarConfig.includeFixedRatioSettingButton = false 95 | 96 | if case .none = config.presetTransformationType { 97 | setFixedRatio(ratio) 98 | } 99 | 100 | case .canUseMultiplePresetFixedRatio(let defaultRatio): 101 | if defaultRatio > 0 { 102 | setFixedRatio(defaultRatio) 103 | cropView.aspectRatioLockEnabled = true 104 | config.cropToolbarConfig.presetRatiosButtonSelected = true 105 | } 106 | 107 | config.cropToolbarConfig.includeFixedRatioSettingButton = true 108 | } 109 | 110 | if mode == .normal { 111 | config.cropToolbarConfig.mode = .normal 112 | } else { 113 | config.cropToolbarConfig.mode = .simple 114 | } 115 | 116 | cropToolbar.createToolbarUI(config: config.cropToolbarConfig) 117 | 118 | let heightForVerticalOrientation = config.cropToolbarConfig.cropToolbarHeightForVertialOrientation 119 | let widthForHorizonOrientation = config.cropToolbarConfig.cropToolbarWidthForHorizontalOrientation 120 | cropToolbar.initConstraints(heightForVerticalOrientation: heightForVerticalOrientation, 121 | widthForHorizonOrientation: widthForHorizonOrientation) 122 | } 123 | 124 | private func getRatioType() -> RatioType { 125 | switch config.cropToolbarConfig.fixRatiosShowType { 126 | case .adaptive: 127 | return cropView.getRatioType(byImageIsOriginalisHorizontal: cropView.image.isHorizontal()) 128 | case .horizontal: 129 | return .horizontal 130 | case .vetical: 131 | return .vertical 132 | } 133 | } 134 | 135 | fileprivate func getFixedRatioManager() -> FixedRatioManager { 136 | let type: RatioType = getRatioType() 137 | 138 | let ratio = cropView.getImageRatioH() 139 | 140 | return FixedRatioManager(type: type, 141 | originalRatioH: ratio, 142 | ratioOptions: config.ratioOptions, 143 | customRatios: config.getCustomRatioItems()) 144 | } 145 | 146 | override public func viewDidLoad() { 147 | super.viewDidLoad() 148 | 149 | view.backgroundColor = .black 150 | 151 | createCropView() 152 | createCropToolbar() 153 | if config.cropToolbarConfig.ratioCandidatesShowType == .alwaysShowRatioList && config.cropToolbarConfig.includeFixedRatioSettingButton { 154 | createRatioSelector() 155 | } 156 | initLayout() 157 | updateLayout() 158 | } 159 | 160 | override public func viewDidLayoutSubviews() { 161 | super.viewDidLayoutSubviews() 162 | if initialLayout == false { 163 | initialLayout = true 164 | view.layoutIfNeeded() 165 | cropView.adaptForCropBox() 166 | } 167 | } 168 | 169 | public override var prefersStatusBarHidden: Bool { 170 | return true 171 | } 172 | 173 | public override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { 174 | return [.top, .bottom] 175 | } 176 | 177 | public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { 178 | super.viewWillTransition(to: size, with: coordinator) 179 | cropView.prepareForDeviceRotation() 180 | rotated() 181 | } 182 | 183 | @objc func rotated() { 184 | let currentOrientation = UIDevice.current.orientation 185 | 186 | orientation = currentOrientation 187 | 188 | if UIDevice.current.userInterfaceIdiom == .phone 189 | && currentOrientation == .portraitUpsideDown { 190 | return 191 | } 192 | 193 | updateLayout() 194 | view.layoutIfNeeded() 195 | 196 | // When it is embedded in a container, the timing of viewDidLayoutSubviews 197 | // is different with the normal mode. 198 | // So delay the execution to make sure handleRotate runs after the final 199 | // viewDidLayoutSubviews 200 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in 201 | self?.cropView.handleRotate() 202 | } 203 | } 204 | 205 | private func setFixedRatio(_ ratio: Double, zoom: Bool = true) { 206 | cropToolbar.handleFixedRatioSetted(ratio: ratio) 207 | cropView.aspectRatioLockEnabled = true 208 | 209 | if cropView.viewModel.aspectRatio != CGFloat(ratio) { 210 | cropView.viewModel.aspectRatio = CGFloat(ratio) 211 | 212 | if case .alwaysUsingOnePresetFixedRatio = config.presetFixedRatioType { 213 | self.cropView.setFixedRatioCropBox(zoom: zoom) 214 | } else { 215 | UIView.animate(withDuration: 0.5) { 216 | self.cropView.setFixedRatioCropBox(zoom: zoom) 217 | } 218 | } 219 | 220 | } 221 | } 222 | 223 | private func createCropView() { 224 | if !config.showRotationDial { 225 | cropView.angleDashboardHeight = 0 226 | } 227 | cropView.delegate = self 228 | cropView.clipsToBounds = true 229 | cropView.cropShapeType = config.cropShapeType 230 | cropView.cropVisualEffectType = config.cropVisualEffectType 231 | 232 | if case .alwaysUsingOnePresetFixedRatio = config.presetFixedRatioType { 233 | cropView.forceFixedRatio = true 234 | } else { 235 | cropView.forceFixedRatio = false 236 | } 237 | } 238 | 239 | private func processPresetTransformation(completion: (Transformation)->Void) { 240 | if case .presetInfo(let transformInfo) = config.presetTransformationType { 241 | var newTransform = getTransformInfo(byTransformInfo: transformInfo) 242 | 243 | // The first transform is just for retrieving the final cropBoxFrame 244 | cropView.transform(byTransformInfo: newTransform, rotateDial: false) 245 | 246 | // The second transform is for adjusting the scale of transformInfo 247 | let adjustScale = (cropView.viewModel.cropBoxFrame.width / cropView.viewModel.cropOrignFrame.width) / (transformInfo.maskFrame.width / transformInfo.intialMaskFrame.width) 248 | newTransform.scale *= adjustScale 249 | cropView.transform(byTransformInfo: newTransform) 250 | completion(transformInfo) 251 | } else if case .presetNormalizedInfo(let normailizedInfo) = config.presetTransformationType { 252 | let transformInfo = getTransformInfo(byNormalizedInfo: normailizedInfo); 253 | cropView.transform(byTransformInfo: transformInfo) 254 | cropView.scrollView.frame = transformInfo.maskFrame 255 | completion(transformInfo) 256 | } 257 | } 258 | 259 | public override func viewDidAppear(_ animated: Bool) { 260 | super.viewDidAppear(animated) 261 | processPresetTransformation() { [weak self] transform in 262 | guard let self = self else { return } 263 | if case .alwaysUsingOnePresetFixedRatio(let ratio) = self.config.presetFixedRatioType { 264 | self.cropView.aspectRatioLockEnabled = true 265 | self.cropToolbar.handleFixedRatioSetted(ratio: ratio) 266 | 267 | if ratio == 0 { 268 | self.cropView.viewModel.aspectRatio = transform.maskFrame.width / transform.maskFrame.height 269 | } else { 270 | self.cropView.viewModel.aspectRatio = CGFloat(ratio) 271 | self.cropView.setFixedRatioCropBox(zoom: false, cropBox: cropView.viewModel.cropBoxFrame) 272 | } 273 | } 274 | } 275 | } 276 | 277 | private func getTransformInfo(byTransformInfo transformInfo: Transformation) -> Transformation { 278 | let cropFrame = cropView.viewModel.cropOrignFrame 279 | let contentBound = cropView.getContentBounds() 280 | 281 | let adjustScale: CGFloat 282 | var maskFrameWidth: CGFloat 283 | var maskFrameHeight: CGFloat 284 | 285 | if ( transformInfo.maskFrame.height / transformInfo.maskFrame.width >= contentBound.height / contentBound.width ) { 286 | maskFrameHeight = contentBound.height 287 | maskFrameWidth = transformInfo.maskFrame.width / transformInfo.maskFrame.height * maskFrameHeight 288 | adjustScale = maskFrameHeight / transformInfo.maskFrame.height 289 | } else { 290 | maskFrameWidth = contentBound.width 291 | maskFrameHeight = transformInfo.maskFrame.height / transformInfo.maskFrame.width * maskFrameWidth 292 | adjustScale = maskFrameWidth / transformInfo.maskFrame.width 293 | } 294 | 295 | var newTransform = transformInfo 296 | 297 | newTransform.offset = CGPoint(x:transformInfo.offset.x * adjustScale, 298 | y:transformInfo.offset.y * adjustScale) 299 | 300 | newTransform.maskFrame = CGRect(x: cropFrame.origin.x + (cropFrame.width - maskFrameWidth) / 2, 301 | y: cropFrame.origin.y + (cropFrame.height - maskFrameHeight) / 2, 302 | width: maskFrameWidth, 303 | height: maskFrameHeight) 304 | newTransform.scrollBounds = CGRect(x: transformInfo.scrollBounds.origin.x * adjustScale, 305 | y: transformInfo.scrollBounds.origin.y * adjustScale, 306 | width: transformInfo.scrollBounds.width * adjustScale, 307 | height: transformInfo.scrollBounds.height * adjustScale) 308 | 309 | return newTransform 310 | } 311 | 312 | private func getTransformInfo(byNormalizedInfo normailizedInfo: CGRect) -> Transformation { 313 | let cropFrame = cropView.viewModel.cropBoxFrame 314 | 315 | let scale: CGFloat = min(1/normailizedInfo.width, 1/normailizedInfo.height) 316 | 317 | var offset = cropFrame.origin 318 | offset.x = cropFrame.width * normailizedInfo.origin.x * scale 319 | offset.y = cropFrame.height * normailizedInfo.origin.y * scale 320 | 321 | var maskFrame = cropFrame 322 | 323 | if (normailizedInfo.width > normailizedInfo.height) { 324 | let adjustScale = 1 / normailizedInfo.width 325 | maskFrame.size.height = normailizedInfo.height * cropFrame.height * adjustScale 326 | maskFrame.origin.y += (cropFrame.height - maskFrame.height) / 2 327 | } else if (normailizedInfo.width < normailizedInfo.height) { 328 | let adjustScale = 1 / normailizedInfo.height 329 | maskFrame.size.width = normailizedInfo.width * cropFrame.width * adjustScale 330 | maskFrame.origin.x += (cropFrame.width - maskFrame.width) / 2 331 | } 332 | 333 | let manualZoomed = (scale != 1.0) 334 | let transformantion = Transformation(offset: offset, 335 | rotation: 0, 336 | scale: scale, 337 | manualZoomed: manualZoomed, 338 | intialMaskFrame: .zero, 339 | maskFrame: maskFrame, 340 | scrollBounds: .zero) 341 | return transformantion 342 | } 343 | 344 | private func handleCancel() { 345 | self.delegate?.cropViewControllerDidCancel(self, original: self.image) 346 | } 347 | 348 | private func resetRatioButton() { 349 | cropView.aspectRatioLockEnabled = false 350 | cropToolbar.handleFixedRatioUnSetted() 351 | } 352 | 353 | @objc private func handleSetRatio() { 354 | if cropView.aspectRatioLockEnabled { 355 | resetRatioButton() 356 | return 357 | } 358 | 359 | guard let presentSourceView = cropToolbar.getRatioListPresentSourceView() else { 360 | return 361 | } 362 | 363 | let fixedRatioManager = getFixedRatioManager() 364 | 365 | guard !fixedRatioManager.ratios.isEmpty else { return } 366 | 367 | if fixedRatioManager.ratios.count == 1 { 368 | let ratioItem = fixedRatioManager.ratios[0] 369 | let ratioValue = (fixedRatioManager.type == .horizontal) ? ratioItem.ratioH : ratioItem.ratioV 370 | setFixedRatio(ratioValue) 371 | return 372 | } 373 | 374 | ratioPresenter = RatioPresenter(type: fixedRatioManager.type, 375 | originalRatioH: fixedRatioManager.originalRatioH, 376 | ratios: fixedRatioManager.ratios, 377 | fixRatiosShowType: config.cropToolbarConfig.fixRatiosShowType) 378 | ratioPresenter?.didGetRatio = {[weak self] ratio in 379 | self?.setFixedRatio(ratio, zoom: false) 380 | } 381 | ratioPresenter?.present(by: self, in: presentSourceView) 382 | } 383 | 384 | private func handleReset() { 385 | resetRatioButton() 386 | cropView.reset() 387 | ratioSelector?.reset() 388 | ratioSelector?.update(fixedRatioManager: getFixedRatioManager()) 389 | } 390 | 391 | private func handleRotate(rotateAngle: CGFloat) { 392 | if !disableRotation { 393 | disableRotation = true 394 | cropView.rotateBy90(rotateAngle: rotateAngle) { [weak self] in 395 | self?.disableRotation = false 396 | self?.ratioSelector?.update(fixedRatioManager: self?.getFixedRatioManager()) 397 | } 398 | } 399 | 400 | } 401 | 402 | private func handleAlterCropper90Degree() { 403 | let ratio = Double(cropView.gridOverlayView.frame.height / cropView.gridOverlayView.frame.width) 404 | 405 | cropView.viewModel.aspectRatio = CGFloat(ratio) 406 | 407 | UIView.animate(withDuration: 0.5) { 408 | self.cropView.setFixedRatioCropBox() 409 | } 410 | } 411 | 412 | private func handleCrop() { 413 | let cropResult = cropView.crop() 414 | guard let image = cropResult.croppedImage else { 415 | delegate?.cropViewControllerDidFailToCrop(self, original: cropView.image) 416 | return 417 | } 418 | self.delegate?.cropViewControllerDidCrop(self, 419 | cropped: image, 420 | transformation: cropResult.transformation, 421 | cropInfo: cropResult.cropInfo) 422 | } 423 | } 424 | 425 | // Auto layout 426 | extension CropViewController { 427 | fileprivate func initLayout() { 428 | cropStackView = UIStackView() 429 | cropStackView.axis = .vertical 430 | cropStackView.addArrangedSubview(cropView) 431 | 432 | if let ratioSelector = ratioSelector { 433 | cropStackView.addArrangedSubview(ratioSelector) 434 | } 435 | 436 | stackView = UIStackView() 437 | view.addSubview(stackView!) 438 | 439 | cropStackView?.translatesAutoresizingMaskIntoConstraints = false 440 | stackView?.translatesAutoresizingMaskIntoConstraints = false 441 | cropToolbar.translatesAutoresizingMaskIntoConstraints = false 442 | cropView.translatesAutoresizingMaskIntoConstraints = false 443 | 444 | stackView?.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true 445 | stackView?.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true 446 | stackView?.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor).isActive = true 447 | stackView?.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor).isActive = true 448 | } 449 | 450 | fileprivate func setStackViewAxis() { 451 | if UIDevice.current.orientation == .portrait { 452 | stackView?.axis = .vertical 453 | } else { 454 | stackView?.axis = .horizontal 455 | } 456 | } 457 | 458 | fileprivate func changeStackViewOrder() { 459 | stackView?.removeArrangedSubview(cropStackView) 460 | stackView?.removeArrangedSubview(cropToolbar) 461 | 462 | if UIDevice.current.orientation == .portrait || UIDevice.current.orientation == .landscapeRight { 463 | stackView?.addArrangedSubview(cropStackView) 464 | stackView?.addArrangedSubview(cropToolbar) 465 | } else if UIDevice.current.orientation == .landscapeLeft { 466 | stackView?.addArrangedSubview(cropToolbar) 467 | stackView?.addArrangedSubview(cropStackView) 468 | } 469 | } 470 | 471 | fileprivate func updateLayout() { 472 | setStackViewAxis() 473 | cropToolbar.respondToOrientationChange() 474 | changeStackViewOrder() 475 | } 476 | } 477 | 478 | extension CropViewController: CropViewDelegate { 479 | 480 | func cropViewDidBecomeResettable(_ cropView: CropView) { 481 | cropToolbar.handleCropViewDidBecomeResettable() 482 | } 483 | 484 | func cropViewDidBecomeUnResettable(_ cropView: CropView) { 485 | cropToolbar.handleCropViewDidBecomeUnResettable() 486 | } 487 | } 488 | 489 | extension CropViewController: CropToolbarDelegate { 490 | public func didSelectCancel() { 491 | handleCancel() 492 | self.dismiss(animated: true) 493 | } 494 | 495 | public func didSelectCrop() { 496 | handleCrop() 497 | self.dismiss(animated: true) 498 | } 499 | 500 | public func didSelectCounterClockwiseRotate() { 501 | handleRotate(rotateAngle: -CGFloat.pi / 2) 502 | } 503 | 504 | public func didSelectClockwiseRotate() { 505 | handleRotate(rotateAngle: CGFloat.pi / 2) 506 | } 507 | 508 | public func didSelectReset() { 509 | handleReset() 510 | } 511 | 512 | public func didSelectSetRatio() { 513 | handleSetRatio() 514 | } 515 | 516 | public func didSelectRatio(ratio: Double) { 517 | setFixedRatio(ratio) 518 | } 519 | 520 | public func didSelectAlterCropper90Degree() { 521 | handleAlterCropper90Degree() 522 | } 523 | } 524 | 525 | // API 526 | extension CropViewController { 527 | public func crop() { 528 | let cropResult = cropView.crop() 529 | guard let image = cropResult.croppedImage else { 530 | delegate?.cropViewControllerDidFailToCrop(self, original: cropView.image) 531 | return 532 | } 533 | 534 | delegate?.cropViewControllerDidCrop(self, 535 | cropped: image, 536 | transformation: cropResult.transformation, 537 | cropInfo: cropResult.cropInfo) 538 | } 539 | 540 | public func process(_ image: UIImage) -> UIImage? { 541 | return cropView.crop(image).croppedImage 542 | } 543 | } 544 | 545 | -------------------------------------------------------------------------------- /Sources/DGCropImage/CropView/CropView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 신동규 on 2022/01/26. 6 | // 7 | 8 | import UIKit 9 | 10 | protocol CropViewDelegate: AnyObject { 11 | func cropViewDidBecomeResettable(_ cropView: CropView) 12 | func cropViewDidBecomeUnResettable(_ cropView: CropView) 13 | } 14 | 15 | let cropViewMinimumBoxSize: CGFloat = 42 16 | let minimumAspectRatio: CGFloat = 0 17 | let hotAreaUnit: CGFloat = 32 18 | let cropViewPadding:CGFloat = 14.0 19 | 20 | class CropView: UIView { 21 | 22 | public var dialConfig = DGCropImage.Config().dialConfig 23 | 24 | var cropShapeType: CropShapeType = .rect 25 | var cropVisualEffectType: CropVisualEffectType = .blurDark 26 | var angleDashboardHeight: CGFloat = 60 27 | 28 | var image: UIImage { 29 | didSet { 30 | imageContainer.image = image 31 | } 32 | } 33 | let viewModel: CropViewModel 34 | 35 | weak var delegate: CropViewDelegate? { 36 | didSet { 37 | checkImageStatusChanged() 38 | } 39 | } 40 | 41 | var aspectRatioLockEnabled = false 42 | 43 | // Referred to in extension 44 | let imageContainer: ImageContainer 45 | let gridOverlayView: CropOverlayView 46 | // var rotationDial: RotationDial? 47 | 48 | lazy var scrollView = CropScrollView(frame: bounds) 49 | lazy var cropMaskViewManager = CropMaskViewManager(with: self, 50 | cropRatio: CGFloat(getImageRatioH()), 51 | cropShapeType: cropShapeType, 52 | cropVisualEffectType: cropVisualEffectType) 53 | 54 | var manualZoomed = false 55 | private var cropFrameKVO: NSKeyValueObservation? 56 | var forceFixedRatio = false 57 | var imageStatusChangedCheckForForceFixedRatio = false 58 | 59 | deinit { 60 | print("CropView deinit.") 61 | } 62 | 63 | init(image: UIImage, viewModel: CropViewModel = CropViewModel(), dialConfig: DialConfig = DGCropImage.Config().dialConfig) { 64 | self.image = image 65 | self.viewModel = viewModel 66 | self.dialConfig = dialConfig 67 | 68 | imageContainer = ImageContainer() 69 | gridOverlayView = CropOverlayView() 70 | 71 | super.init(frame: CGRect.zero) 72 | 73 | self.viewModel.statusChanged = { [weak self] status in 74 | self?.render(by: status) 75 | } 76 | 77 | cropFrameKVO = viewModel.observe(\.cropBoxFrame, 78 | options: [.new, .old]) 79 | { [unowned self] _, changed in 80 | guard let cropFrame = changed.newValue else { return } 81 | self.gridOverlayView.frame = cropFrame 82 | 83 | var cropRatio: CGFloat = 1.0 84 | if self.gridOverlayView.frame.height != 0 { 85 | cropRatio = self.gridOverlayView.frame.width / self.gridOverlayView.frame.height 86 | } 87 | 88 | self.cropMaskViewManager.adaptMaskTo(match: cropFrame, cropRatio: cropRatio) 89 | } 90 | 91 | initalRender() 92 | } 93 | 94 | required init?(coder aDecoder: NSCoder) { 95 | fatalError("init(coder:) has not been implemented") 96 | } 97 | 98 | private func initalRender() { 99 | setupUI() 100 | checkImageStatusChanged() 101 | } 102 | 103 | private func render(by viewStatus: CropViewStatus) { 104 | gridOverlayView.isHidden = false 105 | 106 | switch viewStatus { 107 | case .initial: 108 | initalRender() 109 | case .rotating(let angle): 110 | viewModel.degrees = angle.degrees 111 | rotateScrollView() 112 | case .degree90Rotating: 113 | cropMaskViewManager.showVisualEffectBackground() 114 | gridOverlayView.isHidden = true 115 | // rotationDial?.isHidden = true 116 | case .touchImage: 117 | cropMaskViewManager.showDimmingBackground() 118 | gridOverlayView.gridLineNumberType = .crop 119 | gridOverlayView.setGrid(hidden: false, animated: true) 120 | case .touchCropboxHandle(let tappedEdge): 121 | gridOverlayView.handleEdgeTouched(with: tappedEdge) 122 | // rotationDial?.isHidden = true 123 | cropMaskViewManager.showDimmingBackground() 124 | case .touchRotationBoard: 125 | gridOverlayView.gridLineNumberType = .rotate 126 | gridOverlayView.setGrid(hidden: false, animated: true) 127 | cropMaskViewManager.showDimmingBackground() 128 | case .betweenOperation: 129 | gridOverlayView.handleEdgeUntouched() 130 | // rotationDial?.isHidden = false 131 | adaptAngleDashboardToCropBox() 132 | cropMaskViewManager.showVisualEffectBackground() 133 | checkImageStatusChanged() 134 | } 135 | } 136 | 137 | private func isTheSamePoint(p1: CGPoint, p2: CGPoint) -> Bool { 138 | let tolerance = CGFloat.ulpOfOne * 10 139 | if abs(p1.x - p2.x) > tolerance { return false } 140 | if abs(p1.y - p2.y) > tolerance { return false } 141 | 142 | return true 143 | } 144 | 145 | private func imageStatusChanged() -> Bool { 146 | if viewModel.getTotalRadians() != 0 { return true } 147 | 148 | if (forceFixedRatio) { 149 | if imageStatusChangedCheckForForceFixedRatio { 150 | imageStatusChangedCheckForForceFixedRatio = false 151 | return scrollView.zoomScale != 1 152 | } 153 | } 154 | 155 | if !isTheSamePoint(p1: getImageLeftTopAnchorPoint(), p2: .zero) { 156 | return true 157 | } 158 | 159 | if !isTheSamePoint(p1: getImageRightBottomAnchorPoint(), p2: CGPoint(x: 1, y: 1)) { 160 | return true 161 | } 162 | 163 | return false 164 | } 165 | 166 | private func checkImageStatusChanged() { 167 | if imageStatusChanged() { 168 | delegate?.cropViewDidBecomeResettable(self) 169 | } else { 170 | delegate?.cropViewDidBecomeUnResettable(self) 171 | } 172 | } 173 | 174 | private func setupUI() { 175 | setupScrollView() 176 | imageContainer.image = image 177 | 178 | scrollView.addSubview(imageContainer) 179 | scrollView.imageContainer = imageContainer 180 | 181 | setGridOverlayView() 182 | } 183 | 184 | func resetUIFrame() { 185 | cropMaskViewManager.removeMaskViews() 186 | cropMaskViewManager.setup(in: self, cropRatio: CGFloat(getImageRatioH())) 187 | viewModel.resetCropFrame(by: getInitialCropBoxRect()) 188 | 189 | scrollView.transform = .identity 190 | scrollView.resetBy(rect: viewModel.cropBoxFrame) 191 | 192 | imageContainer.frame = scrollView.bounds 193 | imageContainer.center = CGPoint(x: scrollView.bounds.width/2, y: scrollView.bounds.height/2) 194 | 195 | gridOverlayView.superview?.bringSubviewToFront(gridOverlayView) 196 | 197 | setupAngleDashboard() 198 | 199 | if aspectRatioLockEnabled { 200 | setFixedRatioCropBox() 201 | } 202 | } 203 | 204 | func adaptForCropBox() { 205 | resetUIFrame() 206 | } 207 | 208 | private func setupScrollView() { 209 | scrollView.touchesBegan = { [weak self] in 210 | self?.viewModel.setTouchImageStatus() 211 | } 212 | 213 | scrollView.touchesEnded = { [weak self] in 214 | self?.viewModel.setBetweenOperationStatus() 215 | } 216 | 217 | scrollView.delegate = self 218 | addSubview(scrollView) 219 | } 220 | 221 | private func setGridOverlayView() { 222 | gridOverlayView.isUserInteractionEnabled = false 223 | gridOverlayView.gridHidden = true 224 | addSubview(gridOverlayView) 225 | } 226 | 227 | private func setupAngleDashboard() { 228 | if angleDashboardHeight == 0 { 229 | return 230 | } 231 | 232 | // if rotationDial != nil { 233 | // rotationDial?.removeFromSuperview() 234 | // } 235 | 236 | // let boardLength = min(bounds.width, bounds.height) * 0.6 237 | // let rotationDial = RotationDial(frame: CGRect(x: 0, y: 0, width: boardLength, height: angleDashboardHeight), dialConfig: dialConfig) 238 | // self.rotationDial = rotationDial 239 | // rotationDial.isUserInteractionEnabled = true 240 | // addSubview(rotationDial) 241 | 242 | // rotationDial.setRotationCenter(by: gridOverlayView.center, of: self) 243 | // 244 | // rotationDial.didRotate = { [unowned self] angle in 245 | // if self.forceFixedRatio { 246 | // let newRadians = self.viewModel.getTotalRadias(by: angle.radians) 247 | // self.viewModel.setRotatingStatus(by: CGAngle(radians: newRadians)) 248 | // } else { 249 | // self.viewModel.setRotatingStatus(by: angle) 250 | // } 251 | // } 252 | // 253 | // rotationDial.didFinishedRotate = { [unowned self] in 254 | // self.viewModel.setBetweenOperationStatus() 255 | // } 256 | // 257 | // rotationDial.rotateDialPlate(by: CGAngle(radians: viewModel.radians)) 258 | adaptAngleDashboardToCropBox() 259 | } 260 | 261 | private func adaptAngleDashboardToCropBox() { 262 | // guard let rotationDial = rotationDial else { return } 263 | 264 | // let orientation = UIDevice.current.orientation 265 | // 266 | // switch orientation { 267 | // case .landscapeLeft: 268 | // rotationDial.transform = CGAffineTransform(rotationAngle: -CGFloat.pi / 2) 269 | // rotationDial.frame.origin.x = gridOverlayView.frame.maxX 270 | // rotationDial.frame.origin.y = gridOverlayView.frame.origin.y + (gridOverlayView.frame.height - rotationDial.frame.height) / 2 271 | // case .landscapeRight: 272 | // rotationDial.transform = CGAffineTransform(rotationAngle: CGFloat.pi / 2) 273 | // rotationDial.frame.origin.x = gridOverlayView.frame.minX - rotationDial.frame.width 274 | // rotationDial.frame.origin.y = gridOverlayView.frame.origin.y + (gridOverlayView.frame.height - rotationDial.frame.height) / 2 275 | // default: 276 | // rotationDial.transform = CGAffineTransform(rotationAngle: 0) 277 | // rotationDial.frame.origin.x = gridOverlayView.frame.origin.x + (gridOverlayView.frame.width - rotationDial.frame.width) / 2 278 | // rotationDial.frame.origin.y = gridOverlayView.frame.maxY 279 | // } 280 | } 281 | 282 | func updateCropBoxFrame(with point: CGPoint) { 283 | let contentFrame = getContentBounds() 284 | let newCropBoxFrame = viewModel.getNewCropBoxFrame(with: point, and: contentFrame, aspectRatioLockEnabled: aspectRatioLockEnabled) 285 | 286 | let contentBounds = getContentBounds() 287 | 288 | guard newCropBoxFrame.width >= cropViewMinimumBoxSize 289 | && newCropBoxFrame.minX >= contentBounds.minX 290 | && newCropBoxFrame.maxX <= contentBounds.maxX 291 | && newCropBoxFrame.height >= cropViewMinimumBoxSize 292 | && newCropBoxFrame.minY >= contentBounds.minY 293 | && newCropBoxFrame.maxY <= contentBounds.maxY else { 294 | return 295 | } 296 | 297 | if imageContainer.contains(rect: newCropBoxFrame, fromView: self) { 298 | viewModel.cropBoxFrame = newCropBoxFrame 299 | } else { 300 | let minX = max(viewModel.cropBoxFrame.minX, newCropBoxFrame.minX) 301 | let minY = max(viewModel.cropBoxFrame.minY, newCropBoxFrame.minY) 302 | let maxX = min(viewModel.cropBoxFrame.maxX, newCropBoxFrame.maxX) 303 | let maxY = min(viewModel.cropBoxFrame.maxY, newCropBoxFrame.maxY) 304 | 305 | var rect: CGRect 306 | 307 | rect = CGRect(x: minX, y: minY, width: newCropBoxFrame.width, height: maxY - minY) 308 | if imageContainer.contains(rect: rect, fromView: self) { 309 | viewModel.cropBoxFrame = rect 310 | return 311 | } 312 | 313 | rect = CGRect(x: minX, y: minY, width: maxX - minX, height: newCropBoxFrame.height) 314 | if imageContainer.contains(rect: rect, fromView: self) { 315 | viewModel.cropBoxFrame = rect 316 | return 317 | } 318 | 319 | rect = CGRect(x: newCropBoxFrame.minX, y: minY, width: newCropBoxFrame.width, height: maxY - minY) 320 | if imageContainer.contains(rect: rect, fromView: self) { 321 | viewModel.cropBoxFrame = rect 322 | return 323 | } 324 | 325 | rect = CGRect(x: minX, y: newCropBoxFrame.minY, width: maxX - minX, height: newCropBoxFrame.height) 326 | if imageContainer.contains(rect: rect, fromView: self) { 327 | viewModel.cropBoxFrame = rect 328 | return 329 | } 330 | 331 | viewModel.cropBoxFrame = CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) 332 | } 333 | } 334 | } 335 | 336 | 337 | // MARK: - Adjust UI 338 | extension CropView { 339 | private func rotateScrollView() { 340 | let totalRadians = forceFixedRatio ? viewModel.radians : viewModel.getTotalRadians() 341 | 342 | self.scrollView.transform = CGAffineTransform(rotationAngle: totalRadians) 343 | self.updatePosition(by: totalRadians) 344 | } 345 | 346 | private func getInitialCropBoxRect() -> CGRect { 347 | guard image.size.width > 0 && image.size.height > 0 else { 348 | return .zero 349 | } 350 | 351 | let outsideRect = getContentBounds() 352 | let insideRect: CGRect 353 | 354 | if viewModel.isUpOrUpsideDown() { 355 | insideRect = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height) 356 | } else { 357 | insideRect = CGRect(x: 0, y: 0, width: image.size.height, height: image.size.width) 358 | } 359 | 360 | return GeometryHelper.getInscribeRect(fromOutsideRect: outsideRect, andInsideRect: insideRect) 361 | } 362 | 363 | func getContentBounds() -> CGRect { 364 | let rect = self.bounds 365 | var contentRect = CGRect.zero 366 | 367 | let orientation = UIDevice.current.orientation 368 | 369 | if orientation == .portrait { 370 | contentRect.origin.x = rect.origin.x + cropViewPadding 371 | contentRect.origin.y = rect.origin.y + cropViewPadding 372 | 373 | contentRect.size.width = rect.width - 2 * cropViewPadding 374 | contentRect.size.height = rect.height - 2 * cropViewPadding - angleDashboardHeight 375 | } else if orientation == .landscapeLeft || orientation == .landscapeRight { 376 | contentRect.size.width = rect.width - 2 * cropViewPadding - angleDashboardHeight 377 | contentRect.size.height = rect.height - 2 * cropViewPadding 378 | 379 | contentRect.origin.y = rect.origin.y + cropViewPadding 380 | if orientation == .landscapeLeft { 381 | contentRect.origin.x = rect.origin.x + cropViewPadding 382 | } else { 383 | contentRect.origin.x = rect.origin.x + cropViewPadding + angleDashboardHeight 384 | } 385 | } 386 | 387 | return contentRect 388 | } 389 | 390 | fileprivate func getImageLeftTopAnchorPoint() -> CGPoint { 391 | if imageContainer.bounds.size == .zero { 392 | return viewModel.cropLeftTopOnImage 393 | } 394 | 395 | let lt = gridOverlayView.convert(CGPoint(x: 0, y: 0), to: imageContainer) 396 | let point = CGPoint(x: lt.x / imageContainer.bounds.width, y: lt.y / imageContainer.bounds.height) 397 | return point 398 | } 399 | 400 | fileprivate func getImageRightBottomAnchorPoint() -> CGPoint { 401 | if imageContainer.bounds.size == .zero { 402 | return viewModel.cropRightBottomOnImage 403 | } 404 | 405 | let rb = gridOverlayView.convert(CGPoint(x: gridOverlayView.bounds.width, y: gridOverlayView.bounds.height), to: imageContainer) 406 | let point = CGPoint(x: rb.x / imageContainer.bounds.width, y: rb.y / imageContainer.bounds.height) 407 | return point 408 | } 409 | 410 | fileprivate func saveAnchorPoints() { 411 | viewModel.cropLeftTopOnImage = getImageLeftTopAnchorPoint() 412 | viewModel.cropRightBottomOnImage = getImageRightBottomAnchorPoint() 413 | } 414 | 415 | func adjustUIForNewCrop(contentRect:CGRect, 416 | animation: Bool = true, 417 | zoom: Bool = true, 418 | completion: @escaping ()->Void) { 419 | 420 | let scaleX: CGFloat 421 | let scaleY: CGFloat 422 | 423 | scaleX = contentRect.width / viewModel.cropBoxFrame.size.width 424 | scaleY = contentRect.height / viewModel.cropBoxFrame.size.height 425 | 426 | let scale = min(scaleX, scaleY) 427 | 428 | let newCropBounds = CGRect(x: 0, y: 0, width: viewModel.cropBoxFrame.width * scale, height: viewModel.cropBoxFrame.height * scale) 429 | 430 | let radians = forceFixedRatio ? viewModel.radians : viewModel.getTotalRadians() 431 | 432 | // calculate the new bounds of scroll view 433 | let newBoundWidth = abs(cos(radians)) * newCropBounds.size.width + abs(sin(radians)) * newCropBounds.size.height 434 | let newBoundHeight = abs(sin(radians)) * newCropBounds.size.width + abs(cos(radians)) * newCropBounds.size.height 435 | 436 | // calculate the zoom area of scroll view 437 | var scaleFrame = viewModel.cropBoxFrame 438 | 439 | let refContentWidth = abs(cos(radians)) * scrollView.contentSize.width + abs(sin(radians)) * scrollView.contentSize.height 440 | let refContentHeight = abs(sin(radians)) * scrollView.contentSize.width + abs(cos(radians)) * scrollView.contentSize.height 441 | 442 | if scaleFrame.width >= refContentWidth { 443 | scaleFrame.size.width = refContentWidth 444 | } 445 | if scaleFrame.height >= refContentHeight { 446 | scaleFrame.size.height = refContentHeight 447 | } 448 | 449 | let contentOffset = scrollView.contentOffset 450 | let contentOffsetCenter = CGPoint(x: (contentOffset.x + scrollView.bounds.width / 2), 451 | y: (contentOffset.y + scrollView.bounds.height / 2)) 452 | 453 | 454 | scrollView.bounds = CGRect(x: 0, y: 0, width: newBoundWidth, height: newBoundHeight) 455 | 456 | let newContentOffset = CGPoint(x: (contentOffsetCenter.x - newBoundWidth / 2), 457 | y: (contentOffsetCenter.y - newBoundHeight / 2)) 458 | scrollView.contentOffset = newContentOffset 459 | 460 | let newCropBoxFrame = GeometryHelper.getInscribeRect(fromOutsideRect: contentRect, andInsideRect: viewModel.cropBoxFrame) 461 | 462 | func updateUI(by newCropBoxFrame: CGRect, and scaleFrame: CGRect) { 463 | viewModel.cropBoxFrame = newCropBoxFrame 464 | 465 | if zoom { 466 | let zoomRect = convert(scaleFrame, 467 | to: scrollView.imageContainer) 468 | scrollView.zoom(to: zoomRect, animated: false) 469 | } 470 | scrollView.checkContentOffset() 471 | makeSureImageContainsCropOverlay() 472 | } 473 | 474 | if animation { 475 | UIView.animate(withDuration: 0.25, animations: { 476 | updateUI(by: newCropBoxFrame, and: scaleFrame) 477 | }) {_ in 478 | completion() 479 | } 480 | } else { 481 | updateUI(by: newCropBoxFrame, and: scaleFrame) 482 | completion() 483 | } 484 | 485 | manualZoomed = true 486 | } 487 | 488 | func makeSureImageContainsCropOverlay() { 489 | if !imageContainer.contains(rect: gridOverlayView.frame, fromView: self, tolerance: 0.25) { 490 | scrollView.zoomScaleToBound(animated: true) 491 | } 492 | } 493 | 494 | fileprivate func updatePosition(by radians: CGFloat) { 495 | let width = abs(cos(radians)) * gridOverlayView.frame.width + abs(sin(radians)) * gridOverlayView.frame.height 496 | let height = abs(sin(radians)) * gridOverlayView.frame.width + abs(cos(radians)) * gridOverlayView.frame.height 497 | 498 | scrollView.updateLayout(byNewSize: CGSize(width: width, height: height)) 499 | 500 | if !manualZoomed || scrollView.shouldScale() { 501 | scrollView.zoomScaleToBound() 502 | manualZoomed = false 503 | } else { 504 | scrollView.updateMinZoomScale() 505 | } 506 | 507 | scrollView.checkContentOffset() 508 | } 509 | 510 | fileprivate func updatePositionFor90Rotation(by radians: CGFloat) { 511 | 512 | func adjustScrollViewForNormalRatio(by radians: CGFloat) -> CGFloat { 513 | let width = abs(cos(radians)) * gridOverlayView.frame.width + abs(sin(radians)) * gridOverlayView.frame.height 514 | let height = abs(sin(radians)) * gridOverlayView.frame.width + abs(cos(radians)) * gridOverlayView.frame.height 515 | 516 | let newSize: CGSize 517 | if viewModel.rotationType == .none || viewModel.rotationType == .counterclockwise180 { 518 | newSize = CGSize(width: width, height: height) 519 | } else { 520 | newSize = CGSize(width: height, height: width) 521 | } 522 | 523 | let scale = newSize.width / scrollView.bounds.width 524 | scrollView.updateLayout(byNewSize: newSize) 525 | return scale 526 | } 527 | 528 | let scale = adjustScrollViewForNormalRatio(by: radians) 529 | 530 | let newZoomScale = scrollView.zoomScale * scale 531 | scrollView.minimumZoomScale = newZoomScale 532 | scrollView.zoomScale = newZoomScale 533 | 534 | scrollView.checkContentOffset() 535 | } 536 | } 537 | 538 | // MARK: - internal API 539 | extension CropView { 540 | func crop(_ image: UIImage) -> (croppedImage: UIImage?, transformation: Transformation, cropInfo: CropInfo) { 541 | 542 | let cropInfo = getCropInfo() 543 | 544 | let transformation = Transformation( 545 | offset: scrollView.contentOffset, 546 | rotation: getTotalRadians(), 547 | scale: scrollView.zoomScale, 548 | manualZoomed: manualZoomed, 549 | intialMaskFrame: getInitialCropBoxRect(), 550 | maskFrame: gridOverlayView.frame, 551 | scrollBounds: scrollView.bounds 552 | ) 553 | 554 | guard let croppedImage = image.crop(by: cropInfo) else { 555 | return (nil, transformation, cropInfo) 556 | } 557 | 558 | switch cropShapeType { 559 | case .rect, 560 | .square, 561 | .circle(maskOnly: true), 562 | .roundedRect(_, maskOnly: true), 563 | .path(_, maskOnly: true), 564 | .diamond(maskOnly: true), 565 | .heart(maskOnly: true), 566 | .polygon(_, _, maskOnly: true): 567 | return (croppedImage, transformation, cropInfo) 568 | case .ellipse: 569 | return (croppedImage.ellipseMasked, transformation, cropInfo) 570 | case .circle: 571 | return (croppedImage.ellipseMasked, transformation, cropInfo) 572 | case .roundedRect(let radiusToShortSide, maskOnly: false): 573 | let radius = min(croppedImage.size.width, croppedImage.size.height) * radiusToShortSide 574 | return (croppedImage.roundRect(radius), transformation, cropInfo) 575 | case .path(let points, maskOnly: false): 576 | return (croppedImage.clipPath(points), transformation, cropInfo) 577 | case .diamond(maskOnly: false): 578 | let points = [CGPoint(x: 0.5, y: 0), CGPoint(x: 1, y: 0.5), CGPoint(x: 0.5, y: 1), CGPoint(x: 0, y: 0.5)] 579 | return (croppedImage.clipPath(points), transformation, cropInfo) 580 | case .heart(maskOnly: false): 581 | return (croppedImage.heart, transformation, cropInfo) 582 | case .polygon(let sides, let offset, maskOnly: false): 583 | let points = polygonPointArray(sides: sides, originX: 0.5, originY: 0.5, radius: 0.5, offset: 90 + offset) 584 | return (croppedImage.clipPath(points), transformation, cropInfo) 585 | } 586 | } 587 | 588 | func getCropInfo() -> CropInfo { 589 | 590 | let rect = imageContainer.convert(imageContainer.bounds, 591 | to: self) 592 | let point = rect.center 593 | let zeroPoint = gridOverlayView.center 594 | 595 | let translation = CGPoint(x: (point.x - zeroPoint.x), y: (point.y - zeroPoint.y)) 596 | 597 | return CropInfo( 598 | translation: translation, 599 | rotation: getTotalRadians(), 600 | scale: scrollView.zoomScale, 601 | cropSize: gridOverlayView.frame.size, 602 | imageViewSize: imageContainer.bounds.size 603 | ) 604 | 605 | } 606 | 607 | func getTotalRadians() -> CGFloat { 608 | return forceFixedRatio ? viewModel.radians : viewModel.getTotalRadians() 609 | } 610 | 611 | func crop() -> (croppedImage: UIImage?, transformation: Transformation, cropInfo: CropInfo) { 612 | return crop(image) 613 | } 614 | 615 | func handleRotate() { 616 | viewModel.resetCropFrame(by: getInitialCropBoxRect()) 617 | 618 | scrollView.transform = .identity 619 | scrollView.resetBy(rect: viewModel.cropBoxFrame) 620 | 621 | setupAngleDashboard() 622 | rotateScrollView() 623 | 624 | if viewModel.cropRightBottomOnImage != .zero { 625 | var lt = CGPoint(x: viewModel.cropLeftTopOnImage.x * imageContainer.bounds.width, y: viewModel.cropLeftTopOnImage.y * imageContainer.bounds.height) 626 | var rb = CGPoint(x: viewModel.cropRightBottomOnImage.x * imageContainer.bounds.width, y: viewModel.cropRightBottomOnImage.y * imageContainer.bounds.height) 627 | 628 | lt = imageContainer.convert(lt, to: self) 629 | rb = imageContainer.convert(rb, to: self) 630 | 631 | let rect = CGRect(origin: lt, size: CGSize(width: rb.x - lt.x, height: rb.y - lt.y)) 632 | viewModel.cropBoxFrame = rect 633 | 634 | let contentRect = getContentBounds() 635 | 636 | adjustUIForNewCrop(contentRect: contentRect) { [weak self] in 637 | self?.adaptAngleDashboardToCropBox() 638 | self?.viewModel.setBetweenOperationStatus() 639 | } 640 | } 641 | } 642 | 643 | func rotateBy90(rotateAngle: CGFloat, completion: @escaping ()->Void = {}) { 644 | viewModel.setDegree90RotatingStatus() 645 | let rorateDuration = 0.25 646 | 647 | if forceFixedRatio { 648 | viewModel.setRotatingStatus(by: CGAngle(radians: viewModel.radians)) 649 | let angle = CGAngle(radians: rotateAngle + viewModel.radians) 650 | 651 | UIView.animate(withDuration: rorateDuration, animations: { 652 | self.viewModel.setRotatingStatus(by: angle) 653 | }) {[weak self] _ in 654 | guard let self = self else { return } 655 | self.viewModel.rotateBy90(rotateAngle: rotateAngle) 656 | self.viewModel.setBetweenOperationStatus() 657 | completion() 658 | } 659 | 660 | return 661 | } 662 | 663 | var rect = gridOverlayView.frame 664 | rect.size.width = gridOverlayView.frame.height 665 | rect.size.height = gridOverlayView.frame.width 666 | 667 | let newRect = GeometryHelper.getInscribeRect(fromOutsideRect: getContentBounds(), andInsideRect: rect) 668 | 669 | let radian = rotateAngle 670 | let transfrom = scrollView.transform.rotated(by: radian) 671 | 672 | UIView.animate(withDuration: rorateDuration, animations: { 673 | self.viewModel.cropBoxFrame = newRect 674 | self.scrollView.transform = transfrom 675 | self.updatePositionFor90Rotation(by: radian + self.viewModel.radians) 676 | }) {[weak self] _ in 677 | guard let self = self else { return } 678 | self.scrollView.updateMinZoomScale() 679 | self.viewModel.rotateBy90(rotateAngle: rotateAngle) 680 | self.viewModel.setBetweenOperationStatus() 681 | completion() 682 | } 683 | } 684 | 685 | func reset() { 686 | scrollView.removeFromSuperview() 687 | gridOverlayView.removeFromSuperview() 688 | // rotationDial?.removeFromSuperview() 689 | 690 | if forceFixedRatio { 691 | aspectRatioLockEnabled = true 692 | } else { 693 | aspectRatioLockEnabled = false 694 | } 695 | 696 | viewModel.reset(forceFixedRatio: forceFixedRatio) 697 | resetUIFrame() 698 | delegate?.cropViewDidBecomeUnResettable(self) 699 | } 700 | 701 | func prepareForDeviceRotation() { 702 | viewModel.setDegree90RotatingStatus() 703 | saveAnchorPoints() 704 | } 705 | 706 | fileprivate func setRotation(byRadians radians: CGFloat) { 707 | scrollView.transform = CGAffineTransform(rotationAngle: radians) 708 | updatePosition(by: radians) 709 | // rotationDial?.rotateDialPlate(to: CGAngle(radians: radians), animated: false) 710 | } 711 | 712 | 713 | func setFixedRatioCropBox(zoom: Bool = true, cropBox: CGRect? = nil) { 714 | let refCropBox = cropBox ?? getInitialCropBoxRect() 715 | viewModel.setCropBoxFrame(by: refCropBox, and: getImageRatioH()) 716 | 717 | let contentRect = getContentBounds() 718 | adjustUIForNewCrop(contentRect: contentRect, animation: false, zoom: zoom) { [weak self] in 719 | guard let self = self else { return } 720 | if self.forceFixedRatio { 721 | self.imageStatusChangedCheckForForceFixedRatio = true 722 | } 723 | self.viewModel.setBetweenOperationStatus() 724 | } 725 | 726 | adaptAngleDashboardToCropBox() 727 | scrollView.updateMinZoomScale() 728 | } 729 | 730 | func getRatioType(byImageIsOriginalisHorizontal isHorizontal: Bool) -> RatioType { 731 | return viewModel.getRatioType(byImageIsOriginalHorizontal: isHorizontal) 732 | } 733 | 734 | func getImageRatioH() -> Double { 735 | if viewModel.rotationType == .none || viewModel.rotationType == .counterclockwise180 { 736 | return Double(image.ratioH()) 737 | } else { 738 | return Double(1/image.ratioH()) 739 | } 740 | } 741 | 742 | func transform(byTransformInfo transformation: Transformation, rotateDial: Bool = true) { 743 | viewModel.setRotatingStatus(by: CGAngle(radians:transformation.rotation)) 744 | 745 | if (transformation.scrollBounds != .zero) { 746 | scrollView.bounds = transformation.scrollBounds 747 | } 748 | 749 | manualZoomed = transformation.manualZoomed 750 | scrollView.zoomScale = transformation.scale 751 | scrollView.contentOffset = transformation.offset 752 | viewModel.setBetweenOperationStatus() 753 | 754 | if (transformation.maskFrame != .zero) { 755 | viewModel.cropBoxFrame = transformation.maskFrame 756 | } 757 | 758 | if (rotateDial) { 759 | // rotationDial?.rotateDialPlate(by: CGAngle(radians: viewModel.radians)) 760 | adaptAngleDashboardToCropBox() 761 | } 762 | } 763 | } 764 | 765 | --------------------------------------------------------------------------------