diff --git a/README.md b/README.md index 8f61232..8c28a6a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ you can pick the custom transition you want to use: ```swift let viewController = MyViewController() viewController.transitionController.transition = CustomTransition() -present(modalViewController, animated: true) +present(viewController, animated: true) ``` ```objc @@ -102,7 +102,7 @@ commands: ## Guides 1. [Architecture](#architecture) -2. [How to create a simple transition](#how-to-create-a-simple-transition) +2. [How to create a fade transition](#how-to-create-a-fade-transition) 3. [How to customize presentation](#how-to-customize-presentation) 4. [How to customize navigation controller transitions](#how-to-customize-navigation-controller-transitions) @@ -118,23 +118,27 @@ MotionTransitioning provides a thin layer atop these protocols with the followin - Every view controller has its own **transition controller**. This encourages choosing the transition based on the context. - Transitions are represented in terms of **backward/forward** rather than from/to. When presenting, - we're moving forward. When dismissing, we're moving backward. This makes it easier to refer to - each "side" of a transition consistently. -- Transition objects can customize their behavior by conforming to more `TransitionWith*` protocols. - This protocol-oriented design is more Swift-friendly than a variety of optional methods on a - protocol. -- But most importantly: **this library handles the plumbing, allowing you to focus on the motion**. + we're moving forward. When dismissing, we're moving backward. This allows transition code to be + written with fewer conditional branches of logic. +- Transition objects can customize their behavior by conforming to the family of `TransitionWith*` protocols. -### How to create a simple transition +### How to create a fade transition -In this guide we'll create scaffolding for a simple transition. +We'll create a new fade transition so that the following lines of code customizes the presentation +and dismissal of our view controller: + +```swift +let viewController = MyViewController() +viewController.transitionController.transition = FadeTransition() +present(viewController, animated: true) +``` #### Step 1: Define a new Transition type -Transitions must be `NSObject` types that conform to the `Transition` protocol. +A transition is an `NSObject` subclass that conforms to the `Transition` protocol. -The sole method we're expected to implement, `start`, is invoked each time the view controller is -presented or dismissed. +The only method you have to implement is `start(with context:)`. This method is invoked each time +the associated view controller is presented or dismissed. ```swift final class FadeTransition: NSObject, Transition { @@ -146,7 +150,11 @@ final class FadeTransition: NSObject, Transition { #### Step 2: Invoke the completion handler once all animations are complete -If using Core Animation explicitly: +Every transition is provided with a transition context. The transition context must be told when the +transition's motion has completed so that the context can then inform UIKit of the view controller +transition's completion. + +If using explicit Core Animation animations: ```swift final class FadeTransition: NSObject, Transition { @@ -164,7 +172,7 @@ final class FadeTransition: NSObject, Transition { } ``` -If using UIView implicit animations: +If using implicit UIView animations: ```swift final class FadeTransition: NSObject, Transition { @@ -181,41 +189,57 @@ final class FadeTransition: NSObject, Transition { #### Step 3: Implement the motion -With the basic scaffolding in place, you can now implement your motion. +With the basic scaffolding in place, you can now implement your motion. For simplicity's sake we'll +use implicit UIView animations in this example to build our motion, but you're free to use any +animation system you prefer. + +```swift +final class FadeTransition: NSObject, Transition { + func start(with context: TransitionContext) { + // This is a fairly rudimentary way to calculate the values on either side of the transition. + // You may want to try different patterns until you find one that you prefer. + // Also consider trying the MotionAnimator library provided by the Material Motion team: + // https://github.com/material-motion/motion-animator-objc + let backOpacity = 0 + let foreOpacity = 1 + let initialOpacity = context.direction == .forward ? backOpacity : foreOpacity + let finalOpacity = context.direction == .forward ? foreOpacity : backOpacity + context.foreViewController.view.alpha = initialOpacity + UIView.animate(withDuration: context.duration, animations: { + context.foreViewController.view.alpha = finalOpacity + + }, completion: { didComplete in + context.transitionDidEnd() + }) + } +} +``` ### How to customize presentation -You'll customize the presentation of a transition when you need to do any of the following: +Customize the presentation of a transition when you need to do any of the following: - Add views, such as dimming views, that live beyond the lifetime of the transition. - Change the destination frame of the presented view controller. -#### Step 1: Subclass UIPresentationController +You have two options for customizing presentation: -You must subclass UIPresentationController in order to implement your custom behavior. If the user -of your transition can customize any presentation behavior then you'll want to define a custom -initializer. +1. Use the provided `TransitionPresentationController` API. +2. Build your own UIPresentationController subclass. -> Note: Avoid storing the transition context in your presentation controller. Presentation -> controllers live for as long as their associated view controller, while the transition context is -> only valid while a transition is active. Each presentation and dismissal will receive its own -> unique transition context. Storing the context in the presentation controller would keep the -> context alive longer than it's meant to. +#### Option 2: Subclass UIPresentationController -Override any `UIPresentationController` methods you'll need in order to implement your motion. +Start by defining a new presentation controller type: ```swift final class MyPresentationController: UIPresentationController { } ``` -#### Step 2: Implement TransitionWithPresentation on your transition - -This ensures that your transition implement the required methods for presentation. - -Presentation will only be customized if you return `.custom` from the -`defaultModalPresentationStyle` method and a non-nil `UIPresentationController` subclass from the -`presentationController` method. +Your Transition type must conform to `TransitionWithPresentation` in order to customize +presentation. Return your custom presentation controller class from the required methods and be sure +to return the `.custom` presentation style, otherwise UIKit will not use your presentation +controller. ```swift extension VerticalSheetTransition: TransitionWithPresentation { @@ -231,15 +255,14 @@ extension VerticalSheetTransition: TransitionWithPresentation { } ``` -#### Optional Step 3: Implement Transition on your presentation controller - If your presentation controller needs to animate anything, you can conform to the `Transition` protocol in order to receive a `start` invocation each time a transition begins. The presentation controller's `start` will be invoked before the transition's `start`. -> Note: It's possible for your presentation controller and your transition to have different ideas -> of when a transition has completed, so consider which object should be responsible for invoking -> `transitionDidEnd`. The `Transition` object is usually the one that calls this method. +> Note: Just like your transition, your presentation controller must eventually call +> `transitionDidEnd` on its context, otherwise your transition will not complete. This is because +> the transitioning controller waits until all associated transitions have completed before +> informing UIKit of the view controller transition's completion. ```swift extension MyPresentationController: Transition { diff --git a/examples/ContextualExample.swift b/examples/ContextualExample.swift index e5d9b27..b394263 100644 --- a/examples/ContextualExample.swift +++ b/examples/ContextualExample.swift @@ -35,10 +35,10 @@ class ContextualExampleViewController: ExampleViewController { // Note that in this example we're populating the contextual transition with the tapped view. // Our rudimentary transition will animate the context view to the center of the screen from its // current location. - controller.transitionController.transitions = [ + controller.transitionController.transition = CompositeTransition(transitions: [ FadeTransition(target: .foreView), ContextualTransition(contextView: tapGesture.view!) - ] + ]) present(controller, animated: true) } @@ -125,11 +125,8 @@ private class ContextualTransition: NSObject, Transition { y: context.foreViewController.view.bounds.midY) addAnimationToLayer(shift, snapshotContextView.layer) - let fadeOut = CABasicAnimation(keyPath: "opacity") - fadeOut.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut) - fadeOut.fromValue = 1 - fadeOut.toValue = 0 - addAnimationToLayer(fadeOut, snapshotContextView.layer) + context.compose(with: FadeTransition(target: .target(snapshotContextView), + style: .fadeOut)) CATransaction.commit() } diff --git a/examples/CustomPresentationExample.swift b/examples/CustomPresentationExample.swift index fba9271..19b959c 100644 --- a/examples/CustomPresentationExample.swift +++ b/examples/CustomPresentationExample.swift @@ -165,7 +165,7 @@ extension CustomPresentationExampleViewController { extension CustomPresentationExampleViewController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let modal = ModalViewController() - modal.transitionController.transitions = [transitions[indexPath.row].transition] + modal.transitionController.transition = transitions[indexPath.row].transition showDetailViewController(modal, sender: self) } } diff --git a/examples/FadeExample.m b/examples/FadeExample.m index 0d49d24..1631a2c 100644 --- a/examples/FadeExample.m +++ b/examples/FadeExample.m @@ -30,7 +30,7 @@ - (void)didTap { // controller that you'll make use of is the `transitions` property. Setting this property will // dictate how the view controller is presented. For this example we've built a custom // FadeTransition, so we'll make use of that now: - viewController.mdm_transitionController.transitions = @[[[FadeTransition alloc] init]]; + viewController.mdm_transitionController.transition = [[FadeTransition alloc] init]; // Note that once we assign the transition object to the view controller, the transition will // govern all subsequent presentations and dismissals of that view controller instance. If we diff --git a/examples/FadeExample.swift b/examples/FadeExample.swift index d573491..0c0f146 100644 --- a/examples/FadeExample.swift +++ b/examples/FadeExample.swift @@ -30,7 +30,7 @@ class FadeExampleViewController: ExampleViewController { // controller that you'll make use of is the `transition` property. Setting this property will // dictate how the view controller is presented. For this example we've built a custom // FadeTransition, so we'll make use of that now: - modalViewController.transitionController.transitions = [FadeTransition(target: .foreView)] + modalViewController.transitionController.transition = FadeTransition(target: .foreView) // Note that once we assign the transition object to the view controller, the transition will // govern all subsequent presentations and dismissals of that view controller instance. If we diff --git a/examples/MenuExample.swift b/examples/MenuExample.swift index 1b01bfe..8d28d46 100644 --- a/examples/MenuExample.swift +++ b/examples/MenuExample.swift @@ -21,7 +21,7 @@ class MenuExampleViewController: ExampleViewController { func didTap() { let modalViewController = ModalViewController() - modalViewController.transitionController.transitions = [MenuTransition()] + modalViewController.transitionController.transition = MenuTransition() present(modalViewController, animated: true) } diff --git a/examples/NavControllerFadeExample.swift b/examples/NavControllerFadeExample.swift index 05c1dfd..dbbfe94 100644 --- a/examples/NavControllerFadeExample.swift +++ b/examples/NavControllerFadeExample.swift @@ -31,7 +31,7 @@ class NavControllerFadeExampleViewController: ExampleViewController { // controller that you'll make use of is the `transition` property. Setting this property will // dictate how the view controller is presented. For this example we've built a custom // FadeTransition, so we'll make use of that now: - modalViewController.transitionController.transitions = [FadeTransition(target: .foreView)] + modalViewController.transitionController.transition = FadeTransition(target: .foreView) cachedNavDelegate = navigationController?.delegate diff --git a/examples/PhotoAlbumExample.swift b/examples/PhotoAlbumExample.swift index e738e56..bb7f720 100644 --- a/examples/PhotoAlbumExample.swift +++ b/examples/PhotoAlbumExample.swift @@ -19,66 +19,9 @@ import MotionTransitioning // This example demonstrates how to build a photo album contextual transition. -let numberOfImageAssets = 10 -let numberOfPhotosInAlbum = 30 - -struct Photo { - let name: String - let image: UIImage - let uuid: String - - fileprivate init(name: String) { - self.uuid = NSUUID().uuidString - self.name = name - - // NOTE: In a real app you should never load images from disk on the UI thread like this. - // Instead, you should find some way to cache the thumbnails in memory and then asynchronously - // load the full-size photos from disk/network when needed. The photo library APIs provide - // exactly this sort of behavior (square thumbnails are accessible immediately on the UI thread - // while the full-sized photos need to be loaded asynchronously). - self.image = UIImage(named: "\(self.name).jpg")! - } -} - -class PhotoAlbum { - let photos: [Photo] - let identifierToIndex: [String: Int] - - init() { - var photos: [Photo] = [] - var identifierToIndex: [String: Int] = [:] - for index in 0.. UIImageView? { let currentPhoto = (foreViewController as! PhotoAlbumViewController).currentPhoto guard let photoIndex = album.identifierToIndex[currentPhoto.uuid] else { @@ -155,7 +96,7 @@ public class PhotoAlbumExampleViewController: UICollectionViewController, Contex } } -private class PhotoAlbumViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, ContextualImageTransitionForeDelegate { +class PhotoAlbumViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, PhotoAlbumTransitionForeDelegate { var collectionView: UICollectionView! let toolbar = UIToolbar() @@ -229,12 +170,16 @@ private class PhotoAlbumViewController: UIViewController, UICollectionViewDataSo return .lightContent } - // MARK: ContextualImageTransitionForeDelegate + // MARK: PhotoAlbumTransitionForeDelegate - func foreContextView(for transition: ContextualImageTransition) -> UIImageView? { + func foreContextView(for transition: PhotoAlbumTransition) -> UIImageView? { return (collectionView.cellForItem(at: indexPathForCurrentPhoto()) as! PhotoCollectionViewCell).imageView } + func toolbar(for transition: PhotoAlbumTransition) -> UIToolbar? { + return toolbar + } + // MARK: UICollectionViewDataSource func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { diff --git a/examples/PhotoAlbumTransition.swift b/examples/PhotoAlbumTransition.swift new file mode 100644 index 0000000..7fb6ad6 --- /dev/null +++ b/examples/PhotoAlbumTransition.swift @@ -0,0 +1,90 @@ +/* + Copyright 2017-present The Material Motion Authors. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import UIKit +import MotionTransitioning + +protocol PhotoAlbumTransitionForeDelegate: class { + func foreContextView(for transition: PhotoAlbumTransition) -> UIImageView? + func toolbar(for transition: PhotoAlbumTransition) -> UIToolbar? +} + +protocol PhotoAlbumTransitionBackDelegate: class { + func backContextView(for transition: PhotoAlbumTransition, + with foreViewController: UIViewController) -> UIImageView? +} + +final class PhotoAlbumTransition: NSObject, Transition, TransitionWithFeasibility { + weak var backDelegate: PhotoAlbumTransitionBackDelegate? + weak var foreDelegate: PhotoAlbumTransitionForeDelegate? + init(backDelegate: PhotoAlbumTransitionBackDelegate, + foreDelegate: PhotoAlbumTransitionForeDelegate) { + self.backDelegate = backDelegate + self.foreDelegate = foreDelegate + } + + func canPerformTransition(with context: TransitionContext) -> Bool { + guard let backDelegate = backDelegate else { + return false + } + return backDelegate.backContextView(for: self, with: context.foreViewController) != nil + } + + func start(with context: TransitionContext) { + guard let backDelegate = backDelegate, let foreDelegate = foreDelegate else { + return + } + guard let contextView = backDelegate.backContextView(for: self, + with: context.foreViewController) else { + return + } + guard let foreImageView = foreDelegate.foreContextView(for: self) else { + return + } + + let snapshotter = TransitionViewSnapshotter(containerView: context.containerView) + context.defer { + snapshotter.removeAllSnapshots() + } + + foreImageView.isHidden = true + context.defer { + foreImageView.isHidden = false + } + + let imageSize = foreImageView.image!.size + + let fitScale = min(foreImageView.bounds.width / imageSize.width, + foreImageView.bounds.height / imageSize.height) + let fitSize = CGSize(width: fitScale * imageSize.width, height: fitScale * imageSize.height) + + let snapshotContextView = snapshotter.snapshot(of: contextView, + isAppearing: context.direction == .backward) + + context.compose(with: FadeTransition(target: .foreView, style: .fadeIn)) + context.compose(with: SpringFrameTransition(target: .target(snapshotContextView), + size: fitSize)) + + if let toolbar = foreDelegate.toolbar(for: self) { + context.compose(with: SlideUpTransition(target: .target(toolbar))) + } + + // This transition doesn't directly produce any animations, so we inform the context that it is + // complete here, otherwise the transition would never complete: + context.transitionDidEnd() + } +} diff --git a/examples/PhotoCollectionViewCell.swift b/examples/PhotoCollectionViewCell.swift new file mode 100644 index 0000000..e7b8a6a --- /dev/null +++ b/examples/PhotoCollectionViewCell.swift @@ -0,0 +1,37 @@ +/* + Copyright 2017-present The Material Motion Authors. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import UIKit + +class PhotoCollectionViewCell: UICollectionViewCell { + let imageView = UIImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + + imageView.contentMode = .scaleAspectFill + imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + imageView.frame = bounds + imageView.clipsToBounds = true + + contentView.addSubview(imageView) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/examples/apps/Catalog/TransitionsCatalog.xcodeproj/project.pbxproj b/examples/apps/Catalog/TransitionsCatalog.xcodeproj/project.pbxproj index d902e51..5c17ba3 100644 --- a/examples/apps/Catalog/TransitionsCatalog.xcodeproj/project.pbxproj +++ b/examples/apps/Catalog/TransitionsCatalog.xcodeproj/project.pbxproj @@ -8,12 +8,24 @@ /* Begin PBXBuildFile section */ 072A063B1EEE26A900B9B5FC /* MenuExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 072A063A1EEE26A900B9B5FC /* MenuExample.swift */; }; + 6618F5D31F73EB7900A4ABDD /* FadeTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668E28841F4F5389008A4550 /* FadeTransition.swift */; }; + 6618F5D41F73EB7900A4ABDD /* SlideUpTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668E28961F571CA4008A4550 /* SlideUpTransition.swift */; }; + 6618F5D51F73EB7900A4ABDD /* SpringFrameTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668E289A1F572A9D008A4550 /* SpringFrameTransition.swift */; }; + 6618F5D71F73EB8800A4ABDD /* InstantCompletionTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6618F5CF1F73E88C00A4ABDD /* InstantCompletionTransition.swift */; }; + 661F92EC1F69C79900AA259E /* Photo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 661F92EB1F69C79900AA259E /* Photo.swift */; }; + 661F92EE1F69C7CA00AA259E /* PhotoAlbum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 661F92ED1F69C7CA00AA259E /* PhotoAlbum.swift */; }; + 661F92F11F69C7E900AA259E /* PhotoCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 661F92F01F69C7E900AA259E /* PhotoCollectionViewCell.swift */; }; + 661F92F31F69C80E00AA259E /* PhotoAlbumTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 661F92F21F69C80E00AA259E /* PhotoAlbumTransition.swift */; }; 6629151E1ED5E0E0002B9A5D /* CustomPresentationExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6629151D1ED5E0E0002B9A5D /* CustomPresentationExample.swift */; }; 662915201ED5E137002B9A5D /* ModalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6629151F1ED5E137002B9A5D /* ModalViewController.swift */; }; 662915231ED64A10002B9A5D /* TransitionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 662915221ED64A10002B9A5D /* TransitionTests.swift */; }; 666FAA841D384A6B000363DA /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 666FAA831D384A6B000363DA /* AppDelegate.swift */; }; 666FAA8B1D384A6B000363DA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 666FAA8A1D384A6B000363DA /* Assets.xcassets */; }; 666FAA8E1D384A6B000363DA /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 666FAA8C1D384A6B000363DA /* LaunchScreen.storyboard */; }; + 667051C61F7425EC00769148 /* CompositeTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6672D3641F7425B5004E9AF5 /* CompositeTransition.swift */; }; + 667051C71F7425EC00769148 /* TransitionTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6672D3651F7425B5004E9AF5 /* TransitionTarget.swift */; }; + 667051CA1F7425EE00769148 /* CompositeTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6672D3641F7425B5004E9AF5 /* CompositeTransition.swift */; }; + 667051CB1F7425EE00769148 /* TransitionTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6672D3651F7425B5004E9AF5 /* TransitionTarget.swift */; }; 667A3F421DEE269400CB3A99 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667A3F411DEE269400CB3A99 /* AppDelegate.swift */; }; 667A3F491DEE269400CB3A99 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 667A3F481DEE269400CB3A99 /* Assets.xcassets */; }; 667A3F4C1DEE269400CB3A99 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 667A3F4A1DEE269400CB3A99 /* LaunchScreen.storyboard */; }; @@ -23,8 +35,8 @@ 668E288E1F5066AA008A4550 /* PhotoAlbumExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668E288D1F5066AA008A4550 /* PhotoAlbumExample.swift */; }; 668E28901F50673A008A4550 /* PhotoAlbum.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 668E288F1F50673A008A4550 /* PhotoAlbum.xcassets */; }; 668E28971F571CA4008A4550 /* SlideUpTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668E28961F571CA4008A4550 /* SlideUpTransition.swift */; }; - 668E28991F5729C1008A4550 /* TransitionTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668E28981F5729C1008A4550 /* TransitionTarget.swift */; }; - 668E289B1F572A9D008A4550 /* ContextualImageTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668E289A1F572A9D008A4550 /* ContextualImageTransition.swift */; }; + 668E289B1F572A9D008A4550 /* SpringFrameTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668E289A1F572A9D008A4550 /* SpringFrameTransition.swift */; }; + 669AFD661F75360E00FF06DF /* FallbackTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 669AFD651F75360E00FF06DF /* FallbackTransition.swift */; }; 66A320FC1F1E716600E2EAC3 /* NavControllerFadeExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 664CC3D91F1E6F3000B80804 /* NavControllerFadeExample.swift */; }; 66BBC75E1ED37DAD0015CB9B /* FadeExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66BBC75D1ED37DAD0015CB9B /* FadeExample.swift */; }; 66BBC76D1ED4C8790015CB9B /* ExampleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66BBC7691ED4C8790015CB9B /* ExampleViewController.swift */; }; @@ -60,6 +72,11 @@ 2408A4B72C0BA93CC963452F /* Pods_UnitTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_UnitTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3734DFFD1C84494E48784617 /* Pods-TransitionsCatalog.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TransitionsCatalog.release.xcconfig"; path = "../../../Pods/Target Support Files/Pods-TransitionsCatalog/Pods-TransitionsCatalog.release.xcconfig"; sourceTree = ""; }; 56D9DF9E44D993D12FE85E99 /* Pods_TransitionsCatalog.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_TransitionsCatalog.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 6618F5CF1F73E88C00A4ABDD /* InstantCompletionTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstantCompletionTransition.swift; sourceTree = ""; }; + 661F92EB1F69C79900AA259E /* Photo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Photo.swift; path = supplemental/Photo.swift; sourceTree = ""; }; + 661F92ED1F69C7CA00AA259E /* PhotoAlbum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PhotoAlbum.swift; path = supplemental/PhotoAlbum.swift; sourceTree = ""; }; + 661F92F01F69C7E900AA259E /* PhotoCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoCollectionViewCell.swift; sourceTree = ""; }; + 661F92F21F69C80E00AA259E /* PhotoAlbumTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoAlbumTransition.swift; sourceTree = ""; }; 6629151D1ED5E0E0002B9A5D /* CustomPresentationExample.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomPresentationExample.swift; sourceTree = ""; }; 6629151F1ED5E137002B9A5D /* ModalViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ModalViewController.swift; sourceTree = ""; }; 662915211ED5F222002B9A5D /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../../../README.md; sourceTree = ""; }; @@ -72,6 +89,8 @@ 666FAA8F1D384A6B000363DA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 666FAA941D384A6B000363DA /* UnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 666FAA9A1D384A6B000363DA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = ../UnitTests/Info.plist; sourceTree = ""; }; + 6672D3641F7425B5004E9AF5 /* CompositeTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CompositeTransition.swift; path = transitions/CompositeTransition.swift; sourceTree = ""; }; + 6672D3651F7425B5004E9AF5 /* TransitionTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TransitionTarget.swift; path = transitions/TransitionTarget.swift; sourceTree = ""; }; 667A3F3F1DEE269400CB3A99 /* TestHarness.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TestHarness.app; sourceTree = BUILT_PRODUCTS_DIR; }; 667A3F411DEE269400CB3A99 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 667A3F481DEE269400CB3A99 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -83,8 +102,8 @@ 668E288D1F5066AA008A4550 /* PhotoAlbumExample.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoAlbumExample.swift; sourceTree = ""; }; 668E288F1F50673A008A4550 /* PhotoAlbum.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = PhotoAlbum.xcassets; sourceTree = ""; }; 668E28961F571CA4008A4550 /* SlideUpTransition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SlideUpTransition.swift; path = transitions/SlideUpTransition.swift; sourceTree = ""; }; - 668E28981F5729C1008A4550 /* TransitionTarget.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransitionTarget.swift; sourceTree = ""; }; - 668E289A1F572A9D008A4550 /* ContextualImageTransition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ContextualImageTransition.swift; path = transitions/ContextualImageTransition.swift; sourceTree = ""; }; + 668E289A1F572A9D008A4550 /* SpringFrameTransition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SpringFrameTransition.swift; path = transitions/SpringFrameTransition.swift; sourceTree = ""; }; + 669AFD651F75360E00FF06DF /* FallbackTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FallbackTransition.swift; sourceTree = ""; }; 66BBC75D1ED37DAD0015CB9B /* FadeExample.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = FadeExample.swift; path = ../FadeExample.swift; sourceTree = ""; }; 66BBC7691ED4C8790015CB9B /* ExampleViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExampleViewController.swift; sourceTree = ""; }; 66BBC76A1ED4C8790015CB9B /* ExampleViews.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExampleViews.swift; sourceTree = ""; }; @@ -133,6 +152,32 @@ name = Frameworks; sourceTree = ""; }; + 6618F5D11F73E8A000A4ABDD /* Transitions */ = { + isa = PBXGroup; + children = ( + 6618F5CF1F73E88C00A4ABDD /* InstantCompletionTransition.swift */, + 669AFD651F75360E00FF06DF /* FallbackTransition.swift */, + ); + path = Transitions; + sourceTree = ""; + }; + 661F92EA1F69C78600AA259E /* Model */ = { + isa = PBXGroup; + children = ( + 661F92EB1F69C79900AA259E /* Photo.swift */, + 661F92ED1F69C7CA00AA259E /* PhotoAlbum.swift */, + ); + name = Model; + sourceTree = ""; + }; + 661F92EF1F69C7E000AA259E /* Cells */ = { + isa = PBXGroup; + children = ( + 661F92F01F69C7E900AA259E /* PhotoCollectionViewCell.swift */, + ); + name = Cells; + sourceTree = ""; + }; 666FAA771D384A6B000363DA = { isa = PBXGroup; children = ( @@ -173,6 +218,7 @@ 666FAA971D384A6B000363DA /* tests */ = { isa = PBXGroup; children = ( + 6618F5D11F73E8A000A4ABDD /* Transitions */, 662915221ED64A10002B9A5D /* TransitionTests.swift */, 66BBC7711ED728DB0015CB9B /* TransitionWithPresentationTests.swift */, ); @@ -264,7 +310,10 @@ 668E288C1F506698008A4550 /* Photo album */ = { isa = PBXGroup; children = ( + 661F92EF1F69C7E000AA259E /* Cells */, + 661F92EA1F69C78600AA259E /* Model */, 668E288D1F5066AA008A4550 /* PhotoAlbumExample.swift */, + 661F92F21F69C80E00AA259E /* PhotoAlbumTransition.swift */, ); name = "Photo album"; sourceTree = ""; @@ -272,10 +321,11 @@ 668E28951F571C8F008A4550 /* Transitions */ = { isa = PBXGroup; children = ( - 668E289A1F572A9D008A4550 /* ContextualImageTransition.swift */, + 6672D3641F7425B5004E9AF5 /* CompositeTransition.swift */, 668E28841F4F5389008A4550 /* FadeTransition.swift */, 668E28961F571CA4008A4550 /* SlideUpTransition.swift */, - 668E28981F5729C1008A4550 /* TransitionTarget.swift */, + 668E289A1F572A9D008A4550 /* SpringFrameTransition.swift */, + 6672D3651F7425B5004E9AF5 /* TransitionTarget.swift */, ); name = Transitions; sourceTree = ""; @@ -539,17 +589,22 @@ buildActionMask = 2147483647; files = ( 666FAA841D384A6B000363DA /* AppDelegate.swift in Sources */, - 668E28991F5729C1008A4550 /* TransitionTarget.swift in Sources */, 66BBC76F1ED4C8790015CB9B /* HexColor.swift in Sources */, 668E28971F571CA4008A4550 /* SlideUpTransition.swift in Sources */, 66BBC7751ED729A80015CB9B /* FadeExample.m in Sources */, 072A063B1EEE26A900B9B5FC /* MenuExample.swift in Sources */, + 661F92EE1F69C7CA00AA259E /* PhotoAlbum.swift in Sources */, 66BBC76D1ED4C8790015CB9B /* ExampleViewController.swift in Sources */, 667A3F541DEE273000CB3A99 /* TableOfContents.swift in Sources */, 66A320FC1F1E716600E2EAC3 /* NavControllerFadeExample.swift in Sources */, 668E288E1F5066AA008A4550 /* PhotoAlbumExample.swift in Sources */, + 661F92F11F69C7E900AA259E /* PhotoCollectionViewCell.swift in Sources */, 66BBC7701ED4C8790015CB9B /* Layout.swift in Sources */, - 668E289B1F572A9D008A4550 /* ContextualImageTransition.swift in Sources */, + 667051C61F7425EC00769148 /* CompositeTransition.swift in Sources */, + 668E289B1F572A9D008A4550 /* SpringFrameTransition.swift in Sources */, + 667051C71F7425EC00769148 /* TransitionTarget.swift in Sources */, + 661F92F31F69C80E00AA259E /* PhotoAlbumTransition.swift in Sources */, + 661F92EC1F69C79900AA259E /* Photo.swift in Sources */, 6629151E1ED5E0E0002B9A5D /* CustomPresentationExample.swift in Sources */, 668E288B1F4F68D2008A4550 /* ContextualExample.swift in Sources */, 66BBC76E1ED4C8790015CB9B /* ExampleViews.swift in Sources */, @@ -563,8 +618,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 669AFD661F75360E00FF06DF /* FallbackTransition.swift in Sources */, 66BBC7721ED728DB0015CB9B /* TransitionWithPresentationTests.swift in Sources */, 662915231ED64A10002B9A5D /* TransitionTests.swift in Sources */, + 6618F5D31F73EB7900A4ABDD /* FadeTransition.swift in Sources */, + 667051CB1F7425EE00769148 /* TransitionTarget.swift in Sources */, + 6618F5D51F73EB7900A4ABDD /* SpringFrameTransition.swift in Sources */, + 667051CA1F7425EE00769148 /* CompositeTransition.swift in Sources */, + 6618F5D41F73EB7900A4ABDD /* SlideUpTransition.swift in Sources */, + 6618F5D71F73EB8800A4ABDD /* InstantCompletionTransition.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/examples/supplemental/Photo.swift b/examples/supplemental/Photo.swift new file mode 100644 index 0000000..29aacc0 --- /dev/null +++ b/examples/supplemental/Photo.swift @@ -0,0 +1,36 @@ +/* + Copyright 2017-present The Material Motion Authors. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import UIKit + +struct Photo { + let name: String + let image: UIImage + let uuid: String + + init(name: String) { + self.uuid = NSUUID().uuidString + self.name = name + + // NOTE: In a real app you should never load images from disk on the UI thread like this. + // Instead, you should find some way to cache the thumbnails in memory and then asynchronously + // load the full-size photos from disk/network when needed. The photo library APIs provide + // exactly this sort of behavior (square thumbnails are accessible immediately on the UI thread + // while the full-sized photos need to be loaded asynchronously). + self.image = UIImage(named: "\(self.name).jpg")! + } +} diff --git a/examples/supplemental/PhotoAlbum.swift b/examples/supplemental/PhotoAlbum.swift new file mode 100644 index 0000000..707d2ca --- /dev/null +++ b/examples/supplemental/PhotoAlbum.swift @@ -0,0 +1,38 @@ +/* + Copyright 2017-present The Material Motion Authors. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import UIKit + +let numberOfImageAssets = 10 +let numberOfPhotosInAlbum = 30 + +class PhotoAlbum { + let photos: [Photo] + let identifierToIndex: [String: Int] + + init() { + var photos: [Photo] = [] + var identifierToIndex: [String: Int] = [:] + for index in 0.. TimeInterval { + let duration = transitions.flatMap { $0 as? TransitionWithCustomDuration }.map { $0.transitionDuration(with: context) }.max { $0 < $1 } + if let duration = duration { + return duration + } + return 0.35 + } +} + diff --git a/examples/transitions/ContextualImageTransition.swift b/examples/transitions/ContextualImageTransition.swift deleted file mode 100644 index 8829e7d..0000000 --- a/examples/transitions/ContextualImageTransition.swift +++ /dev/null @@ -1,116 +0,0 @@ -/* - Copyright 2017-present The Material Motion Authors. All Rights Reserved. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - */ - -import UIKit -import MotionTransitioning - -protocol ContextualImageTransitionForeDelegate { - func foreContextView(for transition: ContextualImageTransition) -> UIImageView? -} - -protocol ContextualImageTransitionBackDelegate { - func backContextView(for transition: ContextualImageTransition, - with foreViewController: UIViewController) -> UIImageView? -} - -final class ContextualImageTransition: NSObject, Transition, TransitionWithFeasibility { - - let backDelegate: ContextualImageTransitionBackDelegate - let foreDelegate: ContextualImageTransitionForeDelegate - init(backDelegate: ContextualImageTransitionBackDelegate, - foreDelegate: ContextualImageTransitionForeDelegate) { - self.backDelegate = backDelegate - self.foreDelegate = foreDelegate - } - - func canPerformTransition(with context: TransitionContext) -> Bool { - return backDelegate.backContextView(for: self, with: context.foreViewController) != nil - } - - func start(with context: TransitionContext) { - guard let contextView = backDelegate.backContextView(for: self, - with: context.foreViewController) else { - return - } - guard let foreImageView = foreDelegate.foreContextView(for: self) else { - return - } - - // A small helper function for creating bi-directional animations. - // See https://github.com/material-motion/motion-animator-objc for a more versatile - // bidirectional Core Animation implementation. - let addAnimationToLayer: (CABasicAnimation, CALayer) -> Void = { animation, layer in - if context.direction == .backward { - let swap = animation.fromValue - animation.fromValue = animation.toValue - animation.toValue = swap - } - layer.add(animation, forKey: animation.keyPath) - layer.setValue(animation.toValue, forKeyPath: animation.keyPath!) - } - - let snapshotter = TransitionViewSnapshotter(containerView: context.containerView) - context.defer { - snapshotter.removeAllSnapshots() - } - - let imageSize = foreImageView.image!.size - - let fitScale = min(foreImageView.bounds.width / imageSize.width, - foreImageView.bounds.height / imageSize.height) - let fitSize = CGSize(width: fitScale * imageSize.width, height: fitScale * imageSize.height) - - foreImageView.isHidden = true - context.defer { - foreImageView.isHidden = false - } - - CATransaction.begin() - CATransaction.setCompletionBlock { - context.transitionDidEnd() - } - - let fadeIn = CABasicAnimation(keyPath: "opacity") - fadeIn.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut) - fadeIn.fromValue = 0 - fadeIn.toValue = 1 - addAnimationToLayer(fadeIn, context.foreViewController.view.layer) - - let snapshotContextView = snapshotter.snapshot(of: contextView, - isAppearing: context.direction == .backward) - - let shift = CASpringAnimation(keyPath: "position") - shift.damping = 500 - shift.stiffness = 1000 - shift.mass = 3 - shift.duration = 0.5 - shift.fromValue = snapshotContextView.layer.position - shift.toValue = CGPoint(x: context.foreViewController.view.bounds.midX, - y: context.foreViewController.view.bounds.midY) - addAnimationToLayer(shift, snapshotContextView.layer) - - let expansion = CASpringAnimation(keyPath: "bounds.size") - expansion.damping = 500 - expansion.stiffness = 1000 - expansion.mass = 3 - expansion.duration = 0.5 - expansion.fromValue = snapshotContextView.layer.bounds.size - expansion.toValue = fitSize - addAnimationToLayer(expansion, snapshotContextView.layer) - - CATransaction.commit() - } -} diff --git a/examples/transitions/SpringFrameTransition.swift b/examples/transitions/SpringFrameTransition.swift new file mode 100644 index 0000000..9066dd1 --- /dev/null +++ b/examples/transitions/SpringFrameTransition.swift @@ -0,0 +1,73 @@ +/* + Copyright 2017-present The Material Motion Authors. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import UIKit +import MotionTransitioning + +// A small helper function for creating bi-directional animations. +// See https://github.com/material-motion/motion-animator-objc for a more versatile +// bidirectional Core Animation implementation. +func addAnimationToLayer(animation: CABasicAnimation, layer: CALayer, direction: TransitionDirection) { + if direction == .backward { + let swap = animation.fromValue + animation.fromValue = animation.toValue + animation.toValue = swap + } + layer.add(animation, forKey: animation.keyPath) + layer.setValue(animation.toValue, forKeyPath: animation.keyPath!) +} + +final class SpringFrameTransition: NSObject, Transition { + + let target: TransitionTarget + let size: CGSize + init(target: TransitionTarget, size: CGSize) { + self.target = target + self.size = size + + super.init() + } + + func start(with context: TransitionContext) { + let contextView = target.resolve(with: context) + + CATransaction.begin() + CATransaction.setCompletionBlock { + context.transitionDidEnd() + } + + let shift = CASpringAnimation(keyPath: "position") + shift.damping = 500 + shift.stiffness = 1000 + shift.mass = 3 + shift.duration = 0.5 + shift.fromValue = contextView.layer.position + shift.toValue = CGPoint(x: context.foreViewController.view.bounds.midX, + y: context.foreViewController.view.bounds.midY) + addAnimationToLayer(animation: shift, layer: contextView.layer, direction: context.direction) + + let expansion = CASpringAnimation(keyPath: "bounds.size") + expansion.damping = 500 + expansion.stiffness = 1000 + expansion.mass = 3 + expansion.duration = 0.5 + expansion.fromValue = contextView.layer.bounds.size + expansion.toValue = size + addAnimationToLayer(animation: expansion, layer: contextView.layer, direction: context.direction) + + CATransaction.commit() + } +} diff --git a/examples/TransitionTarget.swift b/examples/transitions/TransitionTarget.swift similarity index 100% rename from examples/TransitionTarget.swift rename to examples/transitions/TransitionTarget.swift diff --git a/src/MDMTransition.h b/src/MDMTransition.h index 9a985ee..387da93 100644 --- a/src/MDMTransition.h +++ b/src/MDMTransition.h @@ -80,11 +80,14 @@ NS_SWIFT_NAME(TransitionWithFeasibility) Asks the receiver whether it's capable of performing the transition with the given context. If NO is returned, the receiver's startWithContext: will not be invoked. - If no transition is feasible, then a default UIKit transition will be performed instead. + If the transition is infeasible, then a default UIKit transition will be performed instead. If YES is returned, the receiver's startWithContext: will be invoked. The context's containerView will be nil during this call. + + If your transition composes to other transitions then it may wish to query those transitions for + feasibility as well. */ - (BOOL)canPerformTransitionWithContext:(nonnull id)context; diff --git a/src/MDMTransitionContext.h b/src/MDMTransitionContext.h index a384e44..7f124fb 100644 --- a/src/MDMTransitionContext.h +++ b/src/MDMTransitionContext.h @@ -16,6 +16,8 @@ #import +@protocol MDMTransition; + /** The possible directions of a transition. */ @@ -83,6 +85,15 @@ NS_SWIFT_NAME(TransitionContext) */ @property(nonatomic, strong, readonly, nullable) UIPresentationController *presentationController; +/** + Adds the provided transition as a child of the current transition and invokes its start method. + + Each child transition will receive its own transition context instance to which the transition must + eventually invoke transitionDidEnd. Only once both the parent transition and all of its children + (and their children) have completed will the overall view controller transition be completed. + */ +- (void)composeWithTransition:(nonnull id)transition; + /** Defers execution of the provided work until the completion of the transition. diff --git a/src/MDMTransitionController.h b/src/MDMTransitionController.h index 68f2655..67874be 100644 --- a/src/MDMTransitionController.h +++ b/src/MDMTransitionController.h @@ -28,26 +28,21 @@ NS_SWIFT_NAME(TransitionController) @protocol MDMTransitionController /** - A collection of transition objects that will be used to drive a single view controller transition. + The transition instance that will govern any presentation or dismissal of the view controller. - The transition instances will govern any presentation or dismissal of the view controller. + If no transition is provided then a default UIKit transition will be used. - If no transition instance is provided then a default UIKit transition will be used. - - If any transition conforms to MDMTransitionWithPresentation, then the first such transition's - default modal presentation style will be queried and assigned to the associated view controller's + If the transition conforms to MDMTransitionWithPresentation, then the transition's default modal + presentation style will be queried and assigned to the associated view controller's `modalPresentationStyle` property. - - If any transition conforms to MDMTransitionWithCustomDuration, then the each transition's duration - will queried and the largest value will be used for the overall transition's duration. */ -@property(nonatomic, copy, nullable) NSArray> *transitions; +@property(nonatomic, strong, nullable) id transition; /** - The active transition instances. + The active transition instance. This may be non-nil while a transition is active. */ -@property(nonatomic, strong, nullable, readonly) NSArray> *activeTransitions; +@property(nonatomic, strong, nullable, readonly) id activeTransition; @end diff --git a/src/MDMTransitionPresentationController.m b/src/MDMTransitionPresentationController.m index 557698d..62457c8 100644 --- a/src/MDMTransitionPresentationController.m +++ b/src/MDMTransitionPresentationController.m @@ -66,7 +66,7 @@ - (BOOL)shouldRemovePresentersView { } - (void)dismissalTransitionWillBegin { - if ([self.presentedViewController.mdm_transitionController.activeTransitions count] == 0) { + if (!self.presentedViewController.mdm_transitionController.activeTransition) { [self.presentedViewController.transitionCoordinator animateAlongsideTransition:^(id _Nonnull context) { self.scrimView.alpha = 0; } completion:nil]; @@ -113,6 +113,8 @@ - (void)startWithContext:(NSObject *)context { [UIView animateWithDuration:context.duration animations:^{ self.scrimView.alpha = context.direction == MDMTransitionDirectionForward ? 1 : 0; + } completion:^(BOOL finished) { + [context transitionDidEnd]; }]; } } diff --git a/src/private/MDMViewControllerTransitionController.m b/src/private/MDMViewControllerTransitionController.m index 8308dd3..17481c2 100644 --- a/src/private/MDMViewControllerTransitionController.m +++ b/src/private/MDMViewControllerTransitionController.m @@ -33,7 +33,7 @@ @implementation MDMViewControllerTransitionController { __weak UIViewController *_source; } -@synthesize transitions = _transitions; +@synthesize transition = _transition; - (nonnull instancetype)initWithViewController:(nonnull UIViewController *)viewController { self = [super init]; @@ -46,15 +46,7 @@ - (nonnull instancetype)initWithViewController:(nonnull UIViewController *)viewC #pragma mark - Public - (void)setTransition:(id)transition { - self.transitions = @[transition]; -} - -- (id)transition { - return [self.transitions firstObject]; -} - -- (void)setTransitions:(NSArray> *)transitions { - _transitions = [transitions copy]; + _transition = transition; // Set the default modal presentation style. id withPresentation = [self presentationTransition]; @@ -73,10 +65,8 @@ - (void)setTransitions:(NSArray> *)transitions { } - (id)presentationTransition { - for (id transition in _transitions) { - if ([transition respondsToSelector:@selector(defaultModalPresentationStyle)]) { - return (id)transition; - } + if ([self.transition respondsToSelector:@selector(defaultModalPresentationStyle)]) { + return (id)self.transition; } return nil; } @@ -143,7 +133,7 @@ - (void)prepareForTransitionWithSourceViewController:(nullable UIViewController } NSAssert(!_coordinator, @"A transition is already active."); - _coordinator = [[MDMViewControllerTransitionCoordinator alloc] initWithTransitions:self.transitions + _coordinator = [[MDMViewControllerTransitionCoordinator alloc] initWithTransition:self.transition direction:direction sourceViewController:source backViewController:back diff --git a/src/private/MDMViewControllerTransitionCoordinator.h b/src/private/MDMViewControllerTransitionCoordinator.h index 9a4e6b7..7604853 100644 --- a/src/private/MDMViewControllerTransitionCoordinator.h +++ b/src/private/MDMViewControllerTransitionCoordinator.h @@ -21,12 +21,12 @@ @interface MDMViewControllerTransitionCoordinator : NSObject -- (nonnull instancetype)initWithTransitions:(nonnull NSArray *> *)transitions - direction:(MDMTransitionDirection)direction - sourceViewController:(nullable UIViewController *)sourceViewController - backViewController:(nonnull UIViewController *)backViewController - foreViewController:(nonnull UIViewController *)foreViewController - presentationController:(nullable UIPresentationController *)presentationController; +- (nonnull instancetype)initWithTransition:(nonnull NSObject *)transition + direction:(MDMTransitionDirection)direction + sourceViewController:(nullable UIViewController *)sourceViewController + backViewController:(nonnull UIViewController *)backViewController + foreViewController:(nonnull UIViewController *)foreViewController + presentationController:(nullable UIPresentationController *)presentationController; - (nonnull instancetype)init NS_UNAVAILABLE; - (nonnull NSArray *> *)activeTransitions; diff --git a/src/private/MDMViewControllerTransitionCoordinator.m b/src/private/MDMViewControllerTransitionCoordinator.m index d99201a..6dd7c72 100644 --- a/src/private/MDMViewControllerTransitionCoordinator.m +++ b/src/private/MDMViewControllerTransitionCoordinator.m @@ -18,18 +18,25 @@ #import "MDMTransition.h" -@interface MDMViewControllerTransitionContext : NSObject -@property(nonatomic, strong) id transitionContext; -@property(nonatomic, strong) id transition; +@class MDMViewControllerTransitionContextNode; + +@protocol MDMViewControllerTransitionContextNodeParent +- (void)childNodeTransitionDidEnd:(MDMViewControllerTransitionContextNode *)childNode; @end -@protocol MDMViewControllerTransitionContextDelegate -- (void)transitionContextDidEnd:(MDMViewControllerTransitionContext *)context; +@interface MDMViewControllerTransitionContextNode : NSObject +@property(nonatomic, strong) id transitionContext; +@property(nonatomic, strong, readonly) id transition; +@property(nonatomic, copy, readonly) NSMutableArray *children; @end -@implementation MDMViewControllerTransitionContext { +@implementation MDMViewControllerTransitionContextNode { + // Every node points to the same array in memory. NSMutableArray *_sharedCompletionBlocks; - __weak id _delegate; + + BOOL _hasStarted; + BOOL _didEnd; + __weak id _parent; } @synthesize duration = _duration; @@ -46,9 +53,10 @@ - (instancetype)initWithTransition:(id)transition foreViewController:(UIViewController *)foreViewController presentationController:(UIPresentationController *)presentationController sharedCompletionBlocks:(NSMutableArray *)sharedCompletionBlocks - delegate:(id)delegate { + parent:(id)parent { self = [super init]; if (self) { + _children = [NSMutableArray array]; _transition = transition; _direction = direction; _sourceViewController = sourceViewController; @@ -56,11 +64,124 @@ - (instancetype)initWithTransition:(id)transition _foreViewController = foreViewController; _presentationController = presentationController; _sharedCompletionBlocks = sharedCompletionBlocks; - _delegate = delegate; + _parent = parent; } return self; } +#pragma mark - Private + +- (MDMViewControllerTransitionContextNode *)spawnChildWithTransition:(id)transition { + MDMViewControllerTransitionContextNode *node = + [[MDMViewControllerTransitionContextNode alloc] initWithTransition:transition + direction:_direction + sourceViewController:_sourceViewController + backViewController:_backViewController + foreViewController:_foreViewController + presentationController:_presentationController + sharedCompletionBlocks:_sharedCompletionBlocks + parent:self]; + node.transitionContext = _transitionContext; + return node; +} + +- (void)checkAndNotifyOfCompletion { + BOOL anyChildActive = NO; + for (MDMViewControllerTransitionContextNode *child in _children) { + if (!child->_didEnd) { + anyChildActive = YES; + break; + } + } + + if (!anyChildActive && _didEnd) { // Inform our parent of completion. + [_parent childNodeTransitionDidEnd:self]; + } +} + +#pragma mark - Public + +- (void)start { + if (_hasStarted) { + return; + } + + _hasStarted = YES; + + for (MDMViewControllerTransitionContextNode *child in _children) { + [child attemptFallback]; + + [child start]; + } + + if ([_transition respondsToSelector:@selector(startWithContext:)]) { + [_transition startWithContext:self]; + } else { + _didEnd = YES; + + [self checkAndNotifyOfCompletion]; + } +} + +- (NSArray *)activeTransitions { + NSMutableArray *activeTransitions = [NSMutableArray array]; + if (!_didEnd) { + [activeTransitions addObject:self]; + } + for (MDMViewControllerTransitionContextNode *child in _children) { + [activeTransitions addObjectsFromArray:[child activeTransitions]]; + } + return activeTransitions; +} + +- (void)setTransitionContext:(id)transitionContext { + _transitionContext = transitionContext; + + for (MDMViewControllerTransitionContextNode *child in _children) { + child.transitionContext = transitionContext; + } +} + +- (void)setDuration:(NSTimeInterval)duration { + _duration = duration; + + for (MDMViewControllerTransitionContextNode *child in _children) { + child.duration = duration; + } +} + +- (void)attemptFallback { + id transition = _transition; + while ([transition respondsToSelector:@selector(fallbackTransitionWithContext:)]) { + id withFallback = (id)transition; + + id fallback = [withFallback fallbackTransitionWithContext:self]; + if (fallback == transition) { + break; + } + transition = fallback; + } + _transition = transition; +} + +#pragma mark - MDMViewControllerTransitionContextNodeDelegate + +- (void)childNodeTransitionDidEnd:(MDMViewControllerTransitionContextNode *)contextNode { + [self checkAndNotifyOfCompletion]; +} + +#pragma mark - MDMTransitionContext + +- (void)composeWithTransition:(id)transition { + MDMViewControllerTransitionContextNode *child = [self spawnChildWithTransition:transition]; + + [_children addObject:child]; + + if (_hasStarted) { + [child start]; + } +} + - (UIView *)containerView { return _transitionContext.containerView; } @@ -70,31 +191,35 @@ - (void)deferToCompletion:(void (^)(void))work { } - (void)transitionDidEnd { - [_delegate transitionContextDidEnd:self]; + if (_didEnd) { + return; // No use in re-notifying. + } + _didEnd = YES; + + [self checkAndNotifyOfCompletion]; } @end -@interface MDMViewControllerTransitionCoordinator() +@interface MDMViewControllerTransitionCoordinator() @end @implementation MDMViewControllerTransitionCoordinator { MDMTransitionDirection _direction; UIPresentationController *_presentationController; - NSMutableOrderedSet *_contexts; + MDMViewControllerTransitionContextNode *_root; NSMutableArray *_completionBlocks; - MDMViewControllerTransitionContext *_presentationContext; id _transitionContext; } -- (instancetype)initWithTransitions:(NSArray *> *)originalTransitions - direction:(MDMTransitionDirection)direction - sourceViewController:(UIViewController *)sourceViewController - backViewController:(UIViewController *)backViewController - foreViewController:(UIViewController *)foreViewController - presentationController:(UIPresentationController *)presentationController { +- (instancetype)initWithTransition:(NSObject *)transition + direction:(MDMTransitionDirection)direction + sourceViewController:(UIViewController *)sourceViewController + backViewController:(UIViewController *)backViewController + foreViewController:(UIViewController *)foreViewController + presentationController:(UIPresentationController *)presentationController { self = [super init]; if (self) { _direction = direction; @@ -102,45 +227,39 @@ - (instancetype)initWithTransitions:(NSArray *> *)origin _completionBlocks = [NSMutableArray array]; + // Build our contexts: + + _root = [[MDMViewControllerTransitionContextNode alloc] initWithTransition:transition + direction:direction + sourceViewController:sourceViewController + backViewController:backViewController + foreViewController:foreViewController + presentationController:presentationController + sharedCompletionBlocks:_completionBlocks + parent:self]; + if (_presentationController && [_presentationController respondsToSelector:@selector(startWithContext:)]) { - _presentationContext = - [[MDMViewControllerTransitionContext alloc] initWithTransition:nil - direction:direction - sourceViewController:sourceViewController - backViewController:backViewController - foreViewController:foreViewController - presentationController:presentationController - sharedCompletionBlocks:_completionBlocks - delegate:self]; + MDMViewControllerTransitionContextNode *presentationNode = + [[MDMViewControllerTransitionContextNode alloc] initWithTransition:(id)_presentationController + direction:direction + sourceViewController:sourceViewController + backViewController:backViewController + foreViewController:foreViewController + presentationController:presentationController + sharedCompletionBlocks:_completionBlocks + parent:_root]; + [_root.children addObject:presentationNode]; } - // Build our contexts: - - _contexts = [NSMutableOrderedSet orderedSetWithCapacity:[originalTransitions count]]; - NSMutableArray *transitions = [NSMutableArray arrayWithCapacity:[originalTransitions count]]; - for (id transition in originalTransitions) { - MDMViewControllerTransitionContext *context = - [[MDMViewControllerTransitionContext alloc] initWithTransition:transition - direction:direction - sourceViewController:sourceViewController - backViewController:backViewController - foreViewController:foreViewController - presentationController:presentationController - sharedCompletionBlocks:_completionBlocks - delegate:self]; - if ([transition respondsToSelector:@selector(canPerformTransitionWithContext:)]) { - id withFeasibility = (id)transition; - if (![withFeasibility canPerformTransitionWithContext:context]) { - continue; - } + if ([transition respondsToSelector:@selector(canPerformTransitionWithContext:)]) { + id withFeasibility = (id)transition; + if (![withFeasibility canPerformTransitionWithContext:_root]) { + transition = nil; } - - [transitions addObject:transition]; - [_contexts addObject:context]; } - if ([_contexts count] == 0) { + if (!transition) { self = nil; return nil; // No active transitions means no need for a coordinator. } @@ -148,17 +267,11 @@ - (instancetype)initWithTransitions:(NSArray *> *)origin return self; } -#pragma mark - MDMViewControllerTransitionContextDelegate +#pragma mark - MDMViewControllerTransitionContextNodeDelegate -- (void)transitionContextDidEnd:(MDMViewControllerTransitionContext *)context { - if (context != nil && _presentationContext == context) { - _presentationContext = nil; - } else if ([_contexts containsObject:context]) { - [_contexts removeObject:context]; - } - - if (_contexts != nil && [_contexts count] == 0 && _presentationContext == nil) { - _contexts = nil; +- (void)childNodeTransitionDidEnd:(MDMViewControllerTransitionContextNode *)node { + if (_root != nil && _root == node) { + _root = nil; for (void (^work)() in _completionBlocks) { work(); @@ -166,6 +279,7 @@ - (void)transitionContextDidEnd:(MDMViewControllerTransitionContext *)context { [_completionBlocks removeAllObjects]; [_transitionContext completeTransition:true]; + _transitionContext = nil; [_delegate transitionDidCompleteWithCoordinator:self]; } @@ -174,15 +288,13 @@ - (void)transitionContextDidEnd:(MDMViewControllerTransitionContext *)context { #pragma mark - UIViewControllerAnimatedTransitioning - (NSTimeInterval)transitionDuration:(id)transitionContext { - NSTimeInterval maxDuration = 0; - for (MDMViewControllerTransitionContext *context in _contexts) { - if ([context.transition respondsToSelector:@selector(transitionDurationWithContext:)]) { - id withCustomDuration = (id)context.transition; - maxDuration = MAX(maxDuration, [withCustomDuration transitionDurationWithContext:context]); - } + NSTimeInterval duration = 0.35; + if ([_root.transition respondsToSelector:@selector(transitionDurationWithContext:)]) { + id withCustomDuration = (id)_root.transition; + duration = [withCustomDuration transitionDurationWithContext:_root]; } - - return (maxDuration > 0) ? maxDuration : 0.35; + _root.duration = duration; + return duration; } - (void)animateTransition:(id)transitionContext { @@ -196,19 +308,13 @@ - (void)animateTransition:(id)transitionCo // MDMViewControllerTransitionController. - (NSArray *> *)activeTransitions { - NSMutableArray *transitions = [NSMutableArray array]; - for (MDMViewControllerTransitionContext *context in _contexts) { - [transitions addObject:context.transition]; - } - return transitions; + return [_root activeTransitions]; } #pragma mark - Private - (void)initiateTransition { - for (MDMViewControllerTransitionContext *context in _contexts) { - context.transitionContext = _transitionContext; - } + _root.transitionContext = _transitionContext; UIViewController *from = [_transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey]; if (from) { @@ -240,20 +346,13 @@ - (void)initiateTransition { [to.view layoutIfNeeded]; } - [self mapTransitions]; + [_root attemptFallback]; [self anticipateOnlyExplicitAnimations]; [CATransaction begin]; [CATransaction setAnimationDuration:[self transitionDuration:_transitionContext]]; - if ([_presentationController respondsToSelector:@selector(startWithContext:)]) { - id asTransition = (id)_presentationController; - [asTransition startWithContext:_presentationContext]; - } - - for (MDMViewControllerTransitionContext *context in _contexts) { - [context.transition startWithContext:context]; - } + [_root start]; [CATransaction commit]; } @@ -276,22 +375,4 @@ - (void)anticipateOnlyExplicitAnimations { }]; } -#pragma mark - Private - -- (void)mapTransitions { - for (MDMViewControllerTransitionContext *context in _contexts) { - id transition = context.transition; - while ([transition respondsToSelector:@selector(fallbackTransitionWithContext:)]) { - id withFallback = (id)transition; - - id fallback = [withFallback fallbackTransitionWithContext:context]; - if (fallback == transition) { - break; - } - transition = fallback; - } - context.transition = transition; - } -} - @end diff --git a/tests/unit/TransitionTests.swift b/tests/unit/TransitionTests.swift index 459a44f..88730e6 100644 --- a/tests/unit/TransitionTests.swift +++ b/tests/unit/TransitionTests.swift @@ -30,23 +30,67 @@ class TransitionTests: XCTestCase { window = nil } - func testTransitionDidEndCausesTransitionCompletion() { + func testTransitionDidEndDoesComplete() { let presentedViewController = UIViewController() - presentedViewController.transitionController.transitions = [InstantCompletionTransition()] + presentedViewController.transitionController.transition = InstantCompletionTransition() let didComplete = expectation(description: "Did complete") window.rootViewController!.present(presentedViewController, animated: true) { didComplete.fulfill() } - waitForExpectations(timeout: 0.5) + waitForExpectations(timeout: 0.1) XCTAssertEqual(window.rootViewController!.presentedViewController, presentedViewController) } -} -final class InstantCompletionTransition: NSObject, Transition { - func start(with context: TransitionContext) { - context.transitionDidEnd() + func testTransitionCompositionDoesComplete() { + let presentedViewController = UIViewController() + presentedViewController.transitionController.transition = CompositeTransition(transitions: [ + InstantCompletionTransition(), + InstantCompletionTransition() + ]) + + let didComplete = expectation(description: "Did complete") + window.rootViewController!.present(presentedViewController, animated: true) { + didComplete.fulfill() + } + + waitForExpectations(timeout: 0.1) + + XCTAssertEqual(window.rootViewController!.presentedViewController, presentedViewController) + } + + func testTransitionFallbackToOtherTransitionDoesComplete() { + let presentedViewController = UIViewController() + let transition = FallbackTransition(to: InstantCompletionTransition()) + presentedViewController.transitionController.transition = transition + + let didComplete = expectation(description: "Did complete") + window.rootViewController!.present(presentedViewController, animated: true) { + didComplete.fulfill() + } + + waitForExpectations(timeout: 0.1) + + XCTAssertFalse(transition.startWasInvoked) + XCTAssertEqual(window.rootViewController!.presentedViewController, presentedViewController) + } + + func testTransitionFallbackToSelfDoesComplete() { + let presentedViewController = UIViewController() + let transition = FallbackTransition() + presentedViewController.transitionController.transition = transition + + let didComplete = expectation(description: "Did complete") + window.rootViewController!.present(presentedViewController, animated: true) { + didComplete.fulfill() + } + + waitForExpectations(timeout: 0.1) + + XCTAssertTrue(transition.startWasInvoked) + XCTAssertEqual(window.rootViewController!.presentedViewController, presentedViewController) } } + diff --git a/tests/unit/TransitionWithPresentationTests.swift b/tests/unit/TransitionWithPresentationTests.swift index 5665999..4192e2d 100644 --- a/tests/unit/TransitionWithPresentationTests.swift +++ b/tests/unit/TransitionWithPresentationTests.swift @@ -30,9 +30,10 @@ class TransitionWithPresentationTests: XCTestCase { window = nil } - func testPresentationControllerIsQueried() { + func testPresentationControllerIsQueriedAndCompletesWithoutAnimation() { let presentedViewController = UIViewController() - presentedViewController.transitionController.transitions = [PresentationTransition()] + presentedViewController.transitionController.transition = + PresentationTransition(presentationControllerType: TestingPresentationController.self) let didComplete = expectation(description: "Did complete") window.rootViewController!.present(presentedViewController, animated: true) { @@ -43,18 +44,40 @@ class TransitionWithPresentationTests: XCTestCase { XCTAssert(presentedViewController.presentationController is TestingPresentationController) } + + func testPresentationControllerIsQueriedAndCompletesWithAnimation() { + let presentedViewController = UIViewController() + presentedViewController.transitionController.transition = + PresentationTransition(presentationControllerType: TransitionPresentationController.self) + + let didComplete = expectation(description: "Did complete") + window.rootViewController!.present(presentedViewController, animated: true) { + didComplete.fulfill() + } + + waitForExpectations(timeout: 0.5) + + XCTAssert(presentedViewController.presentationController is TransitionPresentationController) + } } final class TestingPresentationController: UIPresentationController { } final class PresentationTransition: NSObject, TransitionWithPresentation { + let presentationControllerType: UIPresentationController.Type + init(presentationControllerType: UIPresentationController.Type) { + self.presentationControllerType = presentationControllerType + + super.init() + } + func defaultModalPresentationStyle() -> UIModalPresentationStyle { return .custom } func presentationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController?) -> UIPresentationController? { - return TestingPresentationController(presentedViewController: presented, presenting: presenting) + return presentationControllerType.init(presentedViewController: presented, presenting: presenting) } func start(with context: TransitionContext) { diff --git a/tests/unit/Transitions/FallbackTransition.swift b/tests/unit/Transitions/FallbackTransition.swift new file mode 100644 index 0000000..4ba0418 --- /dev/null +++ b/tests/unit/Transitions/FallbackTransition.swift @@ -0,0 +1,44 @@ +/* + Copyright 2017-present The Material Motion Authors. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import MotionTransitioning + +final class FallbackTransition: NSObject, Transition, TransitionWithFallback { + + let fallbackTo: Transition? + + init(to: Transition) { + self.fallbackTo = to + } + + override init() { + self.fallbackTo = nil + } + + func fallbackTransition(with context: TransitionContext) -> Transition { + if let fallbackTo = fallbackTo { + return fallbackTo + } + return self + } + + var startWasInvoked = false + func start(with context: TransitionContext) { + startWasInvoked = true + context.transitionDidEnd() + } +} + diff --git a/tests/unit/Transitions/InstantCompletionTransition.swift b/tests/unit/Transitions/InstantCompletionTransition.swift new file mode 100644 index 0000000..8b5fd02 --- /dev/null +++ b/tests/unit/Transitions/InstantCompletionTransition.swift @@ -0,0 +1,24 @@ +/* + Copyright 2017-present The Material Motion Authors. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import MotionTransitioning + +final class InstantCompletionTransition: NSObject, Transition { + func start(with context: TransitionContext) { + context.transitionDidEnd() + } +} +