├── .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 | [](https://circleci.com/gh/wordpress-mobile/MediaEditor-iOS) [](http://cocoadocs.org/docsets/MediaEditor) [](http://cocoadocs.org/docsets/MediaEditor) [](http://cocoadocs.org/docsets/MediaEditor) [](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 |
--------------------------------------------------------------------------------