diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..1d5bcf70 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +## 2.0.0 (2017-09-12) +[Compare]:https://github.com/avito-tech/Paparazzo/compare/1.1.0...2.0.0 + +##### Enhancements + +* Add support for photo filters + [Timofey Khomutnikov](https://github.com/khomTima) + +* Add supprot for mask cropping + [Vladimir Kaltyrin](https://github.com/vkaltyrin) + +* Add support for photo reordering + [Artem Peskishev](https://github.com/peskish) + +* Add support for cache cleaning + [Vadim Smal](https://github.com/CognitiveDisson) + +##### Bug Fixes + +* Minor fixes and improvements diff --git a/Example/PaparazzoExample.xcodeproj/project.pbxproj b/Example/PaparazzoExample.xcodeproj/project.pbxproj index e9a40433..1c6d883b 100644 --- a/Example/PaparazzoExample.xcodeproj/project.pbxproj +++ b/Example/PaparazzoExample.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 088535F351074F52B19B1688 /* Pods_PaparazzoExample_NoMarshroute.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B9A4E214165A8FB30A7CE203 /* Pods_PaparazzoExample_NoMarshroute.framework */; }; 08BB7920D2F1F31E8D8C1760 /* Pods_PaparazzoExample_Storyboard.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B16F0AF7B8CAAE81DB10090 /* Pods_PaparazzoExample_Storyboard.framework */; }; + 136F1C7C1F4EDE2B00C5CCA1 /* AutoAdjustmentFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136F1C7A1F4EDDF500C5CCA1 /* AutoAdjustmentFilter.swift */; }; 251E57C41E65651F0009A288 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251E57C31E65651F0009A288 /* AppDelegate.swift */; }; 251E57F01E6565890009A288 /* AppSpecificUITheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251E57D51E6565890009A288 /* AppSpecificUITheme.swift */; }; 251E57F21E6565890009A288 /* ExampleAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251E57D91E6565890009A288 /* ExampleAssembly.swift */; }; @@ -59,6 +60,7 @@ /* Begin PBXFileReference section */ 0CD08CDB3C698AABC02FE1C4 /* Pods-PaparazzoExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PaparazzoExample.release.xcconfig"; path = "Pods/Target Support Files/Pods-PaparazzoExample/Pods-PaparazzoExample.release.xcconfig"; sourceTree = ""; }; + 136F1C7A1F4EDDF500C5CCA1 /* AutoAdjustmentFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoAdjustmentFilter.swift; sourceTree = ""; }; 251E57C01E65651F0009A288 /* PaparazzoExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PaparazzoExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 251E57C31E65651F0009A288 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 251E57CF1E65651F0009A288 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -92,8 +94,8 @@ 8EC973CB836BAD3EDC80CABA /* Pods-PaparazzoExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PaparazzoExample.debug.xcconfig"; path = "Pods/Target Support Files/Pods-PaparazzoExample/Pods-PaparazzoExample.debug.xcconfig"; sourceTree = ""; }; AD2DAC113521ADEBFF8A22F1 /* Pods_PaparazzoExample.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_PaparazzoExample.framework; sourceTree = BUILT_PRODUCTS_DIR; }; B9A4E214165A8FB30A7CE203 /* Pods_PaparazzoExample_NoMarshroute.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_PaparazzoExample_NoMarshroute.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - EFA7610D1E829434000EB296 /* ItemProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemProvider.swift; sourceTree = ""; }; E747D4112A5EEA41675E0267 /* Pods-PaparazzoExample_Storyboard.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PaparazzoExample_Storyboard.release.xcconfig"; path = "Pods/Target Support Files/Pods-PaparazzoExample_Storyboard/Pods-PaparazzoExample_Storyboard.release.xcconfig"; sourceTree = ""; }; + EFA7610D1E829434000EB296 /* ItemProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemProvider.swift; sourceTree = ""; }; F2F50FC31E6C9F60006F9171 /* PaparazzoExample_Storyboard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PaparazzoExample_Storyboard.app; sourceTree = BUILT_PRODUCTS_DIR; }; F2F50FC51E6C9F60006F9171 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; F2F50FC71E6C9F60006F9171 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; @@ -131,6 +133,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 136F1C771F4ED91900C5CCA1 /* Filters */ = { + isa = PBXGroup; + children = ( + 136F1C7A1F4EDDF500C5CCA1 /* AutoAdjustmentFilter.swift */, + ); + path = Filters; + sourceTree = ""; + }; 251E57B71E65651F0009A288 = { isa = PBXGroup; children = ( @@ -161,6 +171,7 @@ 251E57C31E65651F0009A288 /* AppDelegate.swift */, 251E57D51E6565890009A288 /* AppSpecificUITheme.swift */, 251E57EF1E6565890009A288 /* NavigationController.swift */, + 136F1C771F4ED91900C5CCA1 /* Filters */, 251E57D71E6565890009A288 /* Example */, 251E57E71E6565890009A288 /* Fonts */, 251E57ED1E6565890009A288 /* MarshrouteHelpers */, @@ -479,7 +490,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_ROOT}/../Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n"; showEnvVarsInLog = 0; }; 6620037BC3D2966EBE230528 /* [CP] Copy Pods Resources */ = { @@ -524,7 +535,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_ROOT}/../Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n"; showEnvVarsInLog = 0; }; BE84EDEE72F665CE084E60AC /* [CP] Embed Pods Frameworks */ = { @@ -554,7 +565,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_ROOT}/../Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n"; showEnvVarsInLog = 0; }; E607E68B79D7FA6BB43CDC07 /* [CP] Embed Pods Frameworks */ = { @@ -597,6 +608,7 @@ 251E57FB1E6565890009A288 /* ExampleViewInput.swift in Sources */, 251E57FA1E6565890009A288 /* ExampleViewController.swift in Sources */, 251E57F21E6565890009A288 /* ExampleAssembly.swift in Sources */, + 136F1C7C1F4EDE2B00C5CCA1 /* AutoAdjustmentFilter.swift in Sources */, 251E57F41E6565890009A288 /* ExampleInteractor.swift in Sources */, 251E58011E6565890009A288 /* MarshrouteFacade.swift in Sources */, 251E58021E6565890009A288 /* NavigationController.swift in Sources */, @@ -757,6 +769,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = PaparazzoExample/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 8.0; @@ -773,6 +786,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = PaparazzoExample/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 8.0; diff --git a/Example/PaparazzoExample/AppDelegate.swift b/Example/PaparazzoExample/AppDelegate.swift index 009528d3..0b61a0a4 100644 --- a/Example/PaparazzoExample/AppDelegate.swift +++ b/Example/PaparazzoExample/AppDelegate.swift @@ -14,8 +14,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate { cleanTemporaryDirectory() window = UIWindow(frame: UIScreen.main.bounds) + + let photoStorage = PhotoStorageImpl() + photoStorage.removeAll() + let mediaPickerAssemblyFactory = Paparazzo.MarshrouteAssemblyFactory( + theme: PaparazzoUITheme.appSpecificTheme(), + photoStorage: photoStorage + ) + let exampleAssembly = ExampleAssemblyImpl(mediaPickerAssemblyFactory: mediaPickerAssemblyFactory) + window?.rootViewController = MarshrouteFacade().navigationController(NavigationController()) { routerSeed in - ExampleAssemblyImpl().viewController(routerSeed: routerSeed) + exampleAssembly.viewController(routerSeed: routerSeed) } window?.makeKeyAndVisible() diff --git a/Example/PaparazzoExample/Example/Assembly/ExampleAssemblyImpl.swift b/Example/PaparazzoExample/Example/Assembly/ExampleAssemblyImpl.swift index 5b40da8b..9b6e2980 100644 --- a/Example/PaparazzoExample/Example/Assembly/ExampleAssemblyImpl.swift +++ b/Example/PaparazzoExample/Example/Assembly/ExampleAssemblyImpl.swift @@ -1,8 +1,15 @@ import UIKit import Marshroute +import Paparazzo final class ExampleAssemblyImpl: ExampleAssembly { + private let mediaPickerAssemblyFactory: MarshrouteAssemblyFactory + + init(mediaPickerAssemblyFactory: MarshrouteAssemblyFactory) { + self.mediaPickerAssemblyFactory = mediaPickerAssemblyFactory + } + // MARK: - ExampleAssembly func viewController(routerSeed: RouterSeed) -> UIViewController { @@ -10,6 +17,7 @@ final class ExampleAssemblyImpl: ExampleAssembly { let interactor = ExampleInteractorImpl() let router = ExampleRouterImpl( + mediaPickerAssemblyFactory: mediaPickerAssemblyFactory, routerSeed: routerSeed ) diff --git a/Example/PaparazzoExample/Example/Presenter/ExamplePresenter.swift b/Example/PaparazzoExample/Example/Presenter/ExamplePresenter.swift index 4a91d1b9..e9287ae0 100644 --- a/Example/PaparazzoExample/Example/Presenter/ExamplePresenter.swift +++ b/Example/PaparazzoExample/Example/Presenter/ExamplePresenter.swift @@ -3,6 +3,8 @@ import ImageSource final class ExamplePresenter { + // MARK: - Dependencies + private let interactor: ExampleInteractor private let router: ExampleRouter @@ -21,10 +23,18 @@ final class ExamplePresenter { private var items: [MediaPickerItem] = [] + private let cropCanvasSize = CGSize(width: 1280, height: 960) + // MARK: - Private + private let croppingOverlayProvidersFactory = Paparazzo.CroppingOverlayProvidersFactoryImpl() + private func setUpView() { + view?.setMediaPickerButtonTitle("Media Picker") + view?.setMaskCropperButtonTitle("Mask Cropper") + view?.setPhotoLibraryButtonTitle("Photo Library") + view?.onShowMediaPickerButtonTap = { [weak self] in self?.interactor.remoteItems { remoteItems in self?.showMediaPicker(remoteItems: remoteItems) @@ -48,21 +58,76 @@ final class ExamplePresenter { } } } + + view?.onMaskCropperButtonTap = { [weak self] in + self?.showMaskCropperCamera() + } + } + + func showMaskCropperCamera() { + let data = MediaPickerData( + items: items, + selectedItem: nil, + maxItemsCount: 1, + cropEnabled: true, + cropCanvasSize: cropCanvasSize, + initialActiveCameraType: .front + ) + + self.router.showMediaPicker( + data: data, + configure: { module in + weak var module = module + module?.setContinueButtonVisible(false) + module?.setCropMode(.custom(croppingOverlayProvidersFactory.circleCroppingOverlayProvider())) + module?.onCancel = { + module?.dismissModule() + } + module?.onFinish = { items in + module?.dismissModule() + } + } + ) + } + + private func showMaskCropperIn(rootModule: MediaPickerModule?, photo: MediaPickerItem) { + + let data = MaskCropperData( + imageSource: photo.image, + cropCanvasSize: cropCanvasSize + ) + router.showMaskCropper( + data: data, + croppingOverlayProvider: croppingOverlayProvidersFactory.heartShapeCroppingOverlayProvider(), + configure: { module in + weak var module = module + module?.onDiscard = { + module?.dismissModule() + } + module?.onConfirm = { _ in + rootModule?.dismissModule() + } + }) } func showMediaPicker(remoteItems: [MediaPickerItem]) { var items = self.items items.append(contentsOf: remoteItems) - - let cropCanvasSize = CGSize(width: 1280, height: 960) - self.router.showMediaPicker( + let data = MediaPickerData( items: items, + autocorrectionFilters: [AutoAdjustmentFilter()], selectedItem: items.last, maxItemsCount: 20, - cropCanvasSize: cropCanvasSize, - configuration: { [weak self] module in + cropEnabled: true, + autocorrectEnabled: true, + cropCanvasSize: cropCanvasSize + ) + + self.router.showMediaPicker( + data: data, + configure: { [weak self] module in self?.configureMediaPicker(module: module) } ) @@ -72,6 +137,7 @@ final class ExamplePresenter { module.onItemsAdd = { _ in debugPrint("mediaPickerDidAddItems") } module.onItemUpdate = { _ in debugPrint("mediaPickerDidUpdateItem") } module.onItemRemove = { _ in debugPrint("mediaPickerDidRemoveItem") } + module.onItemAutocorrect = { _, isAutocorrected, _ in debugPrint("mediaPickerDidAutocorrectItem: \(isAutocorrected)") } module.setContinueButtonTitle("Готово") @@ -79,6 +145,13 @@ final class ExamplePresenter { module?.dismissModule() } + module.onContinueButtonTap = { [weak module] in + module?.setContinueButtonStyle(.spinner) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + module?.finish() + } + } + module.onFinish = { [weak module] items in debugPrint("media picker did finish with \(items.count) items:") items.forEach { debugPrint($0) } diff --git a/Example/PaparazzoExample/Example/Router/ExampleRouter.swift b/Example/PaparazzoExample/Example/Router/ExampleRouter.swift index e80b1d30..ea2180ca 100644 --- a/Example/PaparazzoExample/Example/Router/ExampleRouter.swift +++ b/Example/PaparazzoExample/Example/Router/ExampleRouter.swift @@ -4,16 +4,19 @@ import Paparazzo protocol ExampleRouter: class, RouterFocusable, RouterDismissable { func showMediaPicker( - items: [MediaPickerItem], - selectedItem: MediaPickerItem?, - maxItemsCount: Int?, - cropCanvasSize: CGSize, - configuration: (MediaPickerModule) -> () + data: MediaPickerData, + configure: (MediaPickerModule) -> () + ) + + func showMaskCropper( + data: MaskCropperData, + croppingOverlayProvider: CroppingOverlayProvider, + configure: (MaskCropperModule) -> () ) func showPhotoLibrary( selectedItems: [PhotoLibraryItem], maxSelectedItemsCount: Int?, - configuration: (PhotoLibraryModule) -> () + configure: (PhotoLibraryModule) -> () ) } diff --git a/Example/PaparazzoExample/Example/Router/ExampleRouterImpl.swift b/Example/PaparazzoExample/Example/Router/ExampleRouterImpl.swift index e90a961c..afd424e6 100644 --- a/Example/PaparazzoExample/Example/Router/ExampleRouterImpl.swift +++ b/Example/PaparazzoExample/Example/Router/ExampleRouterImpl.swift @@ -4,31 +4,48 @@ import Paparazzo final class ExampleRouterImpl: BaseRouter, ExampleRouter { - private let mediaPickerAssemblyFactory = Paparazzo.MarshrouteAssemblyFactory( - theme: PaparazzoUITheme.appSpecificTheme() - ) + private let mediaPickerAssemblyFactory: MarshrouteAssemblyFactory + + init( + mediaPickerAssemblyFactory: MarshrouteAssemblyFactory, + routerSeed: RouterSeed) + { + self.mediaPickerAssemblyFactory = mediaPickerAssemblyFactory + super.init(routerSeed: routerSeed) + } // MARK: - ExampleRouter func showMediaPicker( - items: [MediaPickerItem], - selectedItem: MediaPickerItem?, - maxItemsCount: Int?, - cropCanvasSize: CGSize, - configuration: (MediaPickerModule) -> () + data: MediaPickerData, + configure: (MediaPickerModule) -> () ) { pushViewControllerDerivedFrom { routerSeed in let assembly = mediaPickerAssemblyFactory.mediaPickerAssembly() return assembly.module( - items: items, - selectedItem: selectedItem, - maxItemsCount: maxItemsCount, - cropEnabled: true, - cropCanvasSize: cropCanvasSize, + data: data, + routerSeed: routerSeed, + configure: configure + ) + } + } + + func showMaskCropper( + data: MaskCropperData, + croppingOverlayProvider: CroppingOverlayProvider, + configure: (MaskCropperModule) -> () + ) { + pushViewControllerDerivedFrom { routerSeed in + + let assembly = mediaPickerAssemblyFactory.maskCropperAssembly() + + return assembly.module( + data: data, + croppingOverlayProvider: croppingOverlayProvider, routerSeed: routerSeed, - configuration: configuration + configure: configure ) } } @@ -36,7 +53,7 @@ final class ExampleRouterImpl: BaseRouter, ExampleRouter { func showPhotoLibrary( selectedItems: [PhotoLibraryItem], maxSelectedItemsCount: Int?, - configuration: (PhotoLibraryModule) -> () + configure: (PhotoLibraryModule) -> () ) { presentModalNavigationControllerWithRootViewControllerDerivedFrom { routerSeed in @@ -46,7 +63,7 @@ final class ExampleRouterImpl: BaseRouter, ExampleRouter { selectedItems: selectedItems, maxSelectedItemsCount: maxSelectedItemsCount, routerSeed: routerSeed, - configuration: configuration + configure: configure ) } } diff --git a/Example/PaparazzoExample/Example/View/ExampleView.swift b/Example/PaparazzoExample/Example/View/ExampleView.swift index 4e5322d5..f3005ec3 100644 --- a/Example/PaparazzoExample/Example/View/ExampleView.swift +++ b/Example/PaparazzoExample/Example/View/ExampleView.swift @@ -2,10 +2,8 @@ import UIKit final class ExampleView: UIView { - var onShowMediaPickerButtonTap: (() -> ())? - var onShowPhotoLibraryButtonTap: (() -> ())? - private let mediaPickerButton = UIButton() + private let maskCropperButton = UIButton() private let photoLibraryButton = UIButton() // MARK: - Init @@ -13,13 +11,19 @@ final class ExampleView: UIView { init() { super.init(frame: .zero) - mediaPickerButton.setTitle("Show Media Picker", for: .normal) mediaPickerButton.addTarget( self, action: #selector(onShowMediaPickerButtonTap(_:)), for: .touchUpInside ) + maskCropperButton.setTitle("Show Mask Cropper", for: .normal) + maskCropperButton.addTarget( + self, + action: #selector(onMaskCropperButtonTap(_:)), + for: .touchUpInside + ) + photoLibraryButton.setTitle("Show Photo Library", for: .normal) photoLibraryButton.addTarget( self, @@ -28,6 +32,7 @@ final class ExampleView: UIView { ) addSubview(mediaPickerButton) + addSubview(maskCropperButton) addSubview(photoLibraryButton) } @@ -35,16 +40,37 @@ final class ExampleView: UIView { fatalError("init(coder:) has not been implemented") } + // MARK: - ExampleView + + func setMediaPickerButtonTitle(_ title: String) { + mediaPickerButton.setTitle(title, for: .normal) + } + + func setMaskCropperButtonTitle(_ title: String) { + maskCropperButton.setTitle(title, for: .normal) + } + + func setPhotoLibraryButtonTitle(_ title: String) { + photoLibraryButton.setTitle(title, for: .normal) + } + + var onShowMediaPickerButtonTap: (() -> ())? + var onMaskCropperButtonTap: (() -> ())? + var onShowPhotoLibraryButtonTap: (() -> ())? + // MARK: - UIView override func layoutSubviews() { super.layoutSubviews() mediaPickerButton.sizeToFit() - mediaPickerButton.center = CGPoint(x: bounds.midX, y: bounds.midY - 30) + mediaPickerButton.center = CGPoint(x: bounds.midX, y: bounds.midY - 50) + + maskCropperButton.sizeToFit() + maskCropperButton.center = CGPoint(x: bounds.midX, y: bounds.midY) photoLibraryButton.sizeToFit() - photoLibraryButton.center = CGPoint(x: bounds.midX, y: bounds.midY + 30) + photoLibraryButton.center = CGPoint(x: bounds.midX, y: bounds.midY + 50) } // MARK: - Private @@ -53,6 +79,10 @@ final class ExampleView: UIView { onShowMediaPickerButtonTap?() } + @objc private func onMaskCropperButtonTap(_: UIButton) { + onMaskCropperButtonTap?() + } + @objc private func onShowPhotoLibraryButtonTap(_: UIButton) { onShowPhotoLibraryButtonTap?() } diff --git a/Example/PaparazzoExample/Example/View/ExampleViewController.swift b/Example/PaparazzoExample/Example/View/ExampleViewController.swift index 60bce734..4f1b3a8e 100644 --- a/Example/PaparazzoExample/Example/View/ExampleViewController.swift +++ b/Example/PaparazzoExample/Example/View/ExampleViewController.swift @@ -25,6 +25,18 @@ final class ExampleViewController: UIViewController, ExampleViewInput { // MARK: - ExampleViewInput + func setMediaPickerButtonTitle(_ title: String) { + exampleView?.setMediaPickerButtonTitle(title) + } + + func setMaskCropperButtonTitle(_ title: String) { + exampleView?.setMaskCropperButtonTitle(title) + } + + func setPhotoLibraryButtonTitle(_ title: String) { + exampleView?.setPhotoLibraryButtonTitle(title) + } + var onShowMediaPickerButtonTap: (() -> ())? { get { return exampleView?.onShowMediaPickerButtonTap } set { exampleView?.onShowMediaPickerButtonTap = newValue } @@ -34,4 +46,9 @@ final class ExampleViewController: UIViewController, ExampleViewInput { get { return exampleView?.onShowPhotoLibraryButtonTap } set { exampleView?.onShowPhotoLibraryButtonTap = newValue } } + + var onMaskCropperButtonTap: (() -> ())? { + get { return exampleView?.onMaskCropperButtonTap } + set { exampleView?.onMaskCropperButtonTap = newValue } + } } diff --git a/Example/PaparazzoExample/Example/View/ExampleViewInput.swift b/Example/PaparazzoExample/Example/View/ExampleViewInput.swift index b1cda7bb..1898af49 100644 --- a/Example/PaparazzoExample/Example/View/ExampleViewInput.swift +++ b/Example/PaparazzoExample/Example/View/ExampleViewInput.swift @@ -1,6 +1,12 @@ import Foundation protocol ExampleViewInput: class { + + func setMediaPickerButtonTitle(_ title: String) + func setMaskCropperButtonTitle(_ title: String) + func setPhotoLibraryButtonTitle(_ title: String) + var onShowMediaPickerButtonTap: (() -> ())? { get set } + var onMaskCropperButtonTap: (() -> ())? { get set } var onShowPhotoLibraryButtonTap: (() -> ())? { get set } } diff --git a/Example/PaparazzoExample/Filters/AutoAdjustmentFilter.swift b/Example/PaparazzoExample/Filters/AutoAdjustmentFilter.swift new file mode 100644 index 00000000..cda670df --- /dev/null +++ b/Example/PaparazzoExample/Filters/AutoAdjustmentFilter.swift @@ -0,0 +1,58 @@ +import Paparazzo +import ImageSource +import ImageIO +import CoreGraphics +import MobileCoreServices + +final class AutoAdjustmentFilter: Filter { + let fallbackMessage: String? = "Failed to apply autocorrection".uppercased() + + func apply(_ sourceImage: ImageSource, completion: @escaping ((_ resultImage: ImageSource) -> ())) { + + let options = ImageRequestOptions(size: .fullResolution, deliveryMode: .best) + + sourceImage.requestImage(options: options) { [weak self] (result: ImageRequestResult) in + guard let image = result.image else { + completion(sourceImage) + return + } + + var ciImage = CIImage(image: image) + let adjustments = ciImage?.autoAdjustmentFilters() + + adjustments?.forEach { filter in + filter.setValue(ciImage, forKey: kCIInputImageKey) + ciImage = filter.outputImage + } + + let context = CIContext(options: nil) + if let output = ciImage, let cgImage = context.createCGImage(output, from: output.extent) { + + if let image = self?.imageSource(with: cgImage) { + completion(image) + return + } + } + + completion(sourceImage) + } + } + + private func imageSource(with cgImage: CGImage) -> ImageSource? { + + let path = (NSTemporaryDirectory() as NSString).appendingPathComponent("\(UUID().uuidString).jpg") + let url = URL(fileURLWithPath: path) + let destination = CGImageDestinationCreateWithURL(url as CFURL, kUTTypeJPEG, 1, nil) + + if let destination = destination { + + CGImageDestinationAddImage(destination, cgImage, nil) + + if CGImageDestinationFinalize(destination) { + let imageSource = LocalImageSource(path: path, previewImage: cgImage) + return imageSource + } + } + return nil + } +} diff --git a/Example/PaparazzoExample_NoMarshroute/AppDelegate.swift b/Example/PaparazzoExample_NoMarshroute/AppDelegate.swift index f3983a22..fe2e08a9 100644 --- a/Example/PaparazzoExample_NoMarshroute/AppDelegate.swift +++ b/Example/PaparazzoExample_NoMarshroute/AppDelegate.swift @@ -17,7 +17,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private func rootViewController() -> UIViewController { - let assemblyFactory = Paparazzo.AssemblyFactory(theme: PaparazzoUITheme.appSpecificTheme()) + let photoStorage = PhotoStorageImpl() + photoStorage.removeAll() + let assemblyFactory = Paparazzo.AssemblyFactory( + theme: PaparazzoUITheme.appSpecificTheme(), + photoStorage: photoStorage + ) let exampleController = ExampleViewController() @@ -34,7 +39,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { maxItemsCount: 20, cropEnabled: true, cropCanvasSize: CGSize(width: 1280, height: 960), - configuration: { module in + configure: { module in weak var module = module module?.setContinueButtonTitle("Готово") @@ -59,7 +64,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let galleryController = assembly.module( selectedItems: [], maxSelectedItemsCount: 5, - configuration: { module in + configure: { module in weak var module = module module?.onFinish = { _ in module?.dismissModule() diff --git a/Example/Podfile.lock b/Example/Podfile.lock index 0253aeb1..69496efd 100644 --- a/Example/Podfile.lock +++ b/Example/Podfile.lock @@ -94,8 +94,8 @@ SPEC CHECKSUMS: JNWSpringAnimation: cd4c2f4464324f63f176c3624ffccf205211a100 Marshroute: abed7a53cf04db1a60aaddfe797dea7a7d010464 Paparazzo: d4a7ada3cc99d5742f579d63e20f6da838a86398 - SDWebImage: 098e97e6176540799c27e804c96653ee0833d13c + SDWebImage: '098e97e6176540799c27e804c96653ee0833d13c' PODFILE CHECKSUM: de862853608cf1d1ac04ebaaa29cb02cccfc5328 -COCOAPODS: 1.2.0 +COCOAPODS: 1.2.1 diff --git a/ImageSource.podspec b/ImageSource.podspec index 2b3f80a1..c1b26143 100644 --- a/ImageSource.podspec +++ b/ImageSource.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = 'ImageSource' s.module_name = 'ImageSource' - s.version = '1.1.0' + s.version = '2.0.0' s.summary = 'ImageSource by Avito' s.homepage = 'https://github.com/avito-tech/Paparazzo' s.license = 'Avito' diff --git a/ImageSource/Core/ImageSource.swift b/ImageSource/Core/ImageSource.swift index c66410e5..7952351b 100644 --- a/ImageSource/Core/ImageSource.swift +++ b/ImageSource/Core/ImageSource.swift @@ -29,3 +29,7 @@ public protocol ImageSource: class { public func ==(lhs: ImageSource, rhs: ImageSource) -> Bool { return lhs.isEqualTo(rhs) } + +public func !=(lhs: ImageSource, rhs: ImageSource) -> Bool { + return (lhs == rhs) == false +} diff --git a/Paparazzo.podspec b/Paparazzo.podspec index 5d930e41..dd350f0a 100644 --- a/Paparazzo.podspec +++ b/Paparazzo.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = 'Paparazzo' s.module_name = 'Paparazzo' - s.version = '1.1.0' + s.version = '2.0.0' s.summary = "iOS component for picking and editing photos from camera and user's photo library" s.homepage = 'https://github.com/avito-tech/Paparazzo' s.license = 'MIT' diff --git a/Paparazzo/Assets/Assets.xcassets/autocorrect_active.imageset/Contents.json b/Paparazzo/Assets/Assets.xcassets/autocorrect_active.imageset/Contents.json new file mode 100644 index 00000000..c1acf23b --- /dev/null +++ b/Paparazzo/Assets/Assets.xcassets/autocorrect_active.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "autocorrect_active.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "autocorrect_active@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "autocorrect_active@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Paparazzo/Assets/Assets.xcassets/autocorrect_active.imageset/autocorrect_active.png b/Paparazzo/Assets/Assets.xcassets/autocorrect_active.imageset/autocorrect_active.png new file mode 100644 index 00000000..0ab8cc16 Binary files /dev/null and b/Paparazzo/Assets/Assets.xcassets/autocorrect_active.imageset/autocorrect_active.png differ diff --git a/Paparazzo/Assets/Assets.xcassets/autocorrect_active.imageset/autocorrect_active@2x.png b/Paparazzo/Assets/Assets.xcassets/autocorrect_active.imageset/autocorrect_active@2x.png new file mode 100644 index 00000000..b02ad442 Binary files /dev/null and b/Paparazzo/Assets/Assets.xcassets/autocorrect_active.imageset/autocorrect_active@2x.png differ diff --git a/Paparazzo/Assets/Assets.xcassets/autocorrect_active.imageset/autocorrect_active@3x.png b/Paparazzo/Assets/Assets.xcassets/autocorrect_active.imageset/autocorrect_active@3x.png new file mode 100644 index 00000000..8e0b62de Binary files /dev/null and b/Paparazzo/Assets/Assets.xcassets/autocorrect_active.imageset/autocorrect_active@3x.png differ diff --git a/Paparazzo/Assets/Assets.xcassets/autocorrect_inactive.imageset/Contents.json b/Paparazzo/Assets/Assets.xcassets/autocorrect_inactive.imageset/Contents.json new file mode 100644 index 00000000..bed4323b --- /dev/null +++ b/Paparazzo/Assets/Assets.xcassets/autocorrect_inactive.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "autocorrect_inactive.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "autocorrect_inactive@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "autocorrect_inactive@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Paparazzo/Assets/Assets.xcassets/autocorrect_inactive.imageset/autocorrect_inactive.png b/Paparazzo/Assets/Assets.xcassets/autocorrect_inactive.imageset/autocorrect_inactive.png new file mode 100644 index 00000000..d570ef3c Binary files /dev/null and b/Paparazzo/Assets/Assets.xcassets/autocorrect_inactive.imageset/autocorrect_inactive.png differ diff --git a/Paparazzo/Assets/Assets.xcassets/autocorrect_inactive.imageset/autocorrect_inactive@2x.png b/Paparazzo/Assets/Assets.xcassets/autocorrect_inactive.imageset/autocorrect_inactive@2x.png new file mode 100644 index 00000000..d1ae762e Binary files /dev/null and b/Paparazzo/Assets/Assets.xcassets/autocorrect_inactive.imageset/autocorrect_inactive@2x.png differ diff --git a/Paparazzo/Assets/Assets.xcassets/autocorrect_inactive.imageset/autocorrect_inactive@3x.png b/Paparazzo/Assets/Assets.xcassets/autocorrect_inactive.imageset/autocorrect_inactive@3x.png new file mode 100644 index 00000000..6ddba381 Binary files /dev/null and b/Paparazzo/Assets/Assets.xcassets/autocorrect_inactive.imageset/autocorrect_inactive@3x.png differ diff --git a/Paparazzo/Core/DI/AssemblyFactory.swift b/Paparazzo/Core/DI/AssemblyFactory.swift new file mode 100644 index 00000000..109e38ad --- /dev/null +++ b/Paparazzo/Core/DI/AssemblyFactory.swift @@ -0,0 +1,40 @@ +public final class AssemblyFactory: + CameraAssemblyFactory, + MediaPickerAssemblyFactory, + PhotoLibraryAssemblyFactory, + ImageCroppingAssemblyFactory, + MaskCropperAssemblyFactory +{ + + private let theme: PaparazzoUITheme + private let serviceFactory: ServiceFactory + + public init( + theme: PaparazzoUITheme = PaparazzoUITheme(), + photoStorage: PhotoStorage = PhotoStorageImpl()) + { + self.theme = theme + self.serviceFactory = ServiceFactoryImpl(photoStorage: photoStorage) + } + + func cameraAssembly() -> CameraAssembly { + return CameraAssemblyImpl(theme: theme, serviceFactory: serviceFactory) + } + + public func mediaPickerAssembly() -> MediaPickerAssembly { + return MediaPickerAssemblyImpl(assemblyFactory: self, theme: theme, serviceFactory: serviceFactory) + } + + func imageCroppingAssembly() -> ImageCroppingAssembly { + return ImageCroppingAssemblyImpl(theme: theme, serviceFactory: serviceFactory) + } + + public func photoLibraryAssembly() -> PhotoLibraryAssembly { + return PhotoLibraryAssemblyImpl(theme: theme, serviceFactory: serviceFactory) + } + + public func maskCropperAssembly() -> MaskCropperAssembly { + return MaskCropperAssemblyImpl(theme: theme, serviceFactory: serviceFactory) + } + +} diff --git a/Paparazzo/Core/DI/BasePaparazzoAssembly.swift b/Paparazzo/Core/DI/BasePaparazzoAssembly.swift new file mode 100644 index 00000000..3c48cd73 --- /dev/null +++ b/Paparazzo/Core/DI/BasePaparazzoAssembly.swift @@ -0,0 +1,11 @@ +public class BasePaparazzoAssembly { + // MARK: - Dependencies + let theme: PaparazzoUITheme + let serviceFactory: ServiceFactory + + init(theme: PaparazzoUITheme, serviceFactory: ServiceFactory) { + self.theme = theme + self.serviceFactory = serviceFactory + } + +} diff --git a/Paparazzo/Core/DI/ServiceFactory.swift b/Paparazzo/Core/DI/ServiceFactory.swift new file mode 100644 index 00000000..dbf5af54 --- /dev/null +++ b/Paparazzo/Core/DI/ServiceFactory.swift @@ -0,0 +1,37 @@ +import ImageSource + +protocol ServiceFactory: class { + func deviceOrientationService() -> DeviceOrientationService + func cameraService(initialActiveCameraType: CameraType) -> CameraService + func photoLibraryLatestPhotoProvider() -> PhotoLibraryLatestPhotoProvider + func imageCroppingService(image: ImageSource, canvasSize: CGSize) -> ImageCroppingService +} + +final class ServiceFactoryImpl: ServiceFactory { + + private let photoStorage: PhotoStorage + + init(photoStorage: PhotoStorage) { + self.photoStorage = photoStorage + } + + func deviceOrientationService() -> DeviceOrientationService { + return DeviceOrientationServiceImpl() + } + + func cameraService(initialActiveCameraType: CameraType) -> CameraService { + return CameraServiceImpl( + initialActiveCameraType: initialActiveCameraType, + photoStorage: photoStorage + ) + } + + func photoLibraryLatestPhotoProvider() -> PhotoLibraryLatestPhotoProvider { + return PhotoLibraryLatestPhotoProviderImpl() + } + + func imageCroppingService(image: ImageSource, canvasSize: CGSize) -> ImageCroppingService { + return ImageCroppingServiceImpl(image: image, canvasSize: canvasSize) + } + +} diff --git a/Paparazzo/Core/DisposeBag.swift b/Paparazzo/Core/DisposeBag.swift new file mode 100644 index 00000000..1045e203 --- /dev/null +++ b/Paparazzo/Core/DisposeBag.swift @@ -0,0 +1,28 @@ +protocol DisposeBag { + func addDisposable(_: AnyObject) +} + +protocol DisposeBagHolder { + var disposeBag: DisposeBag { get } +} + +// Default `DisposeBag` implementation +extension DisposeBag where Self: DisposeBagHolder { + func addDisposable(_ anyObject: AnyObject) { + disposeBag.addDisposable(anyObject) + } +} + +// Non thread safe `DisposeBag` implementation +final class DisposeBagImpl: DisposeBag { + // MARK: - Private properties + private var disposables: [AnyObject] = [] + + // MARK: - Init + init() {} + + // MARK: - DisposeBag + func addDisposable(_ anyObject: AnyObject) { + disposables.append(anyObject) + } +} diff --git a/Paparazzo/Core/Extensions/UIKitExtensions.swift b/Paparazzo/Core/Extensions/UIKitExtensions.swift index 372e1d20..a3efecd9 100644 --- a/Paparazzo/Core/Extensions/UIKitExtensions.swift +++ b/Paparazzo/Core/Extensions/UIKitExtensions.swift @@ -103,9 +103,14 @@ extension UIView { layer.anchorPoint = anchorPoint } - func snapshot() -> UIImage? { - UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0) - drawHierarchy(in: bounds, afterScreenUpdates: true) + func snapshot(withScale scale: CGFloat = 0) -> UIImage? { + + UIGraphicsBeginImageContextWithOptions(bounds.size, false, scale) + + if let context = UIGraphicsGetCurrentContext() { + self.layer.render(in: context) + } + let image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return image @@ -123,6 +128,11 @@ extension UIView { } ) } + + func setAccessibilityId(_ id: AccessibilityId) { + accessibilityIdentifier = id.rawValue + isAccessibilityElement = true + } } extension UICollectionView { diff --git a/Paparazzo/Core/Helpers/CameraOutputGLKView.swift b/Paparazzo/Core/Helpers/CameraOutputGLKView.swift index df962ee3..4bf7f639 100644 --- a/Paparazzo/Core/Helpers/CameraOutputGLKView.swift +++ b/Paparazzo/Core/Helpers/CameraOutputGLKView.swift @@ -56,7 +56,7 @@ final class CameraOutputGLKView: GLKView { private func drawableBounds(for rect: CGRect) -> CGRect { - let screenScale = UIScreen.main.scale + let screenScale = UIScreen.main.nativeScale var drawableBounds = rect drawableBounds.size.width *= screenScale diff --git a/Paparazzo/Core/Helpers/PhotoStorage.swift b/Paparazzo/Core/Helpers/PhotoStorage.swift new file mode 100644 index 00000000..8e853045 --- /dev/null +++ b/Paparazzo/Core/Helpers/PhotoStorage.swift @@ -0,0 +1,102 @@ +import AVFoundation + +public protocol PhotoStorage { + func savePhoto( + sampleBuffer: CMSampleBuffer?, + callbackQueue: DispatchQueue, + completion: @escaping (PhotoFromCamera?) -> () + ) + func removePhoto(_ photo: PhotoFromCamera) + func removeAll() +} + + +public final class PhotoStorageImpl: PhotoStorage { + + private static let folderName = "Paparazzo" + + private let createFolder = { + PhotoStorageImpl.createPhotoDirectoryIfNotExist() + }() + + // MARK: - Init + public init() {} + + // MARK: - PhotoStorage + public func savePhoto( + sampleBuffer: CMSampleBuffer?, + callbackQueue: DispatchQueue, + completion: @escaping (PhotoFromCamera?) -> ()) + { + DispatchQueue.global(qos: .userInitiated).async { + let imageData = sampleBuffer.flatMap({ AVCaptureStillImageOutput.jpegStillImageNSDataRepresentation($0) }) + var photo: PhotoFromCamera? = nil + if let imageData = imageData { + let path = self.randomTemporaryPhotoFilePath() + do { + try imageData.write( + to: URL(fileURLWithPath: path), + options: [.atomicWrite] + ) + photo = PhotoFromCamera(path: path) + } catch let error { + assert(false, "Couldn't save photo at path \(path) with error: \(error)") + } + } + callbackQueue.async { + completion(photo) + } + } + } + + public func removePhoto(_ photo: PhotoFromCamera) { + do { + try FileManager.default.removeItem(atPath: photo.path) + } catch let error { + assert(false, "Couldn't remove photo at path \(photo.path) with error: \(error)") + } + } + + public func removeAll() { + do { + try FileManager.default.removeItem(atPath: PhotoStorageImpl.photoDirectoryPath()) + PhotoStorageImpl.createPhotoDirectoryIfNotExist() + } catch let error { + assert(false, "Couldn't remove photo folder with error: \(error)") + } + } + + // MARK: - Private + + private static func createPhotoDirectoryIfNotExist() { + var isDirectory: ObjCBool = false + let path = PhotoStorageImpl.photoDirectoryPath() + let exist = FileManager.default.fileExists( + atPath: path, + isDirectory: &isDirectory + ) + if !exist || !isDirectory.boolValue { + do { + try FileManager.default.createDirectory( + atPath: PhotoStorageImpl.photoDirectoryPath(), + withIntermediateDirectories: false, + attributes: nil + ) + } catch let error { + assert(false, "Couldn't create folder for images with error: \(error)") + } + } + } + + private static func photoDirectoryPath() -> String { + let tempDirPath = NSTemporaryDirectory() as NSString + return tempDirPath.appendingPathComponent(PhotoStorageImpl.folderName) + } + + private func randomTemporaryPhotoFilePath() -> String { + let tempName = "\(NSUUID().uuidString).jpg" + let directoryPath = PhotoStorageImpl.photoDirectoryPath() as NSString + return directoryPath.appendingPathComponent(tempName) + } + +} diff --git a/Paparazzo/Core/MediaPickerUITheme.swift b/Paparazzo/Core/MediaPickerUITheme.swift index cb6d96b4..ffa4da2b 100644 --- a/Paparazzo/Core/MediaPickerUITheme.swift +++ b/Paparazzo/Core/MediaPickerUITheme.swift @@ -1,7 +1,12 @@ import UIKit -public struct PaparazzoUITheme: MediaPickerRootModuleUITheme, PhotoLibraryUITheme, ImageCroppingUITheme { - +public struct PaparazzoUITheme: + MediaPickerRootModuleUITheme, + PhotoLibraryUITheme, + ImageCroppingUITheme, + MaskCropperUITheme +{ + public init() {} // MARK: - MediaPickerRootModuleUITheme @@ -9,8 +14,11 @@ public struct PaparazzoUITheme: MediaPickerRootModuleUITheme, PhotoLibraryUIThem public var shutterButtonColor = UIColor(red: 0, green: 170.0/255, blue: 1, alpha: 1) public var shutterButtonDisabledColor = UIColor.lightGray public var mediaRibbonSelectionColor = UIColor(red: 0, green: 170.0/255, blue: 1, alpha: 1) + public var focusIndicatorColor = UIColor(red: 0, green: 170.0/255, blue: 1, alpha: 1) public var removePhotoIcon = PaparazzoUITheme.image(named: "delete") + public var autocorrectPhotoIconInactive = PaparazzoUITheme.image(named: "autocorrect_inactive") + public var autocorrectPhotoIconActive = PaparazzoUITheme.image(named: "autocorrect_active") public var cropPhotoIcon = PaparazzoUITheme.image(named: "crop") public var returnToCameraIcon = PaparazzoUITheme.image(named: "camera") public var closeCameraIcon = PaparazzoUITheme.image(named: "bt-close") @@ -50,6 +58,11 @@ public struct PaparazzoUITheme: MediaPickerRootModuleUITheme, PhotoLibraryUIThem public var cancelRotationBackgroundColor = UIColor.RGB(red: 25, green: 25, blue: 25, alpha: 1) public var cancelRotationTitleColor = UIColor.white public var cancelRotationTitleFont = UIFont.boldSystemFont(ofSize: 14) + + // MARK: - MaskCropperUITheme + + public var maskCropperDiscardPhotoIcon = PaparazzoUITheme.image(named: "discard") + public var maskCropperConfirmPhotoIcon = PaparazzoUITheme.image(named: "confirm") // MARK: - Private @@ -60,57 +73,3 @@ public struct PaparazzoUITheme: MediaPickerRootModuleUITheme, PhotoLibraryUIThem return UIImage(named: name, in: bundle, compatibleWith: nil) } } - -public protocol AccessDeniedViewTheme { - var accessDeniedTitleFont: UIFont { get } - var accessDeniedMessageFont: UIFont { get } - var accessDeniedButtonFont: UIFont { get } -} - -public protocol MediaPickerRootModuleUITheme: AccessDeniedViewTheme { - - var shutterButtonColor: UIColor { get } - var shutterButtonDisabledColor: UIColor { get } - var mediaRibbonSelectionColor: UIColor { get } - var cameraContinueButtonTitleColor: UIColor { get } - var cameraContinueButtonTitleHighlightedColor: UIColor { get } - var cameraButtonsBackgroundNormalColor: UIColor { get } - var cameraButtonsBackgroundHighlightedColor: UIColor { get } - var cameraButtonsBackgroundDisabledColor: UIColor { get } - - var removePhotoIcon: UIImage? { get } - var cropPhotoIcon: UIImage? { get } - var returnToCameraIcon: UIImage? { get } - var closeCameraIcon: UIImage? { get } - var flashOnIcon: UIImage? { get } - var flashOffIcon: UIImage? { get } - var cameraToggleIcon: UIImage? { get } - var photoPeepholePlaceholder: UIImage? { get } - - - var cameraContinueButtonTitleFont: UIFont { get } -} - -public protocol PhotoLibraryUITheme: AccessDeniedViewTheme { - - var photoLibraryDoneButtonFont: UIFont { get } - - var photoLibraryItemSelectionColor: UIColor { get } - var photoCellBackgroundColor: UIColor { get } - - var iCloudIcon: UIImage? { get } -} - -public protocol ImageCroppingUITheme { - - var rotationIcon: UIImage? { get } - var gridIcon: UIImage? { get } - var gridSelectedIcon: UIImage? { get } - var cropperDiscardIcon: UIImage? { get } - var cropperConfirmIcon: UIImage? { get } - - var cancelRotationBackgroundColor: UIColor { get } - var cancelRotationTitleColor: UIColor { get } - var cancelRotationTitleFont: UIFont { get } - var cancelRotationButtonIcon: UIImage? { get } -} diff --git a/Paparazzo/Core/PaparazzoViewController.swift b/Paparazzo/Core/PaparazzoViewController.swift new file mode 100644 index 00000000..9c9f6baa --- /dev/null +++ b/Paparazzo/Core/PaparazzoViewController.swift @@ -0,0 +1,14 @@ +import UIKit + +class PaparazzoViewController: UIViewController, DisposeBag, DisposeBagHolder { + // MARK: - DisposeBagHolder + public let disposeBag: DisposeBag = DisposeBagImpl() + + @nonobjc public init() { + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Paparazzo/Core/PhotoTweakView.swift b/Paparazzo/Core/PhotoTweakView.swift index 0d9ffa35..d88e4ec6 100644 --- a/Paparazzo/Core/PhotoTweakView.swift +++ b/Paparazzo/Core/PhotoTweakView.swift @@ -75,7 +75,7 @@ final class PhotoTweakView: UIView, UIScrollViewDelegate { override var frame: CGRect { didSet { - reset() + resetScrollViewState() calculateFrames() adjustRotation() } @@ -87,9 +87,17 @@ final class PhotoTweakView: UIView, UIScrollViewDelegate { // MARK: - PhotoTweakView + func setMaskVisible(_ visible: Bool) { + topMask.isHidden = !visible + bottomMask.isHidden = !visible + leftMask.isHidden = !visible + rightMask.isHidden = !visible + } + var cropAspectRatio = CGFloat(AspectRatio.defaultRatio.widthToHeightRatio()) { didSet { if cropAspectRatio != oldValue { + resetScale() calculateFrames() } } @@ -99,6 +107,7 @@ final class PhotoTweakView: UIView, UIScrollViewDelegate { func setImage(_ image: UIImage) { scrollView.imageView.image = image + resetScale() calculateFrames() notifyAboutCroppingParametersChange() } @@ -186,7 +195,7 @@ final class PhotoTweakView: UIView, UIScrollViewDelegate { let gridWasHidden = gridView.isHidden gridView.isHidden = true - let previewImage = snapshot().flatMap { snapshot -> CGImage? in + let previewImage = snapshot(withScale: 1).flatMap { snapshot -> CGImage? in let cropRect = CGRect( x: (bounds.left + (bounds.size.width - cropSize.width) / 2) * snapshot.scale, @@ -323,9 +332,13 @@ final class PhotoTweakView: UIView, UIScrollViewDelegate { } } - private func reset() { + private func resetScrollViewState() { scrollView.transform = .identity scrollView.minimumZoomScale = 1 + resetScale() + } + + private func resetScale() { scrollView.zoomScale = 1 } diff --git a/Paparazzo/Core/Services/Camera/CameraService.swift b/Paparazzo/Core/Services/Camera/CameraService.swift index 842968e5..fb48ed6a 100644 --- a/Paparazzo/Core/Services/Camera/CameraService.swift +++ b/Paparazzo/Core/Services/Camera/CameraService.swift @@ -15,10 +15,12 @@ protocol CameraService: class { func takePhoto(completion: @escaping (PhotoFromCamera?) -> ()) func setCaptureSessionRunning(_: Bool) + func focusOnPoint(_ focusPoint: CGPoint) -> Bool + func canToggleCamera(completion: @escaping (Bool) -> ()) func toggleCamera(completion: @escaping (_ newOutputOrientation: ExifOrientation) -> ()) } -struct PhotoFromCamera { +public struct PhotoFromCamera { let path: String } diff --git a/Paparazzo/Core/Services/Camera/CameraServiceImpl.swift b/Paparazzo/Core/Services/Camera/CameraServiceImpl.swift index 38834b33..da32233e 100644 --- a/Paparazzo/Core/Services/Camera/CameraServiceImpl.swift +++ b/Paparazzo/Core/Services/Camera/CameraServiceImpl.swift @@ -2,25 +2,44 @@ import AVFoundation import ImageIO import ImageSource +public enum CameraType { + case back + case front +} + final class CameraServiceImpl: CameraService { // MARK: - Private types and properties private struct Error: Swift.Error {} + private var photoStorage: PhotoStorage private var captureSession: AVCaptureSession? private var output: AVCaptureStillImageOutput? private var backCamera: AVCaptureDevice? private var frontCamera: AVCaptureDevice? - private var activeCamera: AVCaptureDevice? + + private var activeCamera: AVCaptureDevice? { + return camera(for: activeCameraType) + } + + private var activeCameraType: CameraType // MARK: - Init - init() { + init( + initialActiveCameraType: CameraType, + photoStorage: PhotoStorage) + { + + self.photoStorage = photoStorage + let videoDevices = AVCaptureDevice.devices(withMediaType: AVMediaTypeVideo) as? [AVCaptureDevice] backCamera = videoDevices?.filter({ $0.position == .back }).first frontCamera = videoDevices?.filter({ $0.position == .front }).first + + self.activeCameraType = initialActiveCameraType } func getCaptureSession(completion: @escaping (AVCaptureSession?) -> ()) { @@ -84,8 +103,6 @@ final class CameraServiceImpl: CameraService { try CameraServiceImpl.configureCamera(backCamera) - let activeCamera = backCamera - let input = try AVCaptureDeviceInput(device: activeCamera) let output = AVCaptureStillImageOutput() @@ -100,7 +117,6 @@ final class CameraServiceImpl: CameraService { captureSession.startRunning() - self.activeCamera = activeCamera self.output = output self.captureSession = captureSession @@ -120,6 +136,35 @@ final class CameraServiceImpl: CameraService { } } + func focusOnPoint(_ focusPoint: CGPoint) -> Bool { + guard let activeCamera = self.activeCamera, + activeCamera.isFocusPointOfInterestSupported || activeCamera.isExposurePointOfInterestSupported else { + return false + } + + do { + try activeCamera.lockForConfiguration() + + if activeCamera.isFocusPointOfInterestSupported { + activeCamera.focusPointOfInterest = focusPoint + activeCamera.focusMode = .continuousAutoFocus + } + + if activeCamera.isExposurePointOfInterestSupported { + activeCamera.exposurePointOfInterest = focusPoint + activeCamera.exposureMode = .continuousAutoExposure + } + + activeCamera.unlockForConfiguration() + + return true + } + catch { + debugPrint("Couldn't focus camera: \(error)") + return false + } + } + func canToggleCamera(completion: @escaping (Bool) -> ()) { completion(frontCamera != nil && backCamera != nil) } @@ -129,7 +174,8 @@ final class CameraServiceImpl: CameraService { do { - let targetCamera = (activeCamera == backCamera) ? frontCamera : backCamera + let targetCameraType: CameraType = (activeCamera == backCamera) ? .front : .back + let targetCamera = camera(for: targetCameraType) let newInput = try AVCaptureDeviceInput(device: targetCamera) try captureSession.configure { @@ -149,7 +195,7 @@ final class CameraServiceImpl: CameraService { try CameraServiceImpl.configureCamera(targetCamera) } - activeCamera = targetCamera + activeCameraType = targetCameraType } catch { debugPrint("Couldn't toggle camera: \(error)") @@ -200,10 +246,8 @@ final class CameraServiceImpl: CameraService { } output.captureStillImageAsynchronously(from: connection) { [weak self] sampleBuffer, error in - self?.savePhoto(sampleBuffer: sampleBuffer) { photo in - DispatchQueue.main.async { - completion(photo) - } + self?.photoStorage.savePhoto(sampleBuffer: sampleBuffer, callbackQueue: .main) { photo in + completion(photo) } } } @@ -227,24 +271,6 @@ final class CameraServiceImpl: CameraService { private let captureSessionSetupQueue = DispatchQueue(label: "ru.avito.AvitoMediaPicker.CameraServiceImpl.captureSessionSetupQueue") - private func savePhoto(sampleBuffer: CMSampleBuffer?, completion: @escaping (PhotoFromCamera?) -> ()) { - - let path = randomTemporaryPhotoFilePath() - - DispatchQueue.global(qos: .userInitiated).async { - if let data = sampleBuffer.flatMap({ AVCaptureStillImageOutput.jpegStillImageNSDataRepresentation($0) }) { - do { - try data.write(to: URL(fileURLWithPath: path), options: [.atomicWrite]) - completion(PhotoFromCamera(path: path)) - } catch { - completion(nil) - } - } else { - completion(nil) - } - } - } - private func videoOutputConnection() -> AVCaptureConnection? { guard let output = output else { return nil } @@ -271,12 +297,6 @@ final class CameraServiceImpl: CameraService { camera?.unlockForConfiguration() } - private func randomTemporaryPhotoFilePath() -> String { - let tempDirPath = NSTemporaryDirectory() as NSString - let tempName = "\(NSUUID().uuidString).jpg" - return tempDirPath.appendingPathComponent(tempName) - } - private func outputOrientationForCamera(_ camera: AVCaptureDevice?) -> ExifOrientation { if camera == frontCamera { return .leftMirrored @@ -284,4 +304,14 @@ final class CameraServiceImpl: CameraService { return .left } } + + private func camera(for cameraType: CameraType) -> AVCaptureDevice? { + switch cameraType { + case .back: + return backCamera + case .front: + return frontCamera + } + } + } diff --git a/Paparazzo/Core/Services/CroppingOverlayProviders/CircleCroppingOverlayProvider.swift b/Paparazzo/Core/Services/CroppingOverlayProviders/CircleCroppingOverlayProvider.swift new file mode 100644 index 00000000..993e3e0e --- /dev/null +++ b/Paparazzo/Core/Services/CroppingOverlayProviders/CircleCroppingOverlayProvider.swift @@ -0,0 +1,21 @@ +final class CircleCroppingOverlayProvider: CroppingOverlayProvider { + + func calculateRectToCrop(in bounds: CGRect) -> CGRect { + let diameter = bounds.width - 16 + return CGRect( + origin: CGPoint( + x: bounds.center.x - diameter / 2, + y: bounds.center.y - diameter / 2 + ), + size: CGSize( + width: diameter, + height: diameter + ) + ) + } + + func croppingPath(in rect: CGRect) -> CGPath { + return UIBezierPath(ovalIn: rect).cgPath + } + +} diff --git a/Paparazzo/Core/Services/CroppingOverlayProviders/CroppingOverlayProvider.swift b/Paparazzo/Core/Services/CroppingOverlayProviders/CroppingOverlayProvider.swift new file mode 100644 index 00000000..1722f4bc --- /dev/null +++ b/Paparazzo/Core/Services/CroppingOverlayProviders/CroppingOverlayProvider.swift @@ -0,0 +1,6 @@ +import UIKit + +public protocol CroppingOverlayProvider: class { + func calculateRectToCrop(in bounds: CGRect) -> CGRect + func croppingPath(in rect: CGRect) -> CGPath +} diff --git a/Paparazzo/Core/Services/CroppingOverlayProviders/CroppingOverlayProvidersFactory.swift b/Paparazzo/Core/Services/CroppingOverlayProviders/CroppingOverlayProvidersFactory.swift new file mode 100644 index 00000000..f1d247f5 --- /dev/null +++ b/Paparazzo/Core/Services/CroppingOverlayProviders/CroppingOverlayProvidersFactory.swift @@ -0,0 +1,23 @@ +public protocol CroppingOverlayProvidersFactory: class { + func circleCroppingOverlayProvider() -> CroppingOverlayProvider + func rectangleCroppingOverlayProvider(cornerRadius: CGFloat, margin: CGFloat) -> CroppingOverlayProvider + func heartShapeCroppingOverlayProvider() -> CroppingOverlayProvider +} + +public class CroppingOverlayProvidersFactoryImpl: CroppingOverlayProvidersFactory { + + public init() {} + + public func circleCroppingOverlayProvider() -> CroppingOverlayProvider { + return CircleCroppingOverlayProvider() + } + + public func rectangleCroppingOverlayProvider(cornerRadius: CGFloat, margin: CGFloat) -> CroppingOverlayProvider { + return RectangleCroppingOverlayProvider(cornerRadius: cornerRadius, margin: margin) + } + + public func heartShapeCroppingOverlayProvider() -> CroppingOverlayProvider { + return HeartShapeCroppingOverlayProvider() + } + +} diff --git a/Paparazzo/Core/Services/CroppingOverlayProviders/HeartShapeCroppingOverlayProvider.swift b/Paparazzo/Core/Services/CroppingOverlayProviders/HeartShapeCroppingOverlayProvider.swift new file mode 100644 index 00000000..daba154d --- /dev/null +++ b/Paparazzo/Core/Services/CroppingOverlayProviders/HeartShapeCroppingOverlayProvider.swift @@ -0,0 +1,65 @@ +import UIKit + +private extension Int { + var degreesToRadians: CGFloat { return CGFloat(self) * .pi / 180 } +} + +final class HeartShapeCroppingOverlayProvider: CroppingOverlayProvider { + + // MARK :- CroppingOverlayProvider + + func calculateRectToCrop(in bounds: CGRect) -> CGRect { + return CGRect( + origin: CGPoint( + x: bounds.center.x - bounds.width / 2, + y: bounds.center.y - bounds.height / 2 + ), + size: CGSize( + width: bounds.width, + height: bounds.width + ) + ) + } + + func croppingPath(in rect: CGRect) -> CGPath { + let path = UIBezierPath() + + //Calculate Radius of Arcs using Pythagoras + let sideOne = rect.width * 0.4 + let sideTwo = rect.height * 0.3 + let arcRadius = sqrt(sideOne * sideOne + sideTwo * sideTwo) / 2 + + //Left Hand Curve + path.addArc( + withCenter: CGPoint(x: rect.width * 0.3, y: rect.height * 0.35), + radius: arcRadius, + startAngle: 135.degreesToRadians, + endAngle: 315.degreesToRadians, + clockwise: true + ) + + //Top Centre Dip + path.addLine(to: CGPoint(x: rect.width / 2, y: rect.height * 0.2)) + + //Right Hand Curve + path.addArc( + withCenter: CGPoint(x: rect.width * 0.7, y: rect.height * 0.35), + radius: arcRadius, + startAngle: 225.degreesToRadians, + endAngle: 45.degreesToRadians, + clockwise: true + ) + + //Right Bottom Line + path.addLine(to: CGPoint(x: rect.width * 0.5, y: rect.height * 0.95)) + + //Left Bottom Line + path.close() + + let transform = CGAffineTransform(translationX: 0, y: rect.centerY / 2 - 22.5) + path.apply(transform) + + return path.cgPath + } + +} diff --git a/Paparazzo/Core/Services/CroppingOverlayProviders/RectangleCroppingOverlayProvider.swift b/Paparazzo/Core/Services/CroppingOverlayProviders/RectangleCroppingOverlayProvider.swift new file mode 100644 index 00000000..b12c7795 --- /dev/null +++ b/Paparazzo/Core/Services/CroppingOverlayProviders/RectangleCroppingOverlayProvider.swift @@ -0,0 +1,33 @@ +import UIKit + +final class RectangleCroppingOverlayProvider: CroppingOverlayProvider { + + private let cornerRadius: CGFloat + private let margin: CGFloat + + init(cornerRadius: CGFloat, margin: CGFloat) { + self.cornerRadius = cornerRadius + self.margin = margin + } + + // MARK :- CroppingOverlayProvider + + func calculateRectToCrop(in bounds: CGRect) -> CGRect { + let diameter = bounds.width - margin + return CGRect( + origin: CGPoint( + x: bounds.center.x - diameter / 2, + y: bounds.center.y - diameter / 2 + ), + size: CGSize( + width: diameter, + height: diameter + ) + ) + } + + func croppingPath(in rect: CGRect) -> CGPath { + return UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).cgPath + } + +} diff --git a/Paparazzo/Core/Services/ImageCropping/ImageCroppingService.swift b/Paparazzo/Core/Services/ImageCropping/ImageCroppingService.swift new file mode 100644 index 00000000..8799b488 --- /dev/null +++ b/Paparazzo/Core/Services/ImageCropping/ImageCroppingService.swift @@ -0,0 +1,87 @@ +import ImageSource + +struct ImageCroppingData { + let originalImage: ImageSource + var previewImage: ImageSource? + var parameters: ImageCroppingParameters? +} + +protocol ImageCroppingService: class { + func canvasSize(completion: @escaping (CGSize) -> ()) + func imageWithParameters(completion: @escaping (ImageCroppingData) -> ()) + func croppedImage(previewImage: CGImage, completion: @escaping (CroppedImageSource) -> ()) + func croppedImageAspectRatio(completion: @escaping (Float) -> ()) + func setCroppingParameters(_ parameters: ImageCroppingParameters) +} + +final class ImageCroppingServiceImpl: ImageCroppingService { + + // MARK: - Private + + private let originalImage: ImageSource + private let previewImage: ImageSource? + private var parameters: ImageCroppingParameters? + private let canvasSize: CGSize + + // MARK: - Init + + init(image: ImageSource, canvasSize: CGSize) { + + if let image = image as? CroppedImageSource { + originalImage = image.originalImage + parameters = image.croppingParameters + } else { + originalImage = image + } + + previewImage = image + + self.canvasSize = canvasSize + } + + // MARK: - ImageCroppingService + + func canvasSize(completion: @escaping (CGSize) -> ()) { + completion(canvasSize) + } + + func imageWithParameters(completion: @escaping (ImageCroppingData) -> ()) { + completion( + ImageCroppingData( + originalImage: originalImage, + previewImage: previewImage, + parameters: parameters + ) + ) + } + + func croppedImage(previewImage: CGImage, completion: @escaping (CroppedImageSource) -> ()) { + completion( + CroppedImageSource( + originalImage: originalImage, + sourceSize: canvasSize, + parameters: parameters, + previewImage: previewImage + ) + ) + } + + func croppedImageAspectRatio(completion: @escaping (Float) -> ()) { + if let parameters = parameters, parameters.cropSize.height > 0 { + completion(Float(parameters.cropSize.width / parameters.cropSize.height)) + } else { + originalImage.imageSize { size in + if let size = size { + completion(Float(size.width / size.height)) + } else { + completion(AspectRatio.defaultRatio.widthToHeightRatio()) + } + } + } + } + + func setCroppingParameters(_ parameters: ImageCroppingParameters) { + self.parameters = parameters + } + +} diff --git a/Paparazzo/Core/ThemeConfigurable.swift b/Paparazzo/Core/ThemeConfigurable.swift new file mode 100644 index 00000000..58330065 --- /dev/null +++ b/Paparazzo/Core/ThemeConfigurable.swift @@ -0,0 +1,5 @@ +public protocol ThemeConfigurable { + associatedtype ThemeType + + func setTheme(_ theme: ThemeType) +} diff --git a/Paparazzo/Core/Utilities/AccessibilityId.swift b/Paparazzo/Core/Utilities/AccessibilityId.swift new file mode 100644 index 00000000..865954f3 --- /dev/null +++ b/Paparazzo/Core/Utilities/AccessibilityId.swift @@ -0,0 +1,31 @@ +public enum AccessibilityId: String { + // CameraControls + case photoView + case shutterButton + case cameraToggleButton + case flashButton + + // Cells + case cameraThumbnailCell + case mainCameraCell + case mediaItemThumbnailCell + case photoPreviewCell + + // ImageCropping + case rotationButton + case gridButton + case rotationCancelButton + case confirmButton + case discardButton + case aspectRatioButton + case titleLabel + + // MediaPicker + case continueButton + case closeButton + + // PhotoControls + case removeButton + case autocorrectButton + case cropButton +} diff --git a/Paparazzo/Core/Utilities/Debouncer.swift b/Paparazzo/Core/Utilities/Debouncer.swift new file mode 100644 index 00000000..e8e461ff --- /dev/null +++ b/Paparazzo/Core/Utilities/Debouncer.swift @@ -0,0 +1,32 @@ +public protocol Debouncable { + func debounce(_ closure: @escaping () -> ()) + func cancel() +} + +public final class Debouncer: Debouncable { + private var lastFireTime = DispatchTime(uptimeNanoseconds: 0) + private let queue: DispatchQueue + private let delay: TimeInterval + + public init(delay: TimeInterval, queue: DispatchQueue = DispatchQueue.main) { + self.delay = delay + self.queue = queue + } + + public func debounce(_ closure: @escaping () -> ()) { + lastFireTime = DispatchTime.now() + queue.asyncAfter(deadline: .now() + delay) { [weak self] in + if let strongSelf = self { + let now = DispatchTime.now() + let when = strongSelf.lastFireTime + strongSelf.delay + if now >= when { + closure() + } + } + } + } + + public func cancel() { + debounce {} + } +} diff --git a/Paparazzo/Core/VIPER/AssemblyFactory.swift b/Paparazzo/Core/VIPER/AssemblyFactory.swift deleted file mode 100644 index ae17cbe6..00000000 --- a/Paparazzo/Core/VIPER/AssemblyFactory.swift +++ /dev/null @@ -1,24 +0,0 @@ -public final class AssemblyFactory: CameraAssemblyFactory, MediaPickerAssemblyFactory, ImageCroppingAssemblyFactory, PhotoLibraryAssemblyFactory { - - private let theme: PaparazzoUITheme - - public init(theme: PaparazzoUITheme = PaparazzoUITheme()) { - self.theme = theme - } - - func cameraAssembly() -> CameraAssembly { - return CameraAssemblyImpl(theme: theme) - } - - public func mediaPickerAssembly() -> MediaPickerAssembly { - return MediaPickerAssemblyImpl(assemblyFactory: self, theme: theme) - } - - func imageCroppingAssembly() -> ImageCroppingAssembly { - return ImageCroppingAssemblyImpl(theme: theme) - } - - public func photoLibraryAssembly() -> PhotoLibraryAssembly { - return PhotoLibraryAssemblyImpl(theme: theme) - } -} diff --git a/Paparazzo/Core/VIPER/Camera/Assembly/CameraAssembly.swift b/Paparazzo/Core/VIPER/Camera/Assembly/CameraAssembly.swift index 0e553155..b55844eb 100644 --- a/Paparazzo/Core/VIPER/Camera/Assembly/CameraAssembly.swift +++ b/Paparazzo/Core/VIPER/Camera/Assembly/CameraAssembly.swift @@ -1,7 +1,7 @@ import UIKit protocol CameraAssembly: class { - func module() -> (UIView, CameraModuleInput) + func module(initialActiveCameraType: CameraType) -> (UIView, CameraModuleInput) } protocol CameraAssemblyFactory { diff --git a/Paparazzo/Core/VIPER/Camera/Assembly/CameraAssemblyImpl.swift b/Paparazzo/Core/VIPER/Camera/Assembly/CameraAssemblyImpl.swift index 049faf0e..ded4fb6e 100644 --- a/Paparazzo/Core/VIPER/Camera/Assembly/CameraAssemblyImpl.swift +++ b/Paparazzo/Core/VIPER/Camera/Assembly/CameraAssemblyImpl.swift @@ -1,20 +1,16 @@ import UIKit -final class CameraAssemblyImpl: CameraAssembly { - - private let theme: MediaPickerRootModuleUITheme - - init(theme: MediaPickerRootModuleUITheme) { - self.theme = theme - } +final class CameraAssemblyImpl: BasePaparazzoAssembly, CameraAssembly { // MARK: - CameraAssembly - func module() -> (UIView, CameraModuleInput) { - - let cameraService = CameraServiceImpl() + func module(initialActiveCameraType: CameraType) -> (UIView, CameraModuleInput) { let deviceOrientationService = DeviceOrientationServiceImpl() + let cameraService = serviceFactory.cameraService( + initialActiveCameraType: initialActiveCameraType + ) + let interactor = CameraInteractorImpl( cameraService: cameraService, deviceOrientationService: deviceOrientationService diff --git a/Paparazzo/Core/VIPER/Camera/Interactor/CameraInteractor.swift b/Paparazzo/Core/VIPER/Camera/Interactor/CameraInteractor.swift index 1deab752..5848d90a 100644 --- a/Paparazzo/Core/VIPER/Camera/Interactor/CameraInteractor.swift +++ b/Paparazzo/Core/VIPER/Camera/Interactor/CameraInteractor.swift @@ -18,6 +18,8 @@ protocol CameraInteractor: class { func setPreviewImagesSizeForNewPhotos(_: CGSize) func observeDeviceOrientation(handler: @escaping (DeviceOrientation) -> ()) + + func focusCameraOnPoint(_: CGPoint) -> Bool } struct CameraOutputParameters { diff --git a/Paparazzo/Core/VIPER/Camera/Interactor/CameraInteractorImpl.swift b/Paparazzo/Core/VIPER/Camera/Interactor/CameraInteractorImpl.swift index 766fe2df..b203e4bd 100644 --- a/Paparazzo/Core/VIPER/Camera/Interactor/CameraInteractorImpl.swift +++ b/Paparazzo/Core/VIPER/Camera/Interactor/CameraInteractorImpl.swift @@ -79,4 +79,8 @@ final class CameraInteractorImpl: CameraInteractor { deviceOrientationService.onOrientationChange = handler handler(deviceOrientationService.currentOrientation) } + + func focusCameraOnPoint(_ focusPoint: CGPoint) -> Bool { + return cameraService.focusOnPoint(focusPoint) + } } diff --git a/Paparazzo/Core/VIPER/Camera/Module/CameraModuleInput.swift b/Paparazzo/Core/VIPER/Camera/Module/CameraModuleInput.swift index f5159c0a..fdb6bb20 100644 --- a/Paparazzo/Core/VIPER/Camera/Module/CameraModuleInput.swift +++ b/Paparazzo/Core/VIPER/Camera/Module/CameraModuleInput.swift @@ -17,4 +17,8 @@ protocol CameraModuleInput: class { func setPreviewImagesSizeForNewPhotos(_: CGSize) func mainModuleDidAppear(animated: Bool) + + func setAccessDeniedTitle(_: String) + func setAccessDeniedMessage(_: String) + func setAccessDeniedButtonTitle(_: String) } diff --git a/Paparazzo/Core/VIPER/Camera/Presenter/CameraPresenter.swift b/Paparazzo/Core/VIPER/Camera/Presenter/CameraPresenter.swift index 43f34572..82f4b40d 100644 --- a/Paparazzo/Core/VIPER/Camera/Presenter/CameraPresenter.swift +++ b/Paparazzo/Core/VIPER/Camera/Presenter/CameraPresenter.swift @@ -61,6 +61,18 @@ final class CameraPresenter: CameraModuleInput { view?.mainModuleDidAppear(animated: animated) } + func setAccessDeniedTitle(_ title: String) { + view?.setAccessDeniedTitle(title) + } + + func setAccessDeniedMessage(_ message: String) { + view?.setAccessDeniedMessage(message) + } + + func setAccessDeniedButtonTitle(_ title: String) { + view?.setAccessDeniedButtonTitle(title) + } + // MARK: - Private private func setUpView() { @@ -83,6 +95,12 @@ final class CameraPresenter: CameraModuleInput { } } + view?.onFocusTap = { [weak self] focusPoint, touchPoint in + if self?.interactor.focusCameraOnPoint(focusPoint) == true { + self?.view?.displayFocus(onPoint: touchPoint) + } + } + interactor.observeDeviceOrientation { [weak self] deviceOrientation in self?.view?.adjustForDeviceOrientation(deviceOrientation) } diff --git a/Paparazzo/Core/VIPER/Camera/View/CameraView.swift b/Paparazzo/Core/VIPER/Camera/View/CameraView.swift index d2b58f5c..c66a63bd 100644 --- a/Paparazzo/Core/VIPER/Camera/View/CameraView.swift +++ b/Paparazzo/Core/VIPER/Camera/View/CameraView.swift @@ -1,11 +1,16 @@ import ImageSource import UIKit +import AVFoundation -final class CameraView: UIView, CameraViewInput { +final class CameraView: UIView, CameraViewInput, ThemeConfigurable { + + typealias ThemeType = MediaPickerRootModuleUITheme private let accessDeniedView = AccessDeniedView() private var cameraOutputView: CameraOutputView? private var outputParameters: CameraOutputParameters? + private var focusIndicator: FocusIndicator? + private var theme: ThemeType? // MARK: - Init @@ -32,8 +37,36 @@ final class CameraView: UIView, CameraViewInput { cameraOutputView?.frame = bounds } + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + super.touchesBegan(touches, with: event) + + let screenSize = bounds.size + guard screenSize.width != 0 && screenSize.height != 0 && accessDeniedView.isHidden == true else { + return + } + + if let touchPoint = touches.first?.location(in: self) { + let focusOriginX = touchPoint.y / screenSize.height + let focusOriginY = 1.0 - touchPoint.x / screenSize.width + let focusPoint = CGPoint(x: focusOriginX, y: focusOriginY) + + onFocusTap?(focusPoint, touchPoint) + } + } + // MARK: - CameraViewInput + var onFocusTap: ((_ focusPoint: CGPoint, _ touchPoint: CGPoint) -> Void)? + + func displayFocus(onPoint focusPoint: CGPoint) { + focusIndicator?.hide() + focusIndicator = FocusIndicator() + if let theme = theme { + focusIndicator?.setColor(theme.focusIndicatorColor) + } + focusIndicator?.animate(in: layer, focusPoint: focusPoint) + } + var onAccessDeniedButtonTap: (() -> ())? { get { return accessDeniedView.onButtonTap } set { accessDeniedView.onButtonTap = newValue } @@ -97,10 +130,12 @@ final class CameraView: UIView, CameraViewInput { } } - // MARK: - CameraView + // MARK: - ThemeConfigurable - func setTheme(_ theme: MediaPickerRootModuleUITheme) { + func setTheme(_ theme: ThemeType) { + self.theme = theme accessDeniedView.setTheme(theme) + focusIndicator?.setColor(theme.focusIndicatorColor) } // MARK: - Dispose bag diff --git a/Paparazzo/Core/VIPER/Camera/View/CameraViewInput.swift b/Paparazzo/Core/VIPER/Camera/View/CameraViewInput.swift index 992d445e..ebdf715c 100644 --- a/Paparazzo/Core/VIPER/Camera/View/CameraViewInput.swift +++ b/Paparazzo/Core/VIPER/Camera/View/CameraViewInput.swift @@ -5,6 +5,10 @@ protocol CameraViewInput: class { func setOutputParameters(_: CameraOutputParameters) func setOutputOrientation(_: ExifOrientation) + // MARK: - Focus + var onFocusTap: ((_ focusPoint: CGPoint, _ touchPoint: CGPoint) -> Void)? { get set } + func displayFocus(onPoint: CGPoint) + // MARK: - Access denied view var onAccessDeniedButtonTap: (() -> ())? { get set } diff --git a/Paparazzo/Core/VIPER/Camera/View/FocusIndicator.swift b/Paparazzo/Core/VIPER/Camera/View/FocusIndicator.swift new file mode 100644 index 00000000..40d312a5 --- /dev/null +++ b/Paparazzo/Core/VIPER/Camera/View/FocusIndicator.swift @@ -0,0 +1,98 @@ +import UIKit + +final class FocusIndicator: CALayer { + + typealias ThemeType = MediaPickerRootModuleUITheme + + private let shapeLayer = CAShapeLayer() + + override init() { + super.init() + + let radius = CGFloat(30) + + let circlePath = UIBezierPath( + arcCenter: CGPoint(x: radius, y: radius), + radius: radius, + startAngle: 0, + endAngle: CGFloat(M_PI * 2), + clockwise: true + ) + + shapeLayer.path = circlePath.cgPath + + shapeLayer.fillColor = UIColor.clear.cgColor + shapeLayer.strokeColor = UIColor.clear.cgColor + shapeLayer.lineWidth = 2.0 + + let shapeContainterLayer = CALayer() + + addSublayer(shapeLayer) + + bounds = CGRect(x: 0, y: 0, width: 2 * radius, height: 2 * radius) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setColor(_ color: UIColor) { + shapeLayer.strokeColor = color.cgColor + } + + func animate(in superlayer: CALayer, focusPoint: CGPoint) { + CATransaction.begin() + CATransaction.setDisableActions(true) + position = focusPoint + + superlayer.addSublayer(self) + CATransaction.setCompletionBlock { + self.removeFromSuperlayer() + } + + self.add(FocusIndicatorScaleAnimation(), forKey: nil) + self.add(FocusIndicatorOpacityAnimation(), forKey: nil) + opacity = 0 + + CATransaction.commit() + } + + func hide() { + removeAllAnimations() + removeFromSuperlayer() + } +} + +final class FocusIndicatorScaleAnimation: CABasicAnimation { + override init() { + super.init() + keyPath = "transform.scale" + fromValue = 0.8 + toValue = 1.0 + duration = 0.3 + autoreverses = true + isRemovedOnCompletion = false + timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseIn) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +final class FocusIndicatorOpacityAnimation: CABasicAnimation { + override init() { + super.init() + keyPath = "opacity" + fromValue = 0 + toValue = 1.0 + duration = 0.3 + autoreverses = true + isRemovedOnCompletion = false + timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseIn) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Paparazzo/Core/VIPER/ImageCropping/Assembly/ImageCroppingAssembly.swift b/Paparazzo/Core/VIPER/ImageCropping/Assembly/ImageCroppingAssembly.swift index d28f3878..93929434 100644 --- a/Paparazzo/Core/VIPER/ImageCropping/Assembly/ImageCroppingAssembly.swift +++ b/Paparazzo/Core/VIPER/ImageCropping/Assembly/ImageCroppingAssembly.swift @@ -5,7 +5,7 @@ protocol ImageCroppingAssembly: class { func module( image: ImageSource, canvasSize: CGSize, - configuration: (ImageCroppingModule) -> ()) + configure: (ImageCroppingModule) -> ()) -> UIViewController } diff --git a/Paparazzo/Core/VIPER/ImageCropping/Assembly/ImageCroppingAssemblyImpl.swift b/Paparazzo/Core/VIPER/ImageCropping/Assembly/ImageCroppingAssemblyImpl.swift index 25796928..e661e125 100644 --- a/Paparazzo/Core/VIPER/ImageCropping/Assembly/ImageCroppingAssemblyImpl.swift +++ b/Paparazzo/Core/VIPER/ImageCropping/Assembly/ImageCroppingAssemblyImpl.swift @@ -1,21 +1,22 @@ import ImageSource import UIKit -public final class ImageCroppingAssemblyImpl: ImageCroppingAssembly { - - private let theme: ImageCroppingUITheme - - init(theme: ImageCroppingUITheme) { - self.theme = theme - } +public final class ImageCroppingAssemblyImpl: BasePaparazzoAssembly , ImageCroppingAssembly { public func module( image: ImageSource, canvasSize: CGSize, - configuration: (ImageCroppingModule) -> () + configure: (ImageCroppingModule) -> () ) -> UIViewController { + + let imageCroppingService = serviceFactory.imageCroppingService( + image: image, + canvasSize: canvasSize + ) - let interactor = ImageCroppingInteractorImpl(image: image, canvasSize: canvasSize) + let interactor = ImageCroppingInteractorImpl( + imageCroppingService: imageCroppingService + ) let presenter = ImageCroppingPresenter( interactor: interactor @@ -27,7 +28,7 @@ public final class ImageCroppingAssemblyImpl: ImageCroppingAssembly { presenter.view = viewController - configuration(presenter) + configure(presenter) return viewController } diff --git a/Paparazzo/Core/VIPER/ImageCropping/Interactor/ImageCroppingInteractor.swift b/Paparazzo/Core/VIPER/ImageCropping/Interactor/ImageCroppingInteractor.swift index 4fbbc264..03f60037 100644 --- a/Paparazzo/Core/VIPER/ImageCropping/Interactor/ImageCroppingInteractor.swift +++ b/Paparazzo/Core/VIPER/ImageCropping/Interactor/ImageCroppingInteractor.swift @@ -5,7 +5,7 @@ protocol ImageCroppingInteractor: class { func canvasSize(completion: @escaping (CGSize) -> ()) - func imageWithParameters(completion: @escaping (_ original: ImageSource, _ preview: ImageSource?, _ parameters: ImageCroppingParameters?) -> ()) + func imageWithParameters(completion: @escaping (ImageCroppingData) -> ()) func croppedImage(previewImage: CGImage, completion: @escaping (CroppedImageSource) -> ()) func croppedImageAspectRatio(completion: @escaping (Float) -> ()) diff --git a/Paparazzo/Core/VIPER/ImageCropping/Interactor/ImageCroppingInteractorImpl.swift b/Paparazzo/Core/VIPER/ImageCropping/Interactor/ImageCroppingInteractorImpl.swift index 804ef964..3e53ca64 100644 --- a/Paparazzo/Core/VIPER/ImageCropping/Interactor/ImageCroppingInteractorImpl.swift +++ b/Paparazzo/Core/VIPER/ImageCropping/Interactor/ImageCroppingInteractorImpl.swift @@ -2,59 +2,31 @@ import ImageSource final class ImageCroppingInteractorImpl: ImageCroppingInteractor { - private let originalImage: ImageSource - private let previewImage: ImageSource? - private var parameters: ImageCroppingParameters? - private let canvasSize: CGSize - - init(image: ImageSource, canvasSize: CGSize) { - - if let image = image as? CroppedImageSource { - originalImage = image.originalImage - parameters = image.croppingParameters - } else { - originalImage = image - } - - previewImage = image - - self.canvasSize = canvasSize + private let imageCroppingService: ImageCroppingService + + init(imageCroppingService: ImageCroppingService) { + self.imageCroppingService = imageCroppingService } // MARK: - CroppingInteractor func canvasSize(completion: @escaping (CGSize) -> ()) { - completion(canvasSize) + imageCroppingService.canvasSize(completion: completion) } - func imageWithParameters(completion: @escaping (_ original: ImageSource, _ preview: ImageSource?, _ parameters: ImageCroppingParameters?) -> ()) { - completion(originalImage, previewImage, parameters) + func imageWithParameters(completion: @escaping (ImageCroppingData) -> ()) { + imageCroppingService.imageWithParameters(completion: completion) } func croppedImage(previewImage: CGImage, completion: @escaping (CroppedImageSource) -> ()) { - completion(CroppedImageSource( - originalImage: originalImage, - sourceSize: canvasSize, - parameters: parameters, - previewImage: previewImage - )) + imageCroppingService.croppedImage(previewImage: previewImage, completion: completion) } func croppedImageAspectRatio(completion: @escaping (Float) -> ()) { - if let parameters = parameters, parameters.cropSize.height > 0 { - completion(Float(parameters.cropSize.width / parameters.cropSize.height)) - } else { - originalImage.imageSize { size in - if let size = size { - completion(Float(size.width / size.height)) - } else { - completion(AspectRatio.defaultRatio.widthToHeightRatio()) - } - } - } + imageCroppingService.croppedImageAspectRatio(completion: completion) } func setCroppingParameters(_ parameters: ImageCroppingParameters) { - self.parameters = parameters + imageCroppingService.setCroppingParameters(parameters) } } diff --git a/Paparazzo/Core/VIPER/ImageCropping/Presenter/ImageCroppingPresenter.swift b/Paparazzo/Core/VIPER/ImageCropping/Presenter/ImageCroppingPresenter.swift index d5019515..d76562fc 100644 --- a/Paparazzo/Core/VIPER/ImageCropping/Presenter/ImageCroppingPresenter.swift +++ b/Paparazzo/Core/VIPER/ImageCropping/Presenter/ImageCroppingPresenter.swift @@ -76,11 +76,11 @@ final class ImageCroppingPresenter: ImageCroppingModule { self?.setAspectRatio(isPortrait ? .portrait_3x4 : .landscape_4x3) - self?.interactor.imageWithParameters { originalImage, previewImage, croppingParameters in - self?.view?.setImage(originalImage, previewImage: previewImage) { + self?.interactor.imageWithParameters { data in + self?.view?.setImage(data.originalImage, previewImage: data.previewImage) { self?.view?.setControlsEnabled(true) - if let croppingParameters = croppingParameters { + if let croppingParameters = data.parameters { self?.view?.setCroppingParameters(croppingParameters) diff --git a/Paparazzo/Core/VIPER/ImageCropping/View/CroppingPreviewView.swift b/Paparazzo/Core/VIPER/ImageCropping/View/CroppingPreviewView.swift new file mode 100644 index 00000000..0d7269b0 --- /dev/null +++ b/Paparazzo/Core/VIPER/ImageCropping/View/CroppingPreviewView.swift @@ -0,0 +1,116 @@ +import ImageSource +import UIKit + +final class CroppingPreviewView: UIView { + private static let greatestFiniteMagnitudeSize = CGSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + + /// Максимальный размер оригинальной картинки. Если меньше размера самой картинки, она будет даунскейлиться. + private var sourceImageMaxSize = CroppingPreviewView.greatestFiniteMagnitudeSize + + private let previewView = PhotoTweakView() + + // MARK: - Private + + private var aspectRatio: AspectRatio = .portrait_3x4 + + // MARK: - Init + + init() { + super.init(frame: .zero) + + clipsToBounds = true + + addSubview(previewView) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - UIView + + override func layoutSubviews() { + super.layoutSubviews() + + previewView.frame = bounds + } + + // MARK: - CroppingPreviewView + + var cropAspectRatio: CGFloat { + get { return previewView.cropAspectRatio } + set { previewView.cropAspectRatio = newValue } + } + + var onCroppingParametersChange: ((ImageCroppingParameters) -> ())? { + get { return previewView.onCroppingParametersChange } + set { previewView.onCroppingParametersChange = newValue } + } + + var onPreviewImageWillLoading: (() -> ())? + var onPreviewImageDidLoad: ((UIImage) -> ())? + var onImageDidLoad: (() -> ())? + + func setMaskVisible(_ visible: Bool) { + previewView.setMaskVisible(visible) + } + + func setImage(_ image: ImageSource, previewImage: ImageSource?, completion: (() -> ())?) { + + if let previewImage = previewImage { + + let screenSize = UIScreen.main.bounds.size + + let previewOptions = ImageRequestOptions(size: .fitSize(screenSize), deliveryMode: .progressive) + + onPreviewImageWillLoading?() + + previewImage.requestImage(options: previewOptions) { [weak self] (result: ImageRequestResult) in + if let image = result.image { + self?.onPreviewImageDidLoad?(image) + } + } + } + + let imageSizeOption: ImageSizeOption = (sourceImageMaxSize == CroppingPreviewView.greatestFiniteMagnitudeSize) + ? .fullResolution + : .fitSize(sourceImageMaxSize) + + let options = ImageRequestOptions(size: imageSizeOption, deliveryMode: .best) + + image.requestImage(options: options) { [weak self] (result: ImageRequestResult) in + if let image = result.image { + self?.previewView.setImage(image) + self?.onImageDidLoad?() + } + completion?() + } + } + + func setImageTiltAngle(_ angle: Float) { + previewView.setTiltAngle(angle.degreesToRadians()) + } + + func turnCounterclockwise() { + previewView.turnCounterclockwise() + } + + func setCroppingParameters(_ parameters: ImageCroppingParameters) { + previewView.setCroppingParameters(parameters) + } + + func setGridVisible(_ visible: Bool) { + previewView.setGridVisible(visible) + } + + func setCanvasSize(_ size: CGSize) { + sourceImageMaxSize = size + } + + func cropPreviewImage() -> CGImage? { + return previewView.cropPreviewImage() + } +} diff --git a/Paparazzo/Core/VIPER/ImageCropping/View/ImageCroppingControlsView.swift b/Paparazzo/Core/VIPER/ImageCropping/View/ImageCroppingControlsView.swift index 86746663..e3df0469 100644 --- a/Paparazzo/Core/VIPER/ImageCropping/View/ImageCroppingControlsView.swift +++ b/Paparazzo/Core/VIPER/ImageCropping/View/ImageCroppingControlsView.swift @@ -1,6 +1,8 @@ import UIKit -final class ImageCroppingControlsView: UIView { +final class ImageCroppingControlsView: UIView, ThemeConfigurable { + + typealias ThemeType = ImageCroppingUITheme // MARK: - Subviews @@ -61,6 +63,15 @@ final class ImageCroppingControlsView: UIView { addSubview(rotationCancelButton) addSubview(discardButton) addSubview(confirmButton) + setUpAccessibilityIdentifiers() + } + + private func setUpAccessibilityIdentifiers() { + rotationButton.setAccessibilityId(.rotationButton) + gridButton.setAccessibilityId(.gridButton) + rotationCancelButton.setAccessibilityId(.rotationCancelButton) + discardButton.setAccessibilityId(.discardButton) + confirmButton.setAccessibilityId(.confirmButton) } required init?(coder aDecoder: NSCoder) { @@ -95,20 +106,9 @@ final class ImageCroppingControlsView: UIView { confirmButton.center = CGPoint(x: bounds.right - bounds.size.width * 0.25, y: discardButton.centerY) } - // MARK: - ImageCroppingControlsView - - var onDiscardButtonTap: (() -> ())? - var onConfirmButtonTap: (() -> ())? - var onRotationCancelButtonTap: (() -> ())? - var onRotateButtonTap: (() -> ())? - var onGridButtonTap: (() -> ())? - - var onRotationAngleChange: ((Float) -> ())? { - get { return rotationSliderView.onSliderValueChange } - set { rotationSliderView.onSliderValueChange = newValue } - } + // MARK: - ThemeConfigurable - func setTheme(_ theme: ImageCroppingUITheme) { + func setTheme(_ theme: ThemeType) { rotationButton.setImage(theme.rotationIcon, for: .normal) gridButton.setImage(theme.gridIcon, for: .normal) gridButton.setImage(theme.gridSelectedIcon, for: .selected) @@ -121,6 +121,19 @@ final class ImageCroppingControlsView: UIView { rotationCancelButton.setImage(theme.cancelRotationButtonIcon, for: .normal) } + // MARK: - ImageCroppingControlsView + + var onDiscardButtonTap: (() -> ())? + var onConfirmButtonTap: (() -> ())? + var onRotationCancelButtonTap: (() -> ())? + var onRotateButtonTap: (() -> ())? + var onGridButtonTap: (() -> ())? + + var onRotationAngleChange: ((Float) -> ())? { + get { return rotationSliderView.onSliderValueChange } + set { rotationSliderView.onSliderValueChange = newValue } + } + func setMinimumRotation(degrees: Float) { rotationSliderView.setMiminumValue(degrees) } diff --git a/Paparazzo/Core/VIPER/ImageCropping/View/ImageCroppingUITheme.swift b/Paparazzo/Core/VIPER/ImageCropping/View/ImageCroppingUITheme.swift new file mode 100644 index 00000000..18c75323 --- /dev/null +++ b/Paparazzo/Core/VIPER/ImageCropping/View/ImageCroppingUITheme.swift @@ -0,0 +1,12 @@ +public protocol ImageCroppingUITheme { + var rotationIcon: UIImage? { get } + var gridIcon: UIImage? { get } + var gridSelectedIcon: UIImage? { get } + var cropperDiscardIcon: UIImage? { get } + var cropperConfirmIcon: UIImage? { get } + + var cancelRotationBackgroundColor: UIColor { get } + var cancelRotationTitleColor: UIColor { get } + var cancelRotationTitleFont: UIFont { get } + var cancelRotationButtonIcon: UIImage? { get } +} diff --git a/Paparazzo/Core/VIPER/ImageCropping/View/ImageCroppingView.swift b/Paparazzo/Core/VIPER/ImageCropping/View/ImageCroppingView.swift index 80c18fa1..b504be72 100644 --- a/Paparazzo/Core/VIPER/ImageCropping/View/ImageCroppingView.swift +++ b/Paparazzo/Core/VIPER/ImageCropping/View/ImageCroppingView.swift @@ -1,14 +1,16 @@ import ImageSource import UIKit -final class ImageCroppingView: UIView { +final class ImageCroppingView: UIView, ThemeConfigurable { + + typealias ThemeType = ImageCroppingUITheme // MARK: - Subviews /// Вьюха, которая показывается до того, как будет доступна полная картинка для редактирования (чтобы избежать моргания) private let splashView = UIImageView() - private let previewView = PhotoTweakView() + private let previewView = CroppingPreviewView() private let controlsView = ImageCroppingControlsView() private let aspectRatioButton = UIButton() private let titleLabel = UILabel() @@ -42,11 +44,33 @@ final class ImageCroppingView: UIView { splashView.contentMode = .scaleAspectFill + previewView.onPreviewImageWillLoading = { [weak self] in + self?.splashView.isHidden = false + } + + previewView.onPreviewImageDidLoad = { [weak self] image in + if self?.splashView.isHidden == false { + self?.splashView.image = image + } + } + + previewView.onImageDidLoad = { [weak self] in + self?.splashView.isHidden = true + self?.splashView.image = nil + } + addSubview(previewView) addSubview(splashView) addSubview(controlsView) addSubview(titleLabel) addSubview(aspectRatioButton) + + setUpAccessibilityIdentifiers() + } + + private func setUpAccessibilityIdentifiers() { + aspectRatioButton.setAccessibilityId(.aspectRatioButton) + titleLabel.setAccessibilityId(.titleLabel) } required init?(coder aDecoder: NSCoder) { @@ -78,11 +102,10 @@ final class ImageCroppingView: UIView { top: bounds.top, bottom: controlsView.top ) - layoutSplashView() } - private func layoutSplashView() { + func layoutSplashView() { let height: CGFloat @@ -99,6 +122,12 @@ final class ImageCroppingView: UIView { splashView.center = previewView.center } + // MARK: - ThemeConfigurable + + func setTheme(_ theme: ThemeType) { + controlsView.setTheme(theme) + } + // MARK: - ImageCroppingView var onDiscardButtonTap: (() -> ())? { @@ -136,35 +165,11 @@ final class ImageCroppingView: UIView { } func setImage(_ image: ImageSource, previewImage: ImageSource?, completion: (() -> ())?) { - - if let previewImage = previewImage { - - let screenSize = UIScreen.main.bounds.size - let previewOptions = ImageRequestOptions(size: .fitSize(screenSize), deliveryMode: .progressive) - - splashView.isHidden = false - - previewImage.requestImage(options: previewOptions) { [weak self] (result: ImageRequestResult) in - if let image = result.image, self?.splashView.isHidden == false { - self?.splashView.image = image - } - } - } - - let options = ImageRequestOptions(size: .fitSize(sourceImageMaxSize), deliveryMode: .best) - - image.requestImage(options: options) { [weak self] (result: ImageRequestResult) in - if let image = result.image { - self?.previewView.setImage(image) - self?.splashView.isHidden = true - self?.splashView.image = nil - } - completion?() - } + previewView.setImage(image, previewImage: previewImage, completion: completion) } func setImageTiltAngle(_ angle: Float) { - previewView.setTiltAngle(angle.degreesToRadians()) + previewView.setImageTiltAngle(angle) } func turnCounterclockwise() { @@ -180,7 +185,7 @@ final class ImageCroppingView: UIView { } func setCanvasSize(_ size: CGSize) { - sourceImageMaxSize = size + previewView.setCanvasSize(size) } func setControlsEnabled(_ enabled: Bool) { @@ -188,10 +193,6 @@ final class ImageCroppingView: UIView { aspectRatioButton.isEnabled = enabled } - func setTheme(_ theme: ImageCroppingUITheme) { - controlsView.setTheme(theme) - } - func setTitle(_ title: String) { titleLabel.text = title } @@ -230,6 +231,7 @@ final class ImageCroppingView: UIView { } func setAspectRatioButtonTitle(_ title: String) { + aspectRatioButton.accessibilityValue = title aspectRatioButton.setTitle(title, for: .normal) } @@ -261,9 +263,6 @@ final class ImageCroppingView: UIView { private var aspectRatio: AspectRatio = .portrait_3x4 - /// Максимальный размер оригинальной картинки. Если меньше размера самой картинки, она будет даунскейлиться. - private var sourceImageMaxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) - private func aspectRatioButtonSize() -> CGSize { switch aspectRatio { case .portrait_3x4: diff --git a/Paparazzo/Core/VIPER/ImageCropping/View/ImageCroppingViewController.swift b/Paparazzo/Core/VIPER/ImageCropping/View/ImageCroppingViewController.swift index 70fb2ff5..ab059af8 100644 --- a/Paparazzo/Core/VIPER/ImageCropping/View/ImageCroppingViewController.swift +++ b/Paparazzo/Core/VIPER/ImageCropping/View/ImageCroppingViewController.swift @@ -1,7 +1,9 @@ import ImageSource import UIKit -final class ImageCroppingViewController: UIViewController, ImageCroppingViewInput { +final class ImageCroppingViewController: PaparazzoViewController, ImageCroppingViewInput, ThemeConfigurable { + + typealias ThemeType = ImageCroppingUITheme private let imageCroppingView = ImageCroppingView() @@ -135,21 +137,12 @@ final class ImageCroppingViewController: UIViewController, ImageCroppingViewInpu imageCroppingView.setGridButtonSelected(selected) } - // MARK: - ImageCroppingViewController + // MARK: - ThemeConfigurable - func setTheme(_ theme: ImageCroppingUITheme) { + func setTheme(_ theme: ThemeType) { imageCroppingView.setTheme(theme) } - // MARK: - Dispose bag - - private var disposables = [AnyObject]() - - func addDisposable(_ object: AnyObject) { - disposables.append(object) - } - - // MARK: - Private private func forcePortraitOrientation() { diff --git a/Paparazzo/Core/VIPER/InfoMessage/InfoMessageAnimator.swift b/Paparazzo/Core/VIPER/InfoMessage/InfoMessageAnimator.swift new file mode 100644 index 00000000..915ed0d1 --- /dev/null +++ b/Paparazzo/Core/VIPER/InfoMessage/InfoMessageAnimator.swift @@ -0,0 +1,184 @@ +import UIKit + +public enum InfoMessageViewDismissType { + case interactive + case timeout + case force +} + +struct InfoMessageAnimatorData { + + let animation: InfoMessageAnimatorBehavior + let timeout: TimeInterval + let onDismiss: ((InfoMessageViewDismissType) -> ())? + + init( + animation: InfoMessageAnimatorBehavior, + timeout: TimeInterval = 0.0, + onDismiss: ((InfoMessageViewDismissType) -> ())?) + { + self.animation = animation + self.timeout = timeout + self.onDismiss = onDismiss + } +} + +private enum AnimatorState { + case initial + case appearing + case appeared + case dismissingByTimer + case dismissingByDismissFunction + case dismissed +} + +final class InfoMessageAnimator: InfoMessageViewInput { + + private weak var container: UIView? + private weak var messageView: InfoMessageView? + + private var dismissingByTimerDebouncer: Debouncer + + // Constant settings + private let behavior: InfoMessageAnimatorBehavior + private let onDismiss: ((InfoMessageViewDismissType) -> ())? + + // Updatable settings + private var timeout: TimeInterval + + // Other state + private var state = AnimatorState.initial + + init(_ data: InfoMessageAnimatorData) + { + behavior = data.animation + onDismiss = data.onDismiss + timeout = data.timeout + dismissingByTimerDebouncer = Debouncer(delay: timeout) + } + + // MARK: - Interface + func appear(messageView: InfoMessageView, in container: UIView) { + self.container = container + self.messageView = messageView + + messageView.size = messageView.sizeThatFits(container.size) + behavior.configure(messageView: messageView, in: container) + container.addSubview(messageView) + container.bringSubview(toFront: messageView) + + changeState(to: .appearing) + } + + func dismiss() { + changeState(to: .dismissingByDismissFunction) + } + + func update(timeout: TimeInterval) { + self.timeout = timeout + dismissingByTimerDebouncer.cancel() + dismissingByTimerDebouncer = Debouncer(delay: timeout) + + switch state { + case .initial, .appearing: + // dismissing is not scheduled + break + case .appeared: + scheduleDismissing() + + case .dismissingByTimer, .dismissingByDismissFunction, .dismissed: + // scheduling is not needed + break + } + } + + // MARK: - States + private func changeState(to newState: AnimatorState) { + guard allowedTransitions().contains(newState) else { return } + let oldState = state + state = newState + + switch newState { + case .initial: + break + case .appearing: + animateAppearing() + case .appeared: + scheduleDismissing() + case .dismissingByTimer: + animateDismissing(dismissType: .timeout) + case .dismissingByDismissFunction: + animateDismissing(dismissType: .force) + case .dismissed: + messageView?.removeFromSuperview() + } + } + + private func allowedTransitions() -> [AnimatorState] { + switch state { + case .initial: + return [.appearing, .dismissingByDismissFunction] + case .appearing: + return [.appeared, .dismissingByDismissFunction] + case .appeared: + return [.dismissingByTimer, .dismissingByDismissFunction] + case .dismissingByTimer, .dismissingByDismissFunction: + return [.dismissed] + case .dismissed: + return [] + } + } + + private func scheduleDismissing() { + if timeout.isZero { + dismissingByTimerDebouncer.cancel() + } else { + dismissingByTimerDebouncer.debounce { [weak self] in + self?.changeState(to: .dismissingByTimer) + } + } + } + + // MARK: - Animations + private func animateAppearing() { + guard + let messageView = self.messageView, + let container = self.container + else { return } + + UIView.animate( + withDuration: 0.5, + delay: 0, + usingSpringWithDamping: 0.75, + initialSpringVelocity: 0.1, + options: .curveEaseIn, + animations: { + self.behavior.present(messageView: messageView, in: container) + }, + completion: {_ in + self.changeState(to: .appeared) + } + ) + } + + private func animateDismissing(dismissType: InfoMessageViewDismissType) { + guard + let messageView = self.messageView, + let container = self.container + else { return } + + UIView.animate( + withDuration: 0.3, + delay: 0, + options: .curveEaseOut, + animations: { + self.behavior.dismiss(messageView: messageView, in: container) + }, + completion: {_ in + self.changeState(to: .dismissed) + self.onDismiss?(dismissType) + } + ) + } +} + diff --git a/Paparazzo/Core/VIPER/InfoMessage/InfoMessageDisplayer.swift b/Paparazzo/Core/VIPER/InfoMessage/InfoMessageDisplayer.swift new file mode 100644 index 00000000..4214f18a --- /dev/null +++ b/Paparazzo/Core/VIPER/InfoMessage/InfoMessageDisplayer.swift @@ -0,0 +1,31 @@ +final class InfoMessageDisplayer { + + private var currentInfoMessage: InfoMessageViewInput? + + init() {} + + private let infoMessageFactory = InfoMessageViewFactoryImpl() + + @discardableResult + func display(viewData: InfoMessageViewData, in container: UIView) -> InfoMessageViewInput { + currentInfoMessage?.dismiss() + currentInfoMessage = nil + + let (messageView, animator) = infoMessageFactory.create(from: viewData) + animator.appear(messageView: messageView, in: container) + currentInfoMessage = animator + + return animator + } +} + +protocol InfoMessageDisplayable: class { + @discardableResult + func showInfoMessage(_ viewData: InfoMessageViewData) -> InfoMessageViewInput +} + +extension InfoMessageDisplayable where Self: UIViewController { + func showInfoMessage(_ viewData: InfoMessageViewData) -> InfoMessageViewInput { + return InfoMessageDisplayer().display(viewData: viewData, in: self.view) + } +} diff --git a/Paparazzo/Core/VIPER/InfoMessage/InfoMessageView.swift b/Paparazzo/Core/VIPER/InfoMessage/InfoMessageView.swift new file mode 100644 index 00000000..c860035b --- /dev/null +++ b/Paparazzo/Core/VIPER/InfoMessage/InfoMessageView.swift @@ -0,0 +1,87 @@ +import UIKit + +struct InfoMessageViewData { + let text: String + let timeout: TimeInterval +} + +final class InfoMessageView: UIView { + + private struct Layout { + static let height: CGFloat = 22 + static let textInsets = UIEdgeInsets(top: 6, left: 6, bottom: 6, right: 6) + static let widthTextInsets = textInsets.left + textInsets.right + static let heightTextInsets = textInsets.top + textInsets.bottom + } + + private struct Spec { + static let font = UIFont.systemFont(ofSize: 14) + static let textColor = UIColor.black + static let cornerRadius: CGFloat = 2 + static let backgroundColor = UIColor.white + static let shadowOffset = CGSize(width: 0, height: 1) + static let shadowOpacity: Float = 0.14 + static let shadowRadius: CGFloat = 2 + } + + private let textLabel = UILabel() + private let contentView = UIView() + + // MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + + addSubview(contentView) + + contentView.layer.cornerRadius = Spec.cornerRadius + contentView.layer.masksToBounds = true + + textLabel.font = Spec.font + textLabel.textColor = Spec.textColor + contentView.addSubview(textLabel) + + contentView.backgroundColor = Spec.backgroundColor + + layer.masksToBounds = false + layer.shadowOffset = Spec.shadowOffset + layer.shadowRadius = Spec.shadowRadius + layer.shadowOpacity = Spec.shadowOpacity + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View data + func setViewData(_ viewData: InfoMessageViewData) { + textLabel.text = viewData.text + } + + // MARK: - Layout + override func layoutSubviews() { + super.layoutSubviews() + + textLabel.frame = CGRect( + x: Layout.textInsets.left, + y: Layout.textInsets.top, + width: bounds.width - Layout.widthTextInsets, + height: bounds.height - Layout.heightTextInsets + ) + + contentView.frame = bounds + } + + override func sizeThatFits(_ size: CGSize) -> CGSize { + let shrinkedSize = CGSize( + width: size.width - Layout.widthTextInsets, + height: Layout.height - Layout.heightTextInsets + ) + + let textSize = textLabel.sizeThatFits(shrinkedSize) + + return CGSize( + width: textSize.width + Layout.widthTextInsets, + height: Layout.height + ) + } +} diff --git a/Paparazzo/Core/VIPER/InfoMessage/InfoMessageViewAnimatorBehavior.swift b/Paparazzo/Core/VIPER/InfoMessage/InfoMessageViewAnimatorBehavior.swift new file mode 100644 index 00000000..ac35f450 --- /dev/null +++ b/Paparazzo/Core/VIPER/InfoMessage/InfoMessageViewAnimatorBehavior.swift @@ -0,0 +1,26 @@ +import UIKit + +protocol InfoMessageAnimatorBehavior { + func configure(messageView: UIView, in container: UIView) + func present(messageView: UIView, in container: UIView) + func dismiss(messageView: UIView, in container: UIView) +} + +final class DefaultInfoMessageAnimatorBehavior: InfoMessageAnimatorBehavior { + + func configure(messageView: UIView, in container: UIView) { + messageView.autoresizingMask = [.flexibleWidth, .flexibleTopMargin] + messageView.alpha = 0 + messageView.bottom = container.height - 20 + messageView.centerX = ceil(container.width / 2) + } + + func present(messageView: UIView, in container: UIView) { + messageView.alpha = 1 + } + + func dismiss(messageView: UIView, in container: UIView) { + messageView.alpha = 0 + } +} + diff --git a/Paparazzo/Core/VIPER/InfoMessage/InfoMessageViewFactory.swift b/Paparazzo/Core/VIPER/InfoMessage/InfoMessageViewFactory.swift new file mode 100644 index 00000000..f4e6ced8 --- /dev/null +++ b/Paparazzo/Core/VIPER/InfoMessage/InfoMessageViewFactory.swift @@ -0,0 +1,28 @@ +protocol InfoMessageViewFactory: class { + + func create( + from viewData: InfoMessageViewData + ) -> ( + view: InfoMessageView, + animator: InfoMessageAnimator + ) +} + +final class InfoMessageViewFactoryImpl: InfoMessageViewFactory { + + func create( + from viewData: InfoMessageViewData + ) -> ( + view: InfoMessageView, + animator: InfoMessageAnimator) + { + let animation = DefaultInfoMessageAnimatorBehavior() + let data = InfoMessageAnimatorData(animation: animation, timeout: viewData.timeout, onDismiss: nil) + let animator = InfoMessageAnimator(data) + + let messageView = InfoMessageView() + messageView.setViewData(viewData) + + return (view: messageView, animator: animator) + } +} diff --git a/Paparazzo/Core/VIPER/InfoMessage/InfoMessageViewInput.swift b/Paparazzo/Core/VIPER/InfoMessage/InfoMessageViewInput.swift new file mode 100644 index 00000000..4a6c092e --- /dev/null +++ b/Paparazzo/Core/VIPER/InfoMessage/InfoMessageViewInput.swift @@ -0,0 +1,4 @@ +public protocol InfoMessageViewInput: class { + func dismiss() +} + diff --git a/Paparazzo/Core/VIPER/MaskCropper/Assembly/MaskCropperAssembly.swift b/Paparazzo/Core/VIPER/MaskCropper/Assembly/MaskCropperAssembly.swift new file mode 100644 index 00000000..78acdce6 --- /dev/null +++ b/Paparazzo/Core/VIPER/MaskCropper/Assembly/MaskCropperAssembly.swift @@ -0,0 +1,14 @@ +import ImageSource +import UIKit + +public protocol MaskCropperAssembly: class { + func module( + data: MaskCropperData, + croppingOverlayProvider: CroppingOverlayProvider, + configure: (MaskCropperModule) -> ()) + -> UIViewController +} + +public protocol MaskCropperAssemblyFactory: class { + func maskCropperAssembly() -> MaskCropperAssembly +} diff --git a/Paparazzo/Core/VIPER/MaskCropper/Assembly/MaskCropperAssemblyImpl.swift b/Paparazzo/Core/VIPER/MaskCropper/Assembly/MaskCropperAssemblyImpl.swift new file mode 100644 index 00000000..6a61f406 --- /dev/null +++ b/Paparazzo/Core/VIPER/MaskCropper/Assembly/MaskCropperAssemblyImpl.swift @@ -0,0 +1,44 @@ +import ImageSource +import UIKit + +public final class MaskCropperAssemblyImpl: BasePaparazzoAssembly, MaskCropperAssembly { + + public func module( + data: MaskCropperData, + croppingOverlayProvider: CroppingOverlayProvider, + configure: (MaskCropperModule) -> () + ) -> UIViewController { + + let imageCroppingService = serviceFactory.imageCroppingService( + image: data.imageSource, + canvasSize: data.cropCanvasSize + ) + + let interactor = MaskCropperInteractorImpl( + imageCroppingService: imageCroppingService + ) + + let viewController = MaskCropperViewController( + croppingOverlayProvider: croppingOverlayProvider + ) + + let router = MaskCropperUIKitRouter( + viewController: viewController + ) + + let presenter = MaskCropperPresenter( + interactor: interactor, + router: router + ) + + viewController.addDisposable(presenter) + viewController.setTheme(theme) + + presenter.view = viewController + + configure(presenter) + + return viewController + } + +} diff --git a/Paparazzo/Core/VIPER/MaskCropper/Interactor/MaskCropperInteractor.swift b/Paparazzo/Core/VIPER/MaskCropper/Interactor/MaskCropperInteractor.swift new file mode 100644 index 00000000..72c27fd8 --- /dev/null +++ b/Paparazzo/Core/VIPER/MaskCropper/Interactor/MaskCropperInteractor.swift @@ -0,0 +1,8 @@ +import ImageSource + +protocol MaskCropperInteractor: class { + func canvasSize(completion: @escaping (CGSize) -> ()) + func imageWithParameters(completion: @escaping (ImageCroppingData) -> ()) + func croppedImage(previewImage: CGImage, completion: @escaping (CroppedImageSource) -> ()) + func setCroppingParameters(_ parameters: ImageCroppingParameters) +} diff --git a/Paparazzo/Core/VIPER/MaskCropper/Interactor/MaskCropperInteractorImpl.swift b/Paparazzo/Core/VIPER/MaskCropper/Interactor/MaskCropperInteractorImpl.swift new file mode 100644 index 00000000..0aa2d78c --- /dev/null +++ b/Paparazzo/Core/VIPER/MaskCropper/Interactor/MaskCropperInteractorImpl.swift @@ -0,0 +1,28 @@ +import ImageSource + +final class MaskCropperInteractorImpl: MaskCropperInteractor { + + private let imageCroppingService: ImageCroppingService + + init(imageCroppingService: ImageCroppingService) { + self.imageCroppingService = imageCroppingService + } + + // MARK: - CroppingInteractor + + func canvasSize(completion: @escaping (CGSize) -> ()) { + imageCroppingService.canvasSize(completion: completion) + } + + func imageWithParameters(completion: @escaping (ImageCroppingData) -> ()) { + imageCroppingService.imageWithParameters(completion: completion) + } + + func croppedImage(previewImage: CGImage, completion: @escaping (CroppedImageSource) -> ()) { + imageCroppingService.croppedImage(previewImage: previewImage, completion: completion) + } + + func setCroppingParameters(_ parameters: ImageCroppingParameters) { + imageCroppingService.setCroppingParameters(parameters) + } +} diff --git a/Paparazzo/Core/VIPER/MaskCropper/Module/MaskCropperData.swift b/Paparazzo/Core/VIPER/MaskCropper/Module/MaskCropperData.swift new file mode 100644 index 00000000..ce095a3f --- /dev/null +++ b/Paparazzo/Core/VIPER/MaskCropper/Module/MaskCropperData.swift @@ -0,0 +1,17 @@ +import ImageSource + +public struct MaskCropperData { + + public let imageSource: ImageSource + public let cropCanvasSize: CGSize + + public init( + imageSource: ImageSource, + cropCanvasSize: CGSize) + { + self.imageSource = imageSource + self.cropCanvasSize = cropCanvasSize + } + +} + diff --git a/Paparazzo/Core/VIPER/MaskCropper/Module/MaskCropperModule.swift b/Paparazzo/Core/VIPER/MaskCropper/Module/MaskCropperModule.swift new file mode 100644 index 00000000..f27eb493 --- /dev/null +++ b/Paparazzo/Core/VIPER/MaskCropper/Module/MaskCropperModule.swift @@ -0,0 +1,9 @@ +import ImageSource + +public protocol MaskCropperModule: class { + + func dismissModule() + + var onDiscard: (() -> ())? { get set } + var onConfirm: ((ImageSource) -> ())? { get set } +} diff --git a/Paparazzo/Core/VIPER/MaskCropper/Presenter/MaskCropperPresenter.swift b/Paparazzo/Core/VIPER/MaskCropper/Presenter/MaskCropperPresenter.swift new file mode 100644 index 00000000..c3545724 --- /dev/null +++ b/Paparazzo/Core/VIPER/MaskCropper/Presenter/MaskCropperPresenter.swift @@ -0,0 +1,61 @@ +import ImageSource + +final class MaskCropperPresenter: MaskCropperModule { + + private let interactor: MaskCropperInteractor + private let router: MaskCropperRouter + + weak var view: MaskCropperViewInput? { + didSet { + setUpView() + } + } + + init(interactor: MaskCropperInteractor, router: MaskCropperRouter) { + self.interactor = interactor + self.router = router + } + + func setUpView() { + + view?.onDiscardTap = { [weak self] in + self?.onDiscard?() + } + + view?.onCroppingParametersChange = { [weak self] parameters in + self?.interactor.setCroppingParameters(parameters) + } + + view?.onConfirmTap = { [weak self] previewImage in + if let previewImage = previewImage { + self?.interactor.croppedImage(previewImage: previewImage) { image in + self?.onConfirm?(image) + } + } else { + self?.onDiscard?() + } + } + + interactor.canvasSize { [weak self] canvasSize in + self?.view?.setCanvasSize(canvasSize) + } + + interactor.imageWithParameters { [weak self] data in + self?.view?.setImage(data.originalImage, previewImage: data.previewImage) { + self?.view?.setControlsEnabled(true) + + if let croppingParameters = data.parameters { + self?.view?.setCroppingParameters(croppingParameters) + } + } + } + } + + var onDiscard: (() -> ())? + var onConfirm: ((ImageSource) -> ())? + + func dismissModule() { + router.dismissCurrentModule() + } + +} diff --git a/Paparazzo/Core/VIPER/MaskCropper/Router/MaskCropperRouter.swift b/Paparazzo/Core/VIPER/MaskCropper/Router/MaskCropperRouter.swift new file mode 100644 index 00000000..e42bee8b --- /dev/null +++ b/Paparazzo/Core/VIPER/MaskCropper/Router/MaskCropperRouter.swift @@ -0,0 +1,6 @@ +import ImageSource + +protocol MaskCropperRouter: class { + func focusOnCurrentModule() + func dismissCurrentModule() +} diff --git a/Paparazzo/Core/VIPER/MaskCropper/Router/MaskCropperUIKitRouter.swift b/Paparazzo/Core/VIPER/MaskCropper/Router/MaskCropperUIKitRouter.swift new file mode 100644 index 00000000..d63e704f --- /dev/null +++ b/Paparazzo/Core/VIPER/MaskCropper/Router/MaskCropperUIKitRouter.swift @@ -0,0 +1,4 @@ +import ImageSource +import UIKit + +final class MaskCropperUIKitRouter: BaseUIKitRouter, MaskCropperRouter {} diff --git a/Paparazzo/Core/VIPER/MaskCropper/View/MaskCropperControlsView.swift b/Paparazzo/Core/VIPER/MaskCropper/View/MaskCropperControlsView.swift new file mode 100644 index 00000000..af95cffd --- /dev/null +++ b/Paparazzo/Core/VIPER/MaskCropper/View/MaskCropperControlsView.swift @@ -0,0 +1,91 @@ +import UIKit + +final class MaskCropperControlsView: UIView, ThemeConfigurable { + + typealias ThemeType = MaskCropperUITheme + + // MARK: - Subviews + + private let discardButton = UIButton(type: .custom) + private let confirmButton = UIButton(type: .custom) + + // MARK: - Init + + init() { + super.init(frame: .zero) + + addSubview(discardButton) + addSubview(confirmButton) + + discardButton.addTarget( + self, + action: #selector(onDiscardTap(_:)), + for: .touchUpInside + ) + + confirmButton.addTarget( + self, + action: #selector(onConfirmTap(_:)), + for: .touchUpInside + ) + + setUpAccessibilityIdentifiers() + } + + private func setUpAccessibilityIdentifiers() { + discardButton.setAccessibilityId(.discardButton) + confirmButton.setAccessibilityId(.confirmButton) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Layout + + public override func layoutSubviews() { + super.layoutSubviews() + + discardButton.size = CGSize.minimumTapAreaSize + discardButton.center = CGPoint( + x: bounds.left + bounds.size.width * 0.25, + y: bounds.bottom - 40 + ) + + confirmButton.size = CGSize.minimumTapAreaSize + confirmButton.center = CGPoint( + x: bounds.right - bounds.size.width * 0.25, + y: discardButton.centerY) + } + + // MARK: - ThemeConfigurable + + func setTheme(_ theme: ThemeType) { + discardButton.setImage( + theme.maskCropperDiscardPhotoIcon, + for: .normal + ) + confirmButton.setImage( + theme.maskCropperConfirmPhotoIcon, + for: .normal + ) + } + + // MARK: - MaskCropperControlsView + + var onDiscardTap: (() -> ())? + var onConfirmTap: (() -> ())? + + func setControlsEnabled(_ enabled: Bool) { + discardButton.isEnabled = enabled + } + + // MARK: - Actions + @objc private func onDiscardTap(_ sender: UIButton) { + onDiscardTap?() + } + + @objc private func onConfirmTap(_ sender: UIButton) { + onConfirmTap?() + } +} diff --git a/Paparazzo/Core/VIPER/MaskCropper/View/MaskCropperOverlayView.swift b/Paparazzo/Core/VIPER/MaskCropper/View/MaskCropperOverlayView.swift new file mode 100644 index 00000000..8b6bef73 --- /dev/null +++ b/Paparazzo/Core/VIPER/MaskCropper/View/MaskCropperOverlayView.swift @@ -0,0 +1,45 @@ +import UIKit + +final class MaskCropperOverlayView: UIView { + + private let croppingOverlayProvider: CroppingOverlayProvider + + // MARK: - Init + + init(croppingOverlayProvider: CroppingOverlayProvider) { + + self.croppingOverlayProvider = croppingOverlayProvider + + super.init(frame: .zero) + + isOpaque = false + } + + // MARK: - Draw + + override func draw(_ rect: CGRect) { + super.draw(rect) + + let context = UIGraphicsGetCurrentContext() + context?.setFillColor(UIColor.white.withAlphaComponent(0.6).cgColor) + context?.fill(rect) + context?.saveGState() + context?.setBlendMode(.clear) + + let rectToCrop = croppingOverlayProvider.calculateRectToCrop(in: bounds) + if rect.intersects(rectToCrop) { + context?.setFillColor(UIColor.clear.cgColor) + + context?.addPath(croppingOverlayProvider.croppingPath(in: rectToCrop)) + context?.drawPath(using: .fill) + } + + context?.restoreGState() + } + + // MARK: - Unused + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Paparazzo/Core/VIPER/MaskCropper/View/MaskCropperUITheme.swift b/Paparazzo/Core/VIPER/MaskCropper/View/MaskCropperUITheme.swift new file mode 100644 index 00000000..8c1b152d --- /dev/null +++ b/Paparazzo/Core/VIPER/MaskCropper/View/MaskCropperUITheme.swift @@ -0,0 +1,4 @@ +public protocol MaskCropperUITheme { + var maskCropperDiscardPhotoIcon: UIImage? { get } + var maskCropperConfirmPhotoIcon: UIImage? { get } +} diff --git a/Paparazzo/Core/VIPER/MaskCropper/View/MaskCropperView.swift b/Paparazzo/Core/VIPER/MaskCropper/View/MaskCropperView.swift new file mode 100644 index 00000000..98e917f9 --- /dev/null +++ b/Paparazzo/Core/VIPER/MaskCropper/View/MaskCropperView.swift @@ -0,0 +1,101 @@ +import ImageSource +import UIKit + +final class MaskCropperView: UIView, ThemeConfigurable { + + typealias ThemeType = MaskCropperUITheme + + private let overlayView: MaskCropperOverlayView + private let controlsView = MaskCropperControlsView() + private let previewView = CroppingPreviewView() + + // MARK: - Constants + + private let aspectRatio = CGFloat(1) + private let controlsExtendedHeight = CGFloat(80) + + // MARK: - Init + + init(croppingOverlayProvider: CroppingOverlayProvider) { + + overlayView = MaskCropperOverlayView( + croppingOverlayProvider: croppingOverlayProvider + ) + + super.init(frame: .zero) + + backgroundColor = .white + clipsToBounds = true + + previewView.setGridVisible(false) + previewView.setMaskVisible(false) + previewView.cropAspectRatio = aspectRatio + + addSubview(previewView) + addSubview(overlayView) + addSubview(controlsView) + + overlayView.isUserInteractionEnabled = false + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - ThemeConfigurable + + func setTheme(_ theme: MaskCropperUITheme) { + controlsView.setTheme(theme) + } + + // MARK: - Layout + + public override func layoutSubviews() { + + previewView.width = width + previewView.height = height - controlsExtendedHeight + + overlayView.frame = previewView.frame + + controlsView.top = previewView.bottom + controlsView.width = width + controlsView.height = controlsExtendedHeight + } + + // MARK: - MaskCropperView + + var onConfirmTap: ((_ previewImage: CGImage?) -> ())? { + didSet { + controlsView.onConfirmTap = { [weak self] in + self?.onConfirmTap?(self?.previewView.cropPreviewImage()) + } + } + } + + var onDiscardTap: (() -> ())? { + get { return controlsView.onDiscardTap } + set { controlsView.onDiscardTap = newValue } + } + + var onCroppingParametersChange: ((ImageCroppingParameters) -> ())? { + get { return previewView.onCroppingParametersChange } + set { previewView.onCroppingParametersChange = newValue } + } + + func setCroppingParameters(_ parameters: ImageCroppingParameters) { + previewView.setCroppingParameters(parameters) + } + + func setImage(_ imageSource: ImageSource, previewImage: ImageSource?, completion: @escaping () -> ()) { + previewView.setImage(imageSource, previewImage: previewImage, completion: completion) + } + + func setCanvasSize(_ canvasSize: CGSize) { + previewView.setCanvasSize(canvasSize) + } + + func setControlsEnabled(_ enabled: Bool) { + controlsView.setControlsEnabled(enabled) + } + +} diff --git a/Paparazzo/Core/VIPER/MaskCropper/View/MaskCropperViewController.swift b/Paparazzo/Core/VIPER/MaskCropper/View/MaskCropperViewController.swift new file mode 100644 index 00000000..40404aaf --- /dev/null +++ b/Paparazzo/Core/VIPER/MaskCropper/View/MaskCropperViewController.swift @@ -0,0 +1,113 @@ +import ImageSource + +final class MaskCropperViewController: + PaparazzoViewController, + MaskCropperViewInput, + ThemeConfigurable +{ + + typealias ThemeType = MaskCropperUITheme + + private let maskCropperView: MaskCropperView + + // MARK: - Init + + init(croppingOverlayProvider: CroppingOverlayProvider) { + maskCropperView = MaskCropperView( + croppingOverlayProvider: croppingOverlayProvider) + + super.init() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - UIViewController + + override func loadView() { + view = maskCropperView + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // Cropping doesn't work in landscape at the moment. + // Forcing orientation doesn't produce severe issues at the moment. + forcePortraitOrientation() + navigationController?.setNavigationBarHidden(true, animated: animated) + UIApplication.shared.setStatusBarHidden(true, with: .fade) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + UIViewController.attemptRotationToDeviceOrientation() + } + + override var prefersStatusBarHidden: Bool { + return true + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return .portrait + } + + // MARK: - MaskCropperViewInput + + var onConfirmTap: ((CGImage?) -> ())? { + get { return maskCropperView.onConfirmTap } + set { maskCropperView.onConfirmTap = newValue } + } + + var onDiscardTap: (() -> ())? { + get { return maskCropperView.onDiscardTap } + set { maskCropperView.onDiscardTap = newValue } + } + + var onCroppingParametersChange: ((ImageCroppingParameters) -> ())? { + get { return maskCropperView.onCroppingParametersChange } + set { maskCropperView.onCroppingParametersChange = newValue } + } + + func setImage(_ imageSource: ImageSource, previewImage: ImageSource?, completion: @escaping () -> ()) { + maskCropperView.setImage(imageSource, previewImage: previewImage, completion: completion) + } + + func setCanvasSize(_ canvasSize: CGSize) { + maskCropperView.setCanvasSize(canvasSize) + } + + func setCroppingParameters(_ parameters: ImageCroppingParameters) { + maskCropperView.setCroppingParameters(parameters) + } + + func setControlsEnabled(_ enabled: Bool) { + maskCropperView.setControlsEnabled(enabled) + } + + // MARK: - ThemeConfigurable + + func setTheme(_ theme: ThemeType) { + maskCropperView.setTheme(theme) + } + + // MARK: - Private + + private func forcePortraitOrientation() { + let initialDeviceOrientation = UIDevice.current.orientation + let targetDeviceOrientation = UIDeviceOrientation.portrait + let targetInterfaceOrientation = UIInterfaceOrientation.portrait + + if UIDevice.current.orientation != targetDeviceOrientation { + + UIApplication.shared.setStatusBarOrientation(targetInterfaceOrientation, animated: true) + UIDevice.current.setValue(NSNumber(value: targetInterfaceOrientation.rawValue as Int), forKey: "orientation") + + DispatchQueue.main.async { + UIDevice.current.setValue(NSNumber(value: initialDeviceOrientation.rawValue as Int), forKey: "orientation") + } + } + } + +} diff --git a/Paparazzo/Core/VIPER/MaskCropper/View/MaskCropperViewInput.swift b/Paparazzo/Core/VIPER/MaskCropper/View/MaskCropperViewInput.swift new file mode 100644 index 00000000..4d9db0bf --- /dev/null +++ b/Paparazzo/Core/VIPER/MaskCropper/View/MaskCropperViewInput.swift @@ -0,0 +1,13 @@ +import ImageSource + +protocol MaskCropperViewInput: class { + func setImage(_: ImageSource, previewImage: ImageSource?, completion: @escaping () -> ()) + func setCroppingParameters(_: ImageCroppingParameters) + func setCanvasSize(_: CGSize) + func setControlsEnabled(_: Bool) + + var onConfirmTap: ((_ previewImage: CGImage?) -> ())? { get set } + var onDiscardTap: (() -> ())? { get set } + + var onCroppingParametersChange: ((ImageCroppingParameters) -> ())? { get set } +} diff --git a/Paparazzo/Core/VIPER/MediaPicker/Assembly/MediaPickerAssembly.swift b/Paparazzo/Core/VIPER/MediaPicker/Assembly/MediaPickerAssembly.swift index 9ca26ee7..b4d6b3bc 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/Assembly/MediaPickerAssembly.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/Assembly/MediaPickerAssembly.swift @@ -2,12 +2,8 @@ import UIKit public protocol MediaPickerAssembly: class { func module( - items: [MediaPickerItem], - selectedItem: MediaPickerItem?, - maxItemsCount: Int?, - cropEnabled: Bool, - cropCanvasSize: CGSize, - configuration: (MediaPickerModule) -> ()) + data: MediaPickerData, + configure: (MediaPickerModule) -> ()) -> UIViewController } diff --git a/Paparazzo/Core/VIPER/MediaPicker/Assembly/MediaPickerAssemblyImpl.swift b/Paparazzo/Core/VIPER/MediaPicker/Assembly/MediaPickerAssemblyImpl.swift index 307ed15e..1e00e1b0 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/Assembly/MediaPickerAssemblyImpl.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/Assembly/MediaPickerAssemblyImpl.swift @@ -1,35 +1,31 @@ import UIKit -public final class MediaPickerAssemblyImpl: MediaPickerAssembly { +public final class MediaPickerAssemblyImpl: BasePaparazzoAssembly, MediaPickerAssembly { - typealias AssemblyFactory = CameraAssemblyFactory & ImageCroppingAssemblyFactory & PhotoLibraryAssemblyFactory + typealias AssemblyFactory = CameraAssemblyFactory & ImageCroppingAssemblyFactory & PhotoLibraryAssemblyFactory & MaskCropperAssemblyFactory private let assemblyFactory: AssemblyFactory - private let theme: PaparazzoUITheme - init(assemblyFactory: AssemblyFactory, theme: PaparazzoUITheme) { + init(assemblyFactory: AssemblyFactory, theme: PaparazzoUITheme, serviceFactory: ServiceFactory) { self.assemblyFactory = assemblyFactory - self.theme = theme + super.init(theme: theme, serviceFactory: serviceFactory) } // MARK: - MediaPickerAssembly public func module( - items: [MediaPickerItem], - selectedItem: MediaPickerItem?, - maxItemsCount: Int?, - cropEnabled: Bool, - cropCanvasSize: CGSize, - configuration: (MediaPickerModule) -> ()) + data: MediaPickerData, + configure: (MediaPickerModule) -> ()) -> UIViewController { let interactor = MediaPickerInteractorImpl( - items: items, - selectedItem: selectedItem, - maxItemsCount: maxItemsCount, - cropCanvasSize: cropCanvasSize, - deviceOrientationService: DeviceOrientationServiceImpl(), - latestLibraryPhotoProvider: PhotoLibraryLatestPhotoProviderImpl() + items: data.items, + autocorrectionFilters: data.autocorrectionFilters, + selectedItem: data.selectedItem, + maxItemsCount: data.maxItemsCount, + cropCanvasSize: data.cropCanvasSize, + deviceOrientationService: serviceFactory.deviceOrientationService(), + latestLibraryPhotoProvider: serviceFactory.photoLibraryLatestPhotoProvider() ) let viewController = MediaPickerViewController() @@ -40,7 +36,7 @@ public final class MediaPickerAssemblyImpl: MediaPickerAssembly { ) let cameraAssembly = assemblyFactory.cameraAssembly() - let (cameraView, cameraModuleInput) = cameraAssembly.module() + let (cameraView, cameraModuleInput) = cameraAssembly.module(initialActiveCameraType: data.initialActiveCameraType) let presenter = MediaPickerPresenter( interactor: interactor, @@ -51,11 +47,12 @@ public final class MediaPickerAssemblyImpl: MediaPickerAssembly { viewController.addDisposable(presenter) viewController.setCameraView(cameraView) viewController.setTheme(theme) - viewController.setShowsCropButton(cropEnabled) + viewController.setShowsCropButton(data.cropEnabled) + viewController.setShowsAutocorrectButton(data.autocorrectEnabled) presenter.view = viewController - configuration(presenter) + configure(presenter) return viewController } diff --git a/Paparazzo/Core/VIPER/MediaPicker/Interactor/Filters/Filter.swift b/Paparazzo/Core/VIPER/MediaPicker/Interactor/Filters/Filter.swift new file mode 100644 index 00000000..6560fee5 --- /dev/null +++ b/Paparazzo/Core/VIPER/MediaPicker/Interactor/Filters/Filter.swift @@ -0,0 +1,7 @@ +import ImageSource + +public protocol Filter { + func apply(_ sourceImage: ImageSource, completion: @escaping ((_ sourceImage: ImageSource) -> Void)) + + var fallbackMessage: String? { get } +} diff --git a/Paparazzo/Core/VIPER/MediaPicker/Interactor/MediaPickerInteractor.swift b/Paparazzo/Core/VIPER/MediaPicker/Interactor/MediaPickerInteractor.swift index dc1d1200..b19aa1c8 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/Interactor/MediaPickerInteractor.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/Interactor/MediaPickerInteractor.swift @@ -2,27 +2,41 @@ import ImageSource protocol MediaPickerInteractor: class { - func addItems(_: [MediaPickerItem], completion: @escaping (_ addedItems: [MediaPickerItem], _ canAddItems: Bool) -> ()) - func addPhotoLibraryItems(_: [PhotoLibraryItem], completion: @escaping (_ addedItems: [MediaPickerItem], _ canAddItems: Bool) -> ()) + var items: [MediaPickerItem] { get } + var cropCanvasSize: CGSize { get } + var photoLibraryItems: [PhotoLibraryItem] { get } + var selectedItem: MediaPickerItem? { get } - func updateItem(_: MediaPickerItem, completion: @escaping () -> ()) - // `completion` вызывается с соседним item'ом — это item, который нужно выделить после того, как удалили `item` - func removeItem(_: MediaPickerItem, completion: @escaping (_ adjacentItem: MediaPickerItem?, _ canAddItems: Bool) -> ()) + func addItems( + _ items: [MediaPickerItem] + ) -> (addedItems: [MediaPickerItem], startIndex: Int) + func addPhotoLibraryItems( + _ photoLibraryItems: [PhotoLibraryItem] + ) -> (addedItems: [MediaPickerItem], startIndex: Int) - func selectItem(_: MediaPickerItem?) - func selectedItem(completion: @escaping (MediaPickerItem?) -> ()) + func updateItem(_ item: MediaPickerItem) - func moveItem(from sourceIndex: Int, to destinationIndex: Int) + // returns the nearby item - the item to select after removing the original item + func removeItem(_ item: MediaPickerItem) -> MediaPickerItem? - func items(completion: @escaping (_ mediaPickerItems: [MediaPickerItem], _ canAddItems: Bool) -> ()) - func photoLibraryItems(completion: @escaping ([PhotoLibraryItem]) -> ()) + func selectItem(_: MediaPickerItem?) - func indexOfItem(_: MediaPickerItem, completion: @escaping (Int?) -> ()) + func moveItem(from sourceIndex: Int, to destinationIndex: Int) - func numberOfItemsAvailableForAdding(completion: @escaping (Int?) -> ()) + func indexOfItem(_ item: MediaPickerItem) -> Int? - func cropCanvasSize(completion: @escaping (CGSize) -> ()) + func numberOfItemsAvailableForAdding() -> Int? func observeDeviceOrientation(handler: @escaping (DeviceOrientation) -> ()) func observeLatestPhotoLibraryItem(handler: @escaping (ImageSource?) -> ()) + + func setCropMode(_: MediaPickerCropMode) + func cropMode() -> MediaPickerCropMode + + func canAddItems() -> Bool + + func autocorrectItem( + onResult: @escaping (_ updatedItem: MediaPickerItem?) -> (), + onError: @escaping (_ errorMessage: String?) -> () + ) } diff --git a/Paparazzo/Core/VIPER/MediaPicker/Interactor/MediaPickerInteractorImpl.swift b/Paparazzo/Core/VIPER/MediaPicker/Interactor/MediaPickerInteractorImpl.swift index 49f38b2c..ed84aaec 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/Interactor/MediaPickerInteractorImpl.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/Interactor/MediaPickerInteractorImpl.swift @@ -6,14 +6,17 @@ final class MediaPickerInteractorImpl: MediaPickerInteractor { private let deviceOrientationService: DeviceOrientationService private let maxItemsCount: Int? - private let cropCanvasSize: CGSize + let cropCanvasSize: CGSize - private var items = [MediaPickerItem]() - private var photoLibraryItems = [PhotoLibraryItem]() - private var selectedItem: MediaPickerItem? + private(set) var items = [MediaPickerItem]() + private var autocorrectionFilters = [Filter]() + private(set) var photoLibraryItems = [PhotoLibraryItem]() + private(set) var selectedItem: MediaPickerItem? + private var mode: MediaPickerCropMode = .normal init( items: [MediaPickerItem], + autocorrectionFilters: [Filter], selectedItem: MediaPickerItem?, maxItemsCount: Int?, cropCanvasSize: CGSize, @@ -21,6 +24,7 @@ final class MediaPickerInteractorImpl: MediaPickerInteractor { latestLibraryPhotoProvider: PhotoLibraryLatestPhotoProvider ) { self.items = items + self.autocorrectionFilters = autocorrectionFilters self.selectedItem = selectedItem self.maxItemsCount = maxItemsCount self.cropCanvasSize = cropCanvasSize @@ -30,6 +34,14 @@ final class MediaPickerInteractorImpl: MediaPickerInteractor { // MARK: - MediaPickerInteractor + func setCropMode(_ mode: MediaPickerCropMode) { + self.mode = mode + } + + func cropMode() -> MediaPickerCropMode { + return mode + } + func observeDeviceOrientation(handler: @escaping (DeviceOrientation) -> ()) { deviceOrientationService.onOrientationChange = handler handler(deviceOrientationService.currentOrientation) @@ -39,16 +51,21 @@ final class MediaPickerInteractorImpl: MediaPickerInteractor { latestLibraryPhotoProvider.observePhoto(handler: handler) } - func addItems(_ items: [MediaPickerItem], completion: @escaping (_ addedItems: [MediaPickerItem], _ canAddItems: Bool) -> ()) { - + func addItems( + _ items: [MediaPickerItem] + ) -> (addedItems: [MediaPickerItem], startIndex: Int) + { let numberOfItemsToAdd = min(items.count, maxItemsCount.flatMap { $0 - self.items.count } ?? Int.max) let itemsToAdd = items[0.. ()) { + func addPhotoLibraryItems( + _ photoLibraryItems: [PhotoLibraryItem] + ) -> (addedItems: [MediaPickerItem], startIndex: Int) + { let mediaPickerItems = photoLibraryItems.map { MediaPickerItem( @@ -59,12 +76,10 @@ final class MediaPickerInteractorImpl: MediaPickerInteractor { self.photoLibraryItems.append(contentsOf: photoLibraryItems) - addItems(mediaPickerItems) { addedItems, canAddMoreItems in - completion(addedItems, canAddMoreItems) - } + return addItems(mediaPickerItems) } - func updateItem(_ item: MediaPickerItem, completion: @escaping () -> ()) { + func updateItem(_ item: MediaPickerItem) { if let index = items.index(of: item) { items[index] = item @@ -73,11 +88,9 @@ final class MediaPickerInteractorImpl: MediaPickerInteractor { if let selectedItem = selectedItem, item == selectedItem { self.selectedItem = item } - - completion() } - func removeItem(_ item: MediaPickerItem, completion: @escaping (_ adjacentItem: MediaPickerItem?, _ canAddItems: Bool) -> ()) { + func removeItem(_ item: MediaPickerItem) -> MediaPickerItem? { var adjacentItem: MediaPickerItem? @@ -98,44 +111,72 @@ final class MediaPickerInteractorImpl: MediaPickerInteractor { photoLibraryItems.remove(at: matchingPhotoLibraryItemIndex) } - completion(adjacentItem, canAddItems()) + return adjacentItem } func selectItem(_ item: MediaPickerItem?) { selectedItem = item } - func selectedItem(completion: @escaping (MediaPickerItem?) -> ()) { - completion(selectedItem) - } - func moveItem(from sourceIndex: Int, to destinationIndex: Int) { items.moveElement(from: sourceIndex, to: destinationIndex) } - func items(completion: @escaping (_ mediaPickerItems: [MediaPickerItem], _ canAddItems: Bool) -> ()) { - completion(items, canAddItems()) + func indexOfItem(_ item: MediaPickerItem) -> Int? { + return items.index(of: item) } - func photoLibraryItems(completion: @escaping ([PhotoLibraryItem]) -> ()) { - completion(photoLibraryItems) + func numberOfItemsAvailableForAdding() -> Int? { + return maxItemsCount.flatMap { $0 - items.count } } - func indexOfItem(_ item: MediaPickerItem, completion: @escaping (Int?) -> ()) { - completion(items.index(of: item)) - } - - func numberOfItemsAvailableForAdding(completion: @escaping (Int?) -> ()) { - completion(maxItemsCount.flatMap { $0 - items.count }) - } - - func cropCanvasSize(completion: @escaping (CGSize) -> ()) { - completion(cropCanvasSize) + func canAddItems() -> Bool { + return maxItemsCount.flatMap { self.items.count < $0 } ?? true } - // MARK: - Private - - private func canAddItems() -> Bool { - return maxItemsCount.flatMap { self.items.count < $0 } ?? true + func autocorrectItem( + onResult: @escaping (_ updatedItem: MediaPickerItem?) -> (), + onError: @escaping (_ errorMessage: String?) -> ()) + { + guard let originalItem = selectedItem else { + onError(nil) + return + } + + var image = originalItem.image + + DispatchQueue.global(qos: .userInitiated).async { + let filtersGroup = DispatchGroup() + var errorMessages = [String]() + + self.autocorrectionFilters.forEach { filter in + filtersGroup.enter() + filter.apply(image) { resultItem in + let isFilterFailed = resultItem == image + if isFilterFailed, let errorMessage = filter.fallbackMessage { + errorMessages.append(errorMessage) + } + + image = resultItem + filtersGroup.leave() + } + filtersGroup.wait() + } + + DispatchQueue.main.async { + guard image != originalItem.image else { + onError(errorMessages.first) + return + } + + let updatedItem = MediaPickerItem( + identifier: originalItem.identifier, + image: image, + source: originalItem.source, + originalItem: originalItem + ) + onResult(updatedItem) + } + } } } diff --git a/Paparazzo/Core/VIPER/MediaPicker/Module/MediaPickerData.swift b/Paparazzo/Core/VIPER/MediaPicker/Module/MediaPickerData.swift new file mode 100644 index 00000000..cbd49916 --- /dev/null +++ b/Paparazzo/Core/VIPER/MediaPicker/Module/MediaPickerData.swift @@ -0,0 +1,32 @@ +import UIKit + +public struct MediaPickerData { + public let items: [MediaPickerItem] + public let autocorrectionFilters: [Filter] + public let selectedItem: MediaPickerItem? + public let maxItemsCount: Int? + public let cropEnabled: Bool + public let autocorrectEnabled: Bool + public let cropCanvasSize: CGSize + public let initialActiveCameraType: CameraType + + public init( + items: [MediaPickerItem], + autocorrectionFilters: [Filter] = [], + selectedItem: MediaPickerItem?, + maxItemsCount: Int?, + cropEnabled: Bool, + autocorrectEnabled: Bool = false, + cropCanvasSize: CGSize, + initialActiveCameraType: CameraType = .back) + { + self.items = items + self.autocorrectionFilters = autocorrectionFilters + self.selectedItem = selectedItem + self.maxItemsCount = maxItemsCount + self.cropEnabled = cropEnabled + self.autocorrectEnabled = autocorrectEnabled + self.cropCanvasSize = cropCanvasSize + self.initialActiveCameraType = initialActiveCameraType + } +} diff --git a/Paparazzo/Core/VIPER/MediaPicker/Module/MediaPickerItem.swift b/Paparazzo/Core/VIPER/MediaPicker/Module/MediaPickerItem.swift index 6faa0a59..815508f5 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/Module/MediaPickerItem.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/Module/MediaPickerItem.swift @@ -1,7 +1,7 @@ import ImageSource /// Главная модель, представляющая фотку в пикере -public struct MediaPickerItem: Equatable { +public final class MediaPickerItem: Equatable { public enum Source { case camera @@ -13,10 +13,18 @@ public struct MediaPickerItem: Equatable { let identifier: String - public init(identifier: String = NSUUID().uuidString, image: ImageSource, source: Source) { + let originalItem: MediaPickerItem? + + public init( + identifier: String = NSUUID().uuidString, + image: ImageSource, + source: Source, + originalItem: MediaPickerItem? = nil) + { self.identifier = identifier self.image = image self.source = source + self.originalItem = originalItem } public static func ==(item1: MediaPickerItem, item2: MediaPickerItem) -> Bool { diff --git a/Paparazzo/Core/VIPER/MediaPicker/Module/MediaPickerModule.swift b/Paparazzo/Core/VIPER/MediaPicker/Module/MediaPickerModule.swift index 3a500a26..3d1e260d 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/Module/MediaPickerModule.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/Module/MediaPickerModule.swift @@ -1,14 +1,40 @@ +public enum MediaPickerCropMode { + case normal + case custom(CroppingOverlayProvider) +} + +public enum MediaPickerContinueButtonStyle { + case normal + case spinner +} + public protocol MediaPickerModule: class { func focusOnModule() func dismissModule() + func finish() + func setContinueButtonTitle(_: String) func setContinueButtonEnabled(_: Bool) + func setContinueButtonVisible(_: Bool) + func setContinueButtonStyle(_: MediaPickerContinueButtonStyle) + + func setAccessDeniedTitle(_: String) + func setAccessDeniedMessage(_: String) + func setAccessDeniedButtonTitle(_: String) + + func setCropMode(_: MediaPickerCropMode) - var onItemsAdd: (([MediaPickerItem]) -> ())? { get set } - var onItemUpdate: ((MediaPickerItem) -> ())? { get set } - var onItemRemove: ((MediaPickerItem) -> ())? { get set } + // startIndex - index of element in previous array of MediaPickerItem, new elements were added after that index + var onItemsAdd: (([MediaPickerItem], _ startIndex: Int) -> ())? { get set } + var onItemUpdate: ((MediaPickerItem, _ index: Int?) -> ())? { get set } + var onItemAutocorrect: ((MediaPickerItem, _ isAutocorrected: Bool, _ index: Int?) -> ())? { get set } + var onItemMove: ((_ sourceIndex: Int, _ destinationIndex: Int) -> ())? { get set } + var onItemRemove: ((MediaPickerItem, _ index: Int?) -> ())? { get set } + var onCropFinish: (() -> ())? { get set } + var onCropCancel: (() -> ())? { get set } + var onContinueButtonTap: (() -> ())? { get set } var onFinish: (([MediaPickerItem]) -> ())? { get set } var onCancel: (() -> ())? { get set } diff --git a/Paparazzo/Core/VIPER/MediaPicker/Presenter/MediaPickerPresenter.swift b/Paparazzo/Core/VIPER/MediaPicker/Presenter/MediaPickerPresenter.swift index ba89a9c5..31204842 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/Presenter/MediaPickerPresenter.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/Presenter/MediaPickerPresenter.swift @@ -24,9 +24,14 @@ final class MediaPickerPresenter: MediaPickerModule { // MARK: - MediaPickerModule - var onItemsAdd: (([MediaPickerItem]) -> ())? - var onItemUpdate: ((MediaPickerItem) -> ())? - var onItemRemove: ((MediaPickerItem) -> ())? + var onItemsAdd: (([MediaPickerItem], _ startIndex: Int) -> ())? + var onItemUpdate: ((MediaPickerItem, _ index: Int?) -> ())? + var onItemAutocorrect: ((MediaPickerItem, _ isAutocorrected: Bool, _ index: Int?) -> ())? + var onItemMove: ((_ sourceIndex: Int, _ destinationIndex: Int) -> ())? + var onItemRemove: ((MediaPickerItem, _ index: Int?) -> ())? + var onCropFinish: (() -> ())? + var onCropCancel: (() -> ())? + var onContinueButtonTap: (() -> ())? var onFinish: (([MediaPickerItem]) -> ())? var onCancel: (() -> ())? @@ -39,6 +44,26 @@ final class MediaPickerPresenter: MediaPickerModule { view?.setContinueButtonEnabled(enabled) } + func setContinueButtonVisible(_ visible: Bool) { + view?.setContinueButtonVisible(visible) + } + + func setContinueButtonStyle(_ style: MediaPickerContinueButtonStyle) { + view?.setContinueButtonStyle(style) + } + + public func setAccessDeniedTitle(_ title: String) { + cameraModuleInput.setAccessDeniedTitle(title) + } + + public func setAccessDeniedMessage(_ message: String) { + cameraModuleInput.setAccessDeniedMessage(message) + } + + public func setAccessDeniedButtonTitle(_ title: String) { + cameraModuleInput.setAccessDeniedButtonTitle(title) + } + func setItems(_ items: [MediaPickerItem], selectedItem: MediaPickerItem?) { addItems(items, fromCamera: false) { [weak self] in if let selectedItem = selectedItem { @@ -47,6 +72,16 @@ final class MediaPickerPresenter: MediaPickerModule { } } + func setCropMode(_ cropMode: MediaPickerCropMode) { + switch cropMode { + case .normal: + view?.setShowPreview(true) + case .custom: + view?.setShowPreview(false) + } + interactor.setCropMode(cropMode) + } + func focusOnModule() { router.focusOnCurrentModule() } @@ -54,68 +89,73 @@ final class MediaPickerPresenter: MediaPickerModule { func dismissModule() { router.dismissCurrentModule() } + + func finish() { + cameraModuleInput.setFlashEnabled(false, completion: nil) + onFinish?(interactor.items) + } // MARK: - Private private var continueButtonTitle: String? private func setUpView() { - weak var `self` = self view?.setContinueButtonTitle(continueButtonTitle ?? "Далее") view?.setPhotoTitle("Фото 1") view?.setCameraControlsEnabled(false) - cameraModuleInput.getOutputParameters { parameters in + cameraModuleInput.getOutputParameters { [weak self] parameters in if let parameters = parameters { self?.view?.setCameraOutputParameters(parameters) self?.view?.setCameraControlsEnabled(true) } } - cameraModuleInput.isFlashAvailable { flashAvailable in + cameraModuleInput.isFlashAvailable { [weak self] flashAvailable in self?.view?.setFlashButtonVisible(flashAvailable) } - cameraModuleInput.isFlashEnabled { isFlashEnabled in + cameraModuleInput.isFlashEnabled { [weak self] isFlashEnabled in self?.view?.setFlashButtonOn(isFlashEnabled) } - cameraModuleInput.canToggleCamera { canToggleCamera in + cameraModuleInput.canToggleCamera { [weak self] canToggleCamera in self?.view?.setCameraToggleButtonVisible(canToggleCamera) } - interactor.observeDeviceOrientation { deviceOrientation in + interactor.observeDeviceOrientation { [weak self] deviceOrientation in self?.view?.adjustForDeviceOrientation(deviceOrientation) } - interactor.observeLatestPhotoLibraryItem { image in + interactor.observeLatestPhotoLibraryItem { [weak self] image in self?.view?.setLatestLibraryPhoto(image) } - interactor.items { items, canAddMoreItems in - guard items.count > 0 else { return } + let items = interactor.items + + if items.count > 0 { + + view?.setCameraButtonVisible(interactor.canAddItems()) - self?.view?.setCameraButtonVisible(canAddMoreItems) - self?.view?.addItems(items, animated: false) { - self?.interactor.selectedItem { selectedItem in - if let selectedItem = selectedItem { - self?.selectItem(selectedItem) - } else if canAddMoreItems { - self?.selectCamera() - } else if let lastItem = items.last { - self?.selectItem(lastItem) - } + view?.addItems(items, animated: false) { [weak self] in + let selectedItem = self?.interactor.selectedItem + if let selectedItem = selectedItem { + self?.selectItem(selectedItem) + } else if self?.interactor.canAddItems() == true { + self?.selectCamera() + } else if let lastItem = items.last { + self?.selectItem(lastItem) } } } - view?.onPhotoLibraryButtonTap = { + view?.onPhotoLibraryButtonTap = { [weak self] in self?.showPhotoLibrary() } - view?.onShutterButtonTap = { + view?.onShutterButtonTap = { [weak self] in // Если фоткать со вспышкой, это занимает много времени, и если несколько раз подряд быстро тапнуть на кнопку, // он будет потом еще долго фоткать :) Поэтому временно блокируем кнопку. @@ -127,17 +167,22 @@ final class MediaPickerPresenter: MediaPickerModule { self?.cameraModuleInput.takePhoto { photo in + let enableShutterButton = { + self?.view?.setShutterButtonEnabled(true) + self?.view?.setPhotoLibraryButtonEnabled(true) + self?.view?.setContinueButtonEnabled(true) + } + if let photo = photo { - self?.addItems([photo], fromCamera: true) + self?.addItems([photo], fromCamera: true, completion: enableShutterButton) + } else { + enableShutterButton() } - self?.view?.setShutterButtonEnabled(true) - self?.view?.setPhotoLibraryButtonEnabled(true) - self?.view?.setContinueButtonEnabled(true) } } - view?.onFlashToggle = { shouldEnableFlash in + view?.onFlashToggle = { [weak self] shouldEnableFlash in self?.cameraModuleInput.setFlashEnabled(shouldEnableFlash) { success in if !success { self?.view?.setFlashButtonOn(!shouldEnableFlash) @@ -145,17 +190,18 @@ final class MediaPickerPresenter: MediaPickerModule { } } - view?.onItemSelect = { item in + view?.onItemSelect = { [weak self] item in self?.interactor.selectItem(item) + self?.updateAutocorrectionStatusForItem(item) self?.adjustViewForSelectedItem(item, animated: true, scrollToSelected: true) } - view?.onItemMove = { sourceIndex, destinationIndex in + view?.onItemMove = { [weak self] (sourceIndex, destinationIndex) in self?.interactor.moveItem(from: sourceIndex, to: destinationIndex) - self?.interactor.selectedItem { item in - if let item = item { - self?.adjustViewForSelectedItem(item, animated: true, scrollToSelected: false) - } + self?.onItemMove?(sourceIndex, destinationIndex) + if let item = self?.interactor.selectedItem { + self?.updateAutocorrectionStatusForItem(item) + self?.adjustViewForSelectedItem(item, animated: true, scrollToSelected: false) } self?.view?.moveItem(from: sourceIndex, to: destinationIndex) } @@ -166,65 +212,99 @@ final class MediaPickerPresenter: MediaPickerModule { self?.view?.scrollToCameraThumbnail(animated: true) } - view?.onCameraToggleButtonTap = { + view?.onCameraToggleButtonTap = { [weak self] in self?.cameraModuleInput.toggleCamera { newOutputOrientation in self?.view?.setCameraOutputOrientation(newOutputOrientation) } } - view?.onSwipeToItem = { item in + view?.onSwipeToItem = { [weak self] item in self?.view?.selectItem(item) } - view?.onSwipeToCamera = { + view?.onSwipeToCamera = { [weak self] in self?.view?.selectCamera() } - view?.onSwipeToCameraProgressChange = { progress in + view?.onSwipeToCameraProgressChange = { [weak self] progress in self?.view?.setPhotoTitleAlpha(1 - progress) } - view?.onCloseButtonTap = { + view?.onCloseButtonTap = { [weak self] in self?.cameraModuleInput.setFlashEnabled(false, completion: nil) self?.onCancel?() } - view?.onContinueButtonTap = { - self?.cameraModuleInput.setFlashEnabled(false, completion: nil) - self?.interactor.items { items, _ in - self?.onFinish?(items) + view?.onContinueButtonTap = { [weak self] in + if let onContinueButtonTap = self?.onContinueButtonTap { + onContinueButtonTap() + } else { + self?.finish() } } - view?.onCropButtonTap = { - self?.interactor.selectedItem { item in - if let item = item { - self?.showCroppingModule(forItem: item) + view?.onCropButtonTap = { [weak self] in + if let item = self?.interactor.selectedItem { + self?.showCroppingModule(forItem: item) + } + } + + view?.onAutocorrectButtonTap = { [weak self] in + if let originalItem = self?.interactor.selectedItem?.originalItem { + self?.view?.showInfoMessage("РАЗМЫТИЕ ВЫКЛ.", timeout: 1.0) + self?.updateItem(originalItem, afterAutocorrect: true) + } else { + self?.view?.showInfoMessage("РАЗМЫТИЕ ВКЛ.", timeout: 1.0) + self?.view?.setAutocorrectionStatus(.corrected) + self?.interactor.autocorrectItem( + onResult: { [weak self] updatedItem in + if let updatedItem = updatedItem { + self?.updateItem(updatedItem, afterAutocorrect: true) + } + }, onError: { [weak self] errorMessage in + if let errorMessage = errorMessage { + self?.view?.showInfoMessage(errorMessage, timeout: 1.0) + } + self?.view?.setAutocorrectionStatus(.original) } + ) } } - view?.onRemoveButtonTap = { + view?.onRemoveButtonTap = { [weak self] in self?.removeSelectedItem() } - view?.onPreviewSizeDetermined = { previewSize in + view?.onPreviewSizeDetermined = { [weak self] previewSize in self?.cameraModuleInput.setPreviewImagesSizeForNewPhotos(previewSize) } - view?.onViewDidAppear = { animated in - self?.cameraModuleInput.mainModuleDidAppear(animated: animated) - } - - view?.onViewWillAppear = { _ in + view?.onViewWillAppear = { [weak self] animated in self?.cameraModuleInput.setCameraOutputNeeded(true) } + view?.onViewDidAppear = { [weak self] animated in + self?.cameraModuleInput.mainModuleDidAppear(animated: animated) + } - view?.onViewDidDisappear = { _ in + view?.onViewDidDisappear = { [weak self] animated in self?.cameraModuleInput.setCameraOutputNeeded(false) } } + private func updateItem(_ updatedItem: MediaPickerItem, afterAutocorrect: Bool = false) { + interactor.updateItem(updatedItem) + view?.updateItem(updatedItem) + adjustPhotoTitleForItem(updatedItem) + let index = interactor.indexOfItem(updatedItem) + updateAutocorrectionStatusForItem(updatedItem) + + if afterAutocorrect { + onItemAutocorrect?(updatedItem, updatedItem.originalItem != nil, index) + } else { + onItemUpdate?(updatedItem, index) + } + } + private func adjustViewForSelectedItem(_ item: MediaPickerItem, animated: Bool, scrollToSelected: Bool) { adjustPhotoTitleForItem(item) @@ -234,16 +314,22 @@ final class MediaPickerPresenter: MediaPickerModule { } } + private func updateAutocorrectionStatusForItem(_ item: MediaPickerItem) { + if item.originalItem == nil { + view?.setAutocorrectionStatus(.original) + } else { + view?.setAutocorrectionStatus(.corrected) + } + } + private func adjustPhotoTitleForItem(_ item: MediaPickerItem) { - interactor.indexOfItem(item) { [weak self] index in - if let index = index { - self?.setTitleForPhotoWithIndex(index) - self?.view?.setPhotoTitleAlpha(1) - - item.image.imageSize { size in - let isPortrait = size.flatMap { $0.height > $0.width } ?? true - self?.view?.setPhotoTitleStyle(isPortrait ? .light : .dark) - } + if let index = interactor.indexOfItem(item) { + setTitleForPhotoWithIndex(index) + view?.setPhotoTitleAlpha(1) + + item.image.imageSize { [weak self] size in + let isPortrait = size.flatMap { $0.height > $0.width } ?? true + self?.view?.setPhotoTitleStyle(isPortrait ? .light : .dark) } } } @@ -253,13 +339,19 @@ final class MediaPickerPresenter: MediaPickerModule { } private func addItems(_ items: [MediaPickerItem], fromCamera: Bool, completion: (() -> ())? = nil) { - interactor.addItems(items) { [weak self] addedItems, canAddItems in - self?.handleItemsAdded(addedItems, fromCamera: fromCamera, canAddMoreItems: canAddItems, completion: completion) - } + let (addedItems, startIndex) = interactor.addItems(items) + handleItemsAdded( + addedItems, + fromCamera: fromCamera, + canAddMoreItems: interactor.canAddItems(), + startIndex: startIndex, + completion: completion + ) } private func selectItem(_ item: MediaPickerItem) { view?.selectItem(item) + updateAutocorrectionStatusForItem(item) adjustViewForSelectedItem(item, animated: false, scrollToSelected: true) } @@ -269,72 +361,133 @@ final class MediaPickerPresenter: MediaPickerModule { view?.scrollToCameraThumbnail(animated: false) } - private func handleItemsAdded(_ items: [MediaPickerItem], fromCamera: Bool, canAddMoreItems: Bool, completion: (() -> ())? = nil) { + private func handleItemsAdded( + _ items: [MediaPickerItem], + fromCamera: Bool, + canAddMoreItems: Bool, + startIndex: Int, + completion: (() -> ())? = nil) + { guard items.count > 0 else { completion?(); return } - view?.addItems(items, animated: fromCamera) { [view] in + view?.addItems(items, animated: fromCamera) { [weak self, view] in + + guard let strongSelf = self else { + completion?() + return + } + view?.setCameraButtonVisible(canAddMoreItems) if canAddMoreItems { view?.setMode(.camera) view?.scrollToCameraThumbnail(animated: true) + completion?() } else if let lastItem = items.last { view?.selectItem(lastItem) view?.scrollToItemThumbnail(lastItem, animated: true) + + let mode = strongSelf.interactor.cropMode() + switch mode { + case .normal: + break + case .custom(let provider): + self?.showMaskCropper( + croppingOverlayProvider: provider, + item: lastItem + ) + } + completion?() } } - interactor.items { [weak self] items, _ in - self?.setTitleForPhotoWithIndex(items.count - 1) - } - - onItemsAdd?(items) + setTitleForPhotoWithIndex(interactor.items.count - 1) - completion?() + onItemsAdd?(items, startIndex) } private func removeSelectedItem() { - interactor.selectedItem { [weak self] item in - guard let item = item else { return } - - self?.interactor.removeItem(item) { adjacentItem, canAddItems in - - self?.view?.removeItem(item) - self?.view?.setCameraButtonVisible(canAddItems) + guard let item = interactor.selectedItem else { return } + + let index = interactor.indexOfItem(item) + let adjacentItem = interactor.removeItem(item) + view?.removeItem(item) + view?.setCameraButtonVisible(interactor.canAddItems()) + + if let adjacentItem = adjacentItem { + view?.selectItem(adjacentItem) + } else { + view?.setMode(.camera) + view?.setPhotoTitleAlpha(0) + } + + onItemRemove?(item, index) + } + + private func showMaskCropper(croppingOverlayProvider: CroppingOverlayProvider, item: MediaPickerItem) { + + let cropCanvasSize = interactor.cropCanvasSize + + let data = MaskCropperData( + imageSource: item.image, + cropCanvasSize: cropCanvasSize + ) + router.showMaskCropper( + data: data, + croppingOverlayProvider: croppingOverlayProvider) { [weak self] module in - if let adjacentItem = adjacentItem { - self?.view?.selectItem(adjacentItem) - } else { - self?.view?.setMode(.camera) - self?.view?.setPhotoTitleAlpha(0) + module.onDiscard = { [weak module] in + + self?.onCropCancel?() + self?.removeSelectedItem() + module?.dismissModule() } - self?.onItemRemove?(item) - } + module.onConfirm = { image in + + self?.onCropFinish?() + let croppedItem = MediaPickerItem( + identifier: item.identifier, + image: image, + source: item.source + ) + + self?.onFinish?([croppedItem]) + } } + } private func showPhotoLibrary() { - interactor.numberOfItemsAvailableForAdding { [weak self] maxItemsCount in - self?.interactor.photoLibraryItems { photoLibraryItems in - - self?.router.showPhotoLibrary(selectedItems: [], maxSelectedItemsCount: maxItemsCount) { module in - - module.onFinish = { result in - self?.router.focusOnCurrentModule() - - switch result { - case .selectedItems(let photoLibraryItems): - self?.interactor.addPhotoLibraryItems(photoLibraryItems) { addedItems, canAddItems in - self?.handleItemsAdded(addedItems, fromCamera: false, canAddMoreItems: canAddItems) - } - case .cancelled: - break - } - } + let maxItemsCount = interactor.numberOfItemsAvailableForAdding() + let photoLibraryItems = interactor.photoLibraryItems + + let data = PhotoLibraryData( + selectedItems: [], + maxSelectedItemsCount: maxItemsCount + ) + + router.showPhotoLibrary(data: data) { [weak self] module in + + guard let strongSelf = self else { return } + + module.onFinish = { result in + self?.router.focusOnCurrentModule() + + switch result { + case .selectedItems(let photoLibraryItems): + let (addedItems, startIndex) = strongSelf.interactor.addPhotoLibraryItems(photoLibraryItems) + self?.handleItemsAdded( + addedItems, + fromCamera: false, + canAddMoreItems: strongSelf.interactor.canAddItems(), + startIndex: startIndex + ) + case .cancelled: + break } } } @@ -342,28 +495,31 @@ final class MediaPickerPresenter: MediaPickerModule { private func showCroppingModule(forItem item: MediaPickerItem) { - interactor.cropCanvasSize { [weak self] cropCanvasSize in + let cropCanvasSize = interactor.cropCanvasSize + + router.showCroppingModule(forImage: item.image, canvasSize: cropCanvasSize) { [weak self] module in - self?.router.showCroppingModule(forImage: item.image, canvasSize: cropCanvasSize) { module in + module.onDiscard = { [weak self] in - module.onDiscard = { [weak self] in - self?.router.focusOnCurrentModule() - } + self?.onCropCancel?() + self?.router.focusOnCurrentModule() + } + + module.onConfirm = { [weak self] croppedImageSource in - module.onConfirm = { [weak self] croppedImageSource in - - let croppedItem = MediaPickerItem( - identifier: item.identifier, - image: croppedImageSource, - source: item.source - ) - - self?.interactor.updateItem(croppedItem) { - self?.view?.updateItem(croppedItem) - self?.adjustPhotoTitleForItem(croppedItem) - self?.onItemUpdate?(croppedItem) - self?.router.focusOnCurrentModule() - } + self?.onCropFinish?() + let croppedItem = MediaPickerItem( + identifier: item.identifier, + image: croppedImageSource, + source: item.source + ) + + self?.interactor.updateItem(croppedItem) + self?.view?.updateItem(croppedItem) + self?.adjustPhotoTitleForItem(croppedItem) + if let index = self?.interactor.indexOfItem(croppedItem) { + self?.onItemUpdate?(croppedItem, index) + self?.router.focusOnCurrentModule() } } } diff --git a/Paparazzo/Core/VIPER/MediaPicker/Router/MediaPickerRouter.swift b/Paparazzo/Core/VIPER/MediaPicker/Router/MediaPickerRouter.swift index e9be78af..9196f6af 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/Router/MediaPickerRouter.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/Router/MediaPickerRouter.swift @@ -3,15 +3,20 @@ import ImageSource protocol MediaPickerRouter: class { func showPhotoLibrary( - selectedItems: [PhotoLibraryItem], - maxSelectedItemsCount: Int?, - configuration: (PhotoLibraryModule) -> () + data: PhotoLibraryData, + configure: (PhotoLibraryModule) -> () ) func showCroppingModule( forImage: ImageSource, canvasSize: CGSize, - configuration: (ImageCroppingModule) -> () + configure: (ImageCroppingModule) -> () + ) + + func showMaskCropper( + data: MaskCropperData, + croppingOverlayProvider: CroppingOverlayProvider, + configure: (MaskCropperModule) -> () ) func focusOnCurrentModule() diff --git a/Paparazzo/Core/VIPER/MediaPicker/Router/MediaPickerUIKitRouter.swift b/Paparazzo/Core/VIPER/MediaPicker/Router/MediaPickerUIKitRouter.swift index cac25543..7013281b 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/Router/MediaPickerUIKitRouter.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/Router/MediaPickerUIKitRouter.swift @@ -3,8 +3,8 @@ import UIKit final class MediaPickerUIKitRouter: BaseUIKitRouter, MediaPickerRouter { - typealias AssemblyFactory = ImageCroppingAssemblyFactory & PhotoLibraryAssemblyFactory - + typealias AssemblyFactory = ImageCroppingAssemblyFactory & PhotoLibraryAssemblyFactory & MaskCropperAssemblyFactory + private let assemblyFactory: AssemblyFactory private var cropViewControllers = [WeakWrapper]() @@ -16,16 +16,14 @@ final class MediaPickerUIKitRouter: BaseUIKitRouter, MediaPickerRouter { // MARK: - PhotoPickerRouter func showPhotoLibrary( - selectedItems: [PhotoLibraryItem], - maxSelectedItemsCount: Int?, - configuration: (PhotoLibraryModule) -> ()) + data: PhotoLibraryData, + configure: (PhotoLibraryModule) -> ()) { let assembly = assemblyFactory.photoLibraryAssembly() let viewController = assembly.module( - selectedItems: selectedItems, - maxSelectedItemsCount: maxSelectedItemsCount, - configuration: configuration + data: data, + configure: configure ) let navigationController = UINavigationController(rootViewController: viewController) @@ -36,14 +34,32 @@ final class MediaPickerUIKitRouter: BaseUIKitRouter, MediaPickerRouter { func showCroppingModule( forImage image: ImageSource, canvasSize: CGSize, - configuration: (ImageCroppingModule) -> ()) + configure: (ImageCroppingModule) -> ()) { let assembly = assemblyFactory.imageCroppingAssembly() let viewController = assembly.module( image: image, canvasSize: canvasSize, - configuration: configuration + configure: configure + ) + + cropViewControllers.append(WeakWrapper(value: viewController)) + + push(viewController, animated: false) + } + + func showMaskCropper( + data: MaskCropperData, + croppingOverlayProvider: CroppingOverlayProvider, + configure: (MaskCropperModule) -> ()) + { + let assembly = assemblyFactory.maskCropperAssembly() + + let viewController = assembly.module( + data: data, + croppingOverlayProvider: croppingOverlayProvider, + configure: configure ) cropViewControllers.append(WeakWrapper(value: viewController)) diff --git a/Paparazzo/Core/VIPER/MediaPicker/View/Controls/ButtonWithActivity.swift b/Paparazzo/Core/VIPER/MediaPicker/View/Controls/ButtonWithActivity.swift new file mode 100644 index 00000000..7d929130 --- /dev/null +++ b/Paparazzo/Core/VIPER/MediaPicker/View/Controls/ButtonWithActivity.swift @@ -0,0 +1,71 @@ +import UIKit + +final class ButtonWithActivity: UIButton { + + // MARK: - Subviews + private let activity = UIActivityIndicatorView(activityIndicatorStyle: .gray) + + // MARK: - State + private var cachedTitle: String? = nil + + var style: MediaPickerContinueButtonStyle = .normal { + didSet { + switch style { + + case .normal: + activity.stopAnimating() + super.setTitle(cachedTitle, for: .normal) + isUserInteractionEnabled = true + + case .spinner: + activity.startAnimating() + cachedTitle = title(for: .normal) + super.setTitle(nil, for: .normal) + isUserInteractionEnabled = false + } + } + } + + + // MARK: - Init + init() { + super.init(frame: .zero) + + addSubview(activity) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Title + override func setTitle(_ title: String?, for state: UIControlState) { + switch style { + case .normal: + super.setTitle(title, for: state) + case .spinner: + if state == .normal { + cachedTitle = title + } else { + super.setTitle(title, for: state) + } + } + + } + + // MARK: - Layout + override func sizeThatFits(_ size: CGSize) -> CGSize { + switch style { + case .normal: + return super.sizeThatFits(size) + case .spinner: + return size.intersectionWidth(self.height) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + + activity.center = CGPoint(x: bounds.midX, y: bounds.midY) + } +} diff --git a/Paparazzo/Core/VIPER/MediaPicker/View/Controls/CameraControlsView.swift b/Paparazzo/Core/VIPER/MediaPicker/View/Controls/CameraControlsView.swift index 428d111c..0598c193 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/View/Controls/CameraControlsView.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/View/Controls/CameraControlsView.swift @@ -2,7 +2,9 @@ import JNWSpringAnimation import ImageSource import UIKit -final class CameraControlsView: UIView { +final class CameraControlsView: UIView, ThemeConfigurable { + + typealias ThemeType = MediaPickerRootModuleUITheme var onShutterButtonTap: (() -> ())? var onPhotoLibraryButtonTap: (() -> ())? @@ -78,6 +80,15 @@ final class CameraControlsView: UIView { addSubview(shutterButton) addSubview(flashButton) addSubview(cameraToggleButton) + + setUpAccessibilityIdentifiers() + } + + private func setUpAccessibilityIdentifiers() { + photoView.setAccessibilityId(.photoView) + shutterButton.setAccessibilityId(.shutterButton) + cameraToggleButton.setAccessibilityId(.cameraToggleButton) + flashButton.setAccessibilityId(.flashButton) } required init?(coder aDecoder: NSCoder) { @@ -153,7 +164,9 @@ final class CameraControlsView: UIView { photoView.isUserInteractionEnabled = enabled } - func setTheme(_ theme: MediaPickerRootModuleUITheme) { + // MARK: - ThemeConfigurable + + func setTheme(_ theme: ThemeType) { self.theme = theme diff --git a/Paparazzo/Core/VIPER/MediaPicker/View/Controls/PhotoControlsView.swift b/Paparazzo/Core/VIPER/MediaPicker/View/Controls/PhotoControlsView.swift index 93d6edf4..5c99c501 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/View/Controls/PhotoControlsView.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/View/Controls/PhotoControlsView.swift @@ -1,15 +1,32 @@ import UIKit -final class PhotoControlsView: UIView { +final class PhotoControlsView: UIView, ThemeConfigurable { + + struct ModeOptions: OptionSet { + let rawValue: Int + + static let hasRemoveButton = ModeOptions(rawValue: 1 << 0) + static let hasAutocorrectButton = ModeOptions(rawValue: 1 << 1) + static let hasCropButton = ModeOptions(rawValue: 1 << 2) + + static let allButtons: ModeOptions = [.hasRemoveButton, .hasAutocorrectButton, .hasCropButton] + } + + typealias ThemeType = MediaPickerRootModuleUITheme // MARK: - Subviews private let removeButton = UIButton() + private let autocorrectButton = UIButton() private let cropButton = UIButton() + private var buttons = [UIButton]() + // MARK: UIView override init(frame: CGRect) { + self.mode = [.hasRemoveButton, .hasCropButton] + super.init(frame: frame) backgroundColor = .white @@ -20,6 +37,12 @@ final class PhotoControlsView: UIView { for: .touchUpInside ) + autocorrectButton.addTarget( + self, + action: #selector(onAutocorrectButtonTap(_:)), + for: .touchUpInside + ) + cropButton.addTarget( self, action: #selector(onCropButtonTap(_:)), @@ -27,7 +50,18 @@ final class PhotoControlsView: UIView { ) addSubview(removeButton) - addSubview(cropButton) // в первой итерации не показываем + addSubview(autocorrectButton) + addSubview(cropButton) + + buttons = [removeButton, autocorrectButton, cropButton] + + setUpAccessibilityIdentifiers() + } + + private func setUpAccessibilityIdentifiers() { + removeButton.setAccessibilityId(.removeButton) + autocorrectButton.setAccessibilityId(.autocorrectButton) + cropButton.setAccessibilityId(.cropButton) } required init?(coder aDecoder: NSCoder) { @@ -37,36 +71,50 @@ final class PhotoControlsView: UIView { override func layoutSubviews() { super.layoutSubviews() - removeButton.size = CGSize.minimumTapAreaSize - cropButton.size = CGSize.minimumTapAreaSize - - if cropButton.isHidden { - removeButton.center = bounds.center - } else { - removeButton.center = CGPoint(x: bounds.left + bounds.size.width * 0.25, y: bounds.centerY) - cropButton.center = CGPoint(x: bounds.right - bounds.size.width * 0.25, y: bounds.centerY) + let visibleButtons = buttons.filter { $0.isHidden == false } + visibleButtons.enumerated().forEach { index, button in + button.size = CGSize.minimumTapAreaSize + button.center = CGPoint( + x: (width * (2.0 * CGFloat(index) + 1.0)) / (2.0 * CGFloat(visibleButtons.count)), + y: bounds.centerY + ) } } + // MARK: - ThemeConfigurable + + func setTheme(_ theme: ThemeType) { + removeButton.setImage(theme.removePhotoIcon, for: .normal) + autocorrectButton.setImage(theme.autocorrectPhotoIconInactive, for: .normal) + autocorrectButton.setImage(theme.autocorrectPhotoIconActive, for: .highlighted) + autocorrectButton.setImage(theme.autocorrectPhotoIconActive, for: .selected) + cropButton.setImage(theme.cropPhotoIcon, for: .normal) + } + // MARK: - PhotoControlsView var onRemoveButtonTap: (() -> ())? + var onAutocorrectButtonTap: (() -> ())? var onCropButtonTap: (() -> ())? var onCameraButtonTap: (() -> ())? + var mode: ModeOptions { + didSet { + removeButton.isHidden = !mode.contains(.hasRemoveButton) + autocorrectButton.isHidden = !mode.contains(.hasAutocorrectButton) + cropButton.isHidden = !mode.contains(.hasCropButton) + setNeedsLayout() + } + } + func setControlsTransform(_ transform: CGAffineTransform) { removeButton.transform = transform + autocorrectButton.transform = transform cropButton.transform = transform } - func setTheme(_ theme: MediaPickerRootModuleUITheme) { - removeButton.setImage(theme.removePhotoIcon, for: .normal) - cropButton.setImage(theme.cropPhotoIcon, for: .normal) - } - - func setShowsCropButton(_ showsCropButton: Bool) { - cropButton.isHidden = !showsCropButton - setNeedsLayout() + func setAutocorrectButtonSelected(_ selected: Bool) { + autocorrectButton.isSelected = selected } // MARK: - Private @@ -75,6 +123,10 @@ final class PhotoControlsView: UIView { onRemoveButtonTap?() } + @objc private func onAutocorrectButtonTap(_: UIButton) { + onAutocorrectButtonTap?() + } + @objc private func onCropButtonTap(_: UIButton) { onCropButtonTap?() } diff --git a/Paparazzo/Core/VIPER/MediaPicker/View/MainView/MainCameraCell.swift b/Paparazzo/Core/VIPER/MediaPicker/View/MainView/MainCameraCell.swift index e63fc757..563abc23 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/View/MainView/MainCameraCell.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/View/MainView/MainCameraCell.swift @@ -8,6 +8,7 @@ final class MainCameraCell: UICollectionViewCell { if let cameraView = cameraView { addSubview(cameraView) + setAccessibilityId(.mainCameraCell) } } } diff --git a/Paparazzo/Core/VIPER/MediaPicker/View/MainView/PhotoPreviewCell.swift b/Paparazzo/Core/VIPER/MediaPicker/View/MainView/PhotoPreviewCell.swift index b603b7ad..57567b28 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/View/MainView/PhotoPreviewCell.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/View/MainView/PhotoPreviewCell.swift @@ -52,6 +52,7 @@ final class PhotoPreviewCell: PhotoCollectionViewCell { func customizeWithItem(_ item: MediaPickerItem) { imageSource = item.image + setAccessibilityId(.photoPreviewCell) } // MARK: - Private diff --git a/Paparazzo/Core/VIPER/MediaPicker/View/MainView/PhotoPreviewView.swift b/Paparazzo/Core/VIPER/MediaPicker/View/MainView/PhotoPreviewView.swift index 8fa372c2..ca161461 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/View/MainView/PhotoPreviewView.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/View/MainView/PhotoPreviewView.swift @@ -97,6 +97,7 @@ final class PhotoPreviewView: UIView, UICollectionViewDataSource, UICollectionVi from: sourceIndex, to: destinationIndex ) + self?.collectionView.moveItem( at: IndexPath(item: sourceIndex, section: 0), to: IndexPath(item: destinationIndex, section: 0) diff --git a/Paparazzo/Core/VIPER/MediaPicker/View/MediaPickerRootModuleUITheme.swift b/Paparazzo/Core/VIPER/MediaPicker/View/MediaPickerRootModuleUITheme.swift new file mode 100644 index 00000000..de8cd693 --- /dev/null +++ b/Paparazzo/Core/VIPER/MediaPicker/View/MediaPickerRootModuleUITheme.swift @@ -0,0 +1,25 @@ +public protocol MediaPickerRootModuleUITheme: AccessDeniedViewTheme { + + var shutterButtonColor: UIColor { get } + var shutterButtonDisabledColor: UIColor { get } + var focusIndicatorColor: UIColor { get } + var mediaRibbonSelectionColor: UIColor { get } + var cameraContinueButtonTitleColor: UIColor { get } + var cameraContinueButtonTitleHighlightedColor: UIColor { get } + var cameraButtonsBackgroundNormalColor: UIColor { get } + var cameraButtonsBackgroundHighlightedColor: UIColor { get } + var cameraButtonsBackgroundDisabledColor: UIColor { get } + + var removePhotoIcon: UIImage? { get } + var autocorrectPhotoIconInactive: UIImage? { get } + var autocorrectPhotoIconActive: UIImage? { get } + var cropPhotoIcon: UIImage? { get } + var returnToCameraIcon: UIImage? { get } + var closeCameraIcon: UIImage? { get } + var flashOnIcon: UIImage? { get } + var flashOffIcon: UIImage? { get } + var cameraToggleIcon: UIImage? { get } + var photoPeepholePlaceholder: UIImage? { get } + + var cameraContinueButtonTitleFont: UIFont { get } +} diff --git a/Paparazzo/Core/VIPER/MediaPicker/View/MediaPickerView.swift b/Paparazzo/Core/VIPER/MediaPicker/View/MediaPickerView.swift index 565181b7..deabf5ab 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/View/MediaPickerView.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/View/MediaPickerView.swift @@ -1,16 +1,17 @@ import ImageSource import UIKit -final class MediaPickerView: UIView { +final class MediaPickerView: UIView, ThemeConfigurable { + + typealias ThemeType = MediaPickerRootModuleUITheme // MARK: - Subviews private let cameraControlsView = CameraControlsView() private let photoControlsView = PhotoControlsView() - private let photoLibraryPeepholeView = UIImageView() private let closeButton = UIButton() - private let continueButton = UIButton() + private let continueButton = ButtonWithActivity() private let photoTitleLabel = UILabel() private let flashView = UIView() @@ -40,6 +41,13 @@ final class MediaPickerView: UIView { private var mode = MediaPickerViewMode.camera private var deviceOrientation = DeviceOrientation.portrait + private let infoMessageDisplayer = InfoMessageDisplayer() + + private var showsPreview: Bool = true { + didSet { + thumbnailRibbonView.isHidden = !showsPreview + } + } // MARK: - UIView @@ -64,7 +72,6 @@ final class MediaPickerView: UIView { photoTitleLabel.layer.masksToBounds = false photoTitleLabel.alpha = 0 - photoLibraryPeepholeView.contentMode = .scaleAspectFill thumbnailRibbonView.onPhotoItemSelect = { [weak self] mediaPickerItem in self?.onItemSelect?(mediaPickerItem) @@ -88,6 +95,8 @@ final class MediaPickerView: UIView { addSubview(continueButton) setMode(.camera) + + setUpAccessibilityIdentifiers() } private func setupButtons() { @@ -110,10 +119,18 @@ final class MediaPickerView: UIView { ) } + private func setUpAccessibilityIdentifiers() { + closeButton.setAccessibilityId(.closeButton) + continueButton.setAccessibilityId(.continueButton) + photoTitleLabel.setAccessibilityId(.titleLabel) + } + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } + // MARK: - Layout + override func layoutSubviews() { super.layoutSubviews() @@ -121,12 +138,17 @@ final class MediaPickerView: UIView { left: bounds.left, right: bounds.right, top: bounds.top, - height: bounds.size.width * cameraAspectRatio + height: showsPreview ? bounds.size.width * cameraAspectRatio : bounds.size.height - controlsExtendedHeight ) - let freeSpaceUnderCamera = bounds.bottom - cameraFrame.bottom - let canFitExtendedControls = (freeSpaceUnderCamera >= controlsExtendedHeight) - let controlsHeight = canFitExtendedControls ? controlsExtendedHeight : controlsCompactHeight + let controlsHeight: CGFloat + if showsPreview { + let freeSpaceUnderCamera = bounds.bottom - cameraFrame.bottom + let canFitExtendedControls = (freeSpaceUnderCamera >= controlsExtendedHeight) + controlsHeight = canFitExtendedControls ? controlsExtendedHeight : controlsCompactHeight + } else { + controlsHeight = controlsExtendedHeight + } photoPreviewView.frame = cameraFrame @@ -164,6 +186,12 @@ final class MediaPickerView: UIView { bottom: cameraControlsView.top, height: photoRibbonHeight ) + thumbnailRibbonView.onDragStart = { [weak self] in + self?.isUserInteractionEnabled = false + } + thumbnailRibbonView.onDragFinish = { [weak self] in + self?.isUserInteractionEnabled = true + } layoutCloseAndContinueButtons() layoutPhotoTitleLabel() @@ -171,6 +199,45 @@ final class MediaPickerView: UIView { flashView.frame = cameraFrame } + // MARK: - ThemeConfigurable + + func setTheme(_ theme: ThemeType) { + + cameraControlsView.setTheme(theme) + photoControlsView.setTheme(theme) + thumbnailRibbonView.setTheme(theme) + + continueButton.setTitleColor(theme.cameraContinueButtonTitleColor, for: .normal) + continueButton.titleLabel?.font = theme.cameraContinueButtonTitleFont + + closeButton.setImage(theme.closeCameraIcon, for: .normal) + + continueButton.setTitleColor( + theme.cameraContinueButtonTitleColor, + for: .normal + ) + continueButton.setTitleColor( + theme.cameraContinueButtonTitleHighlightedColor, + for: .highlighted + ) + + let onePointSize = CGSize(width: 1, height: 1) + for button in [continueButton, closeButton] { + button.setBackgroundImage( + UIImage.imageWithColor(theme.cameraButtonsBackgroundNormalColor, imageSize: onePointSize), + for: .normal + ) + button.setBackgroundImage( + UIImage.imageWithColor(theme.cameraButtonsBackgroundHighlightedColor, imageSize: onePointSize), + for: .highlighted + ) + button.setBackgroundImage( + UIImage.imageWithColor(theme.cameraButtonsBackgroundDisabledColor, imageSize: onePointSize), + for: .disabled + ) + } + } + // MARK: - MediaPickerView var onShutterButtonTap: (() -> ())? { @@ -195,6 +262,11 @@ final class MediaPickerView: UIView { set { photoControlsView.onRemoveButtonTap = newValue } } + var onAutocorrectButtonTap: (() -> ())? { + get { return photoControlsView.onAutocorrectButtonTap } + set { photoControlsView.onAutocorrectButtonTap = newValue } + } + var onCropButtonTap: (() -> ())? { get { return photoControlsView.onCropButtonTap } set { photoControlsView.onCropButtonTap = newValue } @@ -250,6 +322,15 @@ final class MediaPickerView: UIView { adjustForDeviceOrientation(deviceOrientation) } + func setAutocorrectionStatus(_ status: MediaPickerAutocorrectionStatus) { + switch status { + case .original: + photoControlsView.setAutocorrectButtonSelected(false) + case .corrected: + photoControlsView.setAutocorrectButtonSelected(true) + } + } + func setCameraControlsEnabled(_ enabled: Bool) { cameraControlsView.setCameraControlsEnabled(enabled) } @@ -287,6 +368,7 @@ final class MediaPickerView: UIView { } var onCloseButtonTap: (() -> ())? + var onContinueButtonTap: (() -> ())? var onCameraToggleButtonTap: (() -> ())? { @@ -306,6 +388,33 @@ final class MediaPickerView: UIView { cameraControlsView.setPhotoLibraryButtonEnabled(enabled) } + func setContinueButtonVisible(_ visible: Bool) { + continueButton.isHidden = !visible + } + + func setContinueButtonStyle(_ style: MediaPickerContinueButtonStyle) { + guard continueButton.style != style else { return } + + UIView.animate( + withDuration: 0.3, + animations: { + self.continueButton.style = style + if self.deviceOrientation == .portrait { + self.continueButton.size = CGSize( + width: self.continueButton.sizeThatFits().width, + height: self.continueButtonHeight + ) + } else { + self.continueButton.size = CGSize( + width: self.continueButtonHeight, + height: self.continueButton.sizeThatFits().width + ) + } + self.layoutCloseAndContinueButtons() + } + ) + } + func addItems(_ items: [MediaPickerItem], animated: Bool, completion: @escaping () -> ()) { photoPreviewView.addItems(items) thumbnailRibbonView.addItems(items, animated: animated, completion: completion) @@ -376,6 +485,7 @@ final class MediaPickerView: UIView { func setPhotoTitle(_ title: String) { photoTitleLabel.text = title + photoTitleLabel.accessibilityValue = title layoutPhotoTitleLabel() } @@ -396,6 +506,7 @@ final class MediaPickerView: UIView { func setContinueButtonTitle(_ title: String) { continueButton.setTitle(title, for: .normal) + continueButton.accessibilityValue = title continueButton.size = CGSize(width: continueButton.sizeThatFits().width, height: continueButtonHeight) } @@ -403,45 +514,24 @@ final class MediaPickerView: UIView { continueButton.isEnabled = enabled } - func setTheme(_ theme: MediaPickerRootModuleUITheme) { - - cameraControlsView.setTheme(theme) - photoControlsView.setTheme(theme) - thumbnailRibbonView.setTheme(theme) - - continueButton.setTitleColor(theme.cameraContinueButtonTitleColor, for: .normal) - continueButton.titleLabel?.font = theme.cameraContinueButtonTitleFont - - closeButton.setImage(theme.closeCameraIcon, for: .normal) - - continueButton.setTitleColor( - theme.cameraContinueButtonTitleColor, - for: .normal - ) - continueButton.setTitleColor( - theme.cameraContinueButtonTitleHighlightedColor, - for: .highlighted - ) - - let onePointSize = CGSize(width: 1, height: 1) - for button in [continueButton, closeButton] { - button.setBackgroundImage( - UIImage.imageWithColor(theme.cameraButtonsBackgroundNormalColor, imageSize: onePointSize), - for: .normal - ) - button.setBackgroundImage( - UIImage.imageWithColor(theme.cameraButtonsBackgroundHighlightedColor, imageSize: onePointSize), - for: .highlighted - ) - button.setBackgroundImage( - UIImage.imageWithColor(theme.cameraButtonsBackgroundDisabledColor, imageSize: onePointSize), - for: .disabled - ) + func setShowsCropButton(_ showsCropButton: Bool) { + if showsCropButton { + photoControlsView.mode.insert(.hasCropButton) + } else { + photoControlsView.mode.remove(.hasCropButton) } } - func setShowsCropButton(_ showsCropButton: Bool) { - photoControlsView.setShowsCropButton(showsCropButton) + func setShowsAutocorrectButton(_ showsAutocorrectButton: Bool) { + if showsAutocorrectButton { + photoControlsView.mode.insert(.hasAutocorrectButton) + } else { + photoControlsView.mode.remove(.hasAutocorrectButton) + } + } + + func setShowsPreview(_ showsPreview: Bool) { + self.showsPreview = showsPreview } func reloadCamera() { @@ -449,6 +539,10 @@ final class MediaPickerView: UIView { thumbnailRibbonView.reloadCamera() } + func showInfoMessage(_ message: String, timeout: TimeInterval) { + infoMessageDisplayer.display(viewData: InfoMessageViewData(text: message, timeout: timeout), in: photoPreviewView) + } + // MARK: - Private private func layoutCloseAndContinueButtons() { diff --git a/Paparazzo/Core/VIPER/MediaPicker/View/MediaPickerViewController.swift b/Paparazzo/Core/VIPER/MediaPicker/View/MediaPickerViewController.swift index 3839974a..23b14018 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/View/MediaPickerViewController.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/View/MediaPickerViewController.swift @@ -1,34 +1,31 @@ import ImageSource import UIKit -final class MediaPickerViewController: UIViewController, MediaPickerViewInput { +final class MediaPickerViewController: PaparazzoViewController, MediaPickerViewInput, ThemeConfigurable { + + typealias ThemeType = MediaPickerRootModuleUITheme - private var isBeingRotated: Bool = false private let mediaPickerView = MediaPickerView() private var layoutSubviewsPromise = Promise() + private var isAnimatingTransition: Bool = false // MARK: - UIViewController - - override func loadView() { - view = UIView() - view.backgroundColor = .black - view.addSubview(mediaPickerView) - } - + override func viewDidLoad() { super.viewDidLoad() + automaticallyAdjustsScrollViewInsets = false + view.backgroundColor = .black + view.addSubview(mediaPickerView) onViewDidLoad?() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - layoutMediaPickerView(interfaceOrientation: interfaceOrientation) - navigationController?.setNavigationBarHidden(true, animated: animated) UIApplication.shared.setStatusBarHidden(true, with: .fade) - + onViewWillAppear?(animated) } @@ -42,6 +39,11 @@ final class MediaPickerViewController: UIViewController, MediaPickerViewInput { } } + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + onViewDidDisappear?(animated) + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) @@ -74,41 +76,29 @@ final class MediaPickerViewController: UIViewController, MediaPickerViewInput { onViewDidAppear?(animated) } - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - onViewDidDisappear?(animated) - } - override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - if !isBeingRotated { - layoutMediaPickerView(interfaceOrientation: interfaceOrientation) + if !isAnimatingTransition { + layoutMediaPickerView(bounds: view.bounds) } + onPreviewSizeDetermined?(mediaPickerView.previewSize) layoutSubviewsPromise.fulfill() } - override func willAnimateRotation(to toInterfaceOrientation: UIInterfaceOrientation, duration: TimeInterval) { - if shouldAutorotate { - // Compensation animation for rotation. - UIView.animate( - withDuration: duration, - animations: { - self.layoutMediaPickerView(interfaceOrientation: toInterfaceOrientation) - }) - } - super.willAnimateRotation(to: toInterfaceOrientation, duration: duration) - } - - override func didRotate(from fromInterfaceOrientation: UIInterfaceOrientation) { - super.didRotate(from: fromInterfaceOrientation) - isBeingRotated = false - } - - override func willRotate(to toInterfaceOrientation: UIInterfaceOrientation, duration: TimeInterval) { - super.willRotate(to: toInterfaceOrientation, duration: duration) - isBeingRotated = true + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + + isAnimatingTransition = true + + coordinator.animate(alongsideTransition: { [weak self] context in + self?.layoutMediaPickerView(bounds: context.containerView.bounds) + }, + completion: { [weak self] _ in + self?.isAnimatingTransition = false + }) + + super.viewWillTransition(to: size, with: coordinator) } override open var shouldAutorotate: Bool { @@ -145,12 +135,12 @@ final class MediaPickerViewController: UIViewController, MediaPickerViewInput { get { return mediaPickerView.onShutterButtonTap } set { mediaPickerView.onShutterButtonTap = newValue } } - + var onPhotoLibraryButtonTap: (() -> ())? { get { return mediaPickerView.onPhotoLibraryButtonTap } set { mediaPickerView.onPhotoLibraryButtonTap = newValue } } - + var onFlashToggle: ((Bool) -> ())? { get { return mediaPickerView.onFlashToggle } set { mediaPickerView.onFlashToggle = newValue } @@ -171,6 +161,10 @@ final class MediaPickerViewController: UIViewController, MediaPickerViewInput { set { mediaPickerView.onRemoveButtonTap = newValue } } + var onAutocorrectButtonTap: (() -> ())? { + get { return mediaPickerView.onAutocorrectButtonTap } + set { mediaPickerView.onAutocorrectButtonTap = newValue } + } var onCropButtonTap: (() -> ())? { get { return mediaPickerView.onCropButtonTap } set { mediaPickerView.onCropButtonTap = newValue } @@ -210,6 +204,10 @@ final class MediaPickerViewController: UIViewController, MediaPickerViewInput { } } + func setAutocorrectionStatus(_ status: MediaPickerAutocorrectionStatus) { + mediaPickerView.setAutocorrectionStatus(status) + } + func setCameraOutputParameters(_ parameters: CameraOutputParameters) { mediaPickerView.setCameraOutputParameters(parameters) } @@ -238,6 +236,14 @@ final class MediaPickerViewController: UIViewController, MediaPickerViewInput { mediaPickerView.setContinueButtonEnabled(enabled) } + func setContinueButtonVisible(_ visible: Bool) { + mediaPickerView.setContinueButtonVisible(visible) + } + + func setContinueButtonStyle(_ style: MediaPickerContinueButtonStyle) { + mediaPickerView.setContinueButtonStyle(style) + } + func adjustForDeviceOrientation(_ orientation: DeviceOrientation) { UIView.animate(withDuration: 0.25) { self.mediaPickerView.adjustForDeviceOrientation(orientation) @@ -278,7 +284,7 @@ final class MediaPickerViewController: UIViewController, MediaPickerViewInput { func setCameraToggleButtonVisible(_ visible: Bool) { mediaPickerView.setCameraToggleButtonVisible(visible) } - + func addItems(_ items: [MediaPickerItem], animated: Bool, completion: @escaping () -> ()) { mediaPickerView.addItems(items, animated: animated, completion: completion) } @@ -333,36 +339,42 @@ final class MediaPickerViewController: UIViewController, MediaPickerViewInput { mediaPickerView.setPhotoLibraryButtonEnabled(enabled) } + func showInfoMessage(_ message: String, timeout: TimeInterval) { + mediaPickerView.showInfoMessage(message, timeout: timeout) + } + + // MARK: - ThemeConfigurable + + func setTheme(_ theme: ThemeType) { + mediaPickerView.setTheme(theme) + } + // MARK: - MediaPickerViewController func setCameraView(_ view: UIView) { mediaPickerView.setCameraView(view) } - func setTheme(_ theme: MediaPickerRootModuleUITheme) { - mediaPickerView.setTheme(theme) - } - func setShowsCropButton(_ showsCropButton: Bool) { mediaPickerView.setShowsCropButton(showsCropButton) } + func setShowsAutocorrectButton(_ showsAutocorrectButton: Bool) { + mediaPickerView.setShowsAutocorrectButton(showsAutocorrectButton) + } + + func setShowPreview(_ showPreview: Bool) { + mediaPickerView.setShowsPreview(showPreview) + } + // MARK: - Private - func layoutMediaPickerView(interfaceOrientation: UIInterfaceOrientation) { + func layoutMediaPickerView(bounds: CGRect) { // View is rotated, but mediaPickerView isn't. // It rotates in opposite direction and seems not rotated at all. // This allows to not force status bar orientation on this screen and keep UI same as // with forcing status bar orientation. mediaPickerView.transform = CGAffineTransform(interfaceOrientation: interfaceOrientation) - mediaPickerView.frame = view.bounds - } - - // MARK: - Dispose bag - - private var disposables = [AnyObject]() - - func addDisposable(_ object: AnyObject) { - disposables.append(object) + mediaPickerView.frame = bounds } } diff --git a/Paparazzo/Core/VIPER/MediaPicker/View/MediaPickerViewInput.swift b/Paparazzo/Core/VIPER/MediaPicker/View/MediaPickerViewInput.swift index 28c2fe21..c4d1ad1e 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/View/MediaPickerViewInput.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/View/MediaPickerViewInput.swift @@ -11,9 +11,15 @@ enum MediaPickerTitleStyle { case light } +enum MediaPickerAutocorrectionStatus { + case original + case corrected +} + protocol MediaPickerViewInput: class { func setMode(_: MediaPickerViewMode) + func setAutocorrectionStatus(_: MediaPickerAutocorrectionStatus) func adjustForDeviceOrientation(_: DeviceOrientation) func setCameraOutputParameters(_: CameraOutputParameters) @@ -24,7 +30,8 @@ protocol MediaPickerViewInput: class { func setPhotoTitleAlpha(_: CGFloat) func setContinueButtonTitle(_: String) func setContinueButtonEnabled(_: Bool) - + func setContinueButtonStyle(_ style: MediaPickerContinueButtonStyle) + func setLatestLibraryPhoto(_: ImageSource?) func setFlashButtonVisible(_: Bool) @@ -52,6 +59,12 @@ protocol MediaPickerViewInput: class { var onCameraToggleButtonTap: (() -> ())? { get set } func setCameraToggleButtonVisible(_: Bool) + func setContinueButtonVisible(_: Bool) + + func setShowPreview(_ showPreview: Bool) + + func showInfoMessage(_ message: String, timeout: TimeInterval) + // MARK: - Actions in photo ribbon var onItemSelect: ((MediaPickerItem) -> ())? { get set } var onItemMove: ((_ sourceIndex: Int, _ destinationIndex: Int) -> ())? { get set } @@ -63,6 +76,7 @@ protocol MediaPickerViewInput: class { // MARK: - Selected photo actions var onRemoveButtonTap: (() -> ())? { get set } + var onAutocorrectButtonTap: (() -> ())? { get set } var onCropButtonTap: (() -> ())? { get set } var onCameraThumbnailTap: (() -> ())? { get set } @@ -71,8 +85,8 @@ protocol MediaPickerViewInput: class { var onSwipeToCameraProgressChange: ((CGFloat) -> ())? { get set } var onViewDidLoad: (() -> ())? { get set } - var onViewWillAppear: ((_ animated: Bool) -> ())? { get set } var onViewDidAppear: ((_ animated: Bool) -> ())? { get set } + var onViewWillAppear: ((_ animated: Bool) -> ())? { get set } var onViewDidDisappear: ((_ animated: Bool) -> ())? { get set } var onPreviewSizeDetermined: ((_ previewSize: CGSize) -> ())? { get set } diff --git a/Paparazzo/Core/VIPER/MediaPicker/View/ThumbnailsView/CameraThumbnailCell.swift b/Paparazzo/Core/VIPER/MediaPicker/View/ThumbnailsView/CameraThumbnailCell.swift index e316a9a0..fab5e01e 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/View/ThumbnailsView/CameraThumbnailCell.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/View/ThumbnailsView/CameraThumbnailCell.swift @@ -69,6 +69,8 @@ final class CameraThumbnailCell: UICollectionViewCell { adjustBorderColor() addSubview(button) + + setAccessibilityId(.cameraThumbnailCell) } required init?(coder aDecoder: NSCoder) { diff --git a/Paparazzo/Core/VIPER/MediaPicker/View/ThumbnailsView/MediaItemThumbnailCell.swift b/Paparazzo/Core/VIPER/MediaPicker/View/ThumbnailsView/MediaItemThumbnailCell.swift index 7a3f0a83..bc90fec8 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/View/ThumbnailsView/MediaItemThumbnailCell.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/View/ThumbnailsView/MediaItemThumbnailCell.swift @@ -29,5 +29,6 @@ final class MediaItemThumbnailCell: PhotoCollectionViewCell, Customizable { func customizeWithItem(_ item: MediaPickerItem) { imageSource = item.image + setAccessibilityId(.mediaItemThumbnailCell) } } diff --git a/Paparazzo/Core/VIPER/MediaPicker/View/ThumbnailsView/ThumbnailsView.swift b/Paparazzo/Core/VIPER/MediaPicker/View/ThumbnailsView/ThumbnailsView.swift index 8e3d287a..5905d761 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/View/ThumbnailsView/ThumbnailsView.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/View/ThumbnailsView/ThumbnailsView.swift @@ -1,7 +1,27 @@ import ImageSource import UIKit -final class ThumbnailsView: UIView, UICollectionViewDataSource, MediaRibbonLayoutDelegate { +final class ThumbnailsView: UIView, UICollectionViewDataSource, MediaRibbonLayoutDelegate, ThemeConfigurable { + + typealias ThemeType = MediaPickerRootModuleUITheme + + var onDragStart: (() -> ())? { + get { + return layout.onDragStart + } + set { + layout.onDragStart = newValue + } + } + + var onDragFinish: (() -> ())? { + get { + return layout.onDragFinish + } + set { + layout.onDragFinish = newValue + } + } private let layout: ThumbnailsViewLayout private let collectionView: UICollectionView @@ -51,6 +71,12 @@ final class ThumbnailsView: UIView, UICollectionViewDataSource, MediaRibbonLayou collectionView.frame = bounds } + // MARK: - ThemeConfigurable + + func setTheme(_ theme: ThemeType) { + self.theme = theme + } + // MARK: - ThumbnailRibbonView var cameraOutputParameters: CameraOutputParameters? { @@ -74,6 +100,8 @@ final class ThumbnailsView: UIView, UICollectionViewDataSource, MediaRibbonLayou } func selectMediaItem(_ item: MediaPickerItem, animated: Bool = false) { + layout.cancelDrag() + if let indexPath = dataSource.indexPathForItem(item) { collectionView.selectItem(at: indexPath, animated: animated, scrollPosition: []) } @@ -97,10 +125,6 @@ final class ThumbnailsView: UIView, UICollectionViewDataSource, MediaRibbonLayou ) } - func setTheme(_ theme: MediaPickerRootModuleUITheme) { - self.theme = theme - } - func setControlsTransform(_ transform: CGAffineTransform) { layout.itemsTransform = transform diff --git a/Paparazzo/Core/VIPER/MediaPicker/View/ThumbnailsView/ThumbnailsViewLayout.swift b/Paparazzo/Core/VIPER/MediaPicker/View/ThumbnailsView/ThumbnailsViewLayout.swift index 78cc68e6..d552d266 100644 --- a/Paparazzo/Core/VIPER/MediaPicker/View/ThumbnailsView/ThumbnailsViewLayout.swift +++ b/Paparazzo/Core/VIPER/MediaPicker/View/ThumbnailsView/ThumbnailsViewLayout.swift @@ -10,6 +10,9 @@ final class ThumbnailsViewLayout: UICollectionViewFlowLayout { private var draggingView: UIView? private var dragOffset = CGPoint.zero + var onDragStart: (() -> ())? + var onDragFinish: (() -> ())? + override init() { super.init() } @@ -24,6 +27,11 @@ final class ThumbnailsViewLayout: UICollectionViewFlowLayout { setUpGestureRecognizer() } + func cancelDrag() { + longPressGestureRecognizer?.isEnabled = false + longPressGestureRecognizer?.isEnabled = true + } + private func setUpGestureRecognizer() { if let collectionView = collectionView, longPressGestureRecognizer == nil { let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(onLongPress(_:))) @@ -72,6 +80,7 @@ final class ThumbnailsViewLayout: UICollectionViewFlowLayout { case .began: startDragAtLocation(location: location) case .changed: updateDragAtLocation(location: location) case .ended: endDragAtLocation(location: location) + case .cancelled: endDragAtLocation(location: location) default: break } @@ -108,24 +117,19 @@ final class ThumbnailsViewLayout: UICollectionViewFlowLayout { }, completion: nil ) + + onDragStart?() } } private func updateDragAtLocation(location: CGPoint) { guard - let view = draggingView, - let collectionView = collectionView, - let draggingIndexPath = draggingIndexPath, - let delegate = collectionView.delegate as? MediaRibbonLayoutDelegate + let view = draggingView else { return } view.center = CGPoint(x: location.x + dragOffset.x, y: location.y + dragOffset.y) - if let newIndexPath = collectionView.indexPathForItem(at: location), delegate.canMove(to: newIndexPath) { - collectionView.moveItem(at: draggingIndexPath, to: newIndexPath) - self.draggingIndexPath = newIndexPath - beginScrollIfNeeded() - } + moveItem(to: location) } private func endDragAtLocation(location: CGPoint) { @@ -149,22 +153,62 @@ final class ThumbnailsViewLayout: UICollectionViewFlowLayout { }, completion: { _ in cell.isHidden = false - if indexPath != originalIndexPath { - delegate.moveItem(from: originalIndexPath, to: indexPath) - } - dragView.removeFromSuperview() self.draggingIndexPath = nil self.draggingView = nil self.invalidateLayout() + self.onDragFinish?() + }) } + private func indexPathForItemClosestTo(point: CGPoint) -> IndexPath? { + guard + let collectionView = collectionView, + let layoutAttributes = collectionView.collectionViewLayout.layoutAttributesForElements(in: collectionView.bounds) + else { return nil } + + var smallestDistance = CGFloat.greatestFiniteMagnitude + var indexPath: IndexPath? + + for attribute in layoutAttributes { + if attribute.frame.contains(point) { + return attribute.indexPath + } + + let currentDistance = abs(attribute.frame.x - point.x) + if smallestDistance > currentDistance { + smallestDistance = currentDistance + indexPath = attribute.indexPath + } + } + + return indexPath + } + + private func moveItem(to location: CGPoint) { + guard + let collectionView = collectionView, + let draggingIndexPath = draggingIndexPath, + let delegate = collectionView.delegate as? MediaRibbonLayoutDelegate + else { return } + + // AI-6314: If we pass location inside indexPathForItem it can return nil if location is out of collectionView bounds + if let newIndexPath = indexPathForItemClosestTo(point: CGPoint(x: location.x, y: collectionView.height/2)), + delegate.canMove(to: newIndexPath), + draggingIndexPath != newIndexPath { + delegate.moveItem(from: draggingIndexPath, to: newIndexPath) + collectionView.moveItem(at: draggingIndexPath, to: newIndexPath) + self.draggingIndexPath = newIndexPath + } + beginScrollIfNeeded() + } + // MARK: Handle scrolling to the edges - private var continuousScrollDirection: Direction = .none + private var continuousScrollDirection: direction = .none - enum Direction { + enum direction { case left case right case none @@ -180,7 +224,7 @@ final class ThumbnailsViewLayout: UICollectionViewFlowLayout { return 0 } - let proofedPercentage: CGFloat = max(min(1.0, percentage), 0) + let proofedPercentage: CGFloat = max(0, min(percentage, 1.0)) return value * proofedPercentage } } @@ -192,18 +236,15 @@ final class ThumbnailsViewLayout: UICollectionViewFlowLayout { private var displayLink: CADisplayLink? private var offsetFromLeft: CGFloat { - guard let contentOffset = collectionView?.contentOffset else { return 0 } - return contentOffset.x + return collectionView?.contentOffset.x ?? 0 } - private var collectionViewLength: CGFloat { - guard let collectionViewSize = collectionView?.bounds.size else { return 0 } - return collectionViewSize.width + private var collectionViewWidth: CGFloat { + return collectionView?.bounds.size.width ?? 0 } private var contentLength: CGFloat { - guard let contentSize = collectionView?.contentSize else { return 0 } - return contentSize.width + return collectionView?.contentSize.width ?? 0 } private var draggingViewTopEdge: CGFloat? { @@ -238,7 +279,7 @@ final class ThumbnailsViewLayout: UICollectionViewFlowLayout { if draggingViewTopEdge <= offsetFromLeft + triggerInset { continuousScrollDirection = .left setUpDisplayLink() - } else if draggingViewEndEdge >= offsetFromLeft + collectionViewLength - triggerInset { + } else if draggingViewEndEdge >= offsetFromLeft + collectionViewWidth - triggerInset { continuousScrollDirection = .right setUpDisplayLink() } else { @@ -253,7 +294,7 @@ final class ThumbnailsViewLayout: UICollectionViewFlowLayout { var scrollRate = continuousScrollDirection.scrollValue(scrollSpeedValue, percentage: percentage) let offset = offsetFromLeft - let length = collectionViewLength + let length = collectionViewWidth if contentLength <= length { return @@ -266,17 +307,15 @@ final class ThumbnailsViewLayout: UICollectionViewFlowLayout { } draggingView.x += scrollRate - - collectionView?.performBatchUpdates({ - self.collectionView?.contentOffset.x += scrollRate - }, completion: nil) + self.collectionView?.contentOffset.x += scrollRate + moveItem(to: draggingView.center) } private func calculateTriggerPercentage() -> CGFloat { guard draggingView != nil else { return 0 } let offset = offsetFromLeft - let offsetEnd = offsetFromLeft + collectionViewLength + let offsetEnd = offsetFromLeft + collectionViewWidth var percentage: CGFloat = 0 diff --git a/Paparazzo/Core/VIPER/PhotoLibrary/Assembly/PhotoLibraryAssembly.swift b/Paparazzo/Core/VIPER/PhotoLibrary/Assembly/PhotoLibraryAssembly.swift index 556d6489..af63d7e3 100644 --- a/Paparazzo/Core/VIPER/PhotoLibrary/Assembly/PhotoLibraryAssembly.swift +++ b/Paparazzo/Core/VIPER/PhotoLibrary/Assembly/PhotoLibraryAssembly.swift @@ -2,9 +2,8 @@ import UIKit public protocol PhotoLibraryAssembly: class { func module( - selectedItems: [PhotoLibraryItem], - maxSelectedItemsCount: Int?, - configuration: (PhotoLibraryModule) -> () + data: PhotoLibraryData, + configure: (PhotoLibraryModule) -> () ) -> UIViewController } diff --git a/Paparazzo/Core/VIPER/PhotoLibrary/Assembly/PhotoLibraryAssemblyImpl.swift b/Paparazzo/Core/VIPER/PhotoLibrary/Assembly/PhotoLibraryAssemblyImpl.swift index 588a2977..03546960 100644 --- a/Paparazzo/Core/VIPER/PhotoLibrary/Assembly/PhotoLibraryAssemblyImpl.swift +++ b/Paparazzo/Core/VIPER/PhotoLibrary/Assembly/PhotoLibraryAssemblyImpl.swift @@ -1,24 +1,17 @@ import UIKit -public final class PhotoLibraryAssemblyImpl: PhotoLibraryAssembly { - - private let theme: PhotoLibraryUITheme - - init(theme: PhotoLibraryUITheme) { - self.theme = theme - } +public final class PhotoLibraryAssemblyImpl: BasePaparazzoAssembly, PhotoLibraryAssembly { public func module( - selectedItems: [PhotoLibraryItem], - maxSelectedItemsCount: Int?, - configuration: (PhotoLibraryModule) -> ()) + data: PhotoLibraryData, + configure: (PhotoLibraryModule) -> ()) -> UIViewController { let photoLibraryItemsService = PhotoLibraryItemsServiceImpl() let interactor = PhotoLibraryInteractorImpl( - selectedItems: selectedItems, - maxSelectedItemsCount: maxSelectedItemsCount, + selectedItems: data.selectedItems, + maxSelectedItemsCount: data.maxSelectedItemsCount, photoLibraryItemsService: photoLibraryItemsService ) @@ -36,7 +29,7 @@ public final class PhotoLibraryAssemblyImpl: PhotoLibraryAssembly { presenter.view = viewController - configuration(presenter) + configure(presenter) return viewController } diff --git a/Paparazzo/Core/VIPER/PhotoLibrary/Interactor/PhotoLibraryInteractor.swift b/Paparazzo/Core/VIPER/PhotoLibrary/Interactor/PhotoLibraryInteractor.swift index 91006f79..16b4fd8c 100644 --- a/Paparazzo/Core/VIPER/PhotoLibrary/Interactor/PhotoLibraryInteractor.swift +++ b/Paparazzo/Core/VIPER/PhotoLibrary/Interactor/PhotoLibraryInteractor.swift @@ -8,6 +8,7 @@ protocol PhotoLibraryInteractor: class { func selectItem(_: PhotoLibraryItem, completion: @escaping (PhotoLibraryItemSelectionState) -> ()) func deselectItem(_: PhotoLibraryItem, completion: @escaping (PhotoLibraryItemSelectionState) -> ()) + func prepareSelection(completion: @escaping (PhotoLibraryItemSelectionState) -> ()) func selectedItems(completion: @escaping ([PhotoLibraryItem]) -> ()) } @@ -30,8 +31,15 @@ public func ==(item1: PhotoLibraryItem, item2: PhotoLibraryItem) -> Bool { } struct PhotoLibraryItemSelectionState { + + enum PreSelectionAction { + case none + case deselectAll + } + var isAnyItemSelected: Bool var canSelectMoreItems: Bool + var preSelectionAction: PreSelectionAction } struct PhotoLibraryChanges { diff --git a/Paparazzo/Core/VIPER/PhotoLibrary/Interactor/PhotoLibraryInteractorImpl.swift b/Paparazzo/Core/VIPER/PhotoLibrary/Interactor/PhotoLibraryInteractorImpl.swift index c9bc48ac..6da25988 100644 --- a/Paparazzo/Core/VIPER/PhotoLibrary/Interactor/PhotoLibraryInteractorImpl.swift +++ b/Paparazzo/Core/VIPER/PhotoLibrary/Interactor/PhotoLibraryInteractorImpl.swift @@ -25,10 +25,6 @@ final class PhotoLibraryInteractorImpl: PhotoLibraryInteractor { // MARK: - PhotoLibraryInteractor - func setMaxSelectedItemsCount(count: Int?) { - maxSelectedItemsCount = count - } - func observeAuthorizationStatus(handler: @escaping (_ accessGranted: Bool) -> ()) { photoLibraryItemsService.observeAuthorizationStatus(handler: handler) } @@ -72,6 +68,15 @@ final class PhotoLibraryInteractorImpl: PhotoLibraryInteractor { completion(selectionState()) } + func prepareSelection(completion: @escaping (PhotoLibraryItemSelectionState) -> ()) { + if selectedItems.count > 0 && maxSelectedItemsCount == 1 { + selectedItems.removeAll() + completion(selectionState(preSelectionAction: .deselectAll)) + } else { + completion(selectionState()) + } + } + func selectedItems(completion: @escaping ([PhotoLibraryItem]) -> ()) { completion(selectedItems) } @@ -82,10 +87,11 @@ final class PhotoLibraryInteractorImpl: PhotoLibraryInteractor { return maxSelectedItemsCount.flatMap { selectedItems.count < $0 } ?? true } - private func selectionState() -> PhotoLibraryItemSelectionState { + private func selectionState(preSelectionAction: PhotoLibraryItemSelectionState.PreSelectionAction = .none) -> PhotoLibraryItemSelectionState { return PhotoLibraryItemSelectionState( isAnyItemSelected: selectedItems.count > 0, - canSelectMoreItems: canSelectMoreItems() + canSelectMoreItems: canSelectMoreItems(), + preSelectionAction: preSelectionAction ) } diff --git a/Paparazzo/Core/VIPER/PhotoLibrary/Module/PhotoLibraryData.swift b/Paparazzo/Core/VIPER/PhotoLibrary/Module/PhotoLibraryData.swift new file mode 100644 index 00000000..505759f2 --- /dev/null +++ b/Paparazzo/Core/VIPER/PhotoLibrary/Module/PhotoLibraryData.swift @@ -0,0 +1,14 @@ +import UIKit + +public struct PhotoLibraryData { + public let selectedItems: [PhotoLibraryItem] + public let maxSelectedItemsCount: Int? + + public init( + selectedItems: [PhotoLibraryItem], + maxSelectedItemsCount: Int?) + { + self.selectedItems = selectedItems + self.maxSelectedItemsCount = maxSelectedItemsCount + } +} diff --git a/Paparazzo/Core/VIPER/PhotoLibrary/Presenter/PhotoLibraryPresenter.swift b/Paparazzo/Core/VIPER/PhotoLibrary/Presenter/PhotoLibraryPresenter.swift index f65d8b80..29406436 100644 --- a/Paparazzo/Core/VIPER/PhotoLibrary/Presenter/PhotoLibraryPresenter.swift +++ b/Paparazzo/Core/VIPER/PhotoLibrary/Presenter/PhotoLibraryPresenter.swift @@ -93,6 +93,13 @@ final class PhotoLibraryPresenter: PhotoLibraryModule { view?.setDimsUnselectedItems(!state.canSelectMoreItems) view?.setCanSelectMoreItems(state.canSelectMoreItems) view?.setPickButtonEnabled(state.isAnyItemSelected) + + switch state.preSelectionAction { + case .none: + break + case .deselectAll: + view?.deselectAllItems() + } } private func cellData(_ item: PhotoLibraryItem) -> PhotoLibraryItemCellData { @@ -101,6 +108,12 @@ final class PhotoLibraryPresenter: PhotoLibraryModule { cellData.selected = item.selected + cellData.onSelectionPrepare = { [weak self] in + self?.interactor.prepareSelection { [weak self] selectionState in + self?.adjustViewForSelectionState(selectionState) + } + } + cellData.onSelect = { [weak self] in self?.interactor.selectItem(item) { selectionState in self?.adjustViewForSelectionState(selectionState) diff --git a/Paparazzo/Core/VIPER/PhotoLibrary/View/AccessDeniedUITheme.swift b/Paparazzo/Core/VIPER/PhotoLibrary/View/AccessDeniedUITheme.swift new file mode 100644 index 00000000..f5e888b5 --- /dev/null +++ b/Paparazzo/Core/VIPER/PhotoLibrary/View/AccessDeniedUITheme.swift @@ -0,0 +1,5 @@ +public protocol AccessDeniedViewTheme { + var accessDeniedTitleFont: UIFont { get } + var accessDeniedMessageFont: UIFont { get } + var accessDeniedButtonFont: UIFont { get } +} diff --git a/Paparazzo/Core/VIPER/PhotoLibrary/View/AccessDeniedView.swift b/Paparazzo/Core/VIPER/PhotoLibrary/View/AccessDeniedView.swift index 67e0813f..17f66261 100644 --- a/Paparazzo/Core/VIPER/PhotoLibrary/View/AccessDeniedView.swift +++ b/Paparazzo/Core/VIPER/PhotoLibrary/View/AccessDeniedView.swift @@ -1,6 +1,8 @@ import UIKit -final class AccessDeniedView: UIView { +final class AccessDeniedView: UIView, ThemeConfigurable { + + typealias ThemeType = AccessDeniedViewTheme let titleLabel = UILabel() let messageLabel = UILabel() @@ -68,9 +70,9 @@ final class AccessDeniedView: UIView { button.frame = frames.buttonFrame } - // MARK: - AccessDeniedView + // MARK: - ThemeConfigurable - func setTheme(_ theme: AccessDeniedViewTheme) { + func setTheme(_ theme: ThemeType) { titleLabel.font = theme.accessDeniedTitleFont messageLabel.font = theme.accessDeniedMessageFont button.titleLabel?.font = theme.accessDeniedButtonFont diff --git a/Paparazzo/Core/VIPER/PhotoLibrary/View/PhotoLibraryItemCell.swift b/Paparazzo/Core/VIPER/PhotoLibrary/View/PhotoLibraryItemCell.swift index 0bdda7e3..15b334de 100644 --- a/Paparazzo/Core/VIPER/PhotoLibrary/View/PhotoLibraryItemCell.swift +++ b/Paparazzo/Core/VIPER/PhotoLibrary/View/PhotoLibraryItemCell.swift @@ -10,6 +10,7 @@ final class PhotoLibraryItemCell: PhotoCollectionViewCell, Customizable { override init(frame: CGRect) { super.init(frame: frame) contentView.insertSubview(cloudIconView, at: 0) + imageView.isAccessibilityElement = true } required init?(coder aDecoder: NSCoder) { diff --git a/Paparazzo/Core/VIPER/PhotoLibrary/View/PhotoLibraryUITheme.swift b/Paparazzo/Core/VIPER/PhotoLibrary/View/PhotoLibraryUITheme.swift new file mode 100644 index 00000000..239cbc6b --- /dev/null +++ b/Paparazzo/Core/VIPER/PhotoLibrary/View/PhotoLibraryUITheme.swift @@ -0,0 +1,9 @@ +public protocol PhotoLibraryUITheme: AccessDeniedViewTheme { + + var photoLibraryDoneButtonFont: UIFont { get } + + var photoLibraryItemSelectionColor: UIColor { get } + var photoCellBackgroundColor: UIColor { get } + + var iCloudIcon: UIImage? { get } +} diff --git a/Paparazzo/Core/VIPER/PhotoLibrary/View/PhotoLibraryView.swift b/Paparazzo/Core/VIPER/PhotoLibrary/View/PhotoLibraryView.swift index 62d078f4..cb220272 100644 --- a/Paparazzo/Core/VIPER/PhotoLibrary/View/PhotoLibraryView.swift +++ b/Paparazzo/Core/VIPER/PhotoLibrary/View/PhotoLibraryView.swift @@ -1,6 +1,8 @@ import UIKit -final class PhotoLibraryView: UIView, UICollectionViewDelegateFlowLayout { +final class PhotoLibraryView: UIView, UICollectionViewDelegateFlowLayout, ThemeConfigurable { + + typealias ThemeType = PhotoLibraryUITheme // MARK: - State @@ -54,6 +56,13 @@ final class PhotoLibraryView: UIView, UICollectionViewDelegateFlowLayout { accessDeniedView.frame = bounds } + // MARK: - ThemeConfigurable + + func setTheme(_ theme: ThemeType) { + self.theme = theme + accessDeniedView.setTheme(theme) + } + // MARK: - PhotoLibraryView var onAccessDeniedButtonTap: (() -> ())? { @@ -127,13 +136,19 @@ final class PhotoLibraryView: UIView, UICollectionViewDelegateFlowLayout { ) } - func scrollToBottom() { - collectionView.scrollToBottom() + func deselectAndAdjustAllCells() { + + guard let indexPathsForSelectedItems = collectionView.indexPathsForSelectedItems + else { return } + + for indexPath in indexPathsForSelectedItems { + collectionView.deselectItem(at: indexPath, animated: false) + onDeselectItem(at: indexPath) + } } - func setTheme(_ theme: PhotoLibraryUITheme) { - self.theme = theme - accessDeniedView.setTheme(theme) + func scrollToBottom() { + collectionView.scrollToBottom() } func setAccessDeniedViewVisible(_ visible: Bool) { @@ -160,6 +175,9 @@ final class PhotoLibraryView: UIView, UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { let cellData = dataSource.item(at: indexPath) + + cellData.onSelectionPrepare?() + return canSelectMoreItems && cellData.previewAvailable } @@ -174,13 +192,7 @@ final class PhotoLibraryView: UIView, UICollectionViewDelegateFlowLayout { } func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { - - dataSource.mutateItem(at: indexPath) { (cellData: inout PhotoLibraryItemCellData) in - cellData.selected = false - } - dataSource.item(at: indexPath).onDeselect?() - - adjustDimmingForCellAtIndexPath(indexPath) + onDeselectItem(at: indexPath) } // MARK: - Private @@ -275,4 +287,14 @@ final class PhotoLibraryView: UIView, UICollectionViewDelegateFlowLayout { collectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) } } + + private func onDeselectItem(at indexPath: IndexPath) { + dataSource.mutateItem(at: indexPath) { (cellData: inout PhotoLibraryItemCellData) in + cellData.selected = false + } + dataSource.item(at: indexPath).onDeselect?() + + adjustDimmingForCellAtIndexPath(indexPath) + } + } diff --git a/Paparazzo/Core/VIPER/PhotoLibrary/View/PhotoLibraryViewController.swift b/Paparazzo/Core/VIPER/PhotoLibrary/View/PhotoLibraryViewController.swift index 76c5f72a..8971b8fa 100644 --- a/Paparazzo/Core/VIPER/PhotoLibrary/View/PhotoLibraryViewController.swift +++ b/Paparazzo/Core/VIPER/PhotoLibrary/View/PhotoLibraryViewController.swift @@ -1,6 +1,8 @@ import UIKit -final class PhotoLibraryViewController: UIViewController, PhotoLibraryViewInput { +final class PhotoLibraryViewController: PaparazzoViewController, PhotoLibraryViewInput, ThemeConfigurable { + + typealias ThemeType = PhotoLibraryUITheme private let photoLibraryView = PhotoLibraryView() @@ -28,6 +30,13 @@ final class PhotoLibraryViewController: UIViewController, PhotoLibraryViewInput return true } + // MARK: - ThemeConfigurable + + func setTheme(_ theme: ThemeType) { + self.theme = theme + photoLibraryView.setTheme(theme) + } + // MARK: - PhotoLibraryViewInput var onItemSelect: ((PhotoLibraryItem) -> ())? @@ -78,6 +87,10 @@ final class PhotoLibraryViewController: UIViewController, PhotoLibraryViewInput photoLibraryView.dimsUnselectedItems = dimUnselectedItems } + func deselectAllItems() { + photoLibraryView.deselectAndAdjustAllCells() + } + func setPickButtonVisible(_ visible: Bool) { navigationItem.rightBarButtonItem = visible ? pickBarButtonItem : nil } @@ -90,11 +103,6 @@ final class PhotoLibraryViewController: UIViewController, PhotoLibraryViewInput photoLibraryView.scrollToBottom() } - func setTheme(_ theme: PhotoLibraryUITheme) { - self.theme = theme - photoLibraryView.setTheme(theme) - } - func setAccessDeniedViewVisible(_ visible: Bool) { photoLibraryView.setAccessDeniedViewVisible(visible) } @@ -111,14 +119,6 @@ final class PhotoLibraryViewController: UIViewController, PhotoLibraryViewInput photoLibraryView.setAccessDeniedButtonTitle(title) } - // MARK: - Dispose bag - - private var disposables = [AnyObject]() - - func addDisposable(_ object: AnyObject) { - disposables.append(object) - } - // MARK: - Private private var pickBarButtonItem: UIBarButtonItem? diff --git a/Paparazzo/Core/VIPER/PhotoLibrary/View/PhotoLibraryViewInput.swift b/Paparazzo/Core/VIPER/PhotoLibrary/View/PhotoLibraryViewInput.swift index 391abc40..f00cb380 100644 --- a/Paparazzo/Core/VIPER/PhotoLibrary/View/PhotoLibraryViewInput.swift +++ b/Paparazzo/Core/VIPER/PhotoLibrary/View/PhotoLibraryViewInput.swift @@ -12,6 +12,8 @@ protocol PhotoLibraryViewInput: class { func setCanSelectMoreItems(_: Bool) func setDimsUnselectedItems(_: Bool) + func deselectAllItems() + func setPickButtonVisible(_: Bool) func setPickButtonEnabled(_: Bool) @@ -38,6 +40,7 @@ struct PhotoLibraryItemCellData { var previewAvailable = false var onSelect: (() -> ())? + var onSelectionPrepare: (() -> ())? var onDeselect: (() -> ())? init(image: ImageSource) { diff --git a/Paparazzo/Marshroute/MarshrouteAssemblyFactory.swift b/Paparazzo/Marshroute/MarshrouteAssemblyFactory.swift index 60a5fac5..71b5cc98 100644 --- a/Paparazzo/Marshroute/MarshrouteAssemblyFactory.swift +++ b/Paparazzo/Marshroute/MarshrouteAssemblyFactory.swift @@ -1,28 +1,45 @@ +typealias MarshrouteAssemblyFactoryType = CameraAssemblyFactory & ImageCroppingAssemblyFactory & PhotoLibraryMarshrouteAssemblyFactory + public final class MarshrouteAssemblyFactory: CameraAssemblyFactory, MediaPickerMarshrouteAssemblyFactory, ImageCroppingAssemblyFactory, - PhotoLibraryMarshrouteAssemblyFactory + PhotoLibraryMarshrouteAssemblyFactory, + MaskCropperMarshrouteAssemblyFactory { private let theme: PaparazzoUITheme + private let serviceFactory: ServiceFactory + private let photoStorage: PhotoStorage - public init(theme: PaparazzoUITheme = PaparazzoUITheme()) { + public init(theme: PaparazzoUITheme = PaparazzoUITheme(), + photoStorage: PhotoStorage = PhotoStorageImpl()) + { self.theme = theme + self.photoStorage = photoStorage + self.serviceFactory = ServiceFactoryImpl(photoStorage: photoStorage) } func cameraAssembly() -> CameraAssembly { - return CameraAssemblyImpl(theme: theme) + return CameraAssemblyImpl( + theme: theme, + serviceFactory: serviceFactory + ) } public func mediaPickerAssembly() -> MediaPickerMarshrouteAssembly { - return MediaPickerMarshrouteAssemblyImpl(assemblyFactory: self, theme: theme) + return MediaPickerMarshrouteAssemblyImpl(assemblyFactory: self, theme: theme, serviceFactory: serviceFactory) } func imageCroppingAssembly() -> ImageCroppingAssembly { - return ImageCroppingAssemblyImpl(theme: theme) + return ImageCroppingAssemblyImpl(theme: theme, serviceFactory: serviceFactory) } public func photoLibraryAssembly() -> PhotoLibraryMarshrouteAssembly { - return PhotoLibraryMarshrouteAssemblyImpl(theme: theme) + return PhotoLibraryMarshrouteAssemblyImpl(theme: theme, serviceFactory: serviceFactory) + } + + public func maskCropperAssembly() -> MaskCropperMarshrouteAssembly { + return MaskCropperMarshrouteAssemblyImpl(theme: theme, serviceFactory: serviceFactory) } + } diff --git a/Paparazzo/Marshroute/MaskCropper/Assembly/MaskCropperMarshrouteAssembly.swift b/Paparazzo/Marshroute/MaskCropper/Assembly/MaskCropperMarshrouteAssembly.swift new file mode 100644 index 00000000..02f1c8c1 --- /dev/null +++ b/Paparazzo/Marshroute/MaskCropper/Assembly/MaskCropperMarshrouteAssembly.swift @@ -0,0 +1,15 @@ +import Marshroute +import UIKit + +public protocol MaskCropperMarshrouteAssembly: class { + func module( + data: MaskCropperData, + croppingOverlayProvider: CroppingOverlayProvider, + routerSeed: RouterSeed, + configure: (MaskCropperModule) -> ()) + -> UIViewController +} + +public protocol MaskCropperMarshrouteAssemblyFactory: class { + func maskCropperAssembly() -> MaskCropperMarshrouteAssembly +} diff --git a/Paparazzo/Marshroute/MaskCropper/Assembly/MaskCropperMarshrouteAssemblyImpl.swift b/Paparazzo/Marshroute/MaskCropper/Assembly/MaskCropperMarshrouteAssemblyImpl.swift new file mode 100644 index 00000000..b32792c1 --- /dev/null +++ b/Paparazzo/Marshroute/MaskCropper/Assembly/MaskCropperMarshrouteAssemblyImpl.swift @@ -0,0 +1,44 @@ +import Marshroute +import UIKit + +public final class MaskCropperMarshrouteAssemblyImpl: BasePaparazzoAssembly, MaskCropperMarshrouteAssembly { + + public func module( + data: MaskCropperData, + croppingOverlayProvider: CroppingOverlayProvider, + routerSeed: RouterSeed, + configure: (MaskCropperModule) -> () + ) -> UIViewController { + + let imageCroppingService = serviceFactory.imageCroppingService( + image: data.imageSource, + canvasSize: data.cropCanvasSize + ) + + let interactor = MaskCropperInteractorImpl( + imageCroppingService: imageCroppingService + ) + + let router = MaskCropperMarshrouteRouter( + routerSeed: routerSeed + ) + + let presenter = MaskCropperPresenter( + interactor: interactor, + router: router + ) + + let viewController = MaskCropperViewController( + croppingOverlayProvider: croppingOverlayProvider + ) + viewController.addDisposable(presenter) + viewController.setTheme(theme) + + presenter.view = viewController + + configure(presenter) + + return viewController + } + +} diff --git a/Paparazzo/Marshroute/MaskCropper/Router/MaskCropperMarshrouteRouter.swift b/Paparazzo/Marshroute/MaskCropper/Router/MaskCropperMarshrouteRouter.swift new file mode 100644 index 00000000..3ac5e82f --- /dev/null +++ b/Paparazzo/Marshroute/MaskCropper/Router/MaskCropperMarshrouteRouter.swift @@ -0,0 +1,4 @@ +import ImageSource +import Marshroute + +final class MaskCropperMarshrouteRouter: BaseRouter, MaskCropperRouter {} diff --git a/Paparazzo/Marshroute/MediaPicker/Assembly/MediaPickerMarshrouteAssembly.swift b/Paparazzo/Marshroute/MediaPicker/Assembly/MediaPickerMarshrouteAssembly.swift index 808abbc8..d3788961 100644 --- a/Paparazzo/Marshroute/MediaPicker/Assembly/MediaPickerMarshrouteAssembly.swift +++ b/Paparazzo/Marshroute/MediaPicker/Assembly/MediaPickerMarshrouteAssembly.swift @@ -3,13 +3,9 @@ import UIKit public protocol MediaPickerMarshrouteAssembly: class { func module( - items: [MediaPickerItem], - selectedItem: MediaPickerItem?, - maxItemsCount: Int?, - cropEnabled: Bool, - cropCanvasSize: CGSize, + data: MediaPickerData, routerSeed: RouterSeed, - configuration: (MediaPickerModule) -> ()) + configure: (MediaPickerModule) -> ()) -> UIViewController } diff --git a/Paparazzo/Marshroute/MediaPicker/Assembly/MediaPickerMarshrouteAssemblyImpl.swift b/Paparazzo/Marshroute/MediaPicker/Assembly/MediaPickerMarshrouteAssemblyImpl.swift index daa3aa7d..2e1440d1 100644 --- a/Paparazzo/Marshroute/MediaPicker/Assembly/MediaPickerMarshrouteAssemblyImpl.swift +++ b/Paparazzo/Marshroute/MediaPicker/Assembly/MediaPickerMarshrouteAssemblyImpl.swift @@ -1,37 +1,33 @@ import Marshroute import UIKit -public final class MediaPickerMarshrouteAssemblyImpl: MediaPickerMarshrouteAssembly { +public final class MediaPickerMarshrouteAssemblyImpl: BasePaparazzoAssembly, MediaPickerMarshrouteAssembly { - typealias AssemblyFactory = CameraAssemblyFactory & ImageCroppingAssemblyFactory & PhotoLibraryMarshrouteAssemblyFactory + typealias AssemblyFactory = CameraAssemblyFactory & ImageCroppingAssemblyFactory & PhotoLibraryMarshrouteAssemblyFactory & MaskCropperMarshrouteAssemblyFactory private let assemblyFactory: AssemblyFactory - private let theme: PaparazzoUITheme - init(assemblyFactory: AssemblyFactory, theme: PaparazzoUITheme) { + init(assemblyFactory: AssemblyFactory, theme: PaparazzoUITheme, serviceFactory: ServiceFactory) { self.assemblyFactory = assemblyFactory - self.theme = theme + super.init(theme: theme, serviceFactory: serviceFactory) } // MARK: - MediaPickerAssembly public func module( - items: [MediaPickerItem], - selectedItem: MediaPickerItem?, - maxItemsCount: Int?, - cropEnabled: Bool, - cropCanvasSize: CGSize, + data: MediaPickerData, routerSeed: RouterSeed, - configuration: (MediaPickerModule) -> ()) + configure: (MediaPickerModule) -> ()) -> UIViewController { let interactor = MediaPickerInteractorImpl( - items: items, - selectedItem: selectedItem, - maxItemsCount: maxItemsCount, - cropCanvasSize: cropCanvasSize, - deviceOrientationService: DeviceOrientationServiceImpl(), - latestLibraryPhotoProvider: PhotoLibraryLatestPhotoProviderImpl() + items: data.items, + autocorrectionFilters: data.autocorrectionFilters, + selectedItem: data.selectedItem, + maxItemsCount: data.maxItemsCount, + cropCanvasSize: data.cropCanvasSize, + deviceOrientationService: serviceFactory.deviceOrientationService(), + latestLibraryPhotoProvider: serviceFactory.photoLibraryLatestPhotoProvider() ) let router = MediaPickerMarshrouteRouter( @@ -40,7 +36,7 @@ public final class MediaPickerMarshrouteAssemblyImpl: MediaPickerMarshrouteAssem ) let cameraAssembly = assemblyFactory.cameraAssembly() - let (cameraView, cameraModuleInput) = cameraAssembly.module() + let (cameraView, cameraModuleInput) = cameraAssembly.module(initialActiveCameraType: data.initialActiveCameraType) let presenter = MediaPickerPresenter( interactor: interactor, @@ -52,11 +48,12 @@ public final class MediaPickerMarshrouteAssemblyImpl: MediaPickerMarshrouteAssem viewController.addDisposable(presenter) viewController.setCameraView(cameraView) viewController.setTheme(theme) - viewController.setShowsCropButton(cropEnabled) + viewController.setShowsCropButton(data.cropEnabled) + viewController.setShowsAutocorrectButton(data.autocorrectEnabled) presenter.view = viewController - configuration(presenter) + configure(presenter) return viewController } diff --git a/Paparazzo/Marshroute/MediaPicker/Router/MediaPickerMarshrouteRouter.swift b/Paparazzo/Marshroute/MediaPicker/Router/MediaPickerMarshrouteRouter.swift index e82b6d88..f53f300b 100644 --- a/Paparazzo/Marshroute/MediaPicker/Router/MediaPickerMarshrouteRouter.swift +++ b/Paparazzo/Marshroute/MediaPicker/Router/MediaPickerMarshrouteRouter.swift @@ -3,7 +3,7 @@ import Marshroute final class MediaPickerMarshrouteRouter: BaseRouter, MediaPickerRouter { - typealias AssemblyFactory = ImageCroppingAssemblyFactory & PhotoLibraryMarshrouteAssemblyFactory + typealias AssemblyFactory = ImageCroppingAssemblyFactory & PhotoLibraryMarshrouteAssemblyFactory & MaskCropperMarshrouteAssemblyFactory private let assemblyFactory: AssemblyFactory @@ -15,19 +15,18 @@ final class MediaPickerMarshrouteRouter: BaseRouter, MediaPickerRouter { // MARK: - PhotoPickerRouter func showPhotoLibrary( - selectedItems: [PhotoLibraryItem], - maxSelectedItemsCount: Int?, - configuration: (PhotoLibraryModule) -> ()) + data: PhotoLibraryData, + configure: (PhotoLibraryModule) -> ()) { presentModalNavigationControllerWithRootViewControllerDerivedFrom { routerSeed in let assembly = assemblyFactory.photoLibraryAssembly() return assembly.module( - selectedItems: selectedItems, - maxSelectedItemsCount: maxSelectedItemsCount, + selectedItems: data.selectedItems, + maxSelectedItemsCount: data.maxSelectedItemsCount, routerSeed: routerSeed, - configuration: configuration + configure: configure ) } } @@ -35,7 +34,7 @@ final class MediaPickerMarshrouteRouter: BaseRouter, MediaPickerRouter { func showCroppingModule( forImage image: ImageSource, canvasSize: CGSize, - configuration: (ImageCroppingModule) -> ()) + configure: (ImageCroppingModule) -> ()) { pushViewControllerDerivedFrom({ _ in @@ -44,9 +43,27 @@ final class MediaPickerMarshrouteRouter: BaseRouter, MediaPickerRouter { return assembly.module( image: image, canvasSize: canvasSize, - configuration: configuration + configure: configure ) }, animator: NonAnimatedPushAnimator()) } + + func showMaskCropper( + data: MaskCropperData, + croppingOverlayProvider: CroppingOverlayProvider, + configure: (MaskCropperModule) -> ()) + { + pushViewControllerDerivedFrom({ routerSeed in + + let assembly = assemblyFactory.maskCropperAssembly() + + return assembly.module( + data: data, + croppingOverlayProvider: croppingOverlayProvider, + routerSeed: routerSeed, + configure: configure + ) + }, animator: NonAnimatedPushAnimator()) + } } diff --git a/Paparazzo/Marshroute/PhotoLibrary/Assembly/PhotoLibraryMarshrouteAssembly.swift b/Paparazzo/Marshroute/PhotoLibrary/Assembly/PhotoLibraryMarshrouteAssembly.swift index ef6c4fa9..c6247cc7 100644 --- a/Paparazzo/Marshroute/PhotoLibrary/Assembly/PhotoLibraryMarshrouteAssembly.swift +++ b/Paparazzo/Marshroute/PhotoLibrary/Assembly/PhotoLibraryMarshrouteAssembly.swift @@ -6,7 +6,7 @@ public protocol PhotoLibraryMarshrouteAssembly: class { selectedItems: [PhotoLibraryItem], maxSelectedItemsCount: Int?, routerSeed: RouterSeed, - configuration: (PhotoLibraryModule) -> ()) + configure: (PhotoLibraryModule) -> ()) -> UIViewController } diff --git a/Paparazzo/Marshroute/PhotoLibrary/Assembly/PhotoLibraryMarshrouteAssemblyImpl.swift b/Paparazzo/Marshroute/PhotoLibrary/Assembly/PhotoLibraryMarshrouteAssemblyImpl.swift index 407f8fe2..29b50700 100644 --- a/Paparazzo/Marshroute/PhotoLibrary/Assembly/PhotoLibraryMarshrouteAssemblyImpl.swift +++ b/Paparazzo/Marshroute/PhotoLibrary/Assembly/PhotoLibraryMarshrouteAssemblyImpl.swift @@ -1,19 +1,13 @@ import UIKit import Marshroute -public final class PhotoLibraryMarshrouteAssemblyImpl: PhotoLibraryMarshrouteAssembly { - - private let theme: PhotoLibraryUITheme - - init(theme: PhotoLibraryUITheme) { - self.theme = theme - } +public final class PhotoLibraryMarshrouteAssemblyImpl: BasePaparazzoAssembly, PhotoLibraryMarshrouteAssembly { public func module( selectedItems: [PhotoLibraryItem], maxSelectedItemsCount: Int?, routerSeed: RouterSeed, - configuration: (PhotoLibraryModule) -> () + configure: (PhotoLibraryModule) -> () ) -> UIViewController { let photoLibraryItemsService = PhotoLibraryItemsServiceImpl() @@ -37,7 +31,7 @@ public final class PhotoLibraryMarshrouteAssemblyImpl: PhotoLibraryMarshrouteAss presenter.view = viewController - configuration(presenter) + configure(presenter) return viewController } diff --git a/README.md b/README.md index 38b4d515..317acc4b 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,12 @@ | :camera: | Taking photos using camera | | :iphone: | Picking photos from user's photo library | | :scissors: | Photo cropping and rotation | +| :droplet: | Applying filters to photos | ![Demo](PaparazzoDemo.gif) +[Changelog](https://github.com/avito-tech/Paparazzo/blob/master/CHANGELOG.md) | See the changes introduced in each Paparazzo version. + # Contents * [Installation](#installation) @@ -21,6 +24,7 @@ * [Additional parameters of MediaPicker module](#MediaPickerModule) * [Memory constraints when cropping](#memory-constraints) * [Presenting photo library](#present-gallery) + * [Presenting mask cropper](#present-maskCropper) * [UI Customization](#ui-customization) * [ImageSource](#ImageSource) * [Typical use cases](#use-cases) @@ -47,38 +51,50 @@ pod "Paparazzo/Core" You can use either the entire module or photo library exclusively. ## Presenting entire module -Initialize module assembly using `Paparazzo.AssemblyFactory` (or `Paparazzo.MarshrouteAssemblyFactory` if you use Marshroute): +Initialize module assembly using `Paparazzo.AssemblyFactory` (or `Paparazzo.MarshrouteAssemblyFactory` if you use [Marshroute](https://github.com/avito-tech/Marshroute)): ```swift let factory = Paparazzo.AssemblyFactory() let assembly = factory.mediaPickerAssembly() ``` Create view controller using assembly's `module` method: ```swift -let viewController = assembly.module( +let data = MediaPickerData( items: items, - selectedItem: selectedItem, + autocorrectionFilters: filters, + selectedItem: items.last, maxItemsCount: maxItemsCount, cropEnabled: true, - cropCanvasSize: cropCanvasSize, - routerSeed: routerSeed, // omit this parameter if you're using AssemblyFactory - configuration: configuration + autocorrectEnabled: true, + cropCanvasSize: cropCanvasSize +) + +let viewController = assembly.module( + data: data, + routerSeed: routerSeed, // omit this parameter if you're using Paparazzo.AssemblyFactory + configure: configure ) ``` Method parameters: * _items_ — array of photos that should be initially selected when module is presenter. +* _filters_ — array of filters that can be applied to photos. * _selectedItem_ — selected photo. If set to `nil` or if _items_ doesn't contain any photo with matching _identifier_, then the first photo in array will be selected. * _maxItemsCount_ — maximum number of photos that user is allowed to pick. * _cropEnabled_ — boolean flag indicating whether user can perform photo cropping. +* _autocorrectEnabled_ — boolean flag indicating whether user can apply filters to photo . * _cropCanvasSize_ — maximum size of canvas when cropping photos. (see [Memory constraints when cropping](#memory-constraints)). * _routerSeed_ — routerSeed provided by Marshroute. -* _configuration_ — closure that allows you to provide [module's additional parameters](#MediaPickerModule). +* _configure_ — closure that allows you to provide [module's additional parameters](#MediaPickerModule). ### Additional parameters of MediaPicker module Additional parameters is described in protocol `MediaPickerModule`: -* `setContinueButtonTitle(_:)` and `setContinueButtonEnabled(_:)` allow to customize "Continue" button text and availability. +* `setContinueButtonTitle(_:)`, `setContinueButtonEnabled(_:)` , `setContinueButtonVisible(_:)` and `setContinueButtonStyle(_:)` allow to customize "Continue" button text and availability. +* `setAccessDeniedTitle(_:)`, `setAccessDeniedMessage(_:)` and `setAccessDeniedButtonTitle(_:)` allow to customize "Access Deined" view texts. +* `setCropMode(_:)` allow to customize photo crop behavior. * `onItemsAdd` is called when user picks items from photo library or takes a new photo using camera. * `onItemUpdate` is called after user performed cropping. +* `onItemAutocorrect` is called after applying filter. +* `onItemMove` is called after moving photo. * `onItemRemove` is called when user deletes photo. * `onFinish` and `onCancel` is called when user taps Continue and Close respectively. @@ -86,7 +102,7 @@ Additional parameters is described in protocol `MediaPickerModule`: When cropping photo on devices with low RAM capacity your application can crash due to memory warning. It happens because in order to perform actual cropping we need to put a bitmap of the original photo in memory. To descrease a chance of crashing on older devices (such as iPhone 4 or 4s) we can scale the source photo beforehand so that it takes up less space in memory. _cropCanvasSize_ is used for that. It specifies the size of the photo we should be targeting when scaling. ## Presenting photo library -Initialize module assembly using `Paparazzo.AssemblyFactory` (or `Paparazzo.MarshrouteAssemblyFactory` if you use Marshroute): +Initialize module assembly using `Paparazzo.AssemblyFactory` (or `Paparazzo.MarshrouteAssemblyFactory` if you use [Marshroute](https://github.com/avito-tech/Marshroute)): ```swift let factory = Paparazzo.AssemblyFactory() let assembly = factory.photoLibraryAssembly() @@ -96,14 +112,40 @@ Create view controller using assembly's `module` method: let viewController = assembly.module( selectedItems: selectedItems, maxSelectedItemsCount: maxSelectedItemsCount, - routerSeed: routerSeed, // omit this parameter if you're using AssemblyFactory - configuration: configuration + routerSeed: routerSeed, // omit this parameter if you're using Paparazzo.AssemblyFactory + configure: configure ) ``` * _selectedItems_ — preselected photos (or `nil`). * _maxItemsCount_ — maximum number of photos that user is allowed to pick. * _routerSeed_ — routerSeed provided by Marshroute. -* _configuration_ — closure used to provide additional module setup. +* _configure_ — closure used to provide additional module setup. + +## Presenting mask cropper +MaskCropper is a module which provides easy way to customize cropping experience. See CroppingOverlayProvider protocol to get more details. + +Initialize module assembly using `Paparazzo.AssemblyFactory` (or `Paparazzo.MarshrouteAssemblyFactory` if you use [Marshroute](https://github.com/avito-tech/Marshroute)): +```swift +let factory = Paparazzo.AssemblyFactory() +let assembly = factory.maskCropperAssembly() +``` +Create view controller using assembly's `module` method: +```swift +let data = MaskCropperData( + imageSource: photo.image, + cropCanvasSize: cropCanvasSize +) +let viewController = assembly.module( + data: data, + croppingOverlayProvider: croppingOverlayProvider, + routerSeed: routerSeed, // omit this parameter if you're using Paparazzo.AssemblyFactory + configure: configure +) +``` +* _imageSource_ — photo that should be cropped. +* _croppingOverlayProvider_ — provider from CroppingOverlayProvidersFactory. +* _routerSeed_ — routerSeed provided by Marshroute. +* _configure_ — closure used to provide additional module setup. ## UI Customization You can customize colors, fonts and icons used in photo picker. Just pass an instance of `PaparazzoUITheme` to the initializer of assembly factory. @@ -164,8 +206,11 @@ imageSource.imageSize { size in } ``` -# Author +# Authors Andrey Yutkin (ayutkin@avito.ru) +Artem Peskishev (aopeskishev@avito.ru) +Timofey Khomutnikov (tnkhomutnikov@avito.ru) +Vladimir Kaltyrin (vkaltyrin@avito.ru) # License MIT