├── 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 |
--------------------------------------------------------------------------------