Travis CI | |
Frameworks | |
Platform | |
Licence |
RxFlow is a navigation framework for iOS applications based on a Flow Coordinator pattern.
This README is a short story of the whole conception process that led me to this framework.
You will find a very detail explanation of the whole project on my blog:
Regarding navigation within an iOS application, two choices are available:
- Use the builtin mechanism provided by Apple and Xcode: storyboards and segues
- Implement a custom mechanism directly in the code
The disadvantage of these two solutions:
- Builtin mechanism: navigation is relatively static and the storyboards are massive. The navigation code pollutes the UIViewControllers
- Custom mechanism: code can be difficult to set up and can be complex depending on the chosen design pattern (Router, Coordinator)
- Promote the cutting of storyboards into atomic units to enable collaboration and reusability of UIViewControllers
- Allow the presentation of a UIViewController in different ways according to the navigation context
- Ease the implementation of dependency injection
- Remove every navigation mechanism from UIViewControllers
- Promote reactive programming
- Express the navigation in a declarative way while addressing the majority of the navigation cases
- Facilitate the cutting of an application into logical blocks of navigation
In your Cartfile:
github "RxSwiftCommunity/RxFlow"
In your Podfile:
pod 'RxFlow'
The Coordinator pattern is a great way to organize the navigation within your application. It allows to:
- remove the navigation code from UIViewControllers
- reuse UIViewControllers in different navigation contexts
- ease the use of dependency injection
To learn more about it, I suggest you take a look at this article: (Coordinator Redux).
To me, the Coordinator pattern has some drawbacks:
- you have to write the coordination mechanism each time you bootstrap an application
- there can be a lot of boilerplate code because of the delegation pattern that allows to communicate with Coordinators
RxFlow is a reactive implementation of the Coordinator pattern. It has all the great features of this architecture, but introduces some improvements:
- makes the navigation more declarative
- provides a built-in Coordinator that handles the navigation flows you've declared
- uses reactive programming to address the communication with Coordinators issue
There are 6 terms you have to be familiar with to understand RxFlow:
- Flow: each Flow defines a navigation area within your application. This is the place where you declare the navigation actions (such as presenting a UIViewController or another Flow)
- Step: each Step is a navigation state in your application. Combinaisons of Flows and Steps describe all the possible navigation actions. A Step can even embed inner values (such as Ids, URLs, ...) that will be propagated to screens declared in the Flows
- Stepper: it can be anything that can emit Steps. Steppers will be responsible for triggering every navigation actions within the Flows
- Presentable: it is an abstraction of something that can be presented (basically UIViewController and Flow are Presentable). Presentables offer Reactive observables that the Coordinator will subscribe to in order to handle Flow Steps in a UIKit compliant way
- Flowable: it is a simple data structure that combines a Presentable and a stepper. It tells the Coordinator what will be the next thing that will produce new Steps in your Reactive mechanism
- Coordinator: once the developer has defined the suitable combinations of Flows and Steps representing the navigation possibilities, the job of the Coordinator is to mix these combinaisons in a consistent way.
As Steps are seen like some navigation states spread across the application, it seems pretty obvious to use an enum to declare them
enum DemoStep: Step {
case apiKey
case apiKeyIsComplete
case movieList
case moviePicked (withMovieId: Int)
case castPicked (withCastId: Int)
case settings
case settingsDone
case about
}
The following Flow is used as a Navigation stack. All you have to do is:
- declare a root UIViewController on which your navigation will be based
- implement the navigate(to:) function to transform a Step into a navigation action
The navigate(to:) function returns an array of Flowable. This is how the next navigation actions will be produced (the Stepper defined in a Flowable will emit the next Steps)
class WatchedFlow: Flow {
var root: UIViewController {
return self.rootViewController
}
private let rootViewController = UINavigationController()
private let service: MoviesService
init(withService service: MoviesService) {
self.service = service
}
func navigate(to step: Step) -> [Flowable] {
guard let step = step as? DemoStep else { return Flowable.noFlow }
switch step {
case .movieList:
return navigateToMovieListScreen()
case .moviePicked(let movieId):
return navigateToMovieDetailScreen(with: movieId)
case .castPicked(let castId):
return navigateToCastDetailScreen(with: castId)
default:
return Flowable.noFlow
}
}
private func navigateToMovieListScreen () -> [Flowable] {
let viewModel = WatchedViewModel(with: self.service)
let viewController = WatchedViewController.instantiate(with: viewModel)
viewController.title = "Watched"
self.rootViewController.pushViewController(viewController, animated: true)
return [Flowable(nextPresentable: viewController, nextStepper: viewModel)]
}
private func navigateToMovieDetailScreen (with movieId: Int) -> [Flowable] {
let viewModel = MovieDetailViewModel(withService: self.service, andMovieId: movieId)
let viewController = MovieDetailViewController.instantiate(with: viewModel)
viewController.title = viewModel.title
self.rootViewController.pushViewController(viewController, animated: true)
return [Flowable(nextPresentable: viewController, nextStepper: viewModel)]
}
private func navigateToCastDetailScreen (with castId: Int) -> [Flowable] {
let viewModel = CastDetailViewModel(withService: self.service, andCastId: castId)
let viewController = CastDetailViewController.instantiate(with: viewModel)
viewController.title = viewModel.name
self.rootViewController.pushViewController(viewController, animated: true)
return Flowable.noFlow
}
}
In theory a Stepper, as it is a protocol, can be anything (a UIViewController for instance) by I suggest to isolate that behavior in a ViewModel or so. For simple cases (for instance when we only need to bootstrap a Flow with a first Step and don't want to code a basic Stepper for that), RxFlow provides a OneStepper class.
class WatchedViewModel: Stepper {
let movies: [MovieViewModel]
init(with service: MoviesService) {
// we can do some data refactoring in order to display things exactly the way we want (this is the aim of a ViewModel)
self.movies = service.watchedMovies().map({ (movie) -> MovieViewModel in
return MovieViewModel(id: movie.id, title: movie.title, image: movie.image)
})
}
// when a movie is picked, a new Step is emitted.
// That will trigger a navigation action within the WatchedFlow
public func pick (movieId: Int) {
self.step.accept(DemoStep.moviePicked(withMovieId: movieId))
}
}
Of course, it is the aim of a Coordinator. As a Flow is a Presentable, a Flow can launch one other Flow or even several other Flows.
For instance, from the WishlistFlow, we launch the SettingsFlow in a popup.
private func navigateToSettings () -> [Flowable] {
let settingsStepper = SettingsStepper()
let settingsFlow = SettingsFlow(withService: self.service, andStepper: settingsStepper)
Flows.whenReady(flow: settingsFlow, block: { [unowned self] (root: UISplitViewController) in
self.rootViewController.present(root, animated: true)
})
return [Flowable(nextPresentable: settingsFlow, nextStepper: settingsStepper)]
}
For a more complex case, see the MainFlow.swift file in which we handle a UITabBarController.
The coordination process is pretty straightfoward and happens in the AppDelegate.
class AppDelegate: UIResponder, UIApplicationDelegate {
let disposeBag = DisposeBag()
var window: UIWindow?
var coordinator = Coordinator()
let movieService = MoviesService()
lazy var mainFlow = {
return MainFlow(with: self.movieService)
}()
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
guard let window = self.window else { return false }
// listen for Coordinator mechanism is not mandatory
coordinator.rx.didNavigate.subscribe(onNext: { (flow, step) in
print ("did navigate to flow=\(flow) and step=\(step)")
}).disposed(by: self.disposeBag)
// when the MainFlow is ready to be displayed, we assign its root the the Window
Flows.whenReady(flow: mainFlow, block: { [unowned window] (flowRoot) in
window.rootViewController = flowRoot
})
// The navigation begins with the MainFlow at the apiKey Step
// We could also have a specific Stepper that could decide if
// the apiKey should be the fist step or not
coordinator.coordinate(flow: mainFlow, withStepper: OneStepper(withSingleStep: DemoStep.apiKey))
return true
}
}
As a bonus, Coordinator offers a Rx extension that allows you to track the navigation actions (Coordinator.rx.willNavigate and Coordinator.rx.didNavigate).
A demo application is provided to illustrate the core mechanisms. Pretty much every kind of navigation is addressed. The app consists of:
- a MainFlow that represents the main navigation section (a settings screen and then a dashboard composed of two screens in a tab bar controller)
- a WishlistFlow that represents a navigation stack of movies that you want to watch
- a WatchedFlow that represents a navigation stack of movies that you've already seen
- a SettingsFlow that represents the user's preferences in a master/detail presentation
RxFlow relies on:
- SwiftLint for static code analysis (Github SwiftLint)
- RxSwift to expose Steps as Observables the Coordinator can react to (Github RxSwift)
- Reusable in the Demo App to ease the storyboard cutting into atomic ViewControllers (Github Reusable)