├── .ruby-version ├── .bundle └── config ├── Sources ├── Media.xcassets │ ├── Contents.json │ ├── filters.imageset │ │ ├── filters-icon.pdf │ │ └── Contents.json │ ├── gridicons-crop.imageset │ │ ├── gridicons-crop.pdf │ │ └── Contents.json │ └── gridicons-cross.imageset │ │ ├── gridicons-cross.pdf │ │ └── Contents.json ├── Enums │ ├── MediaEditorOperation.swift │ └── MediaEditorStyle.swift ├── MediaEditorCapabilityCell.swift ├── Extensions │ ├── UIImage+AsyncImage.swift │ ├── Bundle+mediaEditor.swift │ ├── UIImage+withSize.swift │ └── PHAsset+AsyncImage.swift ├── MediaEditor.h ├── MediaEditorThumbCell.swift ├── Capabilities │ ├── Filters │ │ ├── Cells │ │ │ └── MediaEditorFilterCell.swift │ │ ├── MediaEditorFilters.swift │ │ └── MediaEditorFilters.storyboard │ ├── Crop │ │ ├── TOCropViewController+Ext.swift │ │ └── MediaEditorCropZoomRotate.swift │ ├── MediaEditorCapability.swift │ └── Drawing │ │ ├── MediaEditorDrawing.swift │ │ ├── MediaEditorAnnotationView.swift │ │ └── MediaEditorDrawing.storyboard ├── Info.plist ├── MediaEditorImageCell.swift ├── AsyncImage.swift ├── MediaEditor.swift └── MediaEditorHub.swift ├── Example ├── Resources │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── image.imageset │ │ │ ├── cXyG__fTLN.jpg │ │ │ └── Contents.json │ │ ├── image2.imageset │ │ │ ├── 8lhI-gKnI2.jpg │ │ │ └── Contents.json │ │ ├── ink.imageset │ │ │ ├── gridicons-ink.pdf │ │ │ └── Contents.json │ │ ├── thumb1.imageset │ │ │ ├── _rSwtEeDGD.jpg │ │ │ └── Contents.json │ │ ├── thumb2.imageset │ │ │ ├── L-cC3qX2DN.jpg │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ └── Base.lproj │ │ └── LaunchScreen.storyboard ├── ViewController.swift ├── AppDelegate.swift ├── Image Editing │ ├── Device Library │ │ ├── ImageViewCollectionCell.swift │ │ └── DeviceLibraryViewController.swift │ ├── Plain UIImage │ │ └── PlainUIImageViewController.swift │ └── Remote Image │ │ └── RemoteImageViewController.swift ├── Extending │ └── BrightnessCapability │ │ ├── AdditionalCapabilityViewController.swift │ │ ├── BrightnessCapability.swift │ │ └── BrightnessCapability.storyboard └── Info.plist ├── Tests ├── Capabilities │ ├── Drawing │ │ ├── demo-drawing │ │ └── MediaEditorDrawingTests.swift │ ├── Filters │ │ └── MediaEditorFilterTests.swift │ └── Crop │ │ └── MediaEditorCropZoomRotateTests.swift ├── Extensions │ ├── UIApplication+topWindow.swift │ └── UIImage+color.swift ├── Info.plist ├── Tests.swift ├── MediaEditorHubTests.swift └── MediaEditorTests.swift ├── Gemfile ├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── MediaEditor.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ └── MediaEditor.xcscheme ├── .rubocop.yml ├── fastlane └── Fastfile ├── .buildkite ├── publish-pod.sh ├── validate_podspec_annotation.md └── pipeline.yml ├── RELEASE-NOTES.txt ├── MediaEditor.podspec ├── CHANGELOG.md ├── .gitignore ├── CONTRIBUTING.md ├── README.md ├── Gemfile.lock └── LICENSE /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.2 2 | -------------------------------------------------------------------------------- /.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_PATH: "vendor/bundle" 3 | -------------------------------------------------------------------------------- /Sources/Media.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Tests/Capabilities/Drawing/demo-drawing: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wordpress-mobile/MediaEditor-iOS/trunk/Tests/Capabilities/Drawing/demo-drawing -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'cocoapods', '~> 1.11' 6 | gem 'fastlane', '~> 2.189' 7 | gem 'rubocop', '~> 1.18' 8 | -------------------------------------------------------------------------------- /Sources/Media.xcassets/filters.imageset/filters-icon.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wordpress-mobile/MediaEditor-iOS/trunk/Sources/Media.xcassets/filters.imageset/filters-icon.pdf -------------------------------------------------------------------------------- /Tests/Extensions/UIApplication+topWindow.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIApplication { 4 | var topWindow: UIWindow? { 5 | return windows.first 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/image.imageset/cXyG__fTLN.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wordpress-mobile/MediaEditor-iOS/trunk/Example/Resources/Assets.xcassets/image.imageset/cXyG__fTLN.jpg -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/image2.imageset/8lhI-gKnI2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wordpress-mobile/MediaEditor-iOS/trunk/Example/Resources/Assets.xcassets/image2.imageset/8lhI-gKnI2.jpg -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/ink.imageset/gridicons-ink.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wordpress-mobile/MediaEditor-iOS/trunk/Example/Resources/Assets.xcassets/ink.imageset/gridicons-ink.pdf -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/thumb1.imageset/_rSwtEeDGD.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wordpress-mobile/MediaEditor-iOS/trunk/Example/Resources/Assets.xcassets/thumb1.imageset/_rSwtEeDGD.jpg -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/thumb2.imageset/L-cC3qX2DN.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wordpress-mobile/MediaEditor-iOS/trunk/Example/Resources/Assets.xcassets/thumb2.imageset/L-cC3qX2DN.jpg -------------------------------------------------------------------------------- /Sources/Media.xcassets/gridicons-crop.imageset/gridicons-crop.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wordpress-mobile/MediaEditor-iOS/trunk/Sources/Media.xcassets/gridicons-crop.imageset/gridicons-crop.pdf -------------------------------------------------------------------------------- /Sources/Media.xcassets/gridicons-cross.imageset/gridicons-cross.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wordpress-mobile/MediaEditor-iOS/trunk/Sources/Media.xcassets/gridicons-cross.imageset/gridicons-cross.pdf -------------------------------------------------------------------------------- /Sources/Enums/MediaEditorOperation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum MediaEditorOperation { 4 | case crop 5 | case rotate 6 | case filter 7 | case other 8 | case draw 9 | } 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Expected behavior 2 | 3 | 4 | ### Actual behavior 5 | 6 | 7 | ### Steps to reproduce the behavior 8 | 9 | 10 | ##### Tested on [device], iOS [version], WPiOS [version] 11 | 12 | -------------------------------------------------------------------------------- /MediaEditor.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Sources/Media.xcassets/filters.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "filters-icon.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # Opt in to new cops by default 2 | AllCops: 3 | Exclude: 4 | - DerivedData/**/* 5 | - vendor/**/* 6 | NewCops: enable 7 | 8 | # Allow the Podspec filename to match the project 9 | Naming/FileName: 10 | Exclude: 11 | - 'MediaEditor.podspec' 12 | -------------------------------------------------------------------------------- /MediaEditor.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/ink.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "gridicons-ink.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template" 14 | } 15 | } -------------------------------------------------------------------------------- /Example/ViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class ViewController: UITableViewController { 4 | 5 | override func viewDidAppear(_ animated: Bool) { 6 | super.viewDidAppear(animated) 7 | 8 | #if !targetEnvironment(simulator) 9 | tableView.footerView(forSection: 2)?.isHidden = true 10 | #endif 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /Sources/MediaEditorCapabilityCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class MediaEditorCapabilityCell: UICollectionViewCell { 4 | @IBOutlet weak var iconButton: UIButton! 5 | 6 | func configure(_ capabilityInfo: (String, UIImage)) { 7 | let (name, icon) = capabilityInfo 8 | iconButton.setImage(icon, for: .normal) 9 | iconButton.accessibilityHint = name 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Fixes # 2 | 3 | To test: 4 | 5 | --- 6 | 7 | PR submission checklist: 8 | 9 | - [ ] I have considered adding unit tests where possible. 10 | - [ ] I have considered adding accessibility improvements for my changes. 11 | - [ ] I have considered if this change warrants release notes and have added them to the appropriate section in the `CHANGELOG.md` if necessary. 12 | -------------------------------------------------------------------------------- /Sources/Extensions/UIImage+AsyncImage.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIImage: AsyncImage { 4 | public var thumb: UIImage? { 5 | return self 6 | } 7 | 8 | public func thumbnail(finishedRetrievingThumbnail: @escaping (UIImage?) -> ()) {} 9 | 10 | public func full(finishedRetrievingFullImage: @escaping (UIImage?) -> ()) {} 11 | 12 | public func cancel() {} 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Media.xcassets/gridicons-crop.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "gridicons-crop.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template", 14 | "preserves-vector-representation" : true 15 | } 16 | } -------------------------------------------------------------------------------- /Sources/Media.xcassets/gridicons-cross.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "gridicons-cross.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template", 14 | "preserves-vector-representation" : true 15 | } 16 | } -------------------------------------------------------------------------------- /Sources/MediaEditor.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | //! Project version number for MediaEditor. 4 | FOUNDATION_EXPORT double MediaEditorVersionNumber; 5 | 6 | //! Project version string for MediaEditor. 7 | FOUNDATION_EXPORT const unsigned char MediaEditorVersionString[]; 8 | 9 | // In this header, you should import all the public headers of your framework using statements like #import 10 | 11 | 12 | -------------------------------------------------------------------------------- /Example/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @UIApplicationMain 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | 6 | var window: UIWindow? 7 | 8 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 9 | // Override point for customization after application launch. 10 | return true 11 | } 12 | 13 | } 14 | 15 | -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/image.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "cXyG__fTLN.jpg", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/image2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "8lhI-gKnI2.jpg", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/thumb1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "_rSwtEeDGD.jpg", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/thumb2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "L-cC3qX2DN.jpg", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Example/Image Editing/Device Library/ImageViewCollectionCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class ImageViewCollectionCell: UICollectionViewCell { 4 | static let identifier = "imageViewCollectionCell" 5 | 6 | @IBOutlet weak var imageView: UIImageView! 7 | @IBOutlet weak var editedLabel: UILabel! 8 | 9 | func configure(image: UIImage, wasEdited: Bool) { 10 | imageView.image = image 11 | editedLabel.isHidden = !wasEdited 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | default_platform(:ios) 4 | 5 | platform :ios do 6 | desc 'Builds the project and runs tests' 7 | lane :test do 8 | run_tests( 9 | project: 'MediaEditor.xcodeproj', 10 | scheme: 'MediaEditor', 11 | devices: ['iPhone 11'], 12 | deployment_target_version: '14.5', 13 | prelaunch_simulator: true, 14 | buildlog_path: File.join(__dir__, '.build', 'logs'), 15 | derived_data_path: File.join(__dir__, '.build', 'derived-data') 16 | ) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /Sources/MediaEditorThumbCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class MediaEditorThumbCell: UICollectionViewCell { 4 | @IBOutlet weak var thumbImageView: UIImageView! 5 | 6 | func showBorder(color: UIColor? = nil) { 7 | layer.borderWidth = 5 8 | layer.borderColor = color?.cgColor ?? Constant.defaultSelectedColor 9 | } 10 | 11 | func hideBorder() { 12 | layer.borderWidth = 0 13 | } 14 | 15 | private enum Constant { 16 | static var defaultSelectedColor = UIColor(red: 0.133, green: 0.443, blue: 0.694, alpha: 1).cgColor 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.buildkite/publish-pod.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | PODSPEC_PATH="MediaEditor.podspec" 4 | SPECS_REPO="git@github.com:wordpress-mobile/cocoapods-specs.git" 5 | SLACK_WEBHOOK=$PODS_SLACK_WEBHOOK 6 | 7 | echo "--- :rubygems: Setting up Gems" 8 | install_gems 9 | 10 | echo "--- :cocoapods: Publishing Pod to CocoaPods CDN" 11 | publish_pod $PODSPEC_PATH 12 | 13 | echo "--- :cocoapods: Publishing Pod to WP Specs Repo" 14 | publish_private_pod $PODSPEC_PATH $SPECS_REPO "$SPEC_REPO_PUBLIC_DEPLOY_KEY" 15 | 16 | echo "--- :slack: Notifying Slack" 17 | slack_notify_pod_published $PODSPEC_PATH $SLACK_WEBHOOK 18 | -------------------------------------------------------------------------------- /Sources/Enums/MediaEditorStyle.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public typealias MediaEditorStyles = [MediaEditorStyle: Any] 4 | 5 | public enum MediaEditorStyle { 6 | case insertLabel 7 | case doneLabel 8 | case doneColor 9 | case cancelLabel 10 | case cancelColor 11 | case resetIcon 12 | case doneIcon 13 | case cancelIcon 14 | case rotateClockwiseIcon 15 | case rotateCounterclockwiseButtonHidden 16 | case loadingLabel 17 | case selectedColor 18 | case errorLoadingImageMessage 19 | case retryIcon 20 | case undoIcon 21 | case redoIcon 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Extensions/Bundle+mediaEditor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Bundle { 4 | @objc public class var mediaEditor: Bundle { 5 | let defaultBundle = Bundle(for: MediaEditor.self) 6 | // If installed with CocoaPods, resources will be in MediaEditor.bundle 7 | if let bundleURL = defaultBundle.resourceURL, 8 | let resourceBundle = Bundle(url: bundleURL.appendingPathComponent("MediaEditor.bundle")) { 9 | return resourceBundle 10 | } 11 | // Otherwise, the default bundle is used for resources 12 | return defaultBundle 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /RELEASE-NOTES.txt: -------------------------------------------------------------------------------- 1 | 1.2.1 2 | ----- 3 | * Improve memory usage of AnnotationView when rendering drawings 4 | 5 | 1.2.0 6 | ----- 7 | * Replace TOCropViewController with CropViewController 8 | 9 | 1.1.0 10 | ----- 11 | * Add Drawing capability using PencilKit 12 | 13 | 1.0.1 14 | ----- 15 | * Expose the Hub 16 | 17 | 1.0.0 18 | ----- 19 | * Adds support to Carthage 20 | * Fix the support to PHAssets saved in iCloud 21 | * Added Filters Capability 22 | * Changes the Capability API 23 | 24 | 0.1.3 25 | ----- 26 | * Fix crashes in iOS 11 and 12 #4 27 | * Fix Autoulayout warnings #5 28 | * Fix swipe left-to-right conflict when cropping #7 29 | -------------------------------------------------------------------------------- /Tests/Extensions/UIImage+color.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIImage { 4 | 5 | /** 6 | Returns an UIImage with a specified background color. 7 | - parameter color: The color of the background 8 | */ 9 | convenience init(color: UIColor) { 10 | let rect: CGRect = CGRect(x: 0, y: 0, width: 1, height: 1) 11 | UIGraphicsBeginImageContext(rect.size); 12 | let context:CGContext = UIGraphicsGetCurrentContext()!; 13 | context.setFillColor(color.cgColor); 14 | context.fill(rect) 15 | 16 | let image:UIImage = UIGraphicsGetImageFromCurrentImageContext()! 17 | UIGraphicsEndImageContext() 18 | 19 | self.init(ciImage: CIImage(image: image)!) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Extensions/UIImage+withSize.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIImage { 4 | func with(size: CGSize) -> UIImage { 5 | let renderer = UIGraphicsImageRenderer(size: size) 6 | let image = renderer.image { _ in 7 | draw(in: CGRect(origin: CGPoint.zero, size: size)) 8 | } 9 | 10 | return image 11 | } 12 | 13 | func fit(size: CGSize) -> UIImage { 14 | let aspect = self.size.width / self.size.height 15 | if size.width / aspect <= size.height { 16 | return with(size: CGSize(width: size.width, height: size.width / aspect)) 17 | } else { 18 | return with(size: CGSize(width: size.height * aspect, height: size.height)) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Capabilities/Filters/Cells/MediaEditorFilterCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class MediaEditorFilterCell: UICollectionViewCell { 4 | @IBOutlet weak var imageView: UIImageView! 5 | @IBOutlet weak var title: UILabel! 6 | 7 | func configure(image: UIImage, title: String) { 8 | imageView.image = image 9 | self.title.text = title 10 | } 11 | 12 | func showBorder(color: UIColor? = nil) { 13 | imageView.layer.borderWidth = 5 14 | imageView.layer.borderColor = color?.cgColor ?? Constant.defaultSelectedColor 15 | } 16 | 17 | func hideBorder() { 18 | imageView.layer.borderWidth = 0 19 | } 20 | 21 | private enum Constant { 22 | static var defaultSelectedColor = UIColor(red: 0.133, green: 0.443, blue: 0.694, alpha: 1).cgColor 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Sources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /.buildkite/validate_podspec_annotation.md: -------------------------------------------------------------------------------- 1 | **`validate_podspec` was bypassed!** 2 | 3 | As of Xcode 14.3, libraries with deployment target below iOS 11 (iOS 12 in Xcode 15) fail to build out of the box. 4 | The reason is a missing file, `/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/arc/libarclite_iphonesimulator.a`, more info [here](https://stackoverflow.com/questions/75574268/missing-file-libarclite-iphoneos-a-xcode-14-3). 5 | 6 | Client apps can work around this with a post install hook that updates the dependency deployment target, but libraries do not have this option. 7 | 8 | Our old Alamofire dependency targets iOS 8.0, making the validation build fail. 9 | 10 | In the interest of using up to date CI (i.e. not waste time downloading old images) we bypass validation until either we are able to drop or upgraed Alamofire, or find a workaround. 11 | -------------------------------------------------------------------------------- /Sources/MediaEditorImageCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class MediaEditorImageCell: UICollectionViewCell { 4 | @IBOutlet weak var imageView: UIImageView! 5 | @IBOutlet weak var errorView: UIView! 6 | @IBOutlet weak var errorLabel: UILabel! 7 | @IBOutlet weak var retryButton: UIButton! 8 | 9 | weak var delegate: MediaEditorHubDelegate? 10 | 11 | func apply(styles: MediaEditorStyles?) { 12 | guard let styles = styles else { 13 | return 14 | } 15 | 16 | if let errorLoadingImageMessage = styles[.errorLoadingImageMessage] as? String { 17 | errorLabel.text = errorLoadingImageMessage 18 | } 19 | 20 | if let retryIcon = styles[.retryIcon] as? UIImage { 21 | retryButton.setImage(retryIcon, for: .normal) 22 | } 23 | 24 | } 25 | 26 | @IBAction func retry(_ sender: Any) { 27 | delegate?.retry() 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /Tests/Tests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tests.swift 3 | // Tests 4 | // 5 | // Created by Leandro Alonso on 21/01/20. 6 | // Copyright © 2020 Automattic, Inc. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class Tests: XCTestCase { 12 | 13 | override func setUp() { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | } 16 | 17 | override func tearDown() { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testExample() { 22 | // This is an example of a functional test case. 23 | // Use XCTAssert and related functions to verify your tests produce the correct results. 24 | } 25 | 26 | func testPerformanceExample() { 27 | // This is an example of a performance test case. 28 | measure { 29 | // Put the code you want to measure the time of here. 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /MediaEditor.podspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Pod::Spec.new do |s| 4 | s.name = 'MediaEditor' 5 | s.version = '1.2.2' 6 | 7 | s.summary = 'An extensible Media Editor for iOS.' 8 | s.description = <<~DESC 9 | An extensible Media Editor for iOS that allows editing single or multiple images. 10 | DESC 11 | 12 | s.homepage = 'https://github.com/wordpress-mobile/MediaEditor-iOS' 13 | s.license = { type: 'GPLv2', file: 'LICENSE' } 14 | s.author = { 'The WordPress Mobile Team' => 'mobile@wordpress.org' } 15 | 16 | s.platform = :ios, '12.0' 17 | s.swift_version = '5.0' 18 | 19 | s.source = { git: 'https://github.com/wordpress-mobile/MediaEditor-iOS.git', tag: s.version.to_s } 20 | s.module_name = 'MediaEditor' 21 | s.source_files = 'Sources/**/*.{h,m,swift}' 22 | s.resources = 'Sources/**/*.{storyboard}' 23 | s.resource_bundles = { 24 | 'MediaEditor' => 'Sources/**/*.{xcassets}' 25 | } 26 | 27 | s.dependency 'CropViewController', '~> 2.5.3' 28 | end 29 | -------------------------------------------------------------------------------- /Sources/AsyncImage.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol AsyncImage { 4 | var thumb: UIImage? { get } 5 | 6 | var isEdited: Bool { get set } 7 | 8 | var editedImage: UIImage? { get set } 9 | 10 | func thumbnail(finishedRetrievingThumbnail: @escaping (UIImage?) -> ()) 11 | 12 | func full(finishedRetrievingFullImage: @escaping (UIImage?) -> ()) 13 | 14 | func cancel() 15 | } 16 | 17 | extension AsyncImage { 18 | public var isEdited: Bool { 19 | get { 20 | return objc_getAssociatedObject(self, &AsyncImageKeys.isEdited) as? Bool ?? false 21 | } 22 | set { 23 | objc_setAssociatedObject(self, &AsyncImageKeys.isEdited, newValue, .OBJC_ASSOCIATION_RETAIN) 24 | } 25 | } 26 | 27 | public var editedImage: UIImage? { 28 | get { 29 | return objc_getAssociatedObject(self, &AsyncImageKeys.editedImage) as? UIImage ?? nil 30 | } 31 | set { 32 | objc_setAssociatedObject(self, &AsyncImageKeys.editedImage, newValue, .OBJC_ASSOCIATION_RETAIN) 33 | } 34 | } 35 | } 36 | 37 | private enum AsyncImageKeys { 38 | static var isEdited = "isEdited" 39 | static var editedImage = "editedImage" 40 | } 41 | -------------------------------------------------------------------------------- /Tests/Capabilities/Filters/MediaEditorFilterTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Nimble 3 | 4 | @testable import MediaEditor 5 | 6 | class MediaEditorFilterTests: XCTestCase { 7 | 8 | func testName() { 9 | let name = MediaEditorFilters.name 10 | 11 | expect(name).to(equal("Filters")) 12 | } 13 | 14 | func testIcon() { 15 | let icon = MediaEditorFilters.icon 16 | 17 | expect(icon).to(equal(UIImage(named: "filters", in: .mediaEditor, compatibleWith: nil)!)) 18 | } 19 | 20 | func testApplyStyles() { 21 | let mediaEditorFilters = MediaEditorFilters.initialize(UIImage(), onFinishEditing: { _, _ in }, onCancel: {}) 22 | mediaEditorFilters.loadView() 23 | 24 | mediaEditorFilters.apply(styles: [ 25 | .doneLabel: "foo", 26 | .cancelLabel: "bar", 27 | .cancelColor: UIColor.black 28 | ]) 29 | 30 | let viewController = mediaEditorFilters as! MediaEditorFilters 31 | expect(viewController.doneButton.titleLabel?.text).to(equal("foo")) 32 | expect(viewController.cancelButton.titleLabel?.text).to(equal("bar")) 33 | expect(viewController.cancelButton.tintColor).to(equal(.black)) 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | The format of this document is inspired by [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 4 | 5 | 32 | 33 | ## Unreleased 34 | 35 | ### Breaking Changes 36 | 37 | _None._ 38 | 39 | ### New Features 40 | 41 | _None._ 42 | 43 | ### Bug Fixes 44 | 45 | _None._ 46 | 47 | ### Internal Changes 48 | 49 | _None._ 50 | 51 | ## 1.2.2 52 | 53 | ### Bug Fixes 54 | 55 | - Fix a `viewWillDisappear` method calling `super.viewWillAppear` [#40] 56 | 57 | ### Internal Changes 58 | 59 | - Add this changelog file [#34] 60 | -------------------------------------------------------------------------------- /Sources/Capabilities/Crop/TOCropViewController+Ext.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import CropViewController 3 | 4 | extension CropViewController { 5 | // CropViewController sometimes resize the image by 1, 2 or 3 points automatically. 6 | // In those cases we're not considering that as a cropping action. 7 | var isCropped: Bool { 8 | return abs(imageSizeDiscardingRotation.width - image.size.width) > 4 || 9 | abs(imageSizeDiscardingRotation.height - image.size.height) > 4 10 | } 11 | 12 | var imageSizeDiscardingRotation: CGSize { 13 | let imageSize = imageCropFrame.size 14 | 15 | let anglesThatChangesImageSize = [90, 270] 16 | if anglesThatChangesImageSize.contains(angle) { 17 | return CGSize(width: imageSize.height, height: imageSize.width) 18 | } else { 19 | return imageSize 20 | } 21 | } 22 | 23 | var isRotated: Bool { 24 | return angle != 0 25 | } 26 | 27 | var actions: [MediaEditorOperation] { 28 | var operations: [MediaEditorOperation] = [] 29 | 30 | if isCropped { 31 | operations.append(.crop) 32 | } 33 | 34 | if isRotated { 35 | operations.append(.rotate) 36 | } 37 | 38 | return operations 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/Capabilities/MediaEditorCapability.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// A type that is a UIViewController and also conforms to MediaEditorCapability 4 | public typealias CapabilityViewController = UIViewController & MediaEditorCapability 5 | 6 | /// A protocol that defines some properties for a Capability 7 | public protocol MediaEditorCapability { 8 | 9 | /// The name of your Capability. Eg.: "Emojis" 10 | static var name: String { get } 11 | 12 | /// An icon that represents your Capability. This will be displayed in the Media Editor interface. 13 | static var icon: UIImage { get } 14 | 15 | /// A static initializer for a CapabilityViewController. 16 | /// 17 | /// Use this method to initialize your UIViewController that conforms to MediaEditorCapability. 18 | /// - Parameter image: `UIImage` to be displayed and edited 19 | /// - Parameter onFinishEditing: block to be called when the user finished editing the image 20 | /// - Parameter onCancel: block to be called when the user cancels editing the image 21 | /// 22 | static func initialize(_ image: UIImage, 23 | onFinishEditing: @escaping (UIImage, [MediaEditorOperation]) -> (), 24 | onCancel: @escaping () -> ()) -> CapabilityViewController 25 | 26 | /// A function that applies given styles into your UIViewController 27 | func apply(styles: MediaEditorStyles) 28 | } 29 | -------------------------------------------------------------------------------- /MediaEditor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "cwlcatchexception", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/mattgallagher/CwlCatchException.git", 7 | "state" : { 8 | "revision" : "35f9e770f54ce62dd8526470f14c6e137cef3eea", 9 | "version" : "2.1.1" 10 | } 11 | }, 12 | { 13 | "identity" : "cwlpreconditiontesting", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git", 16 | "state" : { 17 | "revision" : "c21f7bab5ca8eee0a9998bbd17ca1d0eb45d4688", 18 | "version" : "2.1.0" 19 | } 20 | }, 21 | { 22 | "identity" : "nimble", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/Quick/Nimble.git", 25 | "state" : { 26 | "revision" : "1f3bde57bde12f5e7b07909848c071e9b73d6edc", 27 | "version" : "10.0.0" 28 | } 29 | }, 30 | { 31 | "identity" : "tocropviewcontroller", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/TimOliver/TOCropViewController.git", 34 | "state" : { 35 | "revision" : "d0470491f56e734731bbf77991944c0dfdee3e0e", 36 | "version" : "2.6.1" 37 | } 38 | } 39 | ], 40 | "version" : 2 41 | } 42 | -------------------------------------------------------------------------------- /Example/Resources/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Example/Extending/BrightnessCapability/AdditionalCapabilityViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import MediaEditor 3 | 4 | class AdditionalCapabilityViewController: UIViewController { 5 | @IBOutlet weak var imageView: UIImageView! 6 | 7 | override func viewDidLoad() { 8 | super.viewDidLoad() 9 | 10 | // Append Brightness to the list of capabilities 11 | MediaEditor.capabilities.append(BrightnessViewController.self) 12 | 13 | // Add tap gesture in the image 14 | let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(imageTapped(tapGestureRecognizer:))) 15 | imageView.isUserInteractionEnabled = true 16 | imageView.addGestureRecognizer(tapGestureRecognizer) 17 | } 18 | 19 | override func viewDidAppear(_ animated: Bool) { 20 | super.viewDidAppear(animated) 21 | } 22 | 23 | override func viewWillDisappear(_ animated: Bool) { 24 | super.viewWillDisappear(animated) 25 | if isMovingFromParent { 26 | MediaEditor.capabilities.removeLast() 27 | } 28 | } 29 | 30 | @objc func imageTapped(tapGestureRecognizer: UITapGestureRecognizer) { 31 | guard let image = imageView.image else { 32 | return 33 | } 34 | 35 | // Give the image to the MediaEditor (you can also pass an array of UIImage) 36 | let mediaEditor = MediaEditor(image) 37 | mediaEditor.edit(from: self, onFinishEditing: { images, action in 38 | // Display the edited image 39 | guard let images = images as? [UIImage] else { 40 | return 41 | } 42 | 43 | self.imageView.image = images.first 44 | }, onCancel: { 45 | // User canceled 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Example/Image Editing/Plain UIImage/PlainUIImageViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import MediaEditor 3 | 4 | class PlainUIImageViewController: UIViewController { 5 | @IBOutlet weak var imageView: UIImageView! 6 | @IBOutlet weak var secondImageView: UIImageView! 7 | 8 | override func viewDidLoad() { 9 | super.viewDidLoad() 10 | // Add tap gesture in the first image 11 | let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(imageTapped(tapGestureRecognizer:))) 12 | imageView.isUserInteractionEnabled = true 13 | imageView.addGestureRecognizer(tapGestureRecognizer) 14 | 15 | // Add tap gesture in the second image 16 | let secondTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(imageTapped(tapGestureRecognizer:))) 17 | secondImageView.isUserInteractionEnabled = true 18 | secondImageView.addGestureRecognizer(secondTapGestureRecognizer) 19 | } 20 | 21 | @objc func imageTapped(tapGestureRecognizer: UITapGestureRecognizer) { 22 | guard let firstImage = imageView.image, 23 | let secondImage = secondImageView.image else { 24 | return 25 | } 26 | 27 | // Give the image to the MediaEditor (you can also pass an array of UIImage) 28 | let mediaEditor = MediaEditor([firstImage, secondImage]) 29 | mediaEditor.edit(from: self, onFinishEditing: { images, action in 30 | // Display the edited image 31 | guard let images = images as? [UIImage] else { 32 | return 33 | } 34 | 35 | self.imageView.image = images.first 36 | self.secondImageView.image = images[1] 37 | }, onCancel: { 38 | // User canceled 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.buildkite/pipeline.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/buildkite/pipeline-schema/main/schema.json 2 | --- 3 | 4 | agents: 5 | queue: mac 6 | 7 | env: 8 | IMAGE_ID: xcode-16.4 9 | 10 | # Nodes with values to reuse in the pipeline. 11 | common_params: 12 | plugins: &common_plugins 13 | - automattic/a8c-ci-toolkit#5.3.1 14 | 15 | # This is the default pipeline – it will build and test the library 16 | steps: 17 | ################# 18 | # Build and Test 19 | ################# 20 | - label: "🔬 Build and Test" 21 | key: "test" 22 | command: | 23 | build_and_test_pod 24 | plugins: *common_plugins 25 | artifact_paths: 26 | - .build/logs/*.log 27 | - .build/derived-data/Logs/**/*.xcactivitylog 28 | 29 | ################# 30 | # Validate Podspec 31 | ################# 32 | - label: "🔬 Validate Podspec" 33 | key: "validate" 34 | command: | 35 | # validate_podspec 36 | echo '+++ ⚠️ validate_podspec was bypassed ⚠️' 37 | # post a message in the logs 38 | cat .buildkite/validate_podspec_annotation.md 39 | # and also as an annotation 40 | cat .buildkite/validate_podspec_annotation.md | buildkite-agent annotate --style 'warning' 41 | plugins: *common_plugins 42 | 43 | ################# 44 | # Lint 45 | ################# 46 | - label: "🧹 Lint" 47 | key: "lint" 48 | command: | 49 | lint_pod 50 | plugins: *common_plugins 51 | 52 | ################# 53 | # Publish the Podspec (if we're building a tag) 54 | ################# 55 | - label: "⬆️ Publish Podspec" 56 | key: "publish" 57 | command: .buildkite/publish-pod.sh 58 | plugins: *common_plugins 59 | depends_on: 60 | - "test" 61 | - "validate" 62 | - "lint" 63 | if: build.tag != null 64 | -------------------------------------------------------------------------------- /Example/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIMainStoryboardFile 26 | Main 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | NSCameraUsageDescription 45 | This app wants to take pictures. 46 | NSPhotoLibraryUsageDescription 47 | This app wants to use your photos. 48 | 49 | 50 | -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /Tests/Capabilities/Crop/MediaEditorCropZoomRotateTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import CropViewController 3 | import Nimble 4 | 5 | @testable import MediaEditor 6 | 7 | class MediaEditorCropZoomRotateTests: XCTestCase { 8 | 9 | private let image = UIImage() 10 | 11 | func testIsAMediaEditorCapability() { 12 | let mediaEditorCrop = MediaEditorCropZoomRotate.initialize(image, onFinishEditing: { _, _ in }, onCancel: {}) 13 | 14 | expect(mediaEditorCrop).to(beAKindOf(MediaEditorCapability.self)) 15 | } 16 | 17 | func testDoNotHideNavigation() { 18 | let mediaEditorCrop = MediaEditorCropZoomRotate.initialize(image, onFinishEditing: { _, _ in }, onCancel: {}) 19 | 20 | let viewController = mediaEditorCrop as? CropViewController 21 | 22 | expect(viewController?.hidesNavigationBar).to(beFalse()) 23 | } 24 | 25 | func testOnDidCropToRectCallOnFinishEditing() { 26 | var onFinishEditingCalled = false 27 | let mediaEditorCrop = MediaEditorCropZoomRotate.initialize( 28 | image, 29 | onFinishEditing: { _, _ in 30 | onFinishEditingCalled = true 31 | }, 32 | onCancel: {}) 33 | let viewController = mediaEditorCrop as? CropViewController 34 | 35 | viewController?.onDidCropToRect?(image, .zero, 0) 36 | 37 | expect(onFinishEditingCalled).to(beTrue()) 38 | } 39 | 40 | func testOnDidFinishCancelledCall() { 41 | var onCancelCalled = false 42 | let mediaEditorCrop = MediaEditorCropZoomRotate.initialize( 43 | image, 44 | onFinishEditing: { _, _ in }, 45 | onCancel: { 46 | onCancelCalled = true 47 | } 48 | ) 49 | let viewController = mediaEditorCrop as? CropViewController 50 | 51 | viewController?.onDidFinishCancelled?(true) 52 | 53 | expect(onCancelCalled).to(beTrue()) 54 | } 55 | 56 | func testHideRotateCounterclockwiseButton() { 57 | let mediaEditorCrop = MediaEditorCropZoomRotate.initialize(image, onFinishEditing: { _, _ in }, onCancel: {}) 58 | 59 | mediaEditorCrop.apply(styles: [.rotateCounterclockwiseButtonHidden: true]) 60 | 61 | let viewController = mediaEditorCrop as? CropViewController 62 | expect(viewController?.toolbar.rotateCounterclockwiseButtonHidden).to(beTrue()) 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /Sources/Capabilities/Crop/MediaEditorCropZoomRotate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import CropViewController 3 | 4 | typealias MediaEditorCropZoomRotate = CropViewController 5 | 6 | extension CropViewController: MediaEditorCapability { 7 | public static var name = "Crop, Zoom, Rotate" 8 | 9 | public static var icon = UIImage(named: "gridicons-crop", in: .mediaEditor, compatibleWith: nil)! 10 | 11 | public static func initialize(_ image: UIImage, onFinishEditing: @escaping (UIImage, [MediaEditorOperation]) -> (), onCancel: @escaping () -> ()) -> CapabilityViewController { 12 | let cropViewController = CropViewController(image: image) 13 | 14 | weak var toCrop = cropViewController 15 | 16 | cropViewController.hidesNavigationBar = false 17 | 18 | cropViewController.onDidCropToRect = { image, _, _ in 19 | onFinishEditing(image, toCrop?.actions ?? []) 20 | } 21 | 22 | cropViewController.onDidFinishCancelled = { _ in 23 | onCancel() 24 | } 25 | 26 | return cropViewController 27 | } 28 | 29 | public func apply(styles: MediaEditorStyles) { 30 | if let doneLabel = styles[.doneLabel] as? String { 31 | toolbar.doneTextButton.setTitle(doneLabel, for: .normal) 32 | } 33 | 34 | if let cancelLabel = styles[.cancelLabel] as? String { 35 | toolbar.cancelTextButton.setTitle(cancelLabel, for: .normal) 36 | } 37 | 38 | if let cancelColor = styles[.cancelColor] as? UIColor { 39 | toolbar.cancelTextButton.tintColor = cancelColor 40 | toolbar.cancelIconButton.tintColor = cancelColor 41 | } 42 | 43 | if let resetIcon = styles[.resetIcon] as? UIImage { 44 | toolbar.resetButton.setImage(resetIcon, for: .normal) 45 | } 46 | 47 | if let doneIcon = styles[.doneIcon] as? UIImage { 48 | toolbar.doneIconButton.setImage(doneIcon, for: .normal) 49 | } 50 | 51 | if let cancelIcon = styles[.cancelIcon] as? UIImage { 52 | toolbar.cancelIconButton.setImage(cancelIcon, for: .normal) 53 | } 54 | 55 | if let rotateClockwiseIcon = styles[.rotateClockwiseIcon] as? UIImage { 56 | toolbar.rotateClockwiseButton?.setImage(rotateClockwiseIcon, for: .normal) 57 | } 58 | 59 | if let rotateCounterclockwiseButtonHidden = styles[.rotateCounterclockwiseButtonHidden] as? Bool { 60 | toolbar.rotateCounterclockwiseButtonHidden = rotateCounterclockwiseButtonHidden 61 | } 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # gitignore via 2 | # https://github.com/github/gitignore/blob/4488915eec0b3a45b5c63ead28f286819c0917de/Swift.gitignore 3 | 4 | ## User settings 5 | xcuserdata/ 6 | 7 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 8 | *.xcscmblueprint 9 | *.xccheckout 10 | 11 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 12 | build/ 13 | DerivedData/ 14 | *.moved-aside 15 | *.pbxuser 16 | !default.pbxuser 17 | *.mode1v3 18 | !default.mode1v3 19 | *.mode2v3 20 | !default.mode2v3 21 | *.perspectivev3 22 | !default.perspectivev3 23 | 24 | ## Obj-C/Swift specific 25 | *.hmap 26 | 27 | ## App packaging 28 | *.ipa 29 | *.dSYM.zip 30 | *.dSYM 31 | 32 | ## Playgrounds 33 | timeline.xctimeline 34 | playground.xcworkspace 35 | 36 | # Swift Package Manager 37 | # 38 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 39 | # Packages/ 40 | # Package.pins 41 | # Package.resolved 42 | # *.xcodeproj 43 | # 44 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 45 | # hence it is not needed unless you have added a package configuration file to your project 46 | # .swiftpm 47 | 48 | .build/ 49 | 50 | # CocoaPods 51 | # 52 | # We recommend against adding the Pods directory to your .gitignore. However 53 | # you should judge for yourself, the pros and cons are mentioned at: 54 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 55 | # 56 | # Pods/ 57 | # 58 | # Add this line if you want to avoid checking in source code from the Xcode workspace 59 | # *.xcworkspace 60 | 61 | # Carthage 62 | # 63 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 64 | # Carthage/Checkouts 65 | 66 | Carthage/Build/ 67 | 68 | # Accio dependency management 69 | Dependencies/ 70 | .accio/ 71 | 72 | # fastlane 73 | # 74 | # It is recommended to not store the screenshots in the git repo. 75 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 76 | # For more information about the recommended setup visit: 77 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 78 | 79 | fastlane/report.xml 80 | fastlane/Preview.html 81 | fastlane/screenshots/**/*.png 82 | fastlane/test_output 83 | fastlane/README.md 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 | 92 | # Ignore project's vendor folder 93 | vendor/ 94 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | First off, thank you for contributing! We're excited to collaborate with you! 🎉 4 | 5 | The following is a set of guidelines for the many ways you can join our collective effort. 6 | 7 | Before anything else, please take a moment to read our [Code of Conduct](https://github.com/wordpress-mobile/WordPress-iOS/blob/develop/CODE-OF-CONDUCT.md). We expect all participants, from full-timers to occasional tinkerers, to uphold it. 8 | 9 | ## Reporting Bugs, Asking Questions, and Suggesting Features 10 | 11 | Have a suggestion or feedback? Please go to [Issues](https://github.com/wordpress-mobile/MediaEditor-iOS/issues) and [open a new issue](https://github.com/wordpress-mobile/MediaEditor-iOS/issues/new). Prefix the title with a category like _"Bug:"_, _"Question:"_, or _"Feature Request:"_. Screenshots help us resolve issues and answer questions faster, so thanks for including some if you can. 12 | 13 | ## Submitting Code Changes 14 | 15 | If you're just getting started and want to familiarize yourself with the app’s code, we suggest looking at [these issues](https://github.com/wordpress-mobile/MediaEditor-iOS/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) with the **good first issue** label. But if you’d like to tackle something different, you're more than welcome to visit the [Issues](https://github.com/wordpress-mobile/MediaEditor-iOS/issues) page and pick an item that interests you. 16 | 17 | We always try to avoid duplicating efforts, so if you decide to work on an issue, leave a comment to state your intent. If you choose to focus on a new feature or the change you’re proposing is significant, we recommend waiting for a response before proceeding. The issue may no longer align with project goals. 18 | 19 | If the change is trivial, feel free to send a pull request without notifying us. 20 | 21 | ### Pull Requests and Code Reviews 22 | 23 | All code contributions pass through pull requests. If you haven't created a pull request before, we recommend this free video series, [How to Contribute to an Open Source Project on GitHub](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github). 24 | 25 | The core team monitors and reviews all pull requests. Depending on the changes, we will either approve them or close them with an explanation. We might also work with you to improve a pull request before approval. 26 | 27 | We do our best to respond quickly to all pull requests. If you don't get a response from us after a week, feel free to reach out to us via Slack. 28 | 29 | ## Getting in Touch 30 | 31 | If you have questions or just want to say hi, join the [WordPress Slack](https://make.wordpress.org/chat/) and drop a message on the `#mobile` channel. 32 | -------------------------------------------------------------------------------- /Sources/Extensions/PHAsset+AsyncImage.swift: -------------------------------------------------------------------------------- 1 | import Photos 2 | import UIKit 3 | 4 | /** 5 | This is an extension to allow PHAsset in Media Editor. 6 | */ 7 | extension PHAsset: AsyncImage { 8 | /** 9 | PHAsset doesn't provide a thumbnail right away. 10 | It will be requested in the thumbnail() method 11 | */ 12 | public var thumb: UIImage? { 13 | return nil 14 | } 15 | 16 | /** 17 | Keep track of all ongoing image requests so they can be cancelled. 18 | */ 19 | public var requests: [PHImageRequestID] { 20 | get { 21 | return objc_getAssociatedObject(self, &Keys.requests) as? [PHImageRequestID] ?? [] 22 | } 23 | set { 24 | objc_setAssociatedObject(self, &Keys.requests, newValue, .OBJC_ASSOCIATION_RETAIN) 25 | } 26 | } 27 | 28 | /** 29 | Fetch a thumbnail and then display a better quality one. 30 | */ 31 | public func thumbnail(finishedRetrievingThumbnail: @escaping (UIImage?) -> ()) { 32 | let options = PHImageRequestOptions() 33 | options.deliveryMode = .opportunistic 34 | options.version = .current 35 | options.resizeMode = .fast 36 | options.isNetworkAccessAllowed = true 37 | let requestID = PHImageManager.default().requestImage(for: self, targetSize: UIScreen.main.bounds.size, contentMode: .default, options: options) { image, info in 38 | guard let image = image else { 39 | finishedRetrievingThumbnail(nil) 40 | return 41 | } 42 | 43 | finishedRetrievingThumbnail(image) 44 | } 45 | requests.append(requestID) 46 | } 47 | 48 | /** 49 | Fetch the full quality image. 50 | */ 51 | public func full(finishedRetrievingFullImage: @escaping (UIImage?) -> ()) { 52 | let options = PHImageRequestOptions() 53 | options.deliveryMode = .highQualityFormat 54 | options.version = .current 55 | options.isNetworkAccessAllowed = true 56 | let requestID = PHImageManager.default().requestImage(for: self, targetSize: CGSize(width: pixelWidth, height: pixelHeight), contentMode: .default, options: options) { image, info in 57 | guard let image = image else { 58 | finishedRetrievingFullImage(nil) 59 | return 60 | } 61 | 62 | finishedRetrievingFullImage(image) 63 | } 64 | requests.append(requestID) 65 | } 66 | 67 | /** 68 | Cancel all ongoing requests 69 | */ 70 | public func cancel() { 71 | requests.forEach { PHImageManager.default().cancelImageRequest($0) } 72 | } 73 | 74 | private enum Keys { 75 | static var requests = "requests" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Example/Extending/BrightnessCapability/BrightnessCapability.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import MediaEditor 3 | 4 | // MARK: - BrightnessViewController 5 | 6 | class BrightnessViewController: UIViewController { 7 | @IBOutlet weak var imageView: UIImageView! 8 | @IBOutlet weak var brightnessSlider: UISlider! 9 | 10 | let context = MediaEditor.ciContext 11 | 12 | var onFinishEditing: ((UIImage, [MediaEditorOperation]) -> ())? 13 | 14 | var onCancel: (() -> ())? 15 | 16 | var image: UIImage! 17 | 18 | lazy var ciImage: CIImage? = { 19 | return CIImage(image: image) 20 | }() 21 | 22 | lazy var brightnessFilter: CIFilter? = { 23 | guard let ciImage = ciImage else { 24 | return nil 25 | } 26 | 27 | let ciFilter = CIFilter(name: "CIColorControls") 28 | ciFilter?.setValue(ciImage, forKey: "inputImage") 29 | 30 | return ciFilter 31 | }() 32 | 33 | override func viewDidLoad() { 34 | super.viewDidLoad() 35 | imageView.image = image 36 | } 37 | 38 | 39 | @IBAction func cancel(_ sender: Any) { 40 | onCancel?() 41 | } 42 | 43 | @IBAction func done(_ sender: Any) { 44 | // Check if the user changed the brightness 45 | guard brightnessSlider.value > 0 else { 46 | onCancel?() 47 | return 48 | } 49 | 50 | // Get the UIImage 51 | guard let ciImage = imageView.image?.ciImage, let cgImage = context.createCGImage(ciImage, from: ciImage.extent) else { 52 | onCancel?() 53 | return 54 | } 55 | 56 | onFinishEditing?(UIImage(cgImage: cgImage), []) 57 | } 58 | 59 | // When the slider changes, apply the brightness value 60 | @IBAction func sliderValueChanged(_ sender: UISlider) { 61 | brightnessFilter?.setValue(sender.value, forKey: "inputBrightness") 62 | if let outputImage = brightnessFilter?.outputImage { 63 | imageView.image = UIImage(ciImage: outputImage) 64 | } 65 | } 66 | 67 | // Load it from storyboard 68 | static func fromStoryboard() -> BrightnessViewController { 69 | return UIStoryboard(name: "BrightnessCapability", bundle: .main).instantiateViewController(withIdentifier: "brightnessViewController") as! BrightnessViewController 70 | } 71 | } 72 | 73 | extension BrightnessViewController: MediaEditorCapability { 74 | static var name = "Brightness" 75 | 76 | static var icon = UIImage(named: "ink")! 77 | 78 | static func initialize(_ image: UIImage, onFinishEditing: @escaping (UIImage, [MediaEditorOperation]) -> (), onCancel: @escaping () -> ()) -> CapabilityViewController { 79 | let viewController = BrightnessViewController.fromStoryboard() 80 | viewController.onFinishEditing = onFinishEditing 81 | viewController.onCancel = onCancel 82 | viewController.image = image 83 | return viewController 84 | } 85 | 86 | func apply(styles: MediaEditorStyles) { 87 | // Apply styles here 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /MediaEditor.xcodeproj/xcshareddata/xcschemes/MediaEditor.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 54 | 60 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /Example/Image Editing/Remote Image/RemoteImageViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import MediaEditor 3 | 4 | class RemoteImageViewController: UIViewController { 5 | 6 | @IBOutlet weak var firstImageView: UIImageView! 7 | @IBOutlet weak var secondImageView: UIImageView! 8 | 9 | let images = [ 10 | RemoteImage(thumb: UIImage(named: "thumb1"), fullImageURL: "https://cldup.com/_rSwtEeDGD.jpg"), 11 | RemoteImage(thumb: UIImage(named: "thumb2"), fullImageURL: "https://cldup.com/L-cC3qX2DN.jpg") 12 | ] 13 | 14 | override func viewDidLoad() { 15 | super.viewDidLoad() 16 | // Add tap gesture in the first image 17 | let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(imageTapped(tapGestureRecognizer:))) 18 | firstImageView.isUserInteractionEnabled = true 19 | firstImageView.addGestureRecognizer(tapGestureRecognizer) 20 | 21 | // Add tap gesture in the second image 22 | let secondTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(imageTapped(tapGestureRecognizer:))) 23 | secondImageView.isUserInteractionEnabled = true 24 | secondImageView.addGestureRecognizer(secondTapGestureRecognizer) 25 | 26 | // Show the thumbs in this VC 27 | firstImageView.image = images.first?.thumb 28 | secondImageView.image = images[1].thumb 29 | } 30 | 31 | @objc func imageTapped(tapGestureRecognizer: UITapGestureRecognizer) { 32 | // Give the images to the MediaEditor 33 | let mediaEditor = MediaEditor(images) 34 | mediaEditor.edit(from: self, onFinishEditing: { images, action in 35 | // Display the returned images 36 | if let image = images.first?.editedImage { 37 | self.firstImageView.image = image 38 | } 39 | 40 | if let image = images[1].editedImage { 41 | self.secondImageView.image = image 42 | } 43 | }, onCancel: { 44 | // User canceled 45 | }) 46 | } 47 | 48 | } 49 | /// Here we have a class that conforms to AsyncImage, in order to use remote images in the Media Editor 50 | /// Basically, we need to provide the thumb and the full image 51 | class RemoteImage: AsyncImage { 52 | var thumb: UIImage? 53 | 54 | let fullImageURL: URL 55 | 56 | var tasks: [URLSessionDataTask] = [] 57 | 58 | init(thumb: UIImage?, fullImageURL: String) { 59 | self.thumb = thumb 60 | self.fullImageURL = URL(string: fullImageURL)! 61 | } 62 | 63 | func thumbnail(finishedRetrievingThumbnail: @escaping (UIImage?) -> ()) { 64 | // In this example we already provide the thumb 65 | } 66 | 67 | /// Here we download the full quality image 68 | func full(finishedRetrievingFullImage: @escaping (UIImage?) -> ()) { 69 | let task = URLSession.shared.dataTask(with: fullImageURL) { data, response, error in 70 | guard let data = data, error == nil else { 71 | // If any error occured, calls the callback without an image 72 | finishedRetrievingFullImage(nil) 73 | return 74 | } 75 | 76 | // If the download was succesfull, gives the image to the callback 77 | let downloadedImage = UIImage(data: data) 78 | finishedRetrievingFullImage(downloadedImage) 79 | } 80 | task.resume() 81 | tasks.append(task) 82 | } 83 | 84 | func cancel() { 85 | tasks.forEach { $0.cancel() } 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MediaEditor 2 | 3 | [![CircleCI](https://circleci.com/gh/wordpress-mobile/MediaEditor-iOS.svg?style=svg)](https://circleci.com/gh/wordpress-mobile/MediaEditor-iOS) [![Version](https://img.shields.io/cocoapods/v/MediaEditor.svg?style=flat)](http://cocoadocs.org/docsets/MediaEditor) [![License](https://img.shields.io/cocoapods/l/MediaEditor.svg?style=flat)](http://cocoadocs.org/docsets/MediaEditor) [![Platform](https://img.shields.io/cocoapods/p/MediaEditor.svg?style=flat)](http://cocoadocs.org/docsets/MediaEditor) [![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) 4 | 5 | MediaEditor is an extendable library for iOS that allows you to quickly and easily add image editing features to your app. You can edit single or multiple images, from the device's library or any other source. It has been designed to feel natural and part of the OS. 6 | 7 |

8 | 9 |

10 | 11 | # Features 12 | 13 | - [x] [`PHAsset`](https://developer.apple.com/documentation/photokit/phasset) support 14 | - [x] Editing of Plain `UIImage` 15 | - [x] Editing of remote images 16 | - [x] Single media support 17 | - [x] Multiple media support 18 | - [x] Editing in both portrait and landscape modes 19 | - [x] Cool filters 20 | - [x] Crop, zoom and rotate capability (thanks to [`TOCropViewController`](https://github.com/TimOliver/TOCropViewController)) 21 | - [x] PencilKit support to annotate images 22 | - [x] Easily extendable 23 | - [x] Customizable UI 24 | 25 | ## Usage 26 | 27 | Using `MediaEditor` is very simple, just give it the media and present from a `ViewController`: 28 | 29 | ```swift 30 | let assets: [PHAsset] = [asset1, asset2, asset3] 31 | let mediaEditor = MediaEditor(assets) 32 | mediaEditor.edit(from: self, onFinishEditing: { images, actions in 33 | // images contains the returned images, edited or not 34 | // actions contains the actions made during this session 35 | }, onCancel: { 36 | // User canceled 37 | }) 38 | ``` 39 | 40 | This presents the MediaEditor from the `ViewController` with a callback that is called when the user is finished editing. 41 | 42 | You can easily determine if an image has been edited by checking the `isEdited` property of the objects returned in the `images` array. 43 | 44 | You can initialize the `MediaEditor` with a single or an array of: `PHAsset`, `UIImage` or any other entity that conforms to `AsyncImage`. 45 | 46 | ## More Examples 47 | 48 | Check the Example app for even more ways to use the MediaEditor: 49 | 50 | * Device Library: Edit media from the device library and output them in a `UICollectionView` 51 | * Remote Image: Edit media that is remotely hosted by conforming to the `AsyncImage` protocol and downloading high-quality images only when needed. 52 | * Plain UIImage: Editing plain UIImage's 53 | * Extending the `MediaEditor` capability by adding your own brightness extension 54 | 55 | # Requirements 56 | 57 | * iOS 11.0+ 58 | * Swift 5 59 | 60 | # Installation 61 | 62 | ### Cocoapods 63 | 64 | Add the following to your Podfile: 65 | 66 | ```ruby 67 | pod 'MediaEditor' 68 | ``` 69 | 70 | ### Manual Installation 71 | 72 | 73 | To install manually copy the `Sources/` folder to your project and follow the steps to [manual install `TOCropViewController`](https://github.com/TimOliver/TOCropViewController/blob/master/README.md#installation) too. 74 | 75 | ## Contributing 76 | 77 | Read our [Contributing Guide](CONTRIBUTING.md) to learn about reporting issues, contributing code, and more ways to contribute. 78 | 79 | ## Getting in Touch 80 | 81 | If you have questions about getting setup or just want to say hi, join the [WordPress Slack](https://chat.wordpress.org) and drop a message on the `#mobile` channel. 82 | 83 | ## Author 84 | 85 | WordPress, mobile@automattic.com 86 | 87 | ## License 88 | 89 | MediaEditor is available under the GPL license. See the [LICENSE file](./LICENSE) for more info. 90 | -------------------------------------------------------------------------------- /Example/Image Editing/Device Library/DeviceLibraryViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Photos 3 | import MediaEditor 4 | 5 | class DeviceLibraryViewController: UIViewController { 6 | 7 | @IBOutlet weak var imagesCollectionView: UICollectionView! 8 | @IBOutlet weak var descriptionLabel: UILabel! 9 | 10 | var insertedImages: [AsyncImage] = [] 11 | 12 | override func viewDidLoad() { 13 | super.viewDidLoad() 14 | imagesCollectionView.dataSource = self 15 | imagesCollectionView.delegate = self 16 | } 17 | 18 | @IBAction func edit(_ sender: Any) { 19 | // Load the last 10 Photos 20 | let photos: [PHAsset] = fetchLatestPhotos(limit: 10) 21 | 22 | // Give 'em to the Media Editor 23 | let mediaEditor = MediaEditor(photos) 24 | 25 | // Present the Media Editor 26 | mediaEditor.edit(from: self, onFinishEditing: { images, actions in 27 | self.insertedImages = images 28 | self.imagesCollectionView.reloadData() 29 | self.descriptionLabel.isHidden = true 30 | }, onCancel: { 31 | // User tapped cancel 32 | }) 33 | } 34 | 35 | } 36 | 37 | // MARK: - UICollectionViewDataSource 38 | 39 | extension DeviceLibraryViewController: UICollectionViewDataSource { 40 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 41 | return insertedImages.count 42 | } 43 | 44 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 45 | let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ImageViewCollectionCell.identifier, for: indexPath) as! ImageViewCollectionCell 46 | 47 | // Check if the image was edited 48 | if let editedImage = insertedImages[indexPath.row].editedImage { 49 | cell.configure(image: editedImage, wasEdited: true) 50 | // If it wasn't, load the PHAsset and display it 51 | } else if let phAsset = insertedImages[indexPath.row] as? PHAsset { 52 | loadImage(from: phAsset, into: cell) 53 | } 54 | 55 | return cell 56 | } 57 | } 58 | 59 | // MARK: - Size of the cells 60 | 61 | extension DeviceLibraryViewController: UICollectionViewDelegateFlowLayout { 62 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 63 | let width = (imagesCollectionView.frame.width - 21) / 3 64 | return CGSize(width: width, height: width) 65 | } 66 | } 67 | 68 | // MARK: - PHAsset related methods 69 | 70 | extension DeviceLibraryViewController { 71 | func fetchLatestPhotos(limit: Int) -> [PHAsset] { 72 | var assets: [PHAsset] = [] 73 | 74 | // Create fetch options. 75 | let options = PHFetchOptions() 76 | options.fetchLimit = limit 77 | 78 | // Add sortDescriptor so the lastest photos will be returned. 79 | let sortDescriptor = NSSortDescriptor(key: "creationDate", ascending: false) 80 | options.sortDescriptors = [sortDescriptor] 81 | 82 | // Fetch the photos. 83 | let result = PHAsset.fetchAssets(with: .image, options: options) 84 | 85 | // Add them to an array 86 | result.enumerateObjects { photo, _, _ in 87 | assets.append(photo) 88 | } 89 | 90 | return assets 91 | } 92 | 93 | func loadImage(from asset: PHAsset, into cell: ImageViewCollectionCell) { 94 | let options = PHImageRequestOptions() 95 | options.deliveryMode = .opportunistic 96 | options.version = .current 97 | options.resizeMode = .fast 98 | PHImageManager.default().requestImage(for: asset, targetSize: CGSize(width: asset.pixelWidth, height: asset.pixelHeight), contentMode: .default, options: options) { image, info in 99 | guard let image = image else { 100 | return 101 | } 102 | 103 | cell.configure(image: image, wasEdited: false) 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Tests/Capabilities/Drawing/MediaEditorDrawingTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Nimble 3 | 4 | @testable import MediaEditor 5 | 6 | import XCTest 7 | 8 | @available(iOS 13.0, *) 9 | class MediaEditorDrawingTests: XCTestCase { 10 | 11 | func testName() { 12 | let name = MediaEditorDrawing.name 13 | 14 | expect(name).to(equal("Drawing")) 15 | } 16 | 17 | func testIcon() { 18 | let icon = MediaEditorDrawing.icon 19 | 20 | expect(icon).to(equal(UIImage(systemName: "pencil.tip.crop.circle")!)) 21 | } 22 | 23 | func testApplyStyles() { 24 | let mediaEditorDrawing = MediaEditorDrawing.initialize(UIImage(), onFinishEditing: { _, _ in }, onCancel: {}) 25 | mediaEditorDrawing.loadView() 26 | 27 | let undoIcon = UIImage(systemName: "arrowshape.turn.up.left")! 28 | let redoIcon = UIImage(systemName: "arrowshape.turn.up.right")! 29 | 30 | mediaEditorDrawing.apply(styles: [ 31 | .doneLabel: "foo", 32 | .cancelLabel: "bar", 33 | .cancelColor: UIColor.red, 34 | .undoIcon: undoIcon, 35 | .redoIcon: redoIcon 36 | ]) 37 | 38 | let viewController = mediaEditorDrawing as! MediaEditorDrawing 39 | expect(viewController.doneButton.titleLabel?.text).to(equal("foo")) 40 | expect(viewController.cancelButton.titleLabel?.text).to(equal("bar")) 41 | expect(viewController.cancelButton.tintColor).to(equal(.red)) 42 | expect(viewController.undoButton.tintColor).to(equal(.red)) 43 | expect(viewController.redoButton.tintColor).to(equal(.red)) 44 | expect(viewController.undoButton.image(for: .normal)).to(equal(undoIcon)) 45 | expect(viewController.redoButton.image(for: .normal)).to(equal(redoIcon)) 46 | } 47 | 48 | private let image = UIImage() 49 | 50 | func testIsAMediaEditorCapability() { 51 | let mediaEditorDrawing = MediaEditorDrawing.initialize(image, onFinishEditing: { _, _ in }, onCancel: {}) 52 | 53 | expect(mediaEditorDrawing).to(beAKindOf(MediaEditorCapability.self)) 54 | } 55 | 56 | func testOriginalImageIsReturnedIfNoChangesMade() { 57 | let image = UIImage(systemName: "arrowshape.turn.up.left")! 58 | 59 | var result: UIImage? = nil 60 | let mediaEditorDrawing = MediaEditorDrawing.initialize(image, onFinishEditing: { finishedImage, _ in 61 | result = finishedImage 62 | }, onCancel: {}) as! MediaEditorDrawing 63 | let annotationViewMock = MediaEditorAnnotationViewMock() 64 | 65 | mediaEditorDrawing.loadView() 66 | mediaEditorDrawing.annotationView = annotationViewMock 67 | mediaEditorDrawing.viewDidLoad() 68 | mediaEditorDrawing.done(self) 69 | 70 | expect(image).to(equal(result)) 71 | } 72 | 73 | func testModifiedImageIsReturnedIfChangesAreMade() { 74 | let image = UIImage(systemName: "arrowshape.turn.up.left")! 75 | 76 | var result: UIImage? = nil 77 | let mediaEditorDrawing = MediaEditorDrawing.initialize(image, onFinishEditing: { finishedImage, _ in 78 | result = finishedImage 79 | }, onCancel: {}) as! MediaEditorDrawing 80 | let annotationViewMock = MediaEditorAnnotationViewMock() 81 | annotationViewMock.image = UIImage() 82 | 83 | mediaEditorDrawing.loadView() 84 | mediaEditorDrawing.viewDidLoad() 85 | mediaEditorDrawing.annotationView = annotationViewMock 86 | mediaEditorDrawing.view.setNeedsLayout() 87 | mediaEditorDrawing.view.layoutIfNeeded() 88 | 89 | mediaEditorDrawing.done(self) 90 | 91 | expect(image).notTo(equal(result)) 92 | } 93 | 94 | func testNewEditorHasNoUndoActions() { 95 | let mediaEditorDrawing = MediaEditorDrawing.initialize(UIImage(), onFinishEditing: { _, _ in }, onCancel: {}) as! MediaEditorDrawing 96 | mediaEditorDrawing.loadView() 97 | mediaEditorDrawing.viewDidLoad() 98 | 99 | expect(mediaEditorDrawing.undoButton.isEnabled).to(beFalse()) 100 | expect(mediaEditorDrawing.redoButton.isEnabled).to(beFalse()) 101 | } 102 | } 103 | 104 | @available(iOS 13.0, *) 105 | private class MediaEditorAnnotationViewMock: MediaEditorAnnotationView { 106 | override var canUndo: Bool { 107 | return true 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Sources/Capabilities/Drawing/MediaEditorDrawing.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @available(iOS 13.0, *) 4 | class MediaEditorDrawing: UIViewController { 5 | 6 | @IBOutlet weak var annotationView: MediaEditorAnnotationView! 7 | @IBOutlet weak var cancelButton: UIButton! 8 | @IBOutlet weak var doneButton: UIButton! 9 | @IBOutlet weak var undoButton: UIButton! 10 | @IBOutlet weak var redoButton: UIButton! 11 | 12 | var image: UIImage! 13 | 14 | let context = MediaEditor.ciContext 15 | 16 | var onFinishEditing: ((UIImage, [MediaEditorOperation]) -> ())? 17 | 18 | var onCancel: (() -> ())? 19 | 20 | static func initialize() -> MediaEditorDrawing { 21 | return UIStoryboard( 22 | name: "MediaEditorDrawing", 23 | bundle: Bundle(for: MediaEditorDrawing.self) 24 | ).instantiateViewController(withIdentifier: "drawingViewController") as! MediaEditorDrawing 25 | } 26 | 27 | override func viewDidLoad() { 28 | super.viewDidLoad() 29 | 30 | // Setting images in code to avoid an 'iOS 13 only' warning in the storyboard 31 | undoButton.setImage(UIImage(systemName: "arrow.uturn.left.circle"), for: .normal) 32 | redoButton.setImage(UIImage(systemName: "arrow.uturn.right.circle"), for: .normal) 33 | undoButton.setTitle(nil, for: .normal) 34 | redoButton.setTitle(nil, for: .normal) 35 | 36 | annotationView.undoObserver = self 37 | annotationView.image = image 38 | } 39 | 40 | override func viewWillAppear(_ animated: Bool) { 41 | super.viewWillAppear(animated) 42 | 43 | // Based on Apple's sample PencilKit code from WWDC 2019: https://developer.apple.com/documentation/pencilkit/drawing_with_pencilkit 44 | // Set up the tool picker, using the window of our parent because our view has not 45 | // been added to a window yet. 46 | if let window = parent?.view.window { 47 | annotationView.showTools(in: window) 48 | } 49 | } 50 | 51 | // MARK: - Actions 52 | 53 | @IBAction func cancel(_ sender: Any) { 54 | onCancel?() 55 | } 56 | 57 | @IBAction func done(_ sender: Any) { 58 | guard annotationView.canUndo, 59 | let image = annotationView.image else { 60 | onCancel?() 61 | return 62 | } 63 | 64 | onFinishEditing?(image, [.draw]) 65 | } 66 | } 67 | 68 | @available(iOS 13.0, *) 69 | extension MediaEditorDrawing: MediaEditorCapability { 70 | static var name = "Drawing" 71 | 72 | static var icon = UIImage(systemName: "pencil.tip.crop.circle")! 73 | 74 | static func initialize(_ image: UIImage, onFinishEditing: @escaping (UIImage, [MediaEditorOperation]) -> (), onCancel: @escaping () -> ()) -> CapabilityViewController { 75 | let viewController: MediaEditorDrawing = MediaEditorDrawing.initialize() 76 | viewController.onFinishEditing = onFinishEditing 77 | viewController.onCancel = onCancel 78 | viewController.image = image 79 | return viewController 80 | } 81 | 82 | func apply(styles: MediaEditorStyles) { 83 | if let doneLabel = styles[.doneLabel] as? String { 84 | doneButton.setTitle(doneLabel, for: .normal) 85 | } 86 | 87 | if let cancelLabel = styles[.cancelLabel] as? String { 88 | cancelButton.setTitle(cancelLabel, for: .normal) 89 | } 90 | 91 | if let cancelColor = styles[.cancelColor] as? UIColor { 92 | cancelButton.tintColor = cancelColor 93 | undoButton.tintColor = cancelColor 94 | redoButton.tintColor = cancelColor 95 | } 96 | 97 | if let undoIcon = styles[.undoIcon] as? UIImage { 98 | undoButton.setImage(undoIcon, for: .normal) 99 | } 100 | 101 | if let redoIcon = styles[.redoIcon] as? UIImage { 102 | redoButton.setImage(redoIcon, for: .normal) 103 | } 104 | } 105 | } 106 | 107 | @available(iOS 13.0, *) 108 | extension MediaEditorDrawing: MediaEditorAnnotationViewUndoObserver { 109 | func mediaEditorAnnotationView(_ annotationView: MediaEditorAnnotationView, isHidingUndoControls: Bool) { 110 | let shouldShowCustomControls = !isHidingUndoControls 111 | 112 | undoButton.isHidden = shouldShowCustomControls 113 | redoButton.isHidden = shouldShowCustomControls 114 | } 115 | 116 | func mediaEditorAnnotationViewUndoStatusDidChange(_ view: MediaEditorAnnotationView) { 117 | undoButton.isEnabled = view.canUndo 118 | redoButton.isEnabled = view.canRedo 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Tests/MediaEditorHubTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Nimble 3 | 4 | @testable import MediaEditor 5 | 6 | class MediaEditorHubTests: XCTestCase { 7 | 8 | func testInitializeFromStoryboard() { 9 | let hub: MediaEditorHub = MediaEditorHub.initialize() 10 | 11 | expect(hub).toNot(beNil()) 12 | } 13 | 14 | func testShowImage() { 15 | let hub: MediaEditorHub = MediaEditorHub.initialize() 16 | _ = hub.view 17 | let image = UIImage() 18 | 19 | hub.show(image: image, at: 0) 20 | 21 | let firstImageCell = hub.collectionView(hub.imagesCollectionView, cellForItemAt: IndexPath(row: 0, section: 0)) as? MediaEditorImageCell 22 | expect(firstImageCell?.imageView.image).to(equal(image)) 23 | } 24 | 25 | func testTappingCancelButtonCallsOnCancel() { 26 | var didCallOnCancel = false 27 | let hub: MediaEditorHub = MediaEditorHub.initialize() 28 | _ = hub.view 29 | hub.onCancel = { 30 | didCallOnCancel = true 31 | } 32 | 33 | hub.cancelIconButton.sendActions(for: .touchUpInside) 34 | 35 | expect(didCallOnCancel).to(beTrue()) 36 | } 37 | 38 | func testTappingDoneButtonCallsOnDone() { 39 | var didCallOnDone = false 40 | let hub: MediaEditorHub = MediaEditorHub.initialize() 41 | _ = hub.view 42 | hub.onDone = { 43 | didCallOnDone = true 44 | } 45 | 46 | hub.doneButton.sendActions(for: .touchUpInside) 47 | 48 | expect(didCallOnDone).to(beTrue()) 49 | } 50 | 51 | func testApplyLoadingLabel() { 52 | let hub: MediaEditorHub = MediaEditorHub.initialize() 53 | 54 | hub.apply(styles: [.loadingLabel: "foo"]) 55 | 56 | expect(hub.activityIndicatorLabel.text).to(equal("foo")) 57 | } 58 | 59 | func testApplyErrorLoadingImageLabelIntoImageCell() { 60 | let hub: MediaEditorHub = MediaEditorHub.initialize() 61 | hub.availableThumbs = [0: UIImage()] 62 | 63 | hub.apply(styles: [.errorLoadingImageMessage: "error loading image"]) 64 | 65 | let cell = hub.collectionView(hub.imagesCollectionView, cellForItemAt: IndexPath(row: 0, section: 0)) as? MediaEditorImageCell 66 | expect(cell?.errorLabel.text).to(equal("error loading image")) 67 | } 68 | 69 | 70 | func testShowButtonWithTheCapabilityIcon() { 71 | let hub: MediaEditorHub = MediaEditorHub.initialize() 72 | hub.loadViewIfNeeded() 73 | let icon = UIImage() 74 | 75 | hub.capabilities = [("Foo", icon)] 76 | 77 | let capabilityCell = hub.collectionView(hub.capabilitiesCollectionView, cellForItemAt: IndexPath(row: 0, section: 0)) as? MediaEditorCapabilityCell 78 | expect(capabilityCell?.iconButton.imageView?.image).to(equal(icon)) 79 | } 80 | 81 | func testCallsDelegateWhenCapabilityIsTapped() { 82 | let hub: MediaEditorHub = MediaEditorHub.initialize() 83 | hub.loadViewIfNeeded() 84 | let delegateMock = MediaEditorHubDelegateMock() 85 | hub.delegate = delegateMock 86 | 87 | hub.collectionView(hub.capabilitiesCollectionView, didSelectItemAt: IndexPath(row: 1, section: 0)) 88 | 89 | expect(delegateMock.didCallCapabilityTappedWithIndex).to(equal(1)) 90 | } 91 | 92 | func testShowActivityIndicatorWhenLoadingAnImage() { 93 | let hub: MediaEditorHub = MediaEditorHub.initialize() 94 | hub.loadViewIfNeeded() 95 | 96 | hub.loadingImage(at: 1) 97 | 98 | expect(hub.activityIndicatorView.isHidden).to(beFalse()) 99 | } 100 | 101 | func testDoNotShowActivityIndicatorIfImageIsNotBeingLoaded() { 102 | let hub: MediaEditorHub = MediaEditorHub.initialize() 103 | hub.availableThumbs = [0: UIImage(), 1: UIImage()] 104 | hub.loadViewIfNeeded() 105 | hub.imagesCollectionView.reloadData() 106 | hub.loadingImage(at: 0) 107 | 108 | XCTExpectFailure("We noticed this test failing in https://github.com/wordpress-mobile/MediaEditor-iOS/pull/28 but did not have the bandwidth to fix it") 109 | hub.collectionView(hub.thumbsCollectionView, didSelectItemAt: IndexPath(row: 1, section: 0)) 110 | 111 | expect(hub.activityIndicatorView.isHidden).to(beTrue()) 112 | } 113 | 114 | func testShowActivityIndicatorWhenSwipingToAnImageBeingLoaded() { 115 | let hub: MediaEditorHub = MediaEditorHub.initialize() 116 | hub.availableThumbs = [0: UIImage(), 1: UIImage()] 117 | hub.loadViewIfNeeded() 118 | hub.imagesCollectionView.reloadData() 119 | hub.loadingImage(at: 1) 120 | hub.loadingImage(at: 0) 121 | 122 | XCTExpectFailure("We noticed this test failing in https://github.com/wordpress-mobile/MediaEditor-iOS/pull/28 but did not have the bandwidth to fix it") 123 | hub.collectionView(hub.thumbsCollectionView, didSelectItemAt: IndexPath(row: 1, section: 0)) 124 | 125 | expect(hub.activityIndicatorView.isHidden).to(beFalse()) 126 | } 127 | 128 | func testDisableCapabilitiesWhenImageIsBeingLoaded() { 129 | let hub: MediaEditorHub = MediaEditorHub.initialize() 130 | hub.availableThumbs = [0: UIImage(), 1: UIImage()] 131 | hub.loadViewIfNeeded() 132 | 133 | hub.loadingImage(at: 0) 134 | 135 | expect(hub.capabilitiesCollectionView.isUserInteractionEnabled).to(beFalse()) 136 | } 137 | 138 | func testHideActivityIndicatorWhenImageIsLoaded() { 139 | let hub: MediaEditorHub = MediaEditorHub.initialize() 140 | hub.availableThumbs = [0: UIImage(), 1: UIImage()] 141 | hub.loadViewIfNeeded() 142 | hub.loadingImage(at: 0) 143 | 144 | hub.loadedImage(at: 0) 145 | 146 | expect(hub.activityIndicatorView.isHidden).to(beTrue()) 147 | } 148 | 149 | func testEnableCapabilitiesWhenImageIsLoaded() { 150 | let hub: MediaEditorHub = MediaEditorHub.initialize() 151 | hub.availableThumbs = [0: UIImage(), 1: UIImage()] 152 | hub.loadViewIfNeeded() 153 | hub.loadingImage(at: 0) 154 | 155 | hub.loadedImage(at: 0) 156 | 157 | expect(hub.capabilitiesCollectionView.isUserInteractionEnabled).to(beTrue()) 158 | } 159 | 160 | func testCallRetryDelegate() { 161 | let hub: MediaEditorHub = MediaEditorHub.initialize() 162 | hub.availableThumbs = [0: UIImage(), 1: UIImage()] 163 | hub.loadViewIfNeeded() 164 | let delegateMock = MediaEditorHubDelegateMock() 165 | hub.delegate = delegateMock 166 | 167 | let cell = hub.collectionView(hub.imagesCollectionView, cellForItemAt: IndexPath(row: 0, section: 0)) as? MediaEditorImageCell 168 | cell?.retryButton.sendActions(for: .touchUpInside) 169 | 170 | expect(delegateMock.didCallRetry).to(beTrue()) 171 | } 172 | 173 | } 174 | 175 | private class MediaEditorHubDelegateMock: MediaEditorHubDelegate { 176 | var didCallCapabilityTappedWithIndex: Int? 177 | var didCallRetry = false 178 | 179 | func capabilityTapped(_ index: Int) { 180 | didCallCapabilityTappedWithIndex = index 181 | } 182 | 183 | func retry() { 184 | didCallRetry = true 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /Sources/Capabilities/Filters/MediaEditorFilters.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | struct MediaEditorFilter { 4 | let name: String 5 | let ciFilterName: String 6 | } 7 | 8 | class MediaEditorFilters: UIViewController { 9 | @IBOutlet weak var imageView: UIImageView! 10 | @IBOutlet weak var filtersCollectionView: UICollectionView! 11 | @IBOutlet weak var cancelButton: UIButton! 12 | @IBOutlet weak var doneButton: UIButton! 13 | 14 | var image: UIImage! 15 | 16 | lazy var thumbImage: UIImage = { 17 | let size = Constant.thumbWidth * UIScreen.main.scale 18 | return image.fit(size: CGSize(width: size, height: size)) 19 | }() 20 | 21 | var filters: [MediaEditorFilter] { 22 | return [ 23 | MediaEditorFilter( 24 | name: "None", 25 | ciFilterName: "" 26 | ), 27 | MediaEditorFilter( 28 | name: "Sepia", 29 | ciFilterName: "CISepiaTone" 30 | ), 31 | MediaEditorFilter( 32 | name: "Mono", 33 | ciFilterName: "CIPhotoEffectMono" 34 | ), 35 | MediaEditorFilter( 36 | name: "Noir", 37 | ciFilterName: "CIPhotoEffectNoir" 38 | ), 39 | MediaEditorFilter( 40 | name: "Vintage", 41 | ciFilterName: "CIPhotoEffectProcess" 42 | ), 43 | MediaEditorFilter( 44 | name: "Tonal", 45 | ciFilterName: "CIPhotoEffectTonal" 46 | ), 47 | MediaEditorFilter( 48 | name: "Transfer", 49 | ciFilterName: "CIPhotoEffectTransfer" 50 | ), 51 | MediaEditorFilter( 52 | name: "Chrome", 53 | ciFilterName: "CIPhotoEffectChrome" 54 | ), 55 | MediaEditorFilter( 56 | name: "Fade", 57 | ciFilterName: "CIPhotoEffectFade" 58 | ), 59 | MediaEditorFilter( 60 | name: "Instant", 61 | ciFilterName: "CIPhotoEffectInstant" 62 | ) 63 | ] 64 | } 65 | 66 | let context = MediaEditor.ciContext 67 | 68 | var onFinishEditing: ((UIImage, [MediaEditorOperation]) -> ())? 69 | 70 | var onCancel: (() -> ())? 71 | 72 | private var selectedFilterIndex = IndexPath(row: 0, section: 0) 73 | 74 | override func viewDidLoad() { 75 | super.viewDidLoad() 76 | imageView.image = image 77 | filtersCollectionView.dataSource = self 78 | filtersCollectionView.delegate = self 79 | } 80 | 81 | @IBAction func cancel(_ sender: Any) { 82 | onCancel?() 83 | } 84 | 85 | @IBAction func done(_ sender: Any) { 86 | guard selectedFilterIndex.row > 0 else { 87 | onCancel?() 88 | return 89 | } 90 | 91 | guard let ciImage = imageView.image?.ciImage, let cgImage = context.createCGImage(ciImage, from: ciImage.extent) else { 92 | onCancel?() 93 | return 94 | } 95 | 96 | onFinishEditing?(UIImage(cgImage: cgImage), [.filter]) 97 | } 98 | 99 | func sepiaFilter(_ input: CIImage, intensity: Double) -> CIImage? { 100 | let sepiaFilter = CIFilter(name:"CISepiaTone") 101 | sepiaFilter?.setValue(input, forKey: kCIInputImageKey) 102 | sepiaFilter?.setValue(intensity, forKey: kCIInputIntensityKey) 103 | return sepiaFilter?.outputImage 104 | } 105 | 106 | func filter(_ image: UIImage, name: String) -> UIImage { 107 | guard let ciImage = CIImage(image: image) else { 108 | return image 109 | } 110 | 111 | let sepiaFilter = CIFilter(name: name) 112 | sepiaFilter?.setValue(ciImage, forKey: kCIInputImageKey) 113 | 114 | guard let outputImage = sepiaFilter?.outputImage else { 115 | return image 116 | } 117 | return UIImage(ciImage: outputImage) 118 | } 119 | 120 | static func initialize() -> MediaEditorFilters { 121 | return UIStoryboard( 122 | name: "MediaEditorFilters", 123 | bundle: Bundle(for: MediaEditorFilters.self) 124 | ).instantiateViewController(withIdentifier: "filtersViewController") as! MediaEditorFilters 125 | } 126 | 127 | private enum Constant { 128 | static var thumbWidth: CGFloat = 64 129 | static var filterCellWidth: CGFloat = 71 130 | static var filterCellHeight: CGFloat = 93 131 | } 132 | } 133 | 134 | extension MediaEditorFilters: MediaEditorCapability { 135 | static var name = "Filters" 136 | 137 | static var icon = UIImage(named: "filters", in: .mediaEditor, compatibleWith: nil)! 138 | 139 | static func initialize(_ image: UIImage, onFinishEditing: @escaping (UIImage, [MediaEditorOperation]) -> (), onCancel: @escaping () -> ()) -> CapabilityViewController { 140 | let viewController: MediaEditorFilters = MediaEditorFilters.initialize() 141 | viewController.onFinishEditing = onFinishEditing 142 | viewController.onCancel = onCancel 143 | viewController.image = image 144 | return viewController 145 | } 146 | 147 | func apply(styles: MediaEditorStyles) { 148 | if let doneLabel = styles[.doneLabel] as? String { 149 | doneButton.setTitle(doneLabel, for: .normal) 150 | } 151 | 152 | if let cancelLabel = styles[.cancelLabel] as? String { 153 | cancelButton.setTitle(cancelLabel, for: .normal) 154 | } 155 | 156 | if let cancelColor = styles[.cancelColor] as? UIColor { 157 | cancelButton.tintColor = cancelColor 158 | } 159 | } 160 | } 161 | 162 | // MARK: - UICollectionViewDataSource 163 | 164 | extension MediaEditorFilters: UICollectionViewDataSource { 165 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 166 | return filters.count 167 | } 168 | 169 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 170 | let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "filterCell", for: indexPath) as! MediaEditorFilterCell 171 | 172 | cell.configure(image: filter(thumbImage, name: filters[indexPath.row].ciFilterName), title: filters[indexPath.row].name) 173 | 174 | if indexPath == selectedFilterIndex { 175 | cell.showBorder() 176 | } else { 177 | cell.hideBorder() 178 | } 179 | 180 | return cell 181 | } 182 | } 183 | 184 | // MARK: - UICollectionViewDelegate 185 | 186 | extension MediaEditorFilters: UICollectionViewDelegate { 187 | func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 188 | (collectionView.cellForItem(at: selectedFilterIndex) as? MediaEditorFilterCell)?.hideBorder() 189 | (collectionView.cellForItem(at: indexPath) as? MediaEditorFilterCell)?.showBorder() 190 | selectedFilterIndex = indexPath 191 | imageView.image = filter(image, name: filters[indexPath.row].ciFilterName) 192 | } 193 | } 194 | 195 | // MARK: - UICollectionViewDelegateFlowLayout 196 | 197 | extension MediaEditorFilters: UICollectionViewDelegateFlowLayout { 198 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 199 | return CGSize(width: Constant.filterCellWidth, height: Constant.filterCellHeight) 200 | } 201 | } 202 | 203 | -------------------------------------------------------------------------------- /Sources/Capabilities/Drawing/MediaEditorAnnotationView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import AVFoundation 3 | import PencilKit 4 | 5 | @available(iOS 13.0, *) 6 | protocol MediaEditorAnnotationViewUndoObserver: NSObject { 7 | func mediaEditorAnnotationView(_ annotationView: MediaEditorAnnotationView, isHidingUndoControls: Bool) 8 | func mediaEditorAnnotationViewUndoStatusDidChange(_ view: MediaEditorAnnotationView) 9 | } 10 | 11 | /// Wrapper view that contains an image view and a PencilKit canvas to allow 12 | /// drawing on top of the image. 13 | /// 14 | @available(iOS 13.0, *) 15 | class MediaEditorAnnotationView: UIView { 16 | 17 | private let imageView = UIImageView() 18 | private let canvasView = PKCanvasView() 19 | 20 | private var bottomConstraint: NSLayoutConstraint! 21 | 22 | weak var undoObserver: MediaEditorAnnotationViewUndoObserver? 23 | 24 | var canUndo: Bool { 25 | return canvasView.undoManager?.canUndo ?? false 26 | } 27 | 28 | var canRedo: Bool { 29 | return canvasView.undoManager?.canRedo ?? false 30 | } 31 | 32 | var image: UIImage? { 33 | set { 34 | imageView.image = newValue 35 | } 36 | get { 37 | return renderedImage 38 | } 39 | } 40 | 41 | // Primarily for testing purposes 42 | var drawingData: Data { 43 | set { 44 | do { 45 | canvasView.drawing = try PKDrawing(data: newValue) 46 | } catch { 47 | print("Error setting annotation view drawing data.") 48 | } 49 | } 50 | get { 51 | return canvasView.drawing.dataRepresentation() 52 | } 53 | } 54 | 55 | // MARK: - Initialization 56 | 57 | override init(frame: CGRect) { 58 | super.init(frame: frame) 59 | commonInit() 60 | } 61 | 62 | required init?(coder: NSCoder) { 63 | super.init(coder: coder) 64 | commonInit() 65 | } 66 | 67 | deinit { 68 | undoObserver = nil 69 | 70 | NotificationCenter.default.removeObserver(self, 71 | name: NSNotification.Name.NSUndoManagerCheckpoint, 72 | object: canvasView.undoManager) 73 | } 74 | 75 | private func commonInit() { 76 | configureImageView() 77 | configureCanvasView() 78 | } 79 | 80 | private func configureImageView() { 81 | addSubview(imageView) 82 | imageView.translatesAutoresizingMaskIntoConstraints = false 83 | 84 | imageView.contentMode = .scaleAspectFit 85 | 86 | bottomConstraint = imageView.bottomAnchor.constraint(equalTo: bottomAnchor) 87 | 88 | NSLayoutConstraint.activate([ 89 | imageView.leadingAnchor.constraint(equalTo: leadingAnchor), 90 | imageView.trailingAnchor.constraint(equalTo: trailingAnchor), 91 | imageView.topAnchor.constraint(equalTo: topAnchor), 92 | bottomConstraint 93 | ]) 94 | } 95 | 96 | private func configureCanvasView() { 97 | addSubview(canvasView) 98 | 99 | canvasView.backgroundColor = .clear 100 | canvasView.isOpaque = false 101 | 102 | // Ensure ink remains the same color regardless of light / dark mode 103 | canvasView.overrideUserInterfaceStyle = .light 104 | 105 | NotificationCenter.default.addObserver(forName: NSNotification.Name.NSUndoManagerCheckpoint, object: canvasView.undoManager, queue: nil) { [weak self] _ in 106 | self?.notifyUndoObserver() 107 | } 108 | } 109 | 110 | fileprivate func notifyUndoObserver() { 111 | undoObserver?.mediaEditorAnnotationViewUndoStatusDidChange(self) 112 | } 113 | 114 | // MARK: - View Layout 115 | 116 | override func layoutSubviews() { 117 | super.layoutSubviews() 118 | 119 | let currentFrame = canvasView.frame 120 | let newFrame = calculateCanvasFrame() 121 | canvasView.frame = newFrame 122 | 123 | // If the canvas has changed size (e.g. due to device rotation) apply a transform 124 | // to the drawing so that it still fits the scaled imageview 125 | let transform = CGAffineTransform(scaleX: newFrame.width / currentFrame.width, y: newFrame.height / currentFrame.height) 126 | self.canvasView.drawing.transform(using: transform) 127 | } 128 | 129 | private func calculateCanvasFrame() -> CGRect { 130 | guard let image = imageView.image, 131 | imageView.contentMode == .scaleAspectFit, 132 | image.size.width > 0 && image.size.height > 0 else { 133 | return imageView.bounds 134 | } 135 | 136 | let size = AVMakeRect(aspectRatio: image.size, insideRect: imageView.bounds) 137 | 138 | let x = (imageView.bounds.width - size.width) * 0.5 139 | let y = (imageView.bounds.height - size.height) * 0.5 140 | 141 | return CGRect(x: x, y: y, width: size.width, height: size.height) 142 | } 143 | 144 | // MARK: - Public methods 145 | 146 | /// Displays the system tool picker in the specified window 147 | /// 148 | func showTools(in window: UIWindow) { 149 | if let toolPicker = PKToolPicker.shared(for: window) { 150 | toolPicker.setVisible(true, forFirstResponder: canvasView) 151 | toolPicker.addObserver(canvasView) 152 | toolPicker.addObserver(self) 153 | 154 | canvasView.becomeFirstResponder() 155 | updateLayout(for: toolPicker) 156 | } 157 | } 158 | 159 | /// Renders the initial image with the canvas's image overlaid on top 160 | /// into a single UIImage instance. 161 | /// 162 | private var renderedImage: UIImage? { 163 | guard let imageViewImage = imageView.image else { 164 | return nil 165 | } 166 | 167 | guard canvasView.bounds != .zero else { 168 | return imageViewImage 169 | } 170 | 171 | // Check we actually have some changes 172 | if let undoManager = canvasView.undoManager, 173 | undoManager.canUndo == false { 174 | return imageViewImage 175 | } 176 | 177 | let targetSize = imageViewImage.size 178 | 179 | let canvasViewImage = canvasView.drawing.image(from: canvasView.bounds, scale: UIScreen.main.scale) 180 | 181 | let format = UIGraphicsImageRendererFormat() 182 | format.scale = 1 183 | 184 | let renderer = UIGraphicsImageRenderer(size: targetSize, format: format) 185 | let renderedImage = renderer.image { context in 186 | imageViewImage.draw(at: .zero) 187 | canvasViewImage.draw(in: CGRect(origin: .zero, size: targetSize)) 188 | } 189 | 190 | return renderedImage 191 | } 192 | } 193 | 194 | // Note: Code in this extension reused from WWDC 2019 PencilKit example 195 | // 196 | @available(iOS 13.0, *) 197 | extension MediaEditorAnnotationView: PKToolPickerObserver { 198 | // MARK: Tool Picker Observer 199 | 200 | /// Delegate method: Note that the tool picker has changed which part of the canvas view 201 | /// it obscures, if any. 202 | internal func toolPickerFramesObscuredDidChange(_ toolPicker: PKToolPicker) { 203 | updateLayout(for: toolPicker) 204 | } 205 | 206 | /// Delegate method: Note that the tool picker has become visible or hidden. 207 | internal func toolPickerVisibilityDidChange(_ toolPicker: PKToolPicker) { 208 | updateLayout(for: toolPicker) 209 | } 210 | 211 | /// Helper method to adjust the canvas view size when the tool picker changes which part 212 | /// of the canvas view it obscures, if any. 213 | /// 214 | /// Note that the tool picker floats over the canvas in regular size classes, but docks to 215 | /// the canvas in compact size classes, occupying a part of the screen that the canvas 216 | /// could otherwise use. 217 | fileprivate func updateLayout(for toolPicker: PKToolPicker) { 218 | let obscuredFrame = toolPicker.frameObscured(in: self) 219 | 220 | if obscuredFrame.isNull { 221 | bottomConstraint.constant = 0 222 | undoObserver?.mediaEditorAnnotationView(self, isHidingUndoControls: false) 223 | } else { 224 | bottomConstraint.constant = -obscuredFrame.height 225 | undoObserver?.mediaEditorAnnotationView(self, isHidingUndoControls: true) 226 | } 227 | 228 | setNeedsLayout() 229 | layoutIfNeeded() 230 | 231 | canvasView.scrollIndicatorInsets = canvasView.contentInset 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /Example/Extending/BrightnessCapability/BrightnessCapability.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 52 | 53 | 54 | 55 | 56 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.7) 5 | base64 6 | nkf 7 | rexml 8 | activesupport (6.1.7) 9 | concurrent-ruby (~> 1.0, >= 1.0.2) 10 | i18n (>= 1.6, < 2) 11 | minitest (>= 5.1) 12 | tzinfo (~> 2.0) 13 | zeitwerk (~> 2.3) 14 | addressable (2.8.7) 15 | public_suffix (>= 2.0.2, < 7.0) 16 | algoliasearch (1.27.5) 17 | httpclient (~> 2.8, >= 2.8.3) 18 | json (>= 1.5.1) 19 | artifactory (3.0.17) 20 | ast (2.4.3) 21 | atomos (0.1.3) 22 | aws-eventstream (1.4.0) 23 | aws-partitions (1.1118.0) 24 | aws-sdk-core (3.226.0) 25 | aws-eventstream (~> 1, >= 1.3.0) 26 | aws-partitions (~> 1, >= 1.992.0) 27 | aws-sigv4 (~> 1.9) 28 | base64 29 | jmespath (~> 1, >= 1.6.1) 30 | logger 31 | aws-sdk-kms (1.105.0) 32 | aws-sdk-core (~> 3, >= 3.225.0) 33 | aws-sigv4 (~> 1.5) 34 | aws-sdk-s3 (1.190.0) 35 | aws-sdk-core (~> 3, >= 3.225.0) 36 | aws-sdk-kms (~> 1) 37 | aws-sigv4 (~> 1.5) 38 | aws-sigv4 (1.12.1) 39 | aws-eventstream (~> 1, >= 1.0.2) 40 | babosa (1.0.4) 41 | base64 (0.3.0) 42 | claide (1.1.0) 43 | cocoapods (1.11.3) 44 | addressable (~> 2.8) 45 | claide (>= 1.0.2, < 2.0) 46 | cocoapods-core (= 1.11.3) 47 | cocoapods-deintegrate (>= 1.0.3, < 2.0) 48 | cocoapods-downloader (>= 1.4.0, < 2.0) 49 | cocoapods-plugins (>= 1.0.0, < 2.0) 50 | cocoapods-search (>= 1.0.0, < 2.0) 51 | cocoapods-trunk (>= 1.4.0, < 2.0) 52 | cocoapods-try (>= 1.1.0, < 2.0) 53 | colored2 (~> 3.1) 54 | escape (~> 0.0.4) 55 | fourflusher (>= 2.3.0, < 3.0) 56 | gh_inspector (~> 1.0) 57 | molinillo (~> 0.8.0) 58 | nap (~> 1.0) 59 | ruby-macho (>= 1.0, < 3.0) 60 | xcodeproj (>= 1.21.0, < 2.0) 61 | cocoapods-core (1.11.3) 62 | activesupport (>= 5.0, < 7) 63 | addressable (~> 2.8) 64 | algoliasearch (~> 1.0) 65 | concurrent-ruby (~> 1.1) 66 | fuzzy_match (~> 2.0.4) 67 | nap (~> 1.0) 68 | netrc (~> 0.11) 69 | public_suffix (~> 4.0) 70 | typhoeus (~> 1.0) 71 | cocoapods-deintegrate (1.0.5) 72 | cocoapods-downloader (1.6.3) 73 | cocoapods-plugins (1.0.0) 74 | nap 75 | cocoapods-search (1.0.1) 76 | cocoapods-trunk (1.6.0) 77 | nap (>= 0.8, < 2.0) 78 | netrc (~> 0.11) 79 | cocoapods-try (1.2.0) 80 | colored (1.2) 81 | colored2 (3.1.2) 82 | commander (4.6.0) 83 | highline (~> 2.0.0) 84 | concurrent-ruby (1.1.10) 85 | declarative (0.0.20) 86 | digest-crc (0.7.0) 87 | rake (>= 12.0.0, < 14.0.0) 88 | domain_name (0.6.20240107) 89 | dotenv (2.8.1) 90 | emoji_regex (3.2.3) 91 | escape (0.0.4) 92 | ethon (0.15.0) 93 | ffi (>= 1.15.0) 94 | excon (0.112.0) 95 | faraday (1.10.4) 96 | faraday-em_http (~> 1.0) 97 | faraday-em_synchrony (~> 1.0) 98 | faraday-excon (~> 1.1) 99 | faraday-httpclient (~> 1.0) 100 | faraday-multipart (~> 1.0) 101 | faraday-net_http (~> 1.0) 102 | faraday-net_http_persistent (~> 1.0) 103 | faraday-patron (~> 1.0) 104 | faraday-rack (~> 1.0) 105 | faraday-retry (~> 1.0) 106 | ruby2_keywords (>= 0.0.4) 107 | faraday-cookie_jar (0.0.7) 108 | faraday (>= 0.8.0) 109 | http-cookie (~> 1.0.0) 110 | faraday-em_http (1.0.0) 111 | faraday-em_synchrony (1.0.1) 112 | faraday-excon (1.1.0) 113 | faraday-httpclient (1.0.1) 114 | faraday-multipart (1.1.1) 115 | multipart-post (~> 2.0) 116 | faraday-net_http (1.0.2) 117 | faraday-net_http_persistent (1.2.0) 118 | faraday-patron (1.0.0) 119 | faraday-rack (1.0.0) 120 | faraday-retry (1.0.3) 121 | faraday_middleware (1.2.1) 122 | faraday (~> 1.0) 123 | fastimage (2.4.0) 124 | fastlane (2.228.0) 125 | CFPropertyList (>= 2.3, < 4.0.0) 126 | addressable (>= 2.8, < 3.0.0) 127 | artifactory (~> 3.0) 128 | aws-sdk-s3 (~> 1.0) 129 | babosa (>= 1.0.3, < 2.0.0) 130 | bundler (>= 1.12.0, < 3.0.0) 131 | colored (~> 1.2) 132 | commander (~> 4.6) 133 | dotenv (>= 2.1.1, < 3.0.0) 134 | emoji_regex (>= 0.1, < 4.0) 135 | excon (>= 0.71.0, < 1.0.0) 136 | faraday (~> 1.0) 137 | faraday-cookie_jar (~> 0.0.6) 138 | faraday_middleware (~> 1.0) 139 | fastimage (>= 2.1.0, < 3.0.0) 140 | fastlane-sirp (>= 1.0.0) 141 | gh_inspector (>= 1.1.2, < 2.0.0) 142 | google-apis-androidpublisher_v3 (~> 0.3) 143 | google-apis-playcustomapp_v1 (~> 0.1) 144 | google-cloud-env (>= 1.6.0, < 2.0.0) 145 | google-cloud-storage (~> 1.31) 146 | highline (~> 2.0) 147 | http-cookie (~> 1.0.5) 148 | json (< 3.0.0) 149 | jwt (>= 2.1.0, < 3) 150 | mini_magick (>= 4.9.4, < 5.0.0) 151 | multipart-post (>= 2.0.0, < 3.0.0) 152 | naturally (~> 2.2) 153 | optparse (>= 0.1.1, < 1.0.0) 154 | plist (>= 3.1.0, < 4.0.0) 155 | rubyzip (>= 2.0.0, < 3.0.0) 156 | security (= 0.1.5) 157 | simctl (~> 1.6.3) 158 | terminal-notifier (>= 2.0.0, < 3.0.0) 159 | terminal-table (~> 3) 160 | tty-screen (>= 0.6.3, < 1.0.0) 161 | tty-spinner (>= 0.8.0, < 1.0.0) 162 | word_wrap (~> 1.0.0) 163 | xcodeproj (>= 1.13.0, < 2.0.0) 164 | xcpretty (~> 0.4.1) 165 | xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) 166 | fastlane-sirp (1.0.0) 167 | sysrandom (~> 1.0) 168 | ffi (1.15.5) 169 | fourflusher (2.3.1) 170 | fuzzy_match (2.0.4) 171 | gh_inspector (1.1.3) 172 | google-apis-androidpublisher_v3 (0.54.0) 173 | google-apis-core (>= 0.11.0, < 2.a) 174 | google-apis-core (0.11.3) 175 | addressable (~> 2.5, >= 2.5.1) 176 | googleauth (>= 0.16.2, < 2.a) 177 | httpclient (>= 2.8.1, < 3.a) 178 | mini_mime (~> 1.0) 179 | representable (~> 3.0) 180 | retriable (>= 2.0, < 4.a) 181 | rexml 182 | google-apis-iamcredentials_v1 (0.17.0) 183 | google-apis-core (>= 0.11.0, < 2.a) 184 | google-apis-playcustomapp_v1 (0.13.0) 185 | google-apis-core (>= 0.11.0, < 2.a) 186 | google-apis-storage_v1 (0.31.0) 187 | google-apis-core (>= 0.11.0, < 2.a) 188 | google-cloud-core (1.8.0) 189 | google-cloud-env (>= 1.0, < 3.a) 190 | google-cloud-errors (~> 1.0) 191 | google-cloud-env (1.6.0) 192 | faraday (>= 0.17.3, < 3.0) 193 | google-cloud-errors (1.5.0) 194 | google-cloud-storage (1.47.0) 195 | addressable (~> 2.8) 196 | digest-crc (~> 0.4) 197 | google-apis-iamcredentials_v1 (~> 0.1) 198 | google-apis-storage_v1 (~> 0.31.0) 199 | google-cloud-core (~> 1.6) 200 | googleauth (>= 0.16.2, < 2.a) 201 | mini_mime (~> 1.0) 202 | googleauth (1.8.1) 203 | faraday (>= 0.17.3, < 3.a) 204 | jwt (>= 1.4, < 3.0) 205 | multi_json (~> 1.11) 206 | os (>= 0.9, < 2.0) 207 | signet (>= 0.16, < 2.a) 208 | highline (2.0.3) 209 | http-cookie (1.0.8) 210 | domain_name (~> 0.5) 211 | httpclient (2.9.0) 212 | mutex_m 213 | i18n (1.12.0) 214 | concurrent-ruby (~> 1.0) 215 | jmespath (1.6.2) 216 | json (2.12.2) 217 | jwt (2.10.1) 218 | base64 219 | logger (1.7.0) 220 | mini_magick (4.13.2) 221 | mini_mime (1.1.5) 222 | minitest (5.16.3) 223 | molinillo (0.8.0) 224 | multi_json (1.15.0) 225 | multipart-post (2.4.1) 226 | mutex_m (0.3.0) 227 | nanaimo (0.4.0) 228 | nap (1.1.0) 229 | naturally (2.3.0) 230 | netrc (0.11.0) 231 | nkf (0.2.0) 232 | optparse (0.6.0) 233 | os (1.1.4) 234 | parallel (1.27.0) 235 | parser (3.3.8.0) 236 | ast (~> 2.4.1) 237 | racc 238 | plist (3.7.2) 239 | prism (1.4.0) 240 | public_suffix (4.0.7) 241 | racc (1.8.1) 242 | rainbow (3.1.1) 243 | rake (13.3.0) 244 | regexp_parser (2.10.0) 245 | representable (3.2.0) 246 | declarative (< 0.1.0) 247 | trailblazer-option (>= 0.1.1, < 0.2.0) 248 | uber (< 0.2.0) 249 | retriable (3.1.2) 250 | rexml (3.4.1) 251 | rouge (3.28.0) 252 | rubocop (1.42.0) 253 | json (~> 2.3) 254 | parallel (~> 1.10) 255 | parser (>= 3.1.2.1) 256 | rainbow (>= 2.2.2, < 4.0) 257 | regexp_parser (>= 1.8, < 3.0) 258 | rexml (>= 3.2.5, < 4.0) 259 | rubocop-ast (>= 1.24.1, < 2.0) 260 | ruby-progressbar (~> 1.7) 261 | unicode-display_width (>= 1.4.0, < 3.0) 262 | rubocop-ast (1.45.1) 263 | parser (>= 3.3.7.2) 264 | prism (~> 1.4) 265 | ruby-macho (2.5.1) 266 | ruby-progressbar (1.13.0) 267 | ruby2_keywords (0.0.5) 268 | rubyzip (2.4.1) 269 | security (0.1.5) 270 | signet (0.20.0) 271 | addressable (~> 2.8) 272 | faraday (>= 0.17.5, < 3.a) 273 | jwt (>= 1.5, < 3.0) 274 | multi_json (~> 1.10) 275 | simctl (1.6.10) 276 | CFPropertyList 277 | naturally 278 | sysrandom (1.0.5) 279 | terminal-notifier (2.0.0) 280 | terminal-table (3.0.2) 281 | unicode-display_width (>= 1.1.1, < 3) 282 | trailblazer-option (0.1.2) 283 | tty-cursor (0.7.1) 284 | tty-screen (0.8.2) 285 | tty-spinner (0.9.3) 286 | tty-cursor (~> 0.7) 287 | typhoeus (1.4.0) 288 | ethon (>= 0.9.0) 289 | tzinfo (2.0.5) 290 | concurrent-ruby (~> 1.0) 291 | uber (0.1.0) 292 | unicode-display_width (2.6.0) 293 | word_wrap (1.0.0) 294 | xcodeproj (1.27.0) 295 | CFPropertyList (>= 2.3.3, < 4.0) 296 | atomos (~> 0.1.3) 297 | claide (>= 1.0.2, < 2.0) 298 | colored2 (~> 3.1) 299 | nanaimo (~> 0.4.0) 300 | rexml (>= 3.3.6, < 4.0) 301 | xcpretty (0.4.1) 302 | rouge (~> 3.28.0) 303 | xcpretty-travis-formatter (1.0.1) 304 | xcpretty (~> 0.2, >= 0.0.7) 305 | zeitwerk (2.6.1) 306 | 307 | PLATFORMS 308 | ruby 309 | 310 | DEPENDENCIES 311 | cocoapods (~> 1.11) 312 | fastlane (~> 2.189) 313 | rubocop (~> 1.18) 314 | 315 | BUNDLED WITH 316 | 2.3.22 317 | -------------------------------------------------------------------------------- /Sources/Capabilities/Drawing/MediaEditorDrawing.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 60 | 72 | 73 | 74 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /Sources/MediaEditor.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /** 4 | An object that manages editing media. 5 | 6 | You can start the MediaEditor with a single or an array of `UIImage` or `PHAsset` out of the box. 7 | Also, you can give any other object as long it conforms to the `AsyncImage` protocol. 8 | 9 | # UINavigationController 10 | Since each capability has it's own (or is a) View Controller, the Media Editor is also a Navigation Controller that presents them. 11 | And by being a ViewController, this allows it to be custom presented. 12 | */ 13 | open class MediaEditor: UINavigationController { 14 | /// The capabilities are displayed in the Media Editor. You can add your own capabilities here. 15 | public static var capabilities: [MediaEditorCapability.Type] = { 16 | var capabilities: [MediaEditorCapability.Type] = [MediaEditorFilters.self, MediaEditorCropZoomRotate.self] 17 | 18 | if #available(iOS 13.0, *) { 19 | capabilities.insert(MediaEditorDrawing.self, at: 0) 20 | } 21 | 22 | return capabilities 23 | }() 24 | 25 | /// A CIContext to be shared among capabilities. If your app already has one, you can assign it here. 26 | public static var ciContext = CIContext() 27 | 28 | /// The ViewController that shows thumbnails and capabilities 29 | public var hub: MediaEditorHub = { 30 | let hub: MediaEditorHub = MediaEditorHub.initialize() 31 | hub.loadViewIfNeeded() 32 | return hub 33 | }() 34 | 35 | /// Callback that is called after the user finishes editing images. It gives all the images and the operations made. 36 | public var onFinishEditing: (([AsyncImage], [MediaEditorOperation]) -> ())? 37 | 38 | /// Callback called when the user exists 39 | public var onCancel: (() -> ())? 40 | 41 | /// A dictionary that returns all the available images UIImages. The key is the index of the image. 42 | public private(set) var images: [Int: UIImage] = [:] 43 | 44 | /// All the async images that are being displayed. 45 | public private(set) var asyncImages: [AsyncImage] = [] 46 | 47 | /// Indexes of the images that were edited. 48 | public private(set) var editedImagesIndexes: Set = [] 49 | 50 | /// The actions that were made in this session. 51 | public private(set) var actions: [MediaEditorOperation] = [] 52 | 53 | /// Returns which MediaEditorCapability is being displayed. 54 | public private(set) var currentCapability: CapabilityViewController? 55 | 56 | /// A Boolean value indicating whether the Media Editor is being used to edit plain UIImages 57 | public private(set) var isEditingPlainUIImages = false 58 | 59 | /// The index of the last capability tapped. 60 | public private(set) var lastTappedCapabilityIndex = 0 61 | 62 | /// A Boolean value indicating whether the Media Editor has just a single image and single capability. 63 | public var isSingleImageAndCapability: Bool { 64 | return ((asyncImages.count == 1) || (images.count == 1 && asyncImages.count == 0)) && Self.capabilities.count == 1 65 | } 66 | 67 | /// The index of the image that is currently being displayed or being edited. 68 | public var selectedImageIndex: Int { 69 | return hub.selectedThumbIndex 70 | } 71 | 72 | /// A Dictionary of the styles to be applied in the Media Editor 73 | open var styles: MediaEditorStyles = [:] { 74 | didSet { 75 | currentCapability?.apply(styles: styles) 76 | hub.apply(styles: styles) 77 | } 78 | } 79 | 80 | /// A initializer for a single UIImage. 81 | /// 82 | /// Use this method to initialize the Media Editor with a single plain UIImage. 83 | /// - Parameter image: `UIImage` to be displayed 84 | /// 85 | public init(_ image: UIImage) { 86 | self.images = [0: image] 87 | super.init(nibName: nil, bundle: nil) 88 | viewControllers = [hub] 89 | setup() 90 | } 91 | 92 | /// A initializer for an array of UIImage. 93 | /// 94 | /// Use this method to initialize the Media Editor with multiple UIImage. 95 | /// - Parameter images: `[UIImage]` to be displayed 96 | /// 97 | public init(_ images: [UIImage]) { 98 | self.images = images.enumerated().reduce(into: [:]) { $0[$1.offset] = $1.element } 99 | super.init(nibName: nil, bundle: nil) 100 | viewControllers = [hub] 101 | setup() 102 | } 103 | 104 | /// A initializer for a single AsyncImage. 105 | /// 106 | /// Use this method to initialize the Media Editor with a single AsyncImage. 107 | /// - Parameter asyncImage: `AsyncImage` to be displayed 108 | /// - Note: This method accepts PHAsset out of the box 109 | /// 110 | public init(_ asyncImage: AsyncImage) { 111 | self.asyncImages.append(asyncImage) 112 | super.init(nibName: nil, bundle: nil) 113 | viewControllers = [hub] 114 | setup() 115 | } 116 | 117 | /// A initializer for an array of [AsyncImage]. 118 | /// 119 | /// Use this method to initialize the Media Editor with multiple AsyncImage. 120 | /// - Parameter asyncImages: `[AsyncImage]` to be displayed 121 | /// - Note: This method accepts [PHAsset] out of the box 122 | /// 123 | public init(_ asyncImages: [AsyncImage]) { 124 | self.asyncImages = asyncImages 125 | super.init(nibName: nil, bundle: nil) 126 | viewControllers = [hub] 127 | setup() 128 | } 129 | 130 | required public init?(coder aDecoder: NSCoder) { 131 | super.init(coder: aDecoder) 132 | } 133 | 134 | public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { 135 | super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) 136 | } 137 | 138 | open override func viewDidLoad() { 139 | super.viewDidLoad() 140 | interactivePopGestureRecognizer?.isEnabled = false 141 | } 142 | 143 | public override func viewWillDisappear(_ animated: Bool) { 144 | super.viewWillDisappear(animated) 145 | currentCapability = nil 146 | } 147 | 148 | public func edit(from viewController: UIViewController? = nil, onFinishEditing: @escaping ([AsyncImage], [MediaEditorOperation]) -> (), onCancel: (() -> ())? = nil) { 149 | self.onFinishEditing = onFinishEditing 150 | self.onCancel = onCancel 151 | viewController?.present(self, animated: true) 152 | } 153 | 154 | private func setup() { 155 | setupModalStyle() 156 | setupHub() 157 | setupForAsync() 158 | presentIfSingleImageAndCapability() 159 | } 160 | 161 | private func setupModalStyle() { 162 | modalTransitionStyle = .crossDissolve 163 | modalPresentationStyle = .fullScreen 164 | navigationBar.isHidden = true 165 | } 166 | 167 | private func setupHub() { 168 | hub.delegate = self 169 | 170 | hub.onCancel = { [weak self] in 171 | self?.cancel() 172 | } 173 | 174 | hub.onDone = { [weak self] in 175 | self?.done() 176 | } 177 | 178 | hub.apply(styles: styles) 179 | 180 | hub.availableThumbs = images 181 | 182 | hub.numberOfThumbs = max(images.count, asyncImages.count) 183 | 184 | hub.capabilities = Self.capabilities.reduce(into: []) { $0.append(($1.name, $1.icon)) } 185 | 186 | hub.apply(styles: styles) 187 | } 188 | 189 | private func setupForAsync() { 190 | isEditingPlainUIImages = images.count > 0 191 | 192 | asyncImages.enumerated().forEach { offset, asyncImage in 193 | if let thumb = asyncImage.thumb { 194 | thumbnailAvailable(thumb, offset: offset) 195 | } else { 196 | asyncImage.thumbnail(finishedRetrievingThumbnail: { [weak self] thumb in 197 | self?.thumbnailAvailable(thumb, offset: offset) 198 | }) 199 | } 200 | } 201 | 202 | if isSingleImageAndCapability { 203 | hub.disableDoneButton() 204 | capabilityTapped(0) 205 | } 206 | } 207 | 208 | private func presentIfSingleImageAndCapability() { 209 | guard isSingleImageAndCapability, let image = images[0], let capabilityEntity = Self.capabilities.first else { 210 | return 211 | } 212 | 213 | present(capability: capabilityEntity, with: image) 214 | } 215 | 216 | private func cancel() { 217 | if currentCapability == nil { 218 | cancelPendingAsyncImagesAndDismiss() 219 | } else if isSingleImageAndCapability { 220 | cancelPendingAsyncImagesAndDismiss() 221 | } else { 222 | dismissCapability() 223 | } 224 | } 225 | 226 | private func done() { 227 | let outputImages = isEditingPlainUIImages ? mapEditedImages() : mapEditedAsyncImages() 228 | onFinishEditing?(outputImages, actions) 229 | dismiss(animated: true) 230 | } 231 | 232 | /* 233 | Map the images hash to an images array preserving the original order, 234 | since Hashes are non-order preserving. 235 | */ 236 | private func mapEditedImages() -> [UIImage] { 237 | return images.enumerated().compactMap { index, _ in images[index] } 238 | } 239 | 240 | private func mapEditedAsyncImages() -> [AsyncImage] { 241 | var editedImages: [AsyncImage] = [] 242 | 243 | for (index, var asyncImage) in asyncImages.enumerated() { 244 | if editedImagesIndexes.contains(index), let editedImage = images[index] { 245 | asyncImage.isEdited = true 246 | asyncImage.editedImage = editedImage 247 | } 248 | editedImages.append(asyncImage) 249 | } 250 | 251 | return editedImages 252 | } 253 | 254 | private func cancelPendingAsyncImagesAndDismiss() { 255 | onCancel?() 256 | asyncImages.forEach { $0.cancel() } 257 | dismiss(animated: true) 258 | } 259 | 260 | private func present(capability capabilityEntity: MediaEditorCapability.Type, with image: UIImage) { 261 | prepareTransition() 262 | 263 | let capability = capabilityEntity.initialize( 264 | image, 265 | onFinishEditing: { [weak self] image, actions in 266 | self?.finishEditing(image: image, actions: actions) 267 | }, 268 | onCancel: { [weak self] in 269 | self?.cancel() 270 | } 271 | ) 272 | capability.loadViewIfNeeded() 273 | capability.apply(styles: styles) 274 | currentCapability = capability 275 | 276 | pushViewController(capability, animated: false) 277 | } 278 | 279 | private func finishEditing(image: UIImage, actions: [MediaEditorOperation]) { 280 | images[selectedImageIndex] = image 281 | 282 | self.actions.append(contentsOf: actions) 283 | 284 | if !actions.isEmpty { 285 | editedImagesIndexes.insert(selectedImageIndex) 286 | } 287 | 288 | if isSingleImageAndCapability { 289 | done() 290 | dismiss(animated: true) 291 | } else { 292 | hub.show(image: image, at: selectedImageIndex) 293 | dismissCapability() 294 | } 295 | } 296 | 297 | private func dismissCapability() { 298 | prepareTransition() 299 | popViewController(animated: false) 300 | currentCapability = nil 301 | } 302 | 303 | private func prepareTransition() { 304 | let transition: CATransition = CATransition() 305 | transition.duration = Constants.transitionDuration 306 | transition.type = .fade 307 | view.layer.add(transition, forKey: nil) 308 | } 309 | 310 | private func thumbnailAvailable(_ thumb: UIImage?, offset: Int) { 311 | guard let thumb = thumb else { 312 | return 313 | } 314 | 315 | DispatchQueue.main.async { 316 | self.hub.show(thumb: thumb, at: offset) 317 | } 318 | } 319 | 320 | private func fullImageAvailable(_ image: UIImage?, offset: Int) { 321 | guard let image = image else { 322 | DispatchQueue.main.async { 323 | self.hub.failedToLoad(at: offset) 324 | } 325 | return 326 | } 327 | 328 | self.images[offset] = image 329 | 330 | DispatchQueue.main.async { 331 | self.hub.hideActivityIndicator() 332 | 333 | self.hub.enableDoneButton() 334 | 335 | self.presentIfSingleImageAndCapability() 336 | 337 | self.hub.show(image: image, at: offset) 338 | } 339 | } 340 | 341 | private enum Constants { 342 | static let transitionDuration = 0.3 343 | } 344 | } 345 | 346 | extension MediaEditor: MediaEditorHubDelegate { 347 | func capabilityTapped(_ index: Int) { 348 | lastTappedCapabilityIndex = index 349 | 350 | if let image = images[selectedImageIndex] { 351 | present(capability: Self.capabilities[index], with: image) 352 | } else { 353 | let offset = selectedImageIndex 354 | hub.loadingImage(at: offset) 355 | asyncImages[selectedImageIndex].full(finishedRetrievingFullImage: { [weak self] image in 356 | DispatchQueue.main.async { 357 | 358 | self?.hub.loadedImage(at: offset) 359 | 360 | self?.fullImageAvailable(image, offset: offset) 361 | 362 | if self?.selectedImageIndex == offset, let image = image { 363 | self?.present(capability: Self.capabilities[index], with: image) 364 | } 365 | 366 | } 367 | }) 368 | } 369 | } 370 | 371 | func retry() { 372 | capabilityTapped(lastTappedCapabilityIndex) 373 | } 374 | } 375 | -------------------------------------------------------------------------------- /Sources/MediaEditorHub.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public class MediaEditorHub: UIViewController { 4 | 5 | @IBOutlet public weak var doneButton: UIButton! 6 | @IBOutlet public weak var cancelIconButton: UIButton! 7 | @IBOutlet public weak var activityIndicatorView: UIVisualEffectView! 8 | @IBOutlet public weak var activityIndicatorLabel: UILabel! 9 | @IBOutlet public weak var mainStackView: UIStackView! 10 | @IBOutlet public weak var thumbsCollectionView: UICollectionView! 11 | @IBOutlet public weak var imagesCollectionView: UICollectionView! 12 | @IBOutlet public weak var capabilitiesCollectionView: UICollectionView! 13 | @IBOutlet public weak var toolbarHeight: NSLayoutConstraint! 14 | 15 | weak var delegate: MediaEditorHubDelegate? 16 | 17 | var onCancel: (() -> ())? 18 | 19 | var onDone: (() -> ())? 20 | 21 | var numberOfThumbs = 0 { 22 | didSet { 23 | setupToolbar() 24 | } 25 | } 26 | 27 | var capabilities: [(String, UIImage)] = [] { 28 | didSet { 29 | setupCapabilities() 30 | } 31 | } 32 | 33 | var availableThumbs: [Int: UIImage] = [:] 34 | 35 | var availableImages: [Int: UIImage] = [:] 36 | 37 | private(set) var selectedThumbIndex = 0 { 38 | didSet { 39 | highlightSelectedThumb(current: selectedThumbIndex, before: oldValue) 40 | showOrHideActivityIndicatorAndCapabilities() 41 | } 42 | } 43 | 44 | private(set) var isUserScrolling = false 45 | 46 | private var selectedColor: UIColor? 47 | 48 | private var indexesOfImagesBeingLoaded: [Int] = [] 49 | 50 | private var isSingleImage: Bool { 51 | return numberOfThumbs == 1 52 | } 53 | 54 | private var isSingleCapabilityAndImage: Bool { 55 | isSingleImage && capabilities.count == 1 56 | } 57 | 58 | private var styles: MediaEditorStyles? 59 | 60 | private var hubDidAppeared = false 61 | 62 | override public func viewDidLoad() { 63 | super.viewDidLoad() 64 | thumbsCollectionView.dataSource = self 65 | thumbsCollectionView.delegate = self 66 | capabilitiesCollectionView.dataSource = self 67 | capabilitiesCollectionView.delegate = self 68 | imagesCollectionView.dataSource = self 69 | imagesCollectionView.delegate = self 70 | } 71 | 72 | /// Select the last asset every time the view layout it's subviews until the hub appears. 73 | /// This is needed because of some layout recalculations that happens. 74 | override public func viewDidLayoutSubviews() { 75 | super.viewDidLayoutSubviews() 76 | 77 | if !hubDidAppeared { 78 | selectLastAsset() 79 | } 80 | } 81 | 82 | override public func viewDidAppear(_ animated: Bool) { 83 | super.viewDidAppear(animated) 84 | hubDidAppeared = true 85 | } 86 | 87 | override public func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { 88 | super.traitCollectionDidChange(previousTraitCollection) 89 | reloadImagesAndReposition() 90 | } 91 | 92 | override public func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { 93 | super.viewWillTransition(to: size, with: coordinator) 94 | 95 | coordinator.animate(alongsideTransition: { _ in 96 | self.reloadImagesAndReposition() 97 | }) 98 | } 99 | 100 | @IBAction func cancel(_ sender: Any) { 101 | onCancel?() 102 | } 103 | 104 | @IBAction func done(_ sender: Any) { 105 | onDone?() 106 | } 107 | 108 | func show(image: UIImage, at index: Int) { 109 | availableImages[index] = image 110 | availableThumbs[index] = image 111 | 112 | let imageCell = imagesCollectionView.cellForItem(at: IndexPath(row: index, section: 0)) as? MediaEditorImageCell 113 | imageCell?.errorView.isHidden = true 114 | imageCell?.imageView.image = image 115 | 116 | let cell = thumbsCollectionView.cellForItem(at: IndexPath(row: index, section: 0)) as? MediaEditorThumbCell 117 | cell?.thumbImageView.image = image 118 | 119 | showOrHideActivityIndicatorAndCapabilities() 120 | } 121 | 122 | func show(thumb: UIImage, at index: Int) { 123 | availableThumbs[index] = thumb 124 | 125 | let cell = thumbsCollectionView.cellForItem(at: IndexPath(row: index, section: 0)) as? MediaEditorThumbCell 126 | cell?.thumbImageView.image = thumb 127 | 128 | let imageCell = imagesCollectionView.cellForItem(at: IndexPath(row: index, section: 0)) as? MediaEditorImageCell 129 | imageCell?.imageView.image = availableImages[index] ?? thumb 130 | 131 | showOrHideActivityIndicatorAndCapabilities() 132 | } 133 | 134 | func apply(styles: MediaEditorStyles) { 135 | loadViewIfNeeded() 136 | 137 | self.styles = styles 138 | 139 | if let doneLabel = (styles[.insertLabel] ?? styles[.doneLabel]) as? String { 140 | doneButton.setTitle(String(format: doneLabel, "\(numberOfThumbs)"), for: .normal) 141 | } 142 | 143 | if let cancelColor = styles[.cancelColor] as? UIColor { 144 | cancelIconButton.tintColor = cancelColor 145 | } 146 | 147 | if let doneColor = styles[.doneColor] as? UIColor { 148 | doneButton.tintColor = doneColor 149 | } 150 | 151 | if let cancelIcon = styles[.cancelIcon] as? UIImage { 152 | cancelIconButton.setImage(cancelIcon, for: .normal) 153 | } 154 | 155 | if let loadingLabel = styles[.loadingLabel] as? String { 156 | activityIndicatorLabel.text = loadingLabel 157 | } 158 | 159 | if let color = styles[.selectedColor] as? UIColor { 160 | selectedColor = color 161 | } 162 | } 163 | 164 | func showActivityIndicator() { 165 | activityIndicatorView.isHidden = false 166 | } 167 | 168 | func hideActivityIndicator() { 169 | activityIndicatorView.isHidden = true 170 | } 171 | 172 | func disableDoneButton() { 173 | doneButton.isEnabled = false 174 | } 175 | 176 | func enableDoneButton() { 177 | doneButton.isEnabled = true 178 | } 179 | 180 | func loadingImage(at index: Int) { 181 | indexesOfImagesBeingLoaded.append(index) 182 | showOrHideActivityIndicatorAndCapabilities() 183 | } 184 | 185 | func loadedImage(at index: Int) { 186 | indexesOfImagesBeingLoaded = indexesOfImagesBeingLoaded.filter { $0 != index } 187 | showOrHideActivityIndicatorAndCapabilities() 188 | } 189 | 190 | func failedToLoad(at index: Int) { 191 | let cell = imagesCollectionView.cellForItem(at: IndexPath(row: index, section: 0)) as? MediaEditorImageCell 192 | cell?.errorView.isHidden = false 193 | hideActivityIndicator() 194 | } 195 | 196 | private func reloadImagesAndReposition() { 197 | view.layoutIfNeeded() 198 | thumbsCollectionView.reloadData() 199 | imagesCollectionView.reloadData() 200 | thumbsCollectionView.layoutIfNeeded() 201 | imagesCollectionView.layoutIfNeeded() 202 | thumbsCollectionView.selectItem(at: IndexPath(row: selectedThumbIndex, section: 0), animated: false, scrollPosition: .right) 203 | imagesCollectionView.scrollToItem(at: IndexPath(row: selectedThumbIndex, section: 0), at: .right, animated: false) 204 | } 205 | 206 | private func setupToolbar() { 207 | toolbarHeight.constant = isSingleImage ? Constants.toolbarHeight : Constants.thumbHeight 208 | thumbsCollectionView.isHidden = isSingleImage ? true : false 209 | } 210 | 211 | private func highlightSelectedThumb(current: Int, before: Int) { 212 | let current = thumbsCollectionView.cellForItem(at: IndexPath(row: current, section: 0)) as? MediaEditorThumbCell 213 | let before = thumbsCollectionView.cellForItem(at: IndexPath(row: before, section: 0)) as? MediaEditorThumbCell 214 | before?.hideBorder() 215 | current?.showBorder() 216 | } 217 | 218 | private func showOrHideActivityIndicatorAndCapabilities() { 219 | let imageAvailable = availableThumbs[selectedThumbIndex] ?? availableImages[selectedThumbIndex] 220 | 221 | let isBeingLoaded = imageAvailable == nil || indexesOfImagesBeingLoaded.contains(selectedThumbIndex) 222 | 223 | if isBeingLoaded { 224 | showActivityIndicator() 225 | disableCapabilities() 226 | } else { 227 | hideActivityIndicator() 228 | enableCapabilities() 229 | } 230 | } 231 | 232 | private func disableCapabilities() { 233 | capabilitiesCollectionView.isUserInteractionEnabled = false 234 | capabilitiesCollectionView.layer.opacity = 0.5 235 | } 236 | 237 | private func enableCapabilities() { 238 | capabilitiesCollectionView.isUserInteractionEnabled = true 239 | capabilitiesCollectionView.layer.opacity = 1 240 | } 241 | 242 | private func setupCapabilities() { 243 | capabilitiesCollectionView.isHidden = isSingleCapabilityAndImage ? true : false 244 | capabilitiesCollectionView.reloadData() 245 | } 246 | 247 | private func selectLastAsset() { 248 | DispatchQueue.main.async { 249 | self.selectedThumbIndex = self.numberOfThumbs - 1 250 | self.imagesCollectionView.scrollToItem(at: IndexPath(row: self.selectedThumbIndex, section: 0), at: .right, animated: false) 251 | self.thumbsCollectionView.scrollToItem(at: IndexPath(row: self.selectedThumbIndex, section: 0), at: .right, animated: false) 252 | } 253 | } 254 | 255 | static func initialize() -> MediaEditorHub { 256 | return UIStoryboard(name: "MediaEditorHub", bundle: Bundle(for: MediaEditorHub.self)).instantiateViewController(withIdentifier: "hubViewController") as! MediaEditorHub 257 | } 258 | 259 | private enum Constants { 260 | static var thumbCellIdentifier = "thumbCell" 261 | static var imageCellIdentifier = "imageCell" 262 | static var capabCellIdentifier = "capabilityCell" 263 | static var thumbHeight: CGFloat = 64 264 | static var toolbarHeight: CGFloat = 44 265 | } 266 | } 267 | 268 | // MARK: - UICollectionViewDataSource 269 | 270 | extension MediaEditorHub: UICollectionViewDataSource { 271 | public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 272 | return collectionView == capabilitiesCollectionView ? capabilities.count : numberOfThumbs 273 | } 274 | 275 | public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 276 | if collectionView == thumbsCollectionView { 277 | return cellForThumbsCollectionView(cellForItemAt: indexPath) 278 | } else if collectionView == imagesCollectionView { 279 | return cellForImagesCollectionView(cellForItemAt: indexPath) 280 | } 281 | 282 | return cellForCapabilityCollectionView(cellForItemAt: indexPath) 283 | } 284 | 285 | private func cellForThumbsCollectionView(cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 286 | let cell = thumbsCollectionView.dequeueReusableCell(withReuseIdentifier: Constants.thumbCellIdentifier, for: indexPath) 287 | 288 | if let thumbCell = cell as? MediaEditorThumbCell { 289 | thumbCell.thumbImageView.image = availableThumbs[indexPath.row] 290 | indexPath.row == selectedThumbIndex ? thumbCell.showBorder(color: selectedColor) : thumbCell.hideBorder() 291 | } 292 | 293 | return cell 294 | } 295 | 296 | private func cellForImagesCollectionView(cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 297 | let cell = imagesCollectionView.dequeueReusableCell(withReuseIdentifier: Constants.imageCellIdentifier, for: indexPath) 298 | 299 | if let imageCell = cell as? MediaEditorImageCell { 300 | imageCell.imageView.image = availableImages[indexPath.row] ?? availableThumbs[indexPath.row] 301 | imageCell.errorView.isHidden = true 302 | imageCell.apply(styles: styles) 303 | imageCell.delegate = delegate 304 | } 305 | 306 | showOrHideActivityIndicatorAndCapabilities() 307 | 308 | return cell 309 | } 310 | 311 | private func cellForCapabilityCollectionView(cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 312 | let cell = capabilitiesCollectionView.dequeueReusableCell(withReuseIdentifier: Constants.capabCellIdentifier, for: indexPath) 313 | 314 | if let capabilityCell = cell as? MediaEditorCapabilityCell { 315 | capabilityCell.configure(capabilities[indexPath.row]) 316 | } 317 | 318 | return cell 319 | } 320 | } 321 | 322 | // MARK: - UICollectionViewDelegateFlowLayout 323 | 324 | extension MediaEditorHub: UICollectionViewDelegateFlowLayout { 325 | public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 326 | if collectionView == imagesCollectionView { 327 | return CGSize(width: imagesCollectionView.frame.width, height: imagesCollectionView.frame.height) 328 | } else if collectionView == thumbsCollectionView { 329 | return numberOfThumbs > 1 ? CGSize(width: Constants.thumbHeight, height: Constants.thumbHeight) : .zero 330 | } else { 331 | return CGSize(width: Constants.toolbarHeight, height: Constants.toolbarHeight) 332 | } 333 | } 334 | } 335 | 336 | // MARK: - UICollectionViewDelegate 337 | 338 | extension MediaEditorHub: UICollectionViewDelegate { 339 | public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 340 | if collectionView == thumbsCollectionView { 341 | selectedThumbIndex = indexPath.row 342 | imagesCollectionView.scrollToItem(at: indexPath, at: .right, animated: true) 343 | } else if collectionView == capabilitiesCollectionView { 344 | delegate?.capabilityTapped(indexPath.row) 345 | } 346 | } 347 | 348 | public func scrollViewDidScroll(_ scrollView: UIScrollView) { 349 | guard scrollView == imagesCollectionView, isUserScrolling else { 350 | return 351 | } 352 | 353 | let imageIndexBasedOnScroll = Int(round(scrollView.bounds.origin.x / imagesCollectionView.frame.width)) 354 | 355 | thumbsCollectionView.selectItem(at: IndexPath(row: imageIndexBasedOnScroll, section: 0), animated: true, scrollPosition: .right) 356 | selectedThumbIndex = imageIndexBasedOnScroll 357 | } 358 | 359 | public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { 360 | guard scrollView == imagesCollectionView else { 361 | return 362 | } 363 | 364 | isUserScrolling = true 365 | } 366 | 367 | public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { 368 | guard scrollView == imagesCollectionView else { 369 | return 370 | } 371 | 372 | isUserScrolling = false 373 | } 374 | } 375 | 376 | protocol MediaEditorHubDelegate: AnyObject { 377 | func capabilityTapped(_ index: Int) 378 | func retry() 379 | } 380 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | -------------------------------------------------------------------------------- /Sources/Capabilities/Filters/MediaEditorFilters.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 107 | 108 | 109 | 110 | 111 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | -------------------------------------------------------------------------------- /Tests/MediaEditorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import CropViewController 3 | import Nimble 4 | 5 | @testable import MediaEditor 6 | 7 | class MediaEditorTests: XCTestCase { 8 | private let image = UIImage() 9 | 10 | override class func setUp() { 11 | super.setUp() 12 | MediaEditor.capabilities = [MockCapability.self] 13 | } 14 | 15 | func testNavigationBarIsHidden() { 16 | let mediaEditor = MediaEditor(image) 17 | 18 | expect(mediaEditor.navigationBar.isHidden).to(beTrue()) 19 | } 20 | 21 | func testModalTransitionStyle() { 22 | let mediaEditor = MediaEditor(image) 23 | 24 | expect(mediaEditor.modalTransitionStyle).to(equal(.crossDissolve)) 25 | } 26 | 27 | func testModalPresentationStyle() { 28 | let mediaEditor = MediaEditor(image) 29 | 30 | expect(mediaEditor.modalPresentationStyle).to(equal(.fullScreen)) 31 | } 32 | 33 | func testDisableInteractivePopGestureRecognizer() { 34 | let mediaEditor = MediaEditor(image) 35 | 36 | mediaEditor.viewDidLoad() 37 | 38 | expect(mediaEditor.interactivePopGestureRecognizer?.isEnabled).to(beFalse()) 39 | } 40 | 41 | func testHubDelegate() { 42 | let mediaEditor = MediaEditor(image) 43 | 44 | let hubDelegate = mediaEditor.hub.delegate as? MediaEditor 45 | 46 | expect(hubDelegate).to(equal(mediaEditor)) 47 | } 48 | 49 | func testGivesTheListOfCapabilitiesIconsAndNames() { 50 | let mediaEditor = MediaEditor(image) 51 | 52 | expect(mediaEditor.hub.capabilities.count).to(equal(1)) 53 | } 54 | 55 | func testSettingStylesChangingTheCurrentShownCapability() { 56 | let mediaEditor = MediaEditor(image) 57 | 58 | mediaEditor.styles = [.doneLabel: "foo"] 59 | 60 | let currentCapability = mediaEditor.currentCapability as? MockCapability 61 | expect(currentCapability?.applyCalled).to(beTrue()) 62 | } 63 | 64 | func testEditPresentsFromTheGivenViewController() { 65 | let viewController = UIViewControllerMock() 66 | let mediaEditor = MediaEditor(image) 67 | 68 | mediaEditor.edit(from: viewController, onFinishEditing: { _, _ in }) 69 | 70 | expect(viewController.didCallPresentWith).to(equal(mediaEditor)) 71 | } 72 | 73 | // WHEN: One single image + one single capability 74 | 75 | func testShowTheCapabilityRightAway() { 76 | let mediaEditor = MediaEditor(image) 77 | 78 | expect(mediaEditor.visibleViewController).to(equal((mediaEditor.currentCapability as? MockCapability))) 79 | } 80 | 81 | func testWhenCancelingDismissTheMediaEditor() { 82 | let viewController = UIViewController() 83 | UIApplication.shared.topWindow?.addSubview(viewController.view) 84 | let mediaEditor = MediaEditor(image) 85 | viewController.present(mediaEditor, animated: false) 86 | 87 | (mediaEditor.currentCapability as? MockCapability)?.onCancel() 88 | 89 | expect(viewController.presentedViewController).toEventually(beNil()) 90 | } 91 | 92 | func testWhenFinishEditingCallOnFinishEditing() { 93 | var didCallOnFinishEditing = false 94 | let mediaEditor = MediaEditor(image) 95 | mediaEditor.onFinishEditing = { _, _ in 96 | didCallOnFinishEditing = true 97 | } 98 | 99 | (mediaEditor.currentCapability as? MockCapability)?.onFinishEditing(image, [.rotate]) 100 | 101 | expect(didCallOnFinishEditing).to(beTrue()) 102 | } 103 | 104 | func testWhenFinishEditingKeepRecordOfTheActions() { 105 | let mediaEditor = MediaEditor(image) 106 | (mediaEditor.currentCapability as? MockCapability)?.onFinishEditing(image, [.crop]) 107 | mediaEditor.onFinishEditing = { _, _ in } 108 | 109 | (mediaEditor.currentCapability as? MockCapability)?.onFinishEditing(image, [.rotate]) 110 | 111 | expect(mediaEditor.actions).to(equal([.crop, .rotate])) 112 | } 113 | 114 | func testWhenFinishEditingImagesReturnTheImages() { 115 | var returnedImages: [UIImage] = [] 116 | let mediaEditor = MediaEditor(image) 117 | mediaEditor.onFinishEditing = { images, _ in 118 | returnedImages = images as! [UIImage] 119 | } 120 | 121 | (mediaEditor.currentCapability as? MockCapability)?.onFinishEditing(image, [.rotate]) 122 | 123 | expect(returnedImages).to(equal([image])) 124 | } 125 | 126 | // WHEN: Async image + one single capability 127 | 128 | func testRequestThumbAndFullImageQuality() { 129 | let asyncImage = AsyncImageMock() 130 | 131 | _ = MediaEditor(asyncImage) 132 | 133 | expect(asyncImage.didCallThumbnail).to(beTrue()) 134 | expect(asyncImage.didCallFull).to(beTrue()) 135 | } 136 | 137 | func testIfThumbnailIsAvailableShowItInHub() { 138 | let asyncImage = AsyncImageMock() 139 | asyncImage.thumb = UIImage() 140 | 141 | let mediaEditor = MediaEditor(asyncImage) 142 | UIApplication.shared.topWindow?.addSubview(mediaEditor.view) 143 | 144 | expect((mediaEditor.hub.imagesCollectionView.cellForItem(at: IndexPath(row: 0, section: 0)) as? MediaEditorImageCell)?.imageView.image).toEventually(equal(asyncImage.thumb)) 145 | } 146 | 147 | func testDoNotRequestThumbnailIfOneIsGiven() { 148 | let asyncImage = AsyncImageMock() 149 | asyncImage.thumb = UIImage() 150 | 151 | _ = MediaEditor(asyncImage) 152 | 153 | expect(asyncImage.didCallFull).to(beTrue()) 154 | expect(asyncImage.didCallThumbnail).to(beFalse()) 155 | } 156 | 157 | func testShowActivityIndicatorWhenLoadingImage() { 158 | let asyncImage = AsyncImageMock() 159 | asyncImage.thumb = UIImage() 160 | 161 | let mediaEditor = MediaEditor(asyncImage) 162 | 163 | expect(mediaEditor.hub.activityIndicatorView.isHidden).to(beFalse()) 164 | } 165 | 166 | func testWhenThumbnailIsAvailableShowItInHub() { 167 | let asyncImage = AsyncImageMock() 168 | let thumb = UIImage() 169 | let mediaEditor = MediaEditor(asyncImage) 170 | UIApplication.shared.topWindow?.addSubview(mediaEditor.view) 171 | 172 | asyncImage.simulate(thumbHasBeenDownloaded: thumb) 173 | 174 | expect((mediaEditor.hub.collectionView(mediaEditor.hub.imagesCollectionView, cellForItemAt: IndexPath(row: 0, section: 0)) as? MediaEditorImageCell)?.imageView.image).toEventually(equal(thumb)) 175 | } 176 | 177 | func testWhenFullImageIsAvailableShowItInHub() { 178 | let asyncImage = AsyncImageMock() 179 | let fullImage = UIImage() 180 | let mediaEditor = MediaEditor(asyncImage) 181 | UIApplication.shared.topWindow?.addSubview(mediaEditor.view) 182 | 183 | asyncImage.simulate(fullImageHasBeenDownloaded: fullImage) 184 | 185 | expect((mediaEditor.hub.collectionView(mediaEditor.hub.imagesCollectionView, cellForItemAt: IndexPath(row: 0, section: 0)) as? MediaEditorImageCell)?.imageView.image).toEventually(equal(fullImage)) 186 | } 187 | 188 | func testWhenFullImageIsAvailableHideActivityIndicatorView() { 189 | let asyncImage = AsyncImageMock() 190 | let fullImage = UIImage() 191 | let mediaEditor = MediaEditor(asyncImage) 192 | UIApplication.shared.topWindow?.addSubview(mediaEditor.view) 193 | 194 | asyncImage.simulate(fullImageHasBeenDownloaded: fullImage) 195 | 196 | expect(mediaEditor.hub.activityIndicatorView.isHidden).toEventually(beTrue()) 197 | } 198 | 199 | func testPresentCapabilityAfterFullImageIsAvailable() { 200 | let asyncImage = AsyncImageMock() 201 | let fullImage = UIImage() 202 | let mediaEditor = MediaEditor(asyncImage) 203 | 204 | asyncImage.simulate(fullImageHasBeenDownloaded: fullImage) 205 | 206 | expect((mediaEditor.currentCapability as? MockCapability)).toEventuallyNot(beNil()) 207 | expect(mediaEditor.visibleViewController).to(equal((mediaEditor.currentCapability as? MockCapability))) 208 | } 209 | 210 | func testCallCancelOnAsyncImageWhenUserCancel() { 211 | let asyncImage = AsyncImageMock() 212 | let mediaEditor = MediaEditor(asyncImage) 213 | 214 | mediaEditor.hub.cancelIconButton.sendActions(for: .touchUpInside) 215 | 216 | expect(asyncImage.didCallCancel).to(beTrue()) 217 | } 218 | 219 | func testDoNotDisplayThumbnailIfFullImageIsAlreadyVisible() { 220 | let asyncImage = AsyncImageMock() 221 | let fullImage = UIImage(color: .white) 222 | let thumbImage = UIImage(color: .black) 223 | let mediaEditor = MediaEditor(asyncImage) 224 | UIApplication.shared.topWindow?.addSubview(mediaEditor.view) 225 | mediaEditor.view.layoutIfNeeded() 226 | 227 | asyncImage.simulate(fullImageHasBeenDownloaded: fullImage) 228 | asyncImage.simulate(thumbHasBeenDownloaded: thumbImage) 229 | 230 | expect((mediaEditor.hub.imagesCollectionView.cellForItem(at: IndexPath(row: 0, section: 0)) as? MediaEditorImageCell)?.imageView.image).toEventually(equal(fullImage)) 231 | expect((mediaEditor.hub.imagesCollectionView.cellForItem(at: IndexPath(row: 0, section: 0)) as? MediaEditorImageCell)?.imageView.image).toEventuallyNot(equal(thumbImage)) 232 | } 233 | 234 | func testHidesThumbsToolbar() { 235 | let asyncImage = AsyncImageMock() 236 | 237 | let mediaEditor = MediaEditor(asyncImage) 238 | 239 | expect(mediaEditor.hub.thumbsCollectionView.isHidden).to(beTrue()) 240 | } 241 | 242 | func testWhenFinishEditingAsyncImageReturnTheAsyncImage() { 243 | // Given 244 | var returnedImages: [AsyncImage] = [] 245 | let asyncImage = AsyncImageMock() 246 | let mediaEditor = MediaEditor(asyncImage) 247 | asyncImage.simulate(fullImageHasBeenDownloaded: UIImage()) 248 | mediaEditor.onFinishEditing = { images, _ in 249 | returnedImages = images 250 | } 251 | expect((mediaEditor.currentCapability as? MockCapability)).toEventuallyNot(beNil()) // Wait capability appear 252 | 253 | // When 254 | (mediaEditor.currentCapability as? MockCapability)?.onFinishEditing(image, [.rotate]) 255 | 256 | // Then 257 | expect(returnedImages.first?.isEdited).to(beTrue()) 258 | expect(returnedImages.first?.editedImage).to(equal(image)) 259 | } 260 | 261 | 262 | func testDisableDoneButtonWhileLoading() { 263 | let asyncImage = AsyncImageMock() 264 | 265 | let mediaEditor = MediaEditor(asyncImage) 266 | 267 | expect(mediaEditor.hub.doneButton.isEnabled).to(beFalse()) 268 | } 269 | 270 | func testEnableDoneButtonOnceImageIsLoaded() { 271 | let asyncImage = AsyncImageMock() 272 | let mediaEditor = MediaEditor(asyncImage) 273 | 274 | asyncImage.simulate(fullImageHasBeenDownloaded: image) 275 | 276 | expect(mediaEditor.hub.doneButton.isEnabled).toEventually(beTrue()) 277 | } 278 | 279 | // WHEN: Multiple images + one single capability 280 | 281 | func testShowThumbs() { 282 | let whiteImage = UIImage(color: .white) 283 | let blackImage = UIImage(color: .black) 284 | 285 | let mediaEditor = MediaEditor([whiteImage, blackImage]) 286 | 287 | let firstThumb = mediaEditor.hub.collectionView(mediaEditor.hub.thumbsCollectionView, cellForItemAt: IndexPath(row: 0, section: 0)) as? MediaEditorThumbCell 288 | let secondThumb = mediaEditor.hub.collectionView(mediaEditor.hub.thumbsCollectionView, cellForItemAt: IndexPath(row: 1, section: 0)) as? MediaEditorThumbCell 289 | expect(firstThumb?.thumbImageView.image).to(equal(whiteImage)) 290 | expect(secondThumb?.thumbImageView.image).to(equal(blackImage)) 291 | } 292 | 293 | func testPresentsTheHub() { 294 | let whiteImage = UIImage(color: .white) 295 | let blackImage = UIImage(color: .black) 296 | 297 | let mediaEditor = MediaEditor([whiteImage, blackImage]) 298 | 299 | expect((mediaEditor.currentCapability as? MockCapability)).to(beNil()) 300 | expect(mediaEditor.visibleViewController).to(equal(mediaEditor.hub)) 301 | } 302 | 303 | func testTappingACapabilityPresentsIt() { 304 | let whiteImage = UIImage(color: .white) 305 | let blackImage = UIImage(color: .black) 306 | let mediaEditor = MediaEditor([whiteImage, blackImage]) 307 | 308 | mediaEditor.capabilityTapped(0) 309 | 310 | expect((mediaEditor.currentCapability as? MockCapability)).toNot(beNil()) 311 | expect(mediaEditor.visibleViewController).to(equal((mediaEditor.currentCapability as? MockCapability))) 312 | } 313 | 314 | func testCallingOnCancelWhenShowingACapabilityGoesBackToHub() { 315 | let whiteImage = UIImage(color: .white) 316 | let blackImage = UIImage(color: .black) 317 | let mediaEditor = MediaEditor([whiteImage, blackImage]) 318 | mediaEditor.capabilityTapped(0) 319 | 320 | (mediaEditor.currentCapability as? MockCapability)?.onCancel() 321 | 322 | expect((mediaEditor.currentCapability as? MockCapability)).to(beNil()) 323 | expect(mediaEditor.visibleViewController).to(equal(mediaEditor.hub)) 324 | } 325 | 326 | func testCallingOnFinishWhenShowingACapabilityUpdatesTheImage() { 327 | let whiteImage = UIImage(color: .white) 328 | let blackImage = UIImage(color: .black) 329 | let editedImage = UIImage() 330 | let mediaEditor = MediaEditor([whiteImage, blackImage]) 331 | mediaEditor.capabilityTapped(0) 332 | 333 | (mediaEditor.currentCapability as? MockCapability)?.onFinishEditing(editedImage, [.crop]) 334 | 335 | expect(mediaEditor.images[0]).to(equal(editedImage)) 336 | expect(mediaEditor.hub.availableImages[0]).to(equal(editedImage)) 337 | expect(mediaEditor.hub.availableThumbs[0]).to(equal(editedImage)) 338 | } 339 | 340 | func testWhenCancelingDismissTheCapabilityAndGoesBackToHub() { 341 | let viewController = UIViewController() 342 | UIApplication.shared.topWindow?.addSubview(viewController.view) 343 | let whiteImage = UIImage(color: .white) 344 | let blackImage = UIImage(color: .black) 345 | let mediaEditor = MediaEditor([whiteImage, blackImage]) 346 | viewController.present(mediaEditor, animated: false) 347 | mediaEditor.capabilityTapped(0) 348 | 349 | (mediaEditor.currentCapability as? MockCapability)?.onCancel() 350 | 351 | expect(mediaEditor.visibleViewController).toEventually(equal(mediaEditor.hub)) 352 | } 353 | 354 | func testWhenFinishEditingMultipleImagesReturnAllTheImages() { 355 | var returnedImages: [UIImage] = [] 356 | let editedImage = UIImage(color: .black) 357 | let mediaEditor = MediaEditor([image, image]) 358 | mediaEditor.onFinishEditing = { images, _ in 359 | returnedImages = images as! [UIImage] 360 | } 361 | mediaEditor.capabilityTapped(0) 362 | (mediaEditor.currentCapability as? MockCapability)?.onFinishEditing(editedImage, [.rotate]) 363 | 364 | mediaEditor.hub.doneButton.sendActions(for: .touchUpInside) 365 | 366 | expect(returnedImages).to(equal([editedImage, image])) 367 | } 368 | 369 | func testWhenCancelEditingMultipleImagesCallOnCancel() { 370 | var didCallOnCancel = false 371 | let mediaEditor = MediaEditor([image, image]) 372 | mediaEditor.onCancel = { 373 | didCallOnCancel = true 374 | } 375 | 376 | mediaEditor.hub.cancelIconButton.sendActions(for: .touchUpInside) 377 | 378 | expect(didCallOnCancel).to(beTrue()) 379 | } 380 | 381 | // WHEN: Multiple async images + one single capability 382 | 383 | func testShowThumbsToolbar() { 384 | let asyncImages = [AsyncImageMock(), AsyncImageMock()] 385 | 386 | let mediaEditor = MediaEditor(asyncImages) 387 | 388 | expect(mediaEditor.hub.thumbsCollectionView.isHidden).to(beFalse()) 389 | } 390 | 391 | func testWhenGivenMultipleAsyncImagesPresentsTheHub() { 392 | let asyncImages = [AsyncImageMock(), AsyncImageMock()] 393 | 394 | let mediaEditor = MediaEditor(asyncImages) 395 | 396 | expect((mediaEditor.currentCapability as? MockCapability)).to(beNil()) 397 | expect(mediaEditor.visibleViewController).to(equal(mediaEditor.hub)) 398 | } 399 | 400 | func testTappingACapabilityDoesntPresentItRightAway() { 401 | let asyncImages = [AsyncImageMock(), AsyncImageMock()] 402 | let mediaEditor = MediaEditor(asyncImages) 403 | 404 | mediaEditor.capabilityTapped(0) 405 | 406 | expect((mediaEditor.currentCapability as? MockCapability)).to(beNil()) 407 | expect(mediaEditor.visibleViewController).to(equal(mediaEditor.hub)) 408 | } 409 | 410 | func testTappingACapabilityStartsTheRequestForTheFullImage() { 411 | let firstImage = AsyncImageMock() 412 | let seconImage = AsyncImageMock() 413 | let mediaEditor = MediaEditor([firstImage, seconImage]) 414 | 415 | mediaEditor.capabilityTapped(0) 416 | 417 | expect(firstImage.didCallFull).to(beTrue()) 418 | } 419 | 420 | func testWhenTheFullImageIsAvailableShowTheCapability() { 421 | let fullImage = UIImage() 422 | let firstImage = AsyncImageMock() 423 | let seconImage = AsyncImageMock() 424 | let mediaEditor = MediaEditor([firstImage, seconImage]) 425 | mediaEditor.capabilityTapped(0) 426 | 427 | firstImage.simulate(fullImageHasBeenDownloaded: fullImage) 428 | 429 | expect((mediaEditor.currentCapability as? MockCapability)).toEventuallyNot(beNil()) 430 | expect(mediaEditor.visibleViewController).to(equal((mediaEditor.currentCapability as? MockCapability))) 431 | } 432 | 433 | func testWhenTheFullImageIsAvailableUpdateTheImageReferences() { 434 | let fullImage = UIImage() 435 | let firstImage = AsyncImageMock() 436 | let seconImage = AsyncImageMock() 437 | let mediaEditor = MediaEditor([firstImage, seconImage]) 438 | mediaEditor.capabilityTapped(0) 439 | 440 | firstImage.simulate(fullImageHasBeenDownloaded: fullImage) 441 | 442 | expect(mediaEditor.hub.availableThumbs[0]).toEventually(equal(fullImage)) 443 | expect(mediaEditor.hub.availableImages[0]).to(equal(fullImage)) 444 | expect(mediaEditor.images[0]).to(equal(fullImage)) 445 | } 446 | 447 | func testWhenFinishEditingMultipleAsyncImageReturnAllAsyncImages() { 448 | // Given 449 | var returnedImages: [AsyncImage] = [] 450 | let firstImage = AsyncImageMock() 451 | let seconImage = AsyncImageMock() 452 | let mediaEditor = MediaEditor([firstImage, seconImage]) 453 | mediaEditor.capabilityTapped(0) 454 | firstImage.simulate(fullImageHasBeenDownloaded: UIImage()) 455 | mediaEditor.onFinishEditing = { images, _ in 456 | returnedImages = images 457 | } 458 | expect((mediaEditor.currentCapability as? MockCapability)).toEventuallyNot(beNil()) // Wait capability appear 459 | (mediaEditor.currentCapability as? MockCapability)?.onFinishEditing(image, [.rotate]) 460 | 461 | // When 462 | mediaEditor.hub.doneButton.sendActions(for: .touchUpInside) 463 | 464 | // Then 465 | expect(returnedImages.first?.isEdited).to(beTrue()) 466 | expect(returnedImages.first?.editedImage).to(equal(image)) 467 | } 468 | 469 | func testUpdateEditedImagesIndexesAfterEditingAnImage() { 470 | // Given 471 | let firstImage = AsyncImageMock() 472 | let seconImage = AsyncImageMock() 473 | let mediaEditor = MediaEditor([firstImage, seconImage]) 474 | mediaEditor.capabilityTapped(0) 475 | firstImage.simulate(fullImageHasBeenDownloaded: image) 476 | seconImage.simulate(fullImageHasBeenDownloaded: image) 477 | expect((mediaEditor.currentCapability as? MockCapability)).toEventuallyNot(beNil()) // Wait capability appear 478 | 479 | // When 480 | (mediaEditor.currentCapability as? MockCapability)?.onFinishEditing(image, [.rotate]) 481 | 482 | // Then 483 | expect(mediaEditor.editedImagesIndexes).to(equal([0])) 484 | } 485 | 486 | func testRetryAfterAMediaFailsToLoad() { 487 | // Given 488 | let firstImage = AsyncImageMock() 489 | let seconImage = AsyncImageMock() 490 | let mediaEditor = MediaEditor([firstImage, seconImage]) 491 | mediaEditor.capabilityTapped(0) 492 | firstImage.simulateFailure() 493 | 494 | // When 495 | mediaEditor.retry() 496 | firstImage.simulate(fullImageHasBeenDownloaded: image) 497 | 498 | // Then 499 | expect((mediaEditor.currentCapability as? MockCapability)).toEventuallyNot(beNil()) 500 | } 501 | 502 | } 503 | 504 | class MockCapability: CapabilityViewController { 505 | static var name = "MockCapability" 506 | 507 | static var icon = UIImage() 508 | 509 | var applyCalled = false 510 | 511 | var image: UIImage! 512 | 513 | lazy var viewController: UIViewController = { 514 | return UIViewController() 515 | }() 516 | 517 | var onFinishEditing: ((UIImage, [MediaEditorOperation]) -> ())! 518 | 519 | var onCancel: (() -> ())! 520 | 521 | static func initialize(_ image: UIImage, onFinishEditing: @escaping (UIImage, [MediaEditorOperation]) -> (), onCancel: @escaping () -> ()) -> CapabilityViewController { 522 | let viewController = MockCapability() 523 | viewController.image = image 524 | viewController.onFinishEditing = onFinishEditing 525 | viewController.onCancel = onCancel 526 | return viewController 527 | } 528 | 529 | init() { 530 | super.init(nibName: nil, bundle: nil) 531 | } 532 | 533 | required init?(coder: NSCoder) { 534 | fatalError("init(coder:) has not been implemented") 535 | } 536 | 537 | func apply(styles: MediaEditorStyles) { 538 | applyCalled = true 539 | } 540 | } 541 | 542 | private class AsyncImageMock: AsyncImage { 543 | var didCallThumbnail = false 544 | var didCallFull = false 545 | var didCallCancel = false 546 | 547 | var finishedRetrievingThumbnail: ((UIImage?) -> ())? 548 | var finishedRetrievingFullImage: ((UIImage?) -> ())? 549 | 550 | var thumb: UIImage? 551 | 552 | func thumbnail(finishedRetrievingThumbnail: @escaping (UIImage?) -> ()) { 553 | didCallThumbnail = true 554 | self.finishedRetrievingThumbnail = finishedRetrievingThumbnail 555 | } 556 | 557 | func full(finishedRetrievingFullImage: @escaping (UIImage?) -> ()) { 558 | didCallFull = true 559 | self.finishedRetrievingFullImage = finishedRetrievingFullImage 560 | } 561 | 562 | func cancel() { 563 | didCallCancel = true 564 | } 565 | 566 | func simulate(thumbHasBeenDownloaded thumb: UIImage) { 567 | finishedRetrievingThumbnail?(thumb) 568 | } 569 | 570 | func simulate(fullImageHasBeenDownloaded image: UIImage) { 571 | finishedRetrievingFullImage?(image) 572 | } 573 | 574 | func simulateFailure() { 575 | finishedRetrievingFullImage?(nil) 576 | } 577 | } 578 | 579 | private class UIViewControllerMock: UIViewController { 580 | var didCallPresentWith: UIViewController? 581 | 582 | override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { 583 | didCallPresentWith = viewControllerToPresent 584 | } 585 | } 586 | --------------------------------------------------------------------------------