Open onmyway133 opened 7 years ago
Nice post! I think it would make a little more sense if you explain how it integrates with the AppDelegate before explaining how the AppCoordinator works.
I wrote a Coordinator-based framework with a demo project which also explains some of the concepts in it's README, including how it integrates with the AppDelegate. Feel free to check it out: https://github.com/Flinesoft/Imperio
We are already successfully using it in our projects, thank you @khanlou for your great blog posts which pointed us into the right direction. 👍
Awesome article, and hit me right while I was researching Coordinators.
BUT... I still have a similar issue I have with other coordinator approaches. Suppose I present a flow controller (which contains a navigation controller, and some VC doing stuff), and suddenly one of its child VCs calls dismiss
without notifying the flow controller or any other collaborator. Then the flow controller will be removed too (which is fine), but it's parent flow controller does not notice it. But I need it to notice in order to do some cleanup work. Any ideas on how to solve this?
I could implement deinit
in the presented flow controller and there send a distress signal to its parent flow controller. I hope there are better alternatives.
I have asked the question on SO as well, but leaving away the coordinator part: https://stackoverflow.com/questions/47350720/how-can-a-presentingviewcontroller-get-notified-that-its-presentedviewcontroller
@fabb Hi, the recommended way is that the presented FlowController
should call back (delegate, closure) to the original FlowController
so that it can decide what to clean up and to dismiss this presented FlowController
.
The other workaround I think, is to listen to willMove(toParentViewController:)
inside the presented FlowController
, if it was dismissed/removed, then the parentViewController
will be nil
@onmyway133 Yeah, unfortunately my presented FlowController
doesn't know about the dismiss
call (as in my case it stems from an kind-of-ill-behaved VC in a library I cannot easily modify).
willMove(toParentViewController:)
will not be called for presented VCs, similarly as the childViewControllers
array does not include any presented VCs.
I have some other questions. Let's say I have a tab bar based UI with navigation controllers in each tab. Let's visualize it with some sprinkled flow controllers:
AppFlowController -> TabBarController -> [Tab1FlowController, Tab2FlowController, ...]
Tab1FlowController -> NavigationController -> [ViewController1, ViewController2, SpecialFlowController -> ViewController3, ViewController2]
ViewController3 -> ModalFlowController -> NavigationController -> ...
Tab2FlowController -> NavigationController -> [ViewController1, ViewController4, ViewController5, ViewController6]
Some notes to this:
TabXFlowController
ViewController3
does get an own flow controller, as it might present something, and the next parent flow controller Tab1FlowController
doesn't really need to know about thisQuestion 1: Does this make sense until now, would you structure your app similarly?
So now for the more contrived question. Let's say ViewController4
, ViewController5
and ViewController6
form an own process (checkout or anything else that belongs together) while the first on the stack, ViewController1
is not part of that process. Now at ViewController6
, when I click a button, I want to pop back to ViewController1
. As ViewControllers should be encapsulated, they should not need to know how to navigate somewhere else, but rather delegate it to someone who knows.
Question 2: Whom would you delegate the popping back to ViewController1
and in what way?
I have several ideas for doing it in a clean encapsulated fashion, but I'm not sure which one to choose or if there is an even better one.
ViewController1
initiates the process, it delegates it to Tab2FlowController
which knows that it needs to push ViewController4
. Tab2FlowController
also sets either a dismissal delegate or closure on ViewController4
that the latter can use to pop back to ViewController1
later. It will be passed from ViewController4
to ViewController5
and ViewController6
which will use it in the end.
The downside of this approach is that Tab2FlowController
needs to know that it might contain the process ViewController4
- ViewController6
and pass/be a delegate or pass a closure. On the upside there is compiletime-safety due to the delegate protocol, or initializers taking the closure, and a clear way what to do on dismissal.ViewController1
initiates the process, it delegates it to Tab2FlowController
which knows that it needs to push ViewController4
. Unlike approach 1 though, Tab2FlowController
can be agnostic to ViewController4
needing a way to dismiss itself later, and does not need to pass any delegate or closure. In the end, ViewController6
sends out a dismiss message in a similar way to how unwind segues do, i.e. a traversal mechanism needs to pass the "ViewController6 tells that the process is finished"
message in this way: ViewController6 -> NavigationController -> ViewController5 -> ViewController4 -> ViewController1
, and ViewController1
needs to react to this message. ViewController1
then needs to tell Tab2FlowController
that it wants to be shown which can pop back to it.
The downside is that there is less compiletime-safety, as ViewController6
shoots a dismissal message into the blue. Also it's more elaborate due to the messaging system that needs to follow a similar path as unwind segues, instead of just calling a delegate method / closure. On the upside, Tab2FlowController
can have less knowledge and ViewController1
can handle both the show process
and finish process
steps (yes, it still delegates both to Tab2FlowController
, but in a more generic way not depending on the process at hand).Which way would you choose? Maybe something completely different?
And on it goes with the questions/findings, sorry for spamming guys ;-)
start
methods unnecessary?I have found that instead of dedicated start
methods it's much more natural to just use overwrite viewDidLoad
for that.
E.g. makeKeyAndVisible
will add the view of the AppFlowCoordinator
to the UIWindow anyways, which will implicitly call viewDidLoad
on the AppFlowCoordinator
where it can start doing its thing.
Further down the hierarchy it might even be desirable to use viewDidLoad
instead, as it will keep the lazy loading of the view
property intact. There are some pitfalls though:
view
property vs. add(childController:)
The method add(childController:)
as described in the article also adds the childVC view as a subview to the parentVC view. This implicitly triggers lazy loading of the child view, and viewDidLoad
on the childVC. This might not always be wanted.
E.g. in my example in the previous comment, I have flow controllers contained in a UITabBarController. The UITabBarController by default initially only loads the view of the first tab, the other tabs' view
properties will only be lazy loaded when the tab becomes active later. To keep that functionality, it's necessary to not add the childVC of the TabXFlowController
until its viewDidLoad
method is called, like this:
class Tab1FlowController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let vc1 = NamedViewController(name: "1-1")
let navC = UINavigationController(rootViewController: vc1)
navC.delegate = self
add(childController: navC)
}
}
I also like to sprinkle assert
s everywhere, so I do this:
extension UIViewController {
func add(childController: UIViewController) {
assert(self.isViewLoaded)
addChildViewController(childController)
...
}
}
When I added flow controllers as children of the UITabBarController, I noticed that my tabbar items did not appear, as I previously have only set them on the UINavigationController. Now they need to be set on the flow controller instead. Unfortunately this has to be done before viewDidLoad
is called, as UITabBarController will call it before it adds the views - makes sense, as you need to show tabbar items for all tabs, even those which still have not been lazily loaded.
So something like this is needed:
class Tab1FlowController: UIViewController {
init() {
super.init(nibName: nil, bundle: nil)
// need to set tabBarItem in init, as it will be accessed before viewDidLoad is called
self.tabBarItem = UITabBarItem(title: "1", image: nil, tag: 0)
}
}
Or alternatively, If you like delegation:
class Tab2FlowController: UIViewController {
private let navC: UINavigationController = {
let c = UINavigationController()
c.tabBarItem = UITabBarItem(title: "2", image: nil, tag: 0)
return c
}()
override var tabBarItem: UITabBarItem! {
get {
return navC.tabBarItem
}
set {
navC.tabBarItem = newValue
}
}
}
I'm sure there will be a few more methods that need forwarding in flow controllers.
You know, the ones deemed "uncool" after autolayout appeared? The one you know all too well from translatesAutoresizingMaskIntoConstraints
?
There is still a place for it. Rather than overriding viewDidLayoutSubviews
, just use autoresizing masks when adding the subVC:
extension UIViewController {
func add(childController: UIViewController) {
assert(self.isViewLoaded)
addChildViewController(childController)
view.addSubview(childController.view)
childController.view.frame = self.view.bounds
childController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
childController.didMove(toParentViewController: self)
}
}
As Peter Steinberger pointed out, when we create Swift extensions to an ObjC object, we should still prefix them, as we might run into strange issues. Also I don't like how add(childController:)
conflicts with the name of the UIKit method addChildViewController(_:)
, where the one adds the child view, and the other doesn't. I think we can do better naming this properly. How about this (better ideas welcome)?
extension UIViewController {
@objc(coord_addChildViewControllerAndView:)
func addChildViewControllerAndView(_ childViewController: UIViewController) {
...
}
}
Or just make it unavailable in ObjC, should do the trick too if your app project is not convoluted by too much ObjC cruft:
@nonobjc extension UIViewController {
...
}
UIKit does a little automagic here and there. The problem with that is that it might break due to small changes and not be immediately possible but introduce hard to find bugs.
For example when I put a Tab1FlowController
inbetween UITabBarController and UINavigationController, I broke a little piece of magic: Before doing this, clicking the tabbar item of an already active tab would pop a navigation controller back to its root view. Users are used to this from iOS system apps, and many other apps due to this automagic.
This is how UIKit does it (taken from disassembly of UITabBarController._tabBarItemClicked:
):
Naughty naughty... In this case it is necessary to do it manually:
extension AppFlowController: UITabBarControllerDelegate {
func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
let didSelectCurrentlyActiveTab = viewController == tabBarController.selectedViewController
if didSelectCurrentlyActiveTab, let toRootPoppable = viewController as? ToRootPoppable {
assert(!(viewController is UINavigationController))
toRootPoppable.popToRoot(animated: true)
}
return true
}
}
There might be more issues hidden like this in UIKit.
And a nice way to return the next parent flow controller without having to explicitly set it. I think both the explicit and implicit ways have their perks.
@nonobjc extension UIResponder {
func nextConformingToProtocol<Protocol>() -> Protocol? {
return nextSequence().first { responder in
return responder is Protocol
} as? Protocol
}
func nextSequence() -> AnySequence<UIResponder> {
return AnySequence({ [weak self] () -> AnyIterator<UIResponder> in
var nextResponder: UIResponder? = self
return AnyIterator({
// self is not included in sequence
nextResponder = nextResponder?.next
return nextResponder
})
})
}
}
protocol MyViewControllerFlowDelegate: AnyObject {
func doSomething()
}
class MyViewController: UIViewController {
private var delegate: MyViewControllerFlowDelegate? {
let delegate = nextConformingToProtocol() as MyViewControllerFlowDelegate?
assert(delegate != nil)
return delegate
}
private func someButtonPressed() {
delegate?.doSomething()
}
}
This searches through the responder chain until it finds an object that conforms to the needed protocol. This even works when starting at a UIView.
WARNING: When you call this from a VC that has been presented, it might not work as expected. The responder chain, starting from the presented VC, will contain all VCs up to the presented VC, then as next responder return the root parent VC of the VC that presented (and if that was presented itself, it's next responder will in turn return the root parent VC that presented that VC) and continue normally from there. It's caused due to the strange nature what presentingViewController
returns - which contradicts what documentation says. It will not return the same VC that present
was called on, but rather its root parent. Consider that when using the responder chain.
If I have main flow like this :
MainFlowController
has an embedded tab bar controller. And it has three sub flow controllers, each of them has an embedded navigation controller.
I set the viewControllers
of the tab bar controller with sub flow controllers, in MainFlowController
class:
self.tabBarController.viewControllers = [feedFlowController, profileFlowController, settingsFlowController]
I expect to hide tab bar when feedViewController
pushed, but it not works.
extension FeedFlowController: FeedViewControllerDelegate {
func showDetail(_ detail: String) {
let feedDetail = FeedDetailViewController(detail, dependencyContainer: self.dependencyContainer)
feedDetail.hidesBottomBarWhenPushed = true
embeddedNavigationController.pushViewController(feedDetail, animated: true)
}
}
So, do I have to give the embedded navigation controller of sub flow controller to the viewControllers
of tab bar controller in MainFlowController
?If that, I feel confused with the point of view in this blog.
@onmyway133 Can you help me to understand? Or a better way to solve this?
Seems like hidesBottomBarWhenPushed
does not work for the tab bar when the UITabBarController
is not the direct parent of the UINavigationController
: https://github.com/John-Lluch/SWRevealViewController/issues/13#issuecomment-12155508
@fabb Yes, we have to be careful about the magic hidden in UIViewController
when using FlowController
as container view controller.
@calvingit I stepped a bit through the disassembly. I think we cannot really fix hidesBottomBarWhenPushed
. As far as I found out it works the following way:
push
is called on a UINavigationController
-[UINavigationController _hideOrShowBottomBarIfNeededWithTransition:]
hidesBottomBarWhenPushed
from the pushed VC is readUINavigationController
gets its tabBarController
and calls _selectedViewControllerInTabBar
on itUINavigationController
compares if self
is equal to that _selectedViewControllerInTabBar
, and only if this is true
, the tabbar is hidden.Unfortunately when we use a flow controller as container around that UINavigationController
, the last check is false
, and the tabbar will not be hidden.
The only ugly hack I could think of would be to use a custom subclass of UINavigationController
, overwrite the tabBarController
property and return an NSProxy
object there that delegates everything to the real tabBarController
, except for calls to _selectedViewControllerInTabBar
where we could return self
. Could break a lot of other things though.
I like this idea and have been playing around with it. The issue I ran into was with animated transitions between child view controllers. If you transition to a UINavigationController
the navigation bar size is wrong at the start and animates to the correct size together with the transition.
addChildViewController(targetFlowController)
transition(from: startingFlowController, to: targetFlowController, duration: 2.5, options: [], animations: {
// empty to showcase the issue more clearly
}) { (completed) in
self.remove(childController: startingFlowController)
}
To save some time for everybody else, here are the workarounds I found:
Use UIView
animations instead of transition(from:to:duration:options:animations:completion:)
.
add(childController: targetFlowController) // call the extension function instead of `addChildViewController`
UIView.animate(withDuration: 2.5, animations: {
// again empty to showcase that the issue is resovled
}) { (completed) in
self.remove(childController: startingFlowController)
}
Caveat: ViewDidAppear
is called immediately, at the same time as ViewWillAppear
, instead of waiting for the transition animation to finish. This may cause issues if you are starting animations in ViewDidAppear
of other view controllers.
Remove animations from the navigationBar
in the presented navigation controller's viewWillAppear
method.
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// it is important to wait for a bit as animations have not yet been added
DispatchQueue.main.async {
self.navigationBar.layer.removeAllAnimations()
}
}
or the slightly more complex way that ensures animations from all sublayers of the navigationBar
are removed. I'm not sure if this is necessary.
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
DispatchQueue.main.async {
self.removeSubLayerAnimations(layer: self.navigationBar.layer)
}
}
func removeSubLayerAnimations(layer: CALayer) {
layer.removeAllAnimations()
guard let sublayers = layer.sublayers else { return }
for sublayer in sublayers {
sublayer.removeAllAnimations()
removeSubLayerAnimations(layer: sublayer)
}
}
Caveat: this is a hack, we are messing with system animations which could have unforeseen consequences and could stop potentially working after an iOS update.
If anyone has a better way to solve this issue I would like to hear it :)
The „sequence vs. UI“ categorization of VCs reminds me of this React article which differentiates between „Presentational“ and „Container“ components: https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0
I think using the responder chain is a more elegant approach :
https://augmentedcode.io/2018/11/18/navigating-using-flow-controllers-and-responder-chain-on-ios/
Are there any updates on solving the hideBottomBarWhen pushed using FlowControllers?
Every new architecture that comes out, either iOS or Android, makes me very excited. I'm always looking for ways to structure apps in a better way. But after some times, I see that we're too creative in creating architecture, aka constraint, that is too far away from the platform that we're building. I often think "If we're going too far from the system, then it's very hard to go back"
I like things that embrace the system. One of them is Coordinator which helps in encapsulation and navigation. Thanks to my friend Vadym for showing me
Coordinator
in action.The below screenshot from @khanlou 's talk at CocoaHeads Stockholm clearly says many things about
Coordinator
But after reading A Better MVC, I think we can leverage view controller containment to do navigation using
UIViewController
only.Since I tend to call view controllers as
LoginController, ProfileController, ...
and the termflow
to group those related screens, what should we call aCoordinator
that inherits fromUIViewController
🤔 Let's call itFlowController
😎 .The name is not that important, but the concept is simple.
FlowController
was also inspired by this Flow Controllers on iOS for a Better Navigation Control back in 2014. The idea is from awesome iOS people, this is just a sum up from my experience 😇So
FlowController
can just aUIViewController
friendly version ofCoordinator
. Let see howFlowController
fits better intoMVC
1. FlowController and AppDelegate
Your application starts from
AppDelegate
, in that you setupUIWindow
. So we should follow the same "top down" approach forFlowController
, starting withAppFlowController
. You can construct all dependencies that your app need forAppFlowController
, so that it can pass to other childFlowController
.AppDelegate
is also considered Composition RootHere is how to declare
AppFlowController
inAppDelegate
Here are some hypothetical
FlowController
that you may encounterUIPageViewController
and maybe ask for some permissionsUINavigationController
to show login, sms verification, forget password, and optionally startSignUpFlowController
UITabBarController
with each tab serving main featuresFlowController
chain.The cool thing about
FlowController
is it makes your code very self contained, and grouped by features. So it's easy to move all related things to its own package if you like.2. FlowController as container view controller
Basically,
FlowController
is just a container view controller to solve thesequence
, based on a simple concept calledcomposition
. It manages many child view controllers in its flow. Let' say we have aProductFlowController
that groups together flow related to displaying products,ProductListController
,ProductDetailController
,ProductAuthorController
,ProductMapController
, ... Each can delegate to theProductFlowController
to express its intent, likeProductListController
can delegate to say "product did tap", so thatProductFlowController
can construct and present the next screen in the flow, based on the embeddedUINavigationController
inside it.Normally, a
FlowController
just displays 1 childFlowController
at a time, so normally we can just update its frame3. FlowController as dependency container
Each view controller inside the flow can have different dependencies, so it's not fair if the first view controller needs to carry all the stuff just to be able to pass down to the next view controllers. Here are some dependencies
Instead the
FlowController
can carry all the dependencies needed for that whole flow, so it can pass down to the view controller if needed.Here are some ways that you can use to pass dependencies into
FlowController
4. Adding or removing child FlowController
Coordinator
With
Coordinator
, you need to keep an array of childCoordinators
, and maybe use address (===
operator) to identify themFlowController
With
FlowController
, since it isUIViewController
subclass, it hasviewControllers
to hold all those childFlowController
. Just add these extensions to simplify your adding or removing of childUIViewController
And see in action how
AppFlowController
work with addingand with removing when the child
FlowController
finishes5. AppFlowController does not need to know about UIWindow
Coordinator
Usually you have an
AppCoordinator
, which is held byAppDelegate
, as the root of yourCoordinator
chain. Based on login status, it will determine whichLoginController
orMainController
will be set as therootViewController
, in order to do that, it needs to be injected aUIWindow
You can guess that in the
start
method ofAppCoordinator
, it must setrootViewController
beforewindow?.makeKeyAndVisible()
is called.FlowController
But with
AppFlowController
, you can treat it like a normalUIViewController
, so just setting it as therootViewController
6. LoginFlowController can manage its own flow
Supposed we have login flow based on
UINavigationController
that can displayLoginController
,ForgetPasswordController
,SignUpController
Coordinator
What should we do in the
start
method ofLoginCoordinator
? Construct the initial controllerLoginController
and set it as therootViewController
of theUINavigationController
?LoginCoordinator
can create this embeddedUINavigationController
internally, but then it is not attached to therootViewController
ofUIWindow
, becauseUIWindow
is kept privately inside the parentAppCoordinator
.We can pass
UIWindow
toLoginCoordinator
but then it knows too much. One way is to constructUINavigationController
fromAppCoordinator
and pass that toLoginCoordinator
FlowController
LoginFlowController
leveragescontainer view controller
so it fits nicely with the wayUIKit
works. HereAppFlowController
can just addLoginFlowController
andLoginFlowController
can just create its ownembeddedNavigationController
.7. FlowController and responder chain
Coordinator
Sometimes we want a quick way to bubble up message to parent
Coordinator
, one way to do that is to replicateUIResponder
chain usingassociated object
and protocol extensions, like Inter-connect with CoordinatorFlowController
Since
FlowController
isUIViewController
, which inherits fromUIResponder
, responder chain happens out of the box8. FlowController and trait collection
FlowController
I very much like how Kickstarter uses trait collection in testing. Well, since
FlowController
is a parent view controller, we can just override its trait collection, and that will affect the size classes of all view controllers inside that flow.As in A Better MVC, Part 2: Fixing Encapsulation
From setOverrideTraitCollection
9. FlowController and back button
Coordinator
One problem with
UINavigationController
is that clicking on the defaultback button
pops the view controller out of the navigation stack, soCoordinator
is not aware of that. WithCoordinator
you needs to keepCoordinator
andUIViewController
in sync, add try to hook upUINavigationControllerDelegate
in order to clean up. Like in Back Buttons and CoordinatorsOr creating a class called
NavigationController
that inside manages a list of child coordinators. Like in Navigation coordinatorsFlowController
Since
FlowController
is just plainUIViewController
, you don't need to manually manage childFlowController
. The childFlowController
is gone when you pop or dismiss. If we want to listen toUINavigationController
events, we can just handle that inside theFlowController
10. FlowController and callback
We can use
delegate
pattern to notifyFlowController
to show another view controller in the flowAnother approach is to use
closure
as callback, as proposed by @merowing_, and also in his post Improve your iOS Architecture with FlowControllers11. FlowController and deep linking
TBD. In the mean while, here are some readings about the UX