├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── CZImageEditor │ ├── BoundsPreferenceKey.swift │ ├── CZImageEditor.swift │ ├── CZImageEditorViewModel.swift │ ├── Custom Filters │ ├── CZAirFilter.swift │ ├── CZCrystalFilter.swift │ ├── CZOriginalFilter.swift │ ├── CZVividFilter.swift │ └── RegisterCustomFilters.swift │ ├── EditOption.swift │ ├── Extension.swift │ ├── FilteredImage.swift │ ├── FrameType.swift │ ├── ImageEditorParameters.swift │ ├── PressAndRelease.swift │ └── TextButton.swift ├── Tests └── CZImageEditorTests │ └── CZImageEditorTests.swift └── previews ├── preview1.gif ├── preview2.gif └── preview3.gif /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 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: "CZImageEditor", 8 | platforms: [.iOS(.v15)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, and make them visible to other packages. 11 | .library( 12 | name: "CZImageEditor", 13 | targets: ["CZImageEditor"]), 14 | ], 15 | dependencies: [ 16 | // Dependencies declare other packages that this package depends on. 17 | // .package(url: /* package url */, from: "1.0.0"), 18 | ], 19 | targets: [ 20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 21 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 22 | .target( 23 | name: "CZImageEditor", 24 | dependencies: []), 25 | .testTarget( 26 | name: "CZImageEditorTests", 27 | dependencies: ["CZImageEditor"]), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CZImageEditor 2 | 3 | CZImageEditor is an instagram-like image editor with clean and intuitive UI. It is pure swift and can apply preset filters and customized editings to a binded image. Customized editings include rotation, zooming, cropping, brightness, contrast, saturation, warmth, and sharpen. 4 | 5 | [![Language: Swift 5](https://img.shields.io/badge/language-swift%205-f48041.svg?style=flat)](https://developer.apple.com/swift) 6 | ![Platfor](https://img.shields.io/badge/platform-iOS-lightgrey) 7 | 8 | 9 | 10 | ## Features 11 | 12 | ### Preset Filters 13 | You can pass your own preset filters to the `CIImageEditor`. They should conform to `CIFilter`. By default, four built-in filters will be used: Normal (original), Crystal, Vivid, and Air. 14 | 15 | ### Adjust Image 16 | User can adjust the image by rotating, zooming, and cropping. The edge of the image will be aligned automatically to make sure the cropping frame stays in the range of image. 17 | 18 | ### Custom Editings 19 | User can adjust the image's brightness, contrast, saturation, warmth, and sharpen in the **edit** pannel. 20 | 21 | ### Different Shape of Cropping Frame 22 | You can preset the cropping frame to the editor. There are five options: image's original shape (default), 4 : 3, 1 : 1, 3 : 4, circle. 23 | 24 | ### Keep Track of Changes 25 | All changes and the orginal image will be saved separately. So these changes won't lose when the editor is dismissed. When user reopen the editor again, they have option to revert all changes or apply new changes on the latest version. See Usage section for more details. 26 | 27 | ### Localization 28 | You have the option to apply localization string to this editor. You can set a localization prefix string to all shown text in editor's UI. 29 | 30 | ### Callback clousure 31 | You can add an optional callback clousure which will be excuted when user confirmed the changes made to the image. 32 | 33 | 34 | ## Preview 35 | 36 | Preset Filters | Rotation and Crop | Custom Editing | 37 | :-------------------------:|:-------------------------:|:-------------------------: 38 | ![preview1](./previews/preview1.gif) | ![preview2](./previews/preview2.gif) | ![preview3](./previews/preview3.gif) 39 | 40 | ## Usage 41 | 42 | ### Parameters 43 | Only two required parameters are image and parameters. All other parameters have default values. 44 | 45 | * **image**: A binding to the image about to be edited. 46 | * **parameters**: A binding to a group of parameters that contains the original image and all possible changes have been made to the image. 47 | * **frame**: What frame shape you want to use. By default, it is the same shape of original iamge. You can also choose in 4 by 3, square, 3 by 4, and circle. 48 | * **filters**: The preset filters that can be chosen by user to apply to the image. These filters should conform to `CIFilter`. 49 | * **filterNameFont**: Text font applies to the preset filter name 50 | * **thumbnailMaxSize**: The maximium length of the thumbnail of the image used during editing. 51 | * **localizationPrefix**: A prefix string that attached to all text shown on the screen. 52 | * **actionWhenConfirm**: An optional clousure that excutes when user confirm the changes to the image. 53 | 54 | ### Keep Track of Changes 55 | This editor uses a struct called `ImageEditorParameters` to keep track of the changes made to the image, so users get chance to revert the changes them made. You should create and keep this struct along with the `CZImageEditor` when you use this editor. 56 | 57 | ### Example 58 | The following example shows a typcial scenario of how this editor should be used in your code. 59 | 60 | ```swift 61 | struct ContentView: View { 62 | @State private var image = UIImage(named: "testImage")! 63 | @State private var showImageEditor = false 64 | @State private var savedImageEditorParameters = ImageEditorParameters() 65 | @State private var yourOwnFilters: [CIFilter] = [...] // your own preset filters (optional) 66 | 67 | var body: some View { 68 | VStack { 69 | Image(uiImage: image) 70 | .resizable() 71 | .scaledToFit() 72 | .onTapGesture { 73 | showImageEditor = true 74 | } 75 | } 76 | .frame(width: 200, height: 300) 77 | .fullScreenCover(isPresented: $showImageEditor) { 78 | CZImageEditor(image: $image, parameters: $savedImageEditorParameters, filters: yourOwnFilters) 79 | } 80 | } 81 | } 82 | ``` 83 | ## Installation 84 | 85 | Add a package by selecting `File` → `Add Packages…` in Xcode’s menu bar. 86 | 87 | Search for the CZImageEditor using the repo's URL: 88 | ```console 89 | https://github.com/KaiyiZhao/CZImageEditor.git 90 | ``` 91 | -------------------------------------------------------------------------------- /Sources/CZImageEditor/BoundsPreferenceKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BoundsPreferenceKey.swift 3 | // TestingForPhotoEditor 4 | // 5 | // Created by Kaiyi Zhao on 8/3/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct BoundsPreferenceKey: PreferenceKey { 12 | typealias Value = CGRect 13 | 14 | static var defaultValue: Value = .zero 15 | 16 | static func reduce(value: inout Value, nextValue: () -> Value) { 17 | value = nextValue() 18 | } 19 | } 20 | 21 | extension View { 22 | func getViewCoordinates (in space: CoordinateSpace) -> some View { 23 | self.background { 24 | GeometryReader { geo in 25 | Color.clear 26 | .preference(key: BoundsPreferenceKey.self, value: geo.frame(in: space)) 27 | } 28 | } 29 | } 30 | 31 | func doWithViewCoordinates(in space: CoordinateSpace, _ action: @escaping (CGRect) -> Void) -> some View { 32 | self 33 | .getViewCoordinates(in: space) 34 | .onPreferenceChange(BoundsPreferenceKey.self, perform: action) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/CZImageEditor/CZImageEditor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CZImageEditor.swift 3 | // TestPhotoEditor 4 | // 5 | // Created by Kaiyi Zhao on 8/18/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | import CoreImage 11 | import CoreImage.CIFilterBuiltins 12 | import SwiftUI 13 | 14 | 15 | /// An editor that can apply preset filters and customized editings to a binded image. Customized editings include rotation, zooming, cropping, brightness, contrast, saturation, warmth, and sharpen. 16 | /// 17 | /// This editor uses a struct called ImageEditorParameters to keep track of the changes made to the image, so users get chance to revert the changes them made. You should create and keep this struct along with the CZImageEditor when you use this editor. 18 | /// 19 | /// The following example shows a typcial scenario of how this editor should be used in your code. 20 | /// ``` 21 | /// struct ContentView: View { 22 | /// @State private var image = UIImage(named: "testImage")! 23 | /// @State private var showImageEditor = false 24 | /// @State private var savedImageEditorParameters = ImageEditorParameters() 25 | /// 26 | /// var body: some View { 27 | /// VStack { 28 | /// Image(uiImage: image) 29 | /// .resizable() 30 | /// .scaledToFit() 31 | /// .onTapGesture { 32 | /// showImageEditor = true 33 | /// } 34 | /// } 35 | /// .frame(width: 200, height: 300) 36 | /// .fullScreenCover(isPresented: $showImageEditor) { 37 | /// CZImageEditor(image: $image, parameters: $savedImageEditorParameters) 38 | /// } 39 | /// } 40 | /// } 41 | /// ``` 42 | public struct CZImageEditor: View { 43 | @Binding var image: UIImage 44 | @Binding var parameters: ImageEditorParameters 45 | let frame: FrameType 46 | let filters: [CIFilter] 47 | let filterNameFont: Font 48 | let thumbnailMaxSize: CGFloat 49 | let localizationPrefix: String 50 | let actionWhenConfirm: (() -> Void)? 51 | 52 | // MARK: - init 53 | 54 | /// Only two required parameters are image and parameters. All other parameters have default values 55 | /// - Parameters: 56 | /// - image: A binding to the image about to be edited. 57 | /// - parameters: A binding to a group of parameters that contains the original image and all possible changes have been made to the image. 58 | /// - frame: What frame shape you want to use. By default, it is the same shape of original image. You can also choose 4 by 3, square, 3 by 4, and circle. 59 | /// - filters: The preset filters that can be chosen by user to apply to the image. 60 | /// - filterNameFont: Text font applies to the preset filter name 61 | /// - thumbnailMaxSize: The maximium length of the thumbnail of the image used during editing. 62 | /// - localizationPrefix: A prefix string that attached to all text shown on the screen. 63 | /// - actionWhenConfirm: An optional clousure that excutes when user confirm the changes to the image. 64 | public init(image: Binding, 65 | parameters: Binding, 66 | frame: FrameType = .origin, 67 | filters: [CIFilter] = [ 68 | CZOriginalFilter(), 69 | CZCrystalFilter(), 70 | CZVividFilter(), 71 | CZAirFilter()], 72 | filterNameFont: Font = .caption2, 73 | thumbnailMaxSize: CGFloat = 1600, 74 | localizationPrefix: String = "", 75 | actionWhenConfirm: (() -> Void)? = nil) { 76 | 77 | // register custom filters 78 | CustomFiltersVendor.registerFilters() 79 | 80 | self._image = image 81 | self._parameters = parameters 82 | self.frame = frame 83 | self.filters = filters 84 | self.filterNameFont = filterNameFont 85 | self.thumbnailMaxSize = thumbnailMaxSize 86 | self.localizationPrefix = localizationPrefix 87 | self.actionWhenConfirm = actionWhenConfirm 88 | } 89 | 90 | @Environment(\.dismiss) var dismiss 91 | @StateObject private var vm = CZImageEditorViewModel() 92 | 93 | // option selecting 94 | @State private var editType: EditType = .filter 95 | @State private var selectedEditOption: EditOption? = nil 96 | 97 | // filter option editing 98 | @State private var editingValue: Double = 0 99 | @State private var savedEditingValue: Double = 0 100 | 101 | // layout 102 | @State private var campusRect: CGRect = .zero 103 | 104 | @GestureState private var gesturePanOffset: CGSize = .zero 105 | @State private var savedEditPanOffset: CGSize = .zero 106 | 107 | @GestureState private var gestureZoomScale: CGFloat = 1 108 | @State private var savedEditZoomScale: CGFloat = 1 109 | 110 | // show original image to compare 111 | @State private var showOriginalImage = false 112 | 113 | public var body: some View { 114 | NavigationView { 115 | GeometryReader { geo in 116 | VStack(spacing: 0) { 117 | campus 118 | 119 | middlePanel 120 | 121 | bottomPanel(geo: geo) 122 | } 123 | .toolbar { 124 | ToolbarItem(placement: .navigationBarLeading) { 125 | TextButton(text: "Cancel", color: .white, localizationPrefix: localizationPrefix) { 126 | dismiss() 127 | } 128 | } 129 | 130 | ToolbarItem(placement: .principal) { 131 | TextButton(text: "Compare", color: .white, localizationPrefix: localizationPrefix) { 132 | } 133 | .opacity(changesWereMade ? 1 : 0) 134 | .pressAction { 135 | showOriginalImage = true 136 | } onRelease: { 137 | showOriginalImage = false 138 | } 139 | } 140 | 141 | ToolbarItem(placement: .navigationBarTrailing) { 142 | toolbarRightButton 143 | } 144 | } 145 | } 146 | .navigationBarTitleDisplayMode(.inline) 147 | } 148 | .navigationViewStyle(.stack) 149 | .preferredColorScheme(.dark) 150 | .task { 151 | await vm.initializeVM(fullImage: parameters.fullOriginalImage ?? image, 152 | parameters: parameters, 153 | filters: filters, 154 | thumbnailMaxSize: thumbnailMaxSize) 155 | } 156 | } 157 | } 158 | 159 | // MARK: - Preview 160 | struct ImageEditor_Previews: PreviewProvider { 161 | static var previews: some View { 162 | CZImageEditor(image: Binding.constant(UIColor.gray.uiImage(CGSize(width: 200, height: 200))), parameters: .constant(.init()), frame: .origin, actionWhenConfirm: { }) 163 | } 164 | } 165 | 166 | extension CZImageEditor { 167 | 168 | // MARK: - showing picture 169 | private func refreshLayoutAndPreviews() { 170 | if parameters.attributes.steadyPanOffset == .zero && parameters.attributes.steadyZoomScale == 1 { 171 | zoomToFillFrame() 172 | } 173 | Task { 174 | await vm.loadFilterPreviews() 175 | } 176 | } 177 | 178 | private var showingImage: some View { 179 | Image(uiImage: vm.targetImage ?? UIColor.clear.uiImage()) 180 | .scaleEffect(zoomScale) 181 | .offset(panOffset) 182 | .opacity(showOriginalImage ? 0 : 1) 183 | .onAppear(perform: refreshLayoutAndPreviews) 184 | } 185 | 186 | private var originalImageToCompare: some View { 187 | Image(uiImage: vm.originImage ?? UIColor.clear.uiImage()) 188 | .scaleEffect(initZoomScale) 189 | .opacity(showOriginalImage ? 1 : 0) 190 | } 191 | 192 | private var onAdjust: Bool { 193 | selectedEditOption == .rotation 194 | } 195 | private var campus: some View { 196 | ZStack { 197 | Color.black 198 | .doWithViewCoordinates(in: .local) { rect in 199 | campusRect = rect 200 | } 201 | 202 | if changesWereMade { 203 | Color.clear 204 | .overlay { originalImageToCompare } 205 | } 206 | 207 | if frameSize != .zero && originalPictureSize != .zero { 208 | Color.clear 209 | .overlay { showingImage } 210 | } 211 | 212 | cropMask(in: campusRect) 213 | } 214 | .clipped() 215 | .gesture( 216 | panGesture() 217 | .simultaneously(with: zoomGesture()) 218 | ) 219 | .onChange(of: vm.rotationPercent) { _ in 220 | alignTargetPictureByZooming(animated: false) 221 | } 222 | .onChange(of: frameSize) { newValue in 223 | if newValue != .zero { 224 | vm.frameSize = newValue 225 | } 226 | } 227 | } 228 | // MARK: - Filters 229 | private func filteredImageView(filteredImage: FilteredImage) -> some View { 230 | VStack(spacing: 8) { 231 | Text(LocalizedStringKey(localizationPrefix + filteredImage.name)) 232 | .font(filterNameFont) 233 | .foregroundColor(.white) 234 | 235 | Image(uiImage: filteredImage.image) 236 | .resizable() 237 | .scaledToFill() 238 | .frame(width: 100, height: 100) 239 | .clipped() 240 | } 241 | .onTapGesture { 242 | vm.selectedFilter = filteredImage.filter 243 | vm.applyFiltersToTarget() 244 | } 245 | } 246 | 247 | private var filtersView: some View { 248 | ScrollView(.horizontal, showsIndicators: false) { 249 | HStack(spacing: 16) { 250 | ForEach(vm.filteredImages.sorted(by: { $0.id < $1.id })) { filtered in 251 | filteredImageView(filteredImage: filtered) 252 | } 253 | } 254 | .padding(.vertical, 8) 255 | } 256 | } 257 | 258 | // MARK: Edit options 259 | private func optionDisplayValue(option: EditOption) -> Double { 260 | let range = Double(option == .rotation ? 360 : 200) 261 | return (fetchOptionPercentValue(option: option) - 0.5) * range 262 | } 263 | 264 | private var editOptions: some View { 265 | ScrollView(.horizontal, showsIndicators: false) { 266 | HStack(spacing: 16) { 267 | ForEach(EditOption.allCases, id: \.self) { option in 268 | VStack(spacing: 8) { 269 | Text(LocalizedStringKey(localizationPrefix + option.rawValue)) 270 | .font(filterNameFont) 271 | .foregroundColor(.white) 272 | 273 | editOptionIcon(option: option) 274 | .frame(width: 70, height: 70, alignment: .center) 275 | 276 | Text("\(optionDisplayValue(option: option), specifier: "%.0f")") 277 | .opacity(fetchOptionPercentValue(option: option) == 0.5 ? 0 : 1) 278 | } 279 | .onTapGesture { 280 | editingValue = optionDisplayValue(option: option) 281 | savedEditingValue = editingValue 282 | if option == .rotation { 283 | savedEditPanOffset = vm.steadyPanOffset 284 | savedEditZoomScale = vm.steadyZoomScale 285 | } 286 | withAnimation(selectAnimation) { 287 | selectedEditOption = option 288 | } 289 | } 290 | } 291 | } 292 | 293 | } 294 | } 295 | 296 | private func editCertainOption(option: EditOption) -> some View { 297 | VStack(spacing: 8) { 298 | if option == .rotation { 299 | Slider(value: $editingValue, in: -180...180, step: 1) 300 | Text(LocalizedStringKey(localizationPrefix + "Rotation:")) + Text(" \(editingValue, specifier: "%.0f")") 301 | } else { 302 | Slider(value: $editingValue, in: -100...100, step: 1) 303 | Text(LocalizedStringKey(localizationPrefix + "\(option.rawValue):")) + Text(" \(editingValue, specifier: "%.0f")") 304 | } 305 | } 306 | .onChange(of: editingValue) { newValue in 307 | setValueToOption(value: newValue, option: option) 308 | } 309 | } 310 | 311 | // MARK: - Panels 312 | private var selectAnimation: Animation { 313 | Animation.easeIn(duration: 0.1) 314 | } 315 | 316 | private var changesWereMade: Bool { 317 | return vm.outputAttributes() != ImageEditorParameters.Attributes.init() 318 | } 319 | private var changesWereMadeThisTime: Bool { 320 | return vm.outputAttributes() != parameters.attributes 321 | } 322 | 323 | @ViewBuilder 324 | private var toolbarRightButton: some View { 325 | if changesWereMade && !changesWereMadeThisTime { 326 | TextButton(text: "Revert", color: .red, localizationPrefix: localizationPrefix) { 327 | vm.loadAttributes(attributes: .init()) 328 | vm.targetImage = vm.originImage 329 | Task { 330 | await vm.loadFilterPreviews() 331 | } 332 | parameters = .init(fullOriginalImage: vm.originFullImage, attributes: .init()) 333 | } 334 | } else { 335 | TextButton(text: "Confirm", color: .white, localizationPrefix: localizationPrefix) { 336 | if let originFullImage = vm.originFullImage, 337 | let finalImage = vm.cropImage(originalImage: originFullImage, applyColorFilters: true) { 338 | image = finalImage 339 | parameters = vm.outputParameters() 340 | // print(parameters) 341 | } 342 | actionWhenConfirm?() 343 | dismiss() 344 | } 345 | } 346 | } 347 | 348 | @ViewBuilder 349 | private var middlePanel: some View { 350 | ZStack { 351 | Color.black 352 | 353 | if editType == .edit { 354 | if let selectedEditOption = selectedEditOption { 355 | editCertainOption(option: selectedEditOption) 356 | } else { 357 | editOptions 358 | } 359 | } else if !vm.filteredImages.isEmpty && editType == .filter { 360 | filtersView 361 | } 362 | } 363 | .frame(height: 150) 364 | } 365 | 366 | private func bottomPanel(geo: GeometryProxy) -> some View { 367 | HStack(spacing: 0) { 368 | if selectedEditOption == nil { 369 | Text(LocalizedStringKey(localizationPrefix + EditType.filter.rawValue)) 370 | .fontWeight(editType == .filter ? .bold : .regular) 371 | .animation(.none, value: editType) 372 | .frame(width: geo.size.width/2) 373 | .onTapGesture { 374 | withAnimation(selectAnimation) { 375 | editType = .filter 376 | } 377 | } 378 | 379 | Text(LocalizedStringKey(localizationPrefix + EditType.edit.rawValue)) 380 | .fontWeight(editType == .edit ? .bold : .regular) 381 | .animation(.none, value: editType) 382 | .frame(width: geo.size.width/2) 383 | .onTapGesture { 384 | withAnimation(selectAnimation) { 385 | editType = .edit 386 | } 387 | } 388 | } else { 389 | Text(LocalizedStringKey(localizationPrefix + "Cancel")) 390 | .frame(width: geo.size.width/2) 391 | .onTapGesture { 392 | vm.steadyPanOffset = savedEditPanOffset 393 | vm.steadyZoomScale = savedEditZoomScale 394 | setValueToOption(value: savedEditingValue, option: selectedEditOption!) 395 | withAnimation(selectAnimation) { 396 | selectedEditOption = nil 397 | } 398 | } 399 | 400 | Text(LocalizedStringKey(localizationPrefix + "Done")) 401 | .frame(width: geo.size.width/2) 402 | .onTapGesture { 403 | if selectedEditOption == .rotation { 404 | Task { 405 | await vm.loadFilterPreviews() 406 | } 407 | } 408 | withAnimation(selectAnimation) { 409 | selectedEditOption = nil 410 | } 411 | } 412 | } 413 | } 414 | .frame(height: 40) 415 | .background(Color.black) 416 | } 417 | 418 | // MARK: - enums 419 | 420 | enum EditType: String { 421 | case filter = "Filter" 422 | case edit = "Edit" 423 | } 424 | 425 | // MARK: - Edit Funcs 426 | private func editOptionIcon(option: EditOption) -> some View { 427 | return VStack { 428 | Group { 429 | switch option { 430 | case .rotation: 431 | Image(systemName: "crop.rotate") 432 | .resizable() 433 | .scaledToFit() 434 | case .brightness: 435 | Image(systemName: "sun.max") 436 | .resizable() 437 | .scaledToFit() 438 | case .contrast: 439 | Image(systemName: "circle.righthalf.filled") 440 | .resizable() 441 | .scaledToFit() 442 | case .saturation: 443 | Image(systemName: "drop") 444 | .resizable() 445 | .scaledToFit() 446 | case .sharpen: 447 | Image(systemName: "triangle") 448 | .resizable() 449 | .scaledToFit() 450 | .rotationEffect(Angle(degrees: 180)) 451 | case .warmth: 452 | Image(systemName: "thermometer") 453 | .resizable() 454 | .scaledToFit() 455 | } 456 | } 457 | 458 | } 459 | .foregroundColor(Color.white) 460 | .frame(width: 40, height: 40) 461 | } 462 | 463 | private func setValueToOption(value: Double, option: EditOption) { 464 | switch option { 465 | case .rotation: 466 | vm.rotationPercent = (value + 180)/360 467 | case .brightness: 468 | vm.brightnessPercent = (value + 100)/200 469 | case .contrast: 470 | vm.contrastPercent = (value + 100)/200 471 | case .saturation: 472 | vm.saturationPercent = (value + 100)/200 473 | case .sharpen: 474 | vm.sharpenPercent = (value + 100)/200 475 | case .warmth: 476 | vm.warmthPercent = (value + 100)/200 477 | } 478 | vm.applyFiltersToTarget() 479 | } 480 | 481 | private func fetchOptionPercentValue(option: EditOption) -> Double { 482 | switch option { 483 | case .rotation: return vm.rotationPercent 484 | case .brightness: return vm.brightnessPercent 485 | case .contrast: return vm.contrastPercent 486 | case .saturation: return vm.saturationPercent 487 | case .sharpen: return vm.sharpenPercent 488 | case .warmth: return vm.warmthPercent 489 | } 490 | } 491 | 492 | // MARK: - Cropping 493 | private func HoleShapeMask(frameRect: CGRect) -> Path { 494 | var shape = Rectangle().path(in: frameRect) 495 | let center = frameRect.center 496 | let maskOrigin = center - frameSize / 2 497 | if frame == .circle { 498 | shape.addPath(Circle().path(in: CGRect(origin: maskOrigin, size: frameSize))) 499 | } else { 500 | shape.addPath(Rectangle().path(in: CGRect(origin: maskOrigin, size: frameSize))) 501 | } 502 | return shape 503 | } 504 | 505 | private func cropMask(in rect: CGRect) -> some View { 506 | ZStack { 507 | Rectangle() 508 | .fill(Color.black.opacity(onAdjust ? 0.5 : 1)) 509 | .frame(maxWidth: .infinity, maxHeight: .infinity) 510 | .mask(HoleShapeMask(frameRect: rect).fill(style: FillStyle(eoFill: true))) 511 | 512 | if onAdjust { 513 | Group { 514 | if frame != .circle && frame != .square { 515 | Rectangle() 516 | .stroke(style: .init(lineWidth: 0.9, dash: [5])) 517 | .opacity(0.75) 518 | .frame(width: min(frameSize.width, frameSize.height), height: min(frameSize.width, frameSize.height)) 519 | } 520 | 521 | switch frame { 522 | case .circle: 523 | Circle().stroke() 524 | default: 525 | Rectangle().stroke() 526 | } 527 | } 528 | .frame(width: frameSize.width, height: frameSize.height) 529 | .foregroundColor(.white) 530 | .position(rect.center) 531 | .contentShape(Rectangle()) 532 | .gesture(doubleTapToZoomFillFrame()) 533 | } 534 | } 535 | } 536 | 537 | // MARK: - Picture Alignment 538 | private var animation: Animation { 539 | Animation.easeOut(duration: 0.2) 540 | } 541 | 542 | private func rotatedPicPoints() -> (a: CGSize, b: CGSize, c: CGSize, d: CGSize) { 543 | let picCenter = panOffset.reverseHeight() 544 | let halfPicWidth = showingPictureSize.width / 2 545 | let halfPicHeight = showingPictureSize.height / 2 546 | 547 | // print("showingPictureSize: \(showingPictureSize), zoomScale: \(zoomScale), steadyZoomScale: \(vm.steadyZoomScale)") 548 | 549 | let pointA = CGSize(width: picCenter.width - halfPicWidth, height: picCenter.height + halfPicHeight) 550 | let pointB = CGSize(width: picCenter.width + halfPicWidth, height: picCenter.height + halfPicHeight) 551 | let pointC = CGSize(width: picCenter.width + halfPicWidth, height: picCenter.height - halfPicHeight) 552 | let pointD = CGSize(width: picCenter.width - halfPicWidth, height: picCenter.height - halfPicHeight) 553 | let rotatedPointA = pointA.rotatedVector(radians: Double.pi * 2 - vm.rotatedAngle, center: picCenter) 554 | let rotatedPointB = pointB.rotatedVector(radians: Double.pi * 2 - vm.rotatedAngle, center: picCenter) 555 | let rotatedPointC = pointC.rotatedVector(radians: Double.pi * 2 - vm.rotatedAngle, center: picCenter) 556 | let rotatedPointD = pointD.rotatedVector(radians: Double.pi * 2 - vm.rotatedAngle, center: picCenter) 557 | return (rotatedPointA, rotatedPointB, rotatedPointC, rotatedPointD) 558 | } 559 | 560 | private func framePointOffsetOfLine(_ framePoint: CGSize, point1: CGSize, point2: CGSize) -> CGFloat { 561 | let a = point2.height - point1.height 562 | let b = point1.width - point2.width 563 | let c = point2.width * point1.height - point1.width * point2.height 564 | let dividend = a * framePoint.width + b * framePoint.height + c 565 | let divider = sqrt(a*a + b*b) 566 | let d = dividend / divider 567 | return d 568 | } 569 | 570 | private func frameToPicOffset() -> (da: CGFloat, ab: CGFloat, bc: CGFloat, cd: CGFloat) { 571 | let frameTopLeft = CGSize(width: -frameSize.width/2, height: frameSize.height/2) 572 | let frameTopRight = CGSize(width: frameSize.width/2, height: frameSize.height/2) 573 | let frameBottomRight = CGSize(width: frameSize.width/2, height: -frameSize.height/2) 574 | let frameBottomLeft = CGSize(width: -frameSize.width/2, height: -frameSize.height/2) 575 | 576 | var frameOffsetDA: CGFloat = 0 577 | var frameOffsetAB: CGFloat = 0 578 | var frameOffsetBC: CGFloat = 0 579 | var frameOffsetCD: CGFloat = 0 580 | 581 | let rotatedPicPoints = rotatedPicPoints() 582 | 583 | if vm.rotatedDegrees >= 0 && vm.rotatedDegrees <= 90 { 584 | frameOffsetDA = framePointOffsetOfLine(frameTopLeft, point1: rotatedPicPoints.d, point2: rotatedPicPoints.a) 585 | frameOffsetAB = framePointOffsetOfLine(frameTopRight, point1: rotatedPicPoints.a, point2: rotatedPicPoints.b) 586 | frameOffsetBC = framePointOffsetOfLine(frameBottomRight, point1: rotatedPicPoints.b, point2: rotatedPicPoints.c) 587 | frameOffsetCD = framePointOffsetOfLine(frameBottomLeft, point1: rotatedPicPoints.c, point2: rotatedPicPoints.d) 588 | } else if vm.rotatedDegrees > 90 && vm.rotatedDegrees <= 180 { 589 | frameOffsetDA = framePointOffsetOfLine(frameTopRight, point1: rotatedPicPoints.d, point2: rotatedPicPoints.a) 590 | frameOffsetAB = framePointOffsetOfLine(frameBottomRight, point1: rotatedPicPoints.a, point2: rotatedPicPoints.b) 591 | frameOffsetBC = framePointOffsetOfLine(frameBottomLeft, point1: rotatedPicPoints.b, point2: rotatedPicPoints.c) 592 | frameOffsetCD = framePointOffsetOfLine(frameTopLeft, point1: rotatedPicPoints.c, point2: rotatedPicPoints.d) 593 | } else if vm.rotatedDegrees < 0 && vm.rotatedDegrees >= -90 { 594 | frameOffsetDA = framePointOffsetOfLine(frameBottomLeft, point1: rotatedPicPoints.d, point2: rotatedPicPoints.a) 595 | frameOffsetAB = framePointOffsetOfLine(frameTopLeft, point1: rotatedPicPoints.a, point2: rotatedPicPoints.b) 596 | frameOffsetBC = framePointOffsetOfLine(frameTopRight, point1: rotatedPicPoints.b, point2: rotatedPicPoints.c) 597 | frameOffsetCD = framePointOffsetOfLine(frameBottomRight, point1: rotatedPicPoints.c, point2: rotatedPicPoints.d) 598 | } else if vm.rotatedDegrees < -90 && vm.rotatedDegrees >= -180 { 599 | frameOffsetDA = framePointOffsetOfLine(frameBottomRight, point1: rotatedPicPoints.d, point2: rotatedPicPoints.a) 600 | frameOffsetAB = framePointOffsetOfLine(frameBottomLeft, point1: rotatedPicPoints.a, point2: rotatedPicPoints.b) 601 | frameOffsetBC = framePointOffsetOfLine(frameTopLeft, point1: rotatedPicPoints.b, point2: rotatedPicPoints.c) 602 | frameOffsetCD = framePointOffsetOfLine(frameTopRight, point1: rotatedPicPoints.c, point2: rotatedPicPoints.d) 603 | } 604 | 605 | return (frameOffsetDA, frameOffsetAB, frameOffsetBC, frameOffsetCD) 606 | } 607 | 608 | private func alignTargetPictureByPanning() { 609 | guard targetPictureSize != .zero else { return } 610 | let offsets = frameToPicOffset() 611 | 612 | let offsetVectorDA = CGSize(width: cos(Double.pi - vm.rotatedAngle), height: sin(Double.pi - vm.rotatedAngle)) * -offsets.da 613 | let offsetVectorAB = CGSize(width: cos(Double.pi/2 - vm.rotatedAngle), height: sin(Double.pi/2 - vm.rotatedAngle)) * -offsets.ab 614 | let offsetVectorBC = CGSize(width: cos( -vm.rotatedAngle), height: sin( -vm.rotatedAngle)) * -offsets.bc 615 | let offsetVectorCD = CGSize(width: cos(-Double.pi/2 - vm.rotatedAngle), height: sin(-Double.pi/2 - vm.rotatedAngle)) * -offsets.cd 616 | 617 | var picOffset = CGSize.zero 618 | if offsets.da < 0 { picOffset = picOffset + offsetVectorDA } 619 | if offsets.ab < 0 { picOffset = picOffset + offsetVectorAB } 620 | if offsets.bc < 0 { picOffset = picOffset + offsetVectorBC } 621 | if offsets.cd < 0 { picOffset = picOffset + offsetVectorCD } 622 | 623 | withAnimation(animation) { 624 | vm.steadyPanOffset = vm.steadyPanOffset + (picOffset.reverseHeight().rotatedVector(radians: -vm.rotatedAngle)/zoomScale) 625 | } 626 | } 627 | 628 | private func alignTargetPictureByZooming(animated: Bool = true) { 629 | guard targetPictureSize != .zero else { return } 630 | let offsets = frameToPicOffset() 631 | // print(offsets) 632 | if max(offsets.da, offsets.ab, offsets.bc, offsets.cd) < 0 { 633 | zoomToFillFrame() 634 | return 635 | } 636 | 637 | let largestOffset = min(offsets.da, offsets.ab, offsets.bc, offsets.cd, 0) 638 | guard largestOffset < 0 else { return } 639 | 640 | var scale: CGFloat = 1 641 | let rotatedPicPoints = rotatedPicPoints() 642 | if offsets.da == largestOffset { 643 | let daToFrameCenter = abs(framePointOffsetOfLine(CGSize.zero, point1: rotatedPicPoints.d, point2: rotatedPicPoints.a)) 644 | scale = (daToFrameCenter - largestOffset) / daToFrameCenter 645 | } else if offsets.ab == largestOffset { 646 | let abToFrameCenter = abs(framePointOffsetOfLine(CGSize.zero, point1: rotatedPicPoints.a, point2: rotatedPicPoints.b)) 647 | scale = (abToFrameCenter - largestOffset) / abToFrameCenter 648 | } else if offsets.bc == largestOffset { 649 | let bcToFrameCenter = abs(framePointOffsetOfLine(CGSize.zero, point1: rotatedPicPoints.b, point2: rotatedPicPoints.c)) 650 | scale = (bcToFrameCenter - largestOffset) / bcToFrameCenter 651 | } else if offsets.cd == largestOffset { 652 | let cdToFrameCenter = abs(framePointOffsetOfLine(CGSize.zero, point1: rotatedPicPoints.c, point2: rotatedPicPoints.d)) 653 | scale = (cdToFrameCenter - largestOffset) / cdToFrameCenter 654 | } 655 | 656 | if animated { 657 | withAnimation(animation) { 658 | vm.steadyZoomScale = vm.steadyZoomScale * scale 659 | } 660 | } else { 661 | vm.steadyZoomScale = vm.steadyZoomScale * scale 662 | } 663 | } 664 | 665 | // MARK: - Size vars 666 | private var campusSize: CGSize { 667 | campusRect.size 668 | } 669 | 670 | private var frameSize: CGSize { 671 | frame.frameSize(imageSize: vm.originImage?.size ?? .zero, campusSize: campusSize) 672 | } 673 | 674 | private var originalPictureSize: CGSize { 675 | vm.originImage?.size ?? .zero 676 | } 677 | 678 | private var targetPictureSize: CGSize { 679 | vm.targetImage?.size ?? .zero 680 | } 681 | 682 | private var showingPictureSize: CGSize { 683 | originalPictureSize * zoomScale 684 | } 685 | 686 | 687 | // MARK: - Zooming 688 | private var initZoomScale: CGFloat { 689 | guard originalPictureSize != .zero else { return 1 } 690 | return frameSize.maxRatio(with: originalPictureSize) 691 | } 692 | private var extraZoomScale: CGFloat { 693 | vm.steadyZoomScale * gestureZoomScale 694 | } 695 | 696 | private var zoomScale: CGFloat { 697 | initZoomScale * extraZoomScale 698 | } 699 | 700 | private func zoomToFillFrame() { 701 | guard targetPictureSize != .zero else { return } 702 | withAnimation(animation) { 703 | vm.steadyPanOffset = .zero 704 | } 705 | let offsets = frameToPicOffset() 706 | let rotatedPicPoints = rotatedPicPoints() 707 | 708 | let daToFrameCenter = abs(framePointOffsetOfLine(CGSize.zero, point1: rotatedPicPoints.d, point2: rotatedPicPoints.a)) 709 | let scaleToDA = (daToFrameCenter - offsets.da) / daToFrameCenter 710 | let abToFrameCenter = abs(framePointOffsetOfLine(CGSize.zero, point1: rotatedPicPoints.a, point2: rotatedPicPoints.b)) 711 | let scaleToAB = (abToFrameCenter - offsets.ab) / abToFrameCenter 712 | let bcToFrameCenter = abs(framePointOffsetOfLine(CGSize.zero, point1: rotatedPicPoints.b, point2: rotatedPicPoints.c)) 713 | let scaleToBC = (bcToFrameCenter - offsets.bc) / bcToFrameCenter 714 | let cdToFrameCenter = abs(framePointOffsetOfLine(CGSize.zero, point1: rotatedPicPoints.c, point2: rotatedPicPoints.d)) 715 | let scaleToCD = (cdToFrameCenter - offsets.cd) / cdToFrameCenter 716 | let maxScale = max(scaleToDA, scaleToAB, scaleToBC, scaleToCD) 717 | 718 | withAnimation(animation) { 719 | vm.steadyZoomScale = vm.steadyZoomScale * maxScale 720 | } 721 | } 722 | 723 | private func zoomGesture() -> some Gesture { 724 | MagnificationGesture() 725 | .updating($gestureZoomScale) { latestGestureScale, gestureZoomScale, _ in 726 | if onAdjust { 727 | gestureZoomScale = latestGestureScale 728 | } 729 | } 730 | .onEnded { gestureScaleAtEnd in 731 | if onAdjust { 732 | vm.steadyZoomScale *= gestureScaleAtEnd 733 | alignTargetPictureByZooming() 734 | } 735 | } 736 | } 737 | 738 | private func doubleTapToZoomFillFrame() -> some Gesture { 739 | TapGesture(count: 2) 740 | .onEnded { 741 | if onAdjust { 742 | zoomToFillFrame() 743 | } 744 | } 745 | } 746 | 747 | // MARK: - Panning 748 | private var panOffset: CGSize { 749 | let panOffsetBeforeRotation = (vm.steadyPanOffset + gesturePanOffset) * zoomScale 750 | return panOffsetBeforeRotation.rotatedVector(radians: vm.rotatedAngle) 751 | } 752 | 753 | private func panGesture() -> some Gesture { 754 | DragGesture() 755 | .updating($gesturePanOffset) { latestDragGestureValue, gesturePanOffset, _ in 756 | if onAdjust { 757 | gesturePanOffset = latestDragGestureValue.translation.rotatedVector(radians: -vm.rotatedAngle) / zoomScale 758 | } 759 | } 760 | .onEnded { finalDragGestureValue in 761 | if onAdjust { 762 | vm.steadyPanOffset = vm.steadyPanOffset + (finalDragGestureValue.translation.rotatedVector(radians: -vm.rotatedAngle) / zoomScale) 763 | alignTargetPictureByPanning() 764 | } 765 | } 766 | } 767 | 768 | } 769 | 770 | -------------------------------------------------------------------------------- /Sources/CZImageEditor/CZImageEditorViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CZImageEditorViewModel.swift 3 | // TestingForPhotoEditor 4 | // 5 | // Created by Kaiyi Zhao on 8/1/22. 6 | // 7 | 8 | import Foundation 9 | import CoreImage 10 | import CoreImage.CIFilterBuiltins 11 | import SwiftUI 12 | 13 | class CZImageEditorViewModel: ObservableObject { 14 | @Published var originImage: UIImage? = nil 15 | @Published var targetImage: UIImage? = nil 16 | @Published var filteredImages: [FilteredImage] = [] 17 | var originFullImage: UIImage? = nil 18 | 19 | var frameSize: CGSize = .zero 20 | 21 | @Published var selectedFilter: CIFilter? = nil 22 | let context = CIContext() 23 | var filters: [CIFilter] = [] 24 | 25 | // Editing values 26 | @Published var steadyPanOffset: CGSize = .zero 27 | @Published var steadyZoomScale: CGFloat = 1 28 | @Published var rotationPercent: Double = 0.5 29 | @Published var brightnessPercent: Double = 0.5 30 | @Published var contrastPercent: Double = 0.5 31 | @Published var saturationPercent: Double = 0.5 32 | @Published var sharpenPercent: Double = 0.5 33 | @Published var warmthPercent: Double = 0.5 34 | 35 | var roundedZoomScale: Double { 36 | round(steadyZoomScale * 10000) / 10000.0 37 | } 38 | var rotatedAngle: Double { 39 | Angle(degrees: rotatedDegrees).radians 40 | } 41 | var rotatedDegrees: Double { 42 | EditOption.rotation.calculatedValue(percent: rotationPercent) 43 | } 44 | var initZoomScale: CGFloat { 45 | guard let originalPictureSize = originImage?.size else { return 1 } 46 | return frameSize.maxRatio(with: originalPictureSize) 47 | } 48 | 49 | func initializeVM(fullImage: UIImage, parameters: ImageEditorParameters, filters: [CIFilter], thumbnailMaxSize: CGFloat) async { 50 | let imageSize = fullImage.size 51 | let targetSize = imageSize * CGSize(width: thumbnailMaxSize, height: thumbnailMaxSize).minRatio(with: imageSize) 52 | 53 | let editingImage = max(fullImage.size.width, fullImage.size.height) > thumbnailMaxSize ? await fullImage.byPreparingThumbnail(ofSize: targetSize) : fullImage 54 | await MainActor.run { 55 | originImage = editingImage 56 | targetImage = editingImage 57 | loadAttributes(attributes: parameters.attributes) 58 | applyFiltersToTarget() 59 | } 60 | self.filters = filters 61 | originFullImage = fullImage 62 | } 63 | 64 | func loadAttributes(attributes: ImageEditorParameters.Attributes) { 65 | selectedFilter = attributes.appliedFilter 66 | steadyPanOffset = attributes.steadyPanOffset 67 | steadyZoomScale = attributes.steadyZoomScale 68 | rotationPercent = attributes.savedRotationPercent 69 | brightnessPercent = attributes.savedBrightnessPercent 70 | contrastPercent = attributes.savedContrastPercent 71 | saturationPercent = attributes.savedSaturationPercent 72 | sharpenPercent = attributes.savedSharpenPercent 73 | warmthPercent = attributes.savedWarmthPercent 74 | } 75 | 76 | func outputAttributes() -> ImageEditorParameters.Attributes { 77 | .init(appliedFilter: selectedFilter, steadyPanOffset: steadyPanOffset, steadyZoomScale: roundedZoomScale, savedRotationPercent: rotationPercent, savedBrightnessPercent: brightnessPercent, savedContrastPercent: contrastPercent, savedSaturationPercent: saturationPercent, savedSharpenPercent: sharpenPercent, savedWarmthPercent: warmthPercent) 78 | } 79 | 80 | func outputParameters() -> ImageEditorParameters { 81 | ImageEditorParameters(fullOriginalImage: originFullImage, attributes: outputAttributes()) 82 | } 83 | 84 | func loadFilterPreviews() async { 85 | guard let originImage = originImage, let editImage = cropImage(originalImage: originImage, applyColorFilters: false) else { return } 86 | await MainActor.run { 87 | filteredImages.removeAll(keepingCapacity: true) 88 | } 89 | 90 | let ciImage = CIImage(image: editImage) 91 | 92 | for (id, filter) in filters.enumerated() { 93 | filter.setValue(ciImage, forKey: kCIInputImageKey) 94 | guard let outputImage = filter.outputImage else { return } 95 | 96 | if let cgimg = context.createCGImage(outputImage, from: outputImage.extent) { 97 | DispatchQueue.main.async { [weak self] in 98 | if self?.filteredImages.contains(where: { $0.id == id }) == false { 99 | let displayName = (filter.attributes[kCIAttributeFilterDisplayName] as? String) ?? "Unknown" 100 | self?.filteredImages.append(FilteredImage(id: id, image: UIImage(cgImage: cgimg), filter: filter, name: displayName)) 101 | } 102 | } 103 | } 104 | } 105 | } 106 | 107 | func cropImage(originalImage: UIImage, applyColorFilters: Bool) -> UIImage? { 108 | guard frameSize != .zero, let alteredOriginalImage = applyColorFilters ? applyFilters(input: originalImage) : originalImage.rotate(radians: Float(rotatedAngle)), 109 | let cgImg = alteredOriginalImage.cgImage 110 | else { return nil } 111 | 112 | // print("alteredOriginalImage:\(alteredOriginalImage), orientation: \(originalImage.imageOrientation)") 113 | 114 | let fullWidth = alteredOriginalImage.size.width 115 | let editingWidth = targetImage?.size.width ?? fullWidth 116 | let extraScale = fullWidth / editingWidth 117 | 118 | 119 | let zoomScale = initZoomScale * steadyZoomScale / extraScale 120 | let panOffsetBeforeRotation = steadyPanOffset * extraScale 121 | let panOffset = panOffsetBeforeRotation.rotatedVector(radians: rotatedAngle) 122 | 123 | // adjust frame rect based on picture orientation 124 | let scaledFrameSize = frameSize / zoomScale 125 | 126 | var adjustedPanOffset: CGSize = panOffset 127 | switch originalImage.imageOrientation { 128 | case .up: 129 | // print("orientation: up 5") // checked 130 | break 131 | case .upMirrored: 132 | // print("orientation: upMirrored") 133 | adjustedPanOffset = panOffset.reverseWidth() 134 | case .down: 135 | // print("orientation: down") // checked 136 | adjustedPanOffset = panOffset.reverseWidthHeight() 137 | case .downMirrored: 138 | // print("orientation: downMirrored") 139 | adjustedPanOffset = panOffset.reverseHeight() 140 | case .left: 141 | // print("orientation: left") // checked 142 | adjustedPanOffset = panOffset.rotatedVector(radians: Double.pi / 2) 143 | case .leftMirrored: 144 | // print("orientation: leftMirrored") 145 | adjustedPanOffset = panOffset.rotatedVector(radians: Double.pi / 2).reverseWidth() 146 | case .right: 147 | // print("orientation: right") // checked 148 | adjustedPanOffset = panOffset.rotatedVector(radians: -Double.pi / 2) 149 | case .rightMirrored: 150 | // print("orientation: rightMirrored") 151 | adjustedPanOffset = panOffset.rotatedVector(radians: -Double.pi / 2).reverseWidth() 152 | @unknown default: break 153 | } 154 | // print("panOffset: \(panOffset), adjustedPanOffset: \(adjustedPanOffset)") 155 | 156 | var adjustedPicCenter = alteredOriginalImage.size.center 157 | var adjustedFrameSize = scaledFrameSize 158 | switch originalImage.imageOrientation { 159 | case .up, .upMirrored, .down, .downMirrored: break 160 | case .left, .leftMirrored, .right, .rightMirrored: 161 | adjustedPicCenter = CGPoint(x: adjustedPicCenter.y, y: adjustedPicCenter.x) 162 | adjustedFrameSize = CGSize(width: scaledFrameSize.height, height: scaledFrameSize.width) 163 | @unknown default: break 164 | } 165 | 166 | let frameCenter = adjustedPicCenter - adjustedPanOffset 167 | let frameOrigin = frameCenter - (adjustedFrameSize / 2) 168 | let cropRect = CGRect(origin: frameOrigin, size: adjustedFrameSize) 169 | 170 | guard let croppedImage = cgImg.cropping(to: cropRect) else { return nil } 171 | 172 | return UIImage(cgImage: croppedImage, scale: alteredOriginalImage.scale, orientation: alteredOriginalImage.imageOrientation) 173 | } 174 | 175 | func applyFiltersToTarget() { 176 | guard let originImage = originImage else { return } 177 | targetImage = applyFilters(input: originImage) 178 | } 179 | 180 | func applyFilters(input: UIImage) -> UIImage? { 181 | var ciImage = CIImage(image: input) 182 | 183 | // apply rotation 184 | ciImage = ciImage?.applyingFilter("CIAffineTransform", 185 | parameters: [kCIInputTransformKey: CGAffineTransform(rotationAngle: -rotatedAngle)]) 186 | 187 | // apply selected filter 188 | if let selectedFilter = selectedFilter { 189 | selectedFilter.setValue(ciImage, forKey: kCIInputImageKey) 190 | ciImage = selectedFilter.outputImage ?? ciImage 191 | } 192 | 193 | // apply saturation brightness contrast 194 | let calSaturation = EditOption.saturation.calculatedValue(percent: saturationPercent) 195 | let calBrightness = EditOption.brightness.calculatedValue(percent: brightnessPercent) 196 | let calContrast = EditOption.contrast.calculatedValue(percent: contrastPercent) 197 | 198 | ciImage = ciImage?.applyingFilter("CIColorControls", 199 | parameters: [kCIInputContrastKey: calContrast, // default: 1.0 200 | kCIInputSaturationKey: calSaturation, // default: 1.0 201 | kCIInputBrightnessKey: calBrightness]) // default: 0.0 202 | 203 | // apply temperature filter 204 | let calWarmth = EditOption.warmth.calculatedValue(percent: warmthPercent) 205 | ciImage = ciImage?.applyingFilter("CITemperatureAndTint", parameters: ["inputNeutral": CIVector(x: calWarmth, y: 0)]) 206 | 207 | // apply Sharpness filter 208 | let calSharpen = EditOption.sharpen.calculatedValue(percent: sharpenPercent) 209 | ciImage = ciImage?.applyingFilter("CISharpenLuminance", parameters: [kCIInputSharpnessKey: calSharpen]) 210 | 211 | guard let outputImage = ciImage else { return nil } 212 | if let cgimg = context.createCGImage(outputImage, from: outputImage.extent) { 213 | return UIImage(cgImage: cgimg, scale: input.scale, orientation: input.imageOrientation) 214 | } 215 | return nil 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /Sources/CZImageEditor/Custom Filters/CZAirFilter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CZAirFilter.swift 3 | // TestPhotoEditor 4 | // 5 | // Created by Kaiyi Zhao on 8/21/22. 6 | // 7 | 8 | import CoreImage 9 | import CoreImage.CIFilterBuiltins 10 | 11 | public class CZAirFilter: CIFilter { 12 | @objc dynamic var inputImage: CIImage? 13 | 14 | public override var attributes: [String : Any] { 15 | return [ 16 | kCIAttributeFilterDisplayName: "Air", 17 | 18 | kCIInputImageKey: [kCIAttributeIdentity: 0, 19 | kCIAttributeClass: "CIImage", 20 | kCIAttributeDisplayName: "Image", 21 | kCIAttributeType: kCIAttributeTypeImage] 22 | ] 23 | } 24 | 25 | public override var outputImage: CIImage? { 26 | guard let inputImage = inputImage else { return nil } 27 | let finalImage = inputImage 28 | .applyingFilter("CIExposureAdjust", parameters: [kCIInputEVKey: 0.25]) // default: 0.0 29 | .applyingFilter("CITemperatureAndTint", parameters: ["inputNeutral": CIVector(x: 6000, y: 0)]) // default: 6500 30 | .applyingFilter("CIHighlightShadowAdjust", 31 | parameters: ["inputHighlightAmount": 1.05]) // default: 1.0 32 | // "inputShadowAmount": 0.05]) // default: 0.0 33 | .applyingFilter("CIColorControls", 34 | parameters: [kCIInputContrastKey: 1.05, // default: 1.0 35 | kCIInputSaturationKey: 1.05, // default: 1.0 36 | kCIInputBrightnessKey: 0.01]) // default: 0.0 37 | .applyingFilter("CISharpenLuminance", parameters: [kCIInputSharpnessKey: 0.1]) // default: 0.4 38 | 39 | return finalImage 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/CZImageEditor/Custom Filters/CZCrystalFilter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CZCrystalFilter.swift 3 | // TestPhotoEditor 4 | // 5 | // Created by Kaiyi Zhao on 8/21/22. 6 | // 7 | 8 | import CoreImage 9 | import CoreImage.CIFilterBuiltins 10 | 11 | public class CZCrystalFilter: CIFilter { 12 | @objc dynamic var inputImage: CIImage? 13 | 14 | public override var attributes: [String : Any] { 15 | return [ 16 | kCIAttributeFilterDisplayName: "Crystal", 17 | 18 | kCIInputImageKey: [kCIAttributeIdentity: 0, 19 | kCIAttributeClass: "CIImage", 20 | kCIAttributeDisplayName: "Image", 21 | kCIAttributeType: kCIAttributeTypeImage] 22 | ] 23 | } 24 | 25 | public override var outputImage: CIImage? { 26 | guard let inputImage = inputImage else { return nil } 27 | 28 | let finalImage = inputImage 29 | .applyingFilter("CIExposureAdjust", parameters: [kCIInputEVKey: 0.15]) // default: 0.0 30 | .applyingFilter("CIHighlightShadowAdjust", 31 | parameters: ["inputHighlightAmount": 1.1, // default: 1.0 32 | "inputShadowAmount": -0.05]) // default: 0.0 33 | .applyingFilter("CIColorControls", 34 | parameters: [kCIInputContrastKey: 1.05, // default: 1.0 35 | kCIInputBrightnessKey: 0.01]) // default: 0.0 36 | .applyingFilter("CISharpenLuminance", parameters: [kCIInputSharpnessKey: 0.6]) // default: 0.4 37 | 38 | return finalImage 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/CZImageEditor/Custom Filters/CZOriginalFilter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CZOriginalFilter.swift 3 | // TestPhotoEditor 4 | // 5 | // Created by Kaiyi Zhao on 8/20/22. 6 | // 7 | 8 | import CoreImage 9 | import CoreImage.CIFilterBuiltins 10 | 11 | public class CZOriginalFilter: CIFilter { 12 | @objc dynamic var inputImage: CIImage? 13 | 14 | public override var attributes: [String : Any] { 15 | return [ 16 | kCIAttributeFilterDisplayName: "Normal", 17 | 18 | kCIInputImageKey: [kCIAttributeIdentity: 0, 19 | kCIAttributeClass: "CIImage", 20 | kCIAttributeDisplayName: "Image", 21 | kCIAttributeType: kCIAttributeTypeImage] 22 | ] 23 | } 24 | 25 | public override var outputImage: CIImage? { 26 | return inputImage 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/CZImageEditor/Custom Filters/CZVividFilter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CZVividFilter.swift 3 | // TestPhotoEditor 4 | // 5 | // Created by Kaiyi Zhao on 8/21/22. 6 | // 7 | 8 | import CoreImage 9 | import CoreImage.CIFilterBuiltins 10 | 11 | public class CZVividFilter: CIFilter { 12 | @objc dynamic var inputImage: CIImage? 13 | 14 | public override var attributes: [String : Any] { 15 | return [ 16 | kCIAttributeFilterDisplayName: "Vivid", 17 | 18 | kCIInputImageKey: [kCIAttributeIdentity: 0, 19 | kCIAttributeClass: "CIImage", 20 | kCIAttributeDisplayName: "Image", 21 | kCIAttributeType: kCIAttributeTypeImage] 22 | ] 23 | } 24 | 25 | public override var outputImage: CIImage? { 26 | guard let inputImage = inputImage else { return nil } 27 | 28 | let finalImage = inputImage 29 | .applyingFilter("CIExposureAdjust", parameters: [kCIInputEVKey: 0.1]) // default: 0.0 30 | .applyingFilter("CIColorControls", 31 | parameters: [kCIInputContrastKey: 1.05, // default: 1.0 32 | kCIInputSaturationKey: 1.5, // default: 1.0 33 | kCIInputBrightnessKey: 0.01]) // default: 0.0 34 | .applyingFilter("CITemperatureAndTint", parameters: ["inputNeutral": CIVector(x: 6800, y: 0)]) // default: 6500 35 | .applyingFilter("CIGaussianBlur", parameters: [kCIInputRadiusKey: 0.4]) // default: 0.0 36 | 37 | return finalImage 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/CZImageEditor/Custom Filters/RegisterCustomFilters.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RegisterCustomFilters.swift 3 | // TestPhotoEditor 4 | // 5 | // Created by Kaiyi Zhao on 8/20/22. 6 | // 7 | 8 | import CoreImage 9 | 10 | class CustomFiltersVendor: NSObject, CIFilterConstructor { 11 | static func registerFilters() { 12 | CIFilter.registerName("CZOriginal", constructor: CustomFiltersVendor(), 13 | classAttributes: [kCIAttributeFilterCategories: ["CustomFilters"]]) 14 | CIFilter.registerName("CZCrystal", constructor: CustomFiltersVendor(), 15 | classAttributes: [kCIAttributeFilterCategories: ["CustomFilters"]]) 16 | CIFilter.registerName("CZVivid", constructor: CustomFiltersVendor(), 17 | classAttributes: [kCIAttributeFilterCategories: ["CustomFilters"]]) 18 | CIFilter.registerName("CZAir", constructor: CustomFiltersVendor(), 19 | classAttributes: [kCIAttributeFilterCategories: ["CustomFilters"]]) 20 | } 21 | 22 | func filter(withName name: String) -> CIFilter? { 23 | switch name { 24 | case "CZOriginal": return CZOriginalFilter() 25 | case "CZCrystal": return CZCrystalFilter() 26 | case "CZVivid": return CZVividFilter() 27 | case "CZAir": return CZAirFilter() 28 | default: return nil 29 | } 30 | } 31 | } 32 | 33 | 34 | -------------------------------------------------------------------------------- /Sources/CZImageEditor/EditOption.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditOption.swift 3 | // TestingForPhotoEditor 4 | // 5 | // Created by Kaiyi Zhao on 8/6/22. 6 | // 7 | 8 | import CoreImage 9 | import CoreImage.CIFilterBuiltins 10 | import SwiftUI 11 | 12 | enum EditOption: String, CaseIterable { 13 | case rotation = "Adjust" 14 | case brightness = "Brightness" 15 | case contrast = "Contrast" 16 | case saturation = "Saturation" 17 | case warmth = "Warmth" 18 | case sharpen = "Sharpen" 19 | 20 | var minValue: Double { 21 | switch self { 22 | case .rotation: return -180.0 * 100 23 | case .brightness: return -10 24 | case .contrast: return 50 25 | case .saturation: return -100 26 | case .warmth: return (6500 - 4500) * 100 27 | case .sharpen: return (0.4 - 1.0) * 100 28 | } 29 | } 30 | 31 | var maxValue: Double { 32 | switch self { 33 | case .rotation: return 180.0 * 100 34 | case .brightness: return 10 35 | case .contrast: return 150 36 | case .saturation: return 300.0 37 | case .warmth: return (6500 + 4500) * 100 38 | case .sharpen: return (0.4 + 1.0) * 100 39 | } 40 | } 41 | 42 | func calculatedValue(percent: Double) -> Double { 43 | (percent * (self.maxValue - self.minValue) + self.minValue)/100 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/CZImageEditor/Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extension.swift 3 | // TestingForPhotoEditor 4 | // 5 | // Created by Kaiyi Zhao on 8/1/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension CGRect { 11 | var center: CGPoint { 12 | CGPoint(x: midX, y: midY) 13 | } 14 | } 15 | 16 | extension CGPoint { 17 | static func -(lhs: Self, rhs: Self) -> CGSize { 18 | CGSize(width: lhs.x - rhs.x, height: lhs.y - rhs.y) 19 | } 20 | static func +(lhs: Self, rhs: CGSize) -> CGPoint { 21 | CGPoint(x: lhs.x + rhs.width, y: lhs.y + rhs.height) 22 | } 23 | static func -(lhs: Self, rhs: CGSize) -> CGPoint { 24 | CGPoint(x: lhs.x - rhs.width, y: lhs.y - rhs.height) 25 | } 26 | static func *(lhs: Self, rhs: CGFloat) -> CGPoint { 27 | CGPoint(x: lhs.x * rhs, y: lhs.y * rhs) 28 | } 29 | static func /(lhs: Self, rhs: CGFloat) -> CGPoint { 30 | CGPoint(x: lhs.x / rhs, y: lhs.y / rhs) 31 | } 32 | } 33 | 34 | extension CGSize { 35 | // the center point of an area that is our size 36 | var center: CGPoint { 37 | CGPoint(x: width/2, y: height/2) 38 | } 39 | static func +(lhs: Self, rhs: Self) -> CGSize { 40 | CGSize(width: lhs.width + rhs.width, height: lhs.height + rhs.height) 41 | } 42 | static func -(lhs: Self, rhs: Self) -> CGSize { 43 | CGSize(width: lhs.width - rhs.width, height: lhs.height - rhs.height) 44 | } 45 | static func *(lhs: Self, rhs: CGFloat) -> CGSize { 46 | CGSize(width: lhs.width * rhs, height: lhs.height * rhs) 47 | } 48 | static func /(lhs: Self, rhs: CGFloat) -> CGSize { 49 | CGSize(width: lhs.width/rhs, height: lhs.height/rhs) 50 | } 51 | 52 | func maxRatio(with targetSize: CGSize) -> CGFloat { 53 | max(self.width / targetSize.width, self.height / targetSize.height) 54 | } 55 | 56 | func minRatio(with targetSize: CGSize) -> CGFloat { 57 | min(self.width / targetSize.width, self.height / targetSize.height) 58 | } 59 | 60 | func rotatedVector(radians: CGFloat, center: CGSize = .zero) -> CGSize { 61 | let newX = (self.width - center.width) * cos(radians) - (self.height - center.height) * sin(radians) + center.width 62 | let newY = (self.width - center.width) * sin(radians) + (self.height - center.height) * cos(radians) + center.height 63 | 64 | return CGSize(width: newX, height: newY) 65 | } 66 | 67 | func reverseWidth() -> CGSize { 68 | CGSize(width: -self.width, height: self.height) 69 | } 70 | func reverseHeight() -> CGSize { 71 | CGSize(width: self.width, height: -self.height) 72 | } 73 | func reverseWidthHeight() -> CGSize { 74 | CGSize(width: -self.width, height: -self.height) 75 | } 76 | } 77 | 78 | extension Double { 79 | func angleDegrees() -> Double { 80 | let angle = self.truncatingRemainder(dividingBy: Double.pi * 2) 81 | return angle * 180 / Double.pi 82 | } 83 | } 84 | 85 | extension UIColor { 86 | func uiImage(_ size: CGSize = CGSize(width: 1, height: 1)) -> UIImage { 87 | return UIGraphicsImageRenderer(size: size).image { rendererContext in 88 | self.setFill() 89 | rendererContext.fill(CGRect(origin: .zero, size: size)) 90 | } 91 | } 92 | } 93 | 94 | extension UIImage { 95 | func rotate(radians: Float) -> UIImage? { 96 | var newSize = CGRect(origin: CGPoint.zero, size: self.size).applying(CGAffineTransform(rotationAngle: CGFloat(radians))).size 97 | // Trim off the extremely small float value to prevent core graphics from rounding it up 98 | newSize.width = floor(newSize.width) 99 | newSize.height = floor(newSize.height) 100 | 101 | UIGraphicsBeginImageContextWithOptions(newSize, false, self.scale) 102 | let context = UIGraphicsGetCurrentContext()! 103 | 104 | // Move origin to middle 105 | context.translateBy(x: newSize.width/2, y: newSize.height/2) 106 | // Rotate around middle 107 | context.rotate(by: CGFloat(radians)) 108 | // Draw the image at its center 109 | self.draw(in: CGRect(x: -self.size.width/2, y: -self.size.height/2, width: self.size.width, height: self.size.height)) 110 | 111 | let newImage = UIGraphicsGetImageFromCurrentImageContext() 112 | UIGraphicsEndImageContext() 113 | 114 | return newImage 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Sources/CZImageEditor/FilteredImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FilteredImage.swift 3 | // TestingForPhotoEditor 4 | // 5 | // Created by Kaiyi Zhao on 8/1/22. 6 | // 7 | 8 | import Foundation 9 | import CoreImage 10 | import UIKit 11 | 12 | struct FilteredImage: Identifiable { 13 | let id: Int // filter order to display 14 | let image: UIImage 15 | let filter: CIFilter 16 | let name: String 17 | } 18 | -------------------------------------------------------------------------------- /Sources/CZImageEditor/FrameType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FrameType.swift 3 | // TestingForPhotoEditor 4 | // 5 | // Created by Kaiyi Zhao on 8/7/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public enum FrameType { 11 | case origin 12 | case fourByThree 13 | case square 14 | case threeByFour 15 | case circle 16 | 17 | func frameSize(imageSize: CGSize, campusSize: CGSize) -> CGSize { 18 | switch self { 19 | case .origin: 20 | let imageCampusRatio = imageSize.maxRatio(with: campusSize) 21 | return imageSize / imageCampusRatio * 0.9 22 | case .fourByThree: 23 | let frameWidth = campusSize.width * 0.9 24 | return CGSize(width: frameWidth, height: frameWidth/4*3) 25 | case .square: 26 | let minLength = min(campusSize.width, campusSize.height) * 0.9 27 | return CGSize(width: minLength, height: minLength) 28 | case .threeByFour: 29 | let frameHeight = campusSize.height * 0.9 30 | return CGSize(width: frameHeight/4*3, height: frameHeight) 31 | case .circle: 32 | let minLength = min(campusSize.width, campusSize.height) * 0.8 33 | return CGSize(width: minLength, height: minLength) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/CZImageEditor/ImageEditorParameters.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageEditorParameters.swift 3 | // TestPhotoEditor 4 | // 5 | // Created by Kaiyi Zhao on 8/19/22. 6 | // 7 | 8 | import SwiftUI 9 | import CoreImage 10 | 11 | public struct ImageEditorParameters { 12 | let fullOriginalImage: UIImage? 13 | let attributes: Attributes 14 | 15 | public init(fullOriginalImage: UIImage? = nil, attributes: Attributes = .init()) { 16 | self.fullOriginalImage = fullOriginalImage 17 | self.attributes = attributes 18 | } 19 | 20 | public struct Attributes: Equatable { 21 | let appliedFilter: CIFilter? 22 | let steadyPanOffset: CGSize 23 | let steadyZoomScale: CGFloat 24 | let savedRotationPercent: Double 25 | let savedBrightnessPercent: Double 26 | let savedContrastPercent: Double 27 | let savedSaturationPercent: Double 28 | let savedSharpenPercent: Double 29 | let savedWarmthPercent: Double 30 | 31 | public init(appliedFilter: CIFilter? = nil, 32 | steadyPanOffset: CGSize = .zero, 33 | steadyZoomScale: CGFloat = 1, 34 | savedRotationPercent: Double = 0.5, 35 | savedBrightnessPercent: Double = 0.5, 36 | savedContrastPercent: Double = 0.5, 37 | savedSaturationPercent: Double = 0.5, 38 | savedSharpenPercent: Double = 0.5, 39 | savedWarmthPercent: Double = 0.5) { 40 | self.appliedFilter = appliedFilter 41 | self.steadyPanOffset = steadyPanOffset 42 | self.steadyZoomScale = steadyZoomScale 43 | self.savedRotationPercent = savedRotationPercent 44 | self.savedBrightnessPercent = savedBrightnessPercent 45 | self.savedContrastPercent = savedContrastPercent 46 | self.savedSaturationPercent = savedSaturationPercent 47 | self.savedSharpenPercent = savedSharpenPercent 48 | self.savedWarmthPercent = savedWarmthPercent 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/CZImageEditor/PressAndRelease.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PressAndRelease.swift 3 | // TestPhotoEditor 4 | // 5 | // Created by Kaiyi Zhao on 8/25/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PressActions: ViewModifier { 11 | var onPress: () -> Void 12 | var onRelease: () -> Void 13 | 14 | func body(content: Content) -> some View { 15 | content 16 | .simultaneousGesture( 17 | DragGesture(minimumDistance: 0) 18 | .onChanged({ _ in 19 | onPress() 20 | }) 21 | .onEnded({ _ in 22 | onRelease() 23 | }) 24 | ) 25 | } 26 | } 27 | 28 | extension View { 29 | func pressAction(onPress: @escaping (() -> Void), onRelease: @escaping (() -> Void)) -> some View { 30 | modifier(PressActions(onPress: { 31 | onPress() 32 | }, onRelease: { 33 | onRelease() 34 | })) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/CZImageEditor/TextButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextButton.swift 3 | // Cooking Zeal 4 | // 5 | // Created by Kaiyi Zhao on 7/6/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TextButton: View { 11 | let text: String 12 | let color: Color 13 | let localizationPrefix: String 14 | let action: () -> Void 15 | 16 | init(text: String, color: Color, localizationPrefix: String, action: @escaping () -> Void) { 17 | self.text = text 18 | self.color = color 19 | self.localizationPrefix = localizationPrefix 20 | self.action = action 21 | } 22 | 23 | var body: some View { 24 | Button (action: action) { 25 | HStack(spacing: 0) { 26 | Text(LocalizedStringKey(localizationPrefix + text)) 27 | .font(Font.system(size: 14, weight: .semibold, design: .default)) 28 | .foregroundColor(color) 29 | } 30 | } 31 | .frame(height: 24) 32 | } 33 | } 34 | 35 | struct TextButton_Previews: PreviewProvider { 36 | static var previews: some View { 37 | TextButton(text: "Text Button", color: .accentColor, localizationPrefix: "IE_") { 38 | 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/CZImageEditorTests/CZImageEditorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import CZImageEditor 3 | 4 | final class CZImageEditorTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | XCTAssertEqual(CZImageEditor().text, "Hello, World!") 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /previews/preview1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KaiyiZhao/CZImageEditor/570161228c9114b937f6005288b397cb82a4e760/previews/preview1.gif -------------------------------------------------------------------------------- /previews/preview2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KaiyiZhao/CZImageEditor/570161228c9114b937f6005288b397cb82a4e760/previews/preview2.gif -------------------------------------------------------------------------------- /previews/preview3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KaiyiZhao/CZImageEditor/570161228c9114b937f6005288b397cb82a4e760/previews/preview3.gif --------------------------------------------------------------------------------