The ConcurrentPairNavigator is a stacking Navigator that allows up to two Scenes in its stack. Whenever a second Scene is stacked upon the initial Scene, both the initial Scene and the second Scene will simultaneously be in their 'started' states, hereby differing from navigators such as the StackNavigator which only allow a single Scene in the 'started' state.
This Navigator can come in useful when implementing complex overlays that warrant their own Scene instance:
A 'complex overlay' here means an overlay that is not a simple dialog. The ConcurrentPairNavigator allows for great flexibility to define your layout and its transitions perfectly, but comes with a fair amount of overhead. If you want to display a simple dialog showing a message or asking a question, use an AlertDialog instead.
Usage
You can find a working sample project demonstrating usage of this class here
To be able to allow two Scenes to be active at once, the
ConcurrentPairNavigator wraps the two Scenes in a
CombinedScene instance with the key of the second Scene.
This special Scene implementation ignores any lifecycle calls, and ensures both
Scenes receive the proper Container instances in attach
and
detach
.
It does this by accepting a special CombinedContainer specialization of the Container interface, which allows access to the two sub Containers. It is then the responsibility of the UI layer to properly provide instances of the CombinedContainer interface.
In the following sections you can find out how to make use of the ConcurrentPairNavigator.
The initial Scene
The ConcurrentPairNavigator requires a first, initial Scene. This Scene is just like any other regular Scene, and can implement ProvidesView if you want to.
For example, we can create the first Scene from above using RxJava like this1:
1 interface FirstSceneContainer : Container {
2
3 var count: Long
4
5 /** Registers a listener for when an action is clicked */
6 fun onActionClicked(f: () -> Unit)
7 }
8
9 /**
10 * Displays a counter value that continuously increases starting when this Scene
11 * is started, until this Scene is destroyed.
12 */
13 class FirstScene(
14 private val listener: Events,
15 scheduler: Scheduler = AndroidSchedulers.mainThread()
16 ) : RxScene<FirstSceneContainer>(null), ProvidesView {
17
18 /**
19 * Emits a continuously increasing stream of Longs every 100 milliseconds,
20 * starting at the first subscription, until the Scene is destroyed.
21 */
22 private val counter: Observable<Long> = Observable
23 .interval(0, 100, TimeUnit.MILLISECONDS, scheduler)
24 .replay(1).autoConnect(this)
25
26 /**
27 * Conveniently provides a View and ViewController for this Scene.
28 */
29 override fun createViewController(parent: ViewGroup): ViewController {
30 return FirstSceneViewController(parent.inflate(R.layout.first_scene))
31 }
32
33 override fun onStart() {
34 super.onStart()
35
36 // Subscribe to the counter, updating the container when available.
37 disposables += counter
38 .combineWithLatestView()
39 .subscribe { (count, container) ->
40 container?.count = count
41 }
42 }
43
44 override fun attach(v: FirstSceneContainer) {
45 super.attach(v)
46
47 // Registers a listener with the container.
48 v.onActionClicked { listener.actionClicked() }
49 }
50
51 interface Events {
52
53 /**
54 * Invoked when this Scene's action is clicked.
55 */
56 fun actionClicked()
57 }
58 }
As mentioned, there is nothing special about this Scene that makes it suitable for the ConcurrentPairNavigator.
The second Scene
In our example from above we introduce a new layout that partially overlays the first Scene, while still keeping our first Scene active. This overlay is backed by a second Scene, which can be implemented like so:
1 interface SecondSceneContainer : Container {
2
3 /** Registers a listener for when the 'back' action is clicked */
4 fun onBackClicked(f: () -> Unit)
5 }
6
7 /**
8 * Displays a simple 'back' button.
9 */
10 class SecondScene(
11 private val listener: Events
12 ) : Scene<SecondSceneContainer> {
13
14 override fun attach(v: SecondSceneContainer) {
15
16 // Registers a listener with the container.
17 v.onBackClicked { listener.onBackClicked() }
18 }
19
20 interface Events {
21
22 /**
23 * Invoked when this Scene's 'back' action is clicked.
24 */
25 fun onBackClicked()
26 }
27 }
This Scene does not implement ProvidesView, as this Scene has too little context about how to provide this container. For example, it actually doesn't know it is being displayed using an overlay, and does not know how to provide the entire layout. More on this in "Providing the container".
The ConcurrentPairNavigator
Now we can implement the ConcurrentPairNavigator class to tie our Scenes together:
1 class HelloConcurrentPairNavigator : ConcurrentPairNavigator(null) {
2
3 /**
4 * Creates the first Scene when this Navigator is initialized.
5 */
6 override fun createInitialScene(): Scene<out Container> {
7 return FirstScene(FirstSceneListener())
8 }
9
10 override fun instantiateScene(sceneClass: KClass<out Scene<*>>, state: SceneState?): Scene<out Container> {
11 // Normally you should implement this as well.
12 // For this example however, restoring is omitted.
13 error("Not supported")
14 }
15
16 private inner class FirstSceneListener : FirstScene.Events {
17
18 /**
19 * Pushes a SecondScene instance on the stack.
20 */
21 override fun actionClicked() {
22 push(SecondScene(SecondSceneListener()))
23 }
24 }
25
26 private inner class SecondSceneListener : SecondScene.Events {
27
28 /**
29 * Pops the SecondScene instance from the stack.
30 */
31 override fun onBackClicked() {
32 pop()
33 }
34 }
35 }
Whenever the FirstSceneListener
's actionClicked
function is invoked, it
pushes a new SecondScene
instance on the stack.
This triggers the Navigator to emit a CombinedScene
instance to its listeners.
When the SecondSceneListener
's onBackClicked
function is invoked, the
SecondScene
is popped from the stack—leading to the Navigator emitting
the FirstScene
to its listeners again.
Providing the layouts
Now this is where things get tricky and a bit cumbersome. As with all Scenes, there are three use cases to support when displaying their layouts:
- Displaying as part of a Scene transition from the first to the second Scene;
- Displaying as part of a Scene transition from the second to the first Scene;
- Displaying from scratch, for example when a fresh Activity appears.
Usually when creating a Scene you can cover both of these by providing a simple
ViewController, for example using the ProvidesView interface.
The second use case is automatically covered by a default transition which
replaces the entire layout with the new one.
For the first Scene this is also the case.
In the case of a CombinedScene however, using ProvidesView for the second
Scene isn't gonna cut it: the second Scene's layout is only a subset of the
actual layout that needs to be shown. In our example case this would be the
card at the bottom of the view.
Therefore all these use cases need to be handled manually.
- The ViewController
First though, we need to provide a ViewController implementation. In this case we implement the CombinedContainer implementation:
1 @UseExperimental(ExperimentalConcurrentPairNavigator::class)
2 class FirstSecondViewController(
3 override val view: ViewGroup
4 ) : ViewController, CombinedContainer {
5
6 override val firstContainer: Container by lazy {
7 FirstSceneViewController(view.firstSceneRoot)
8 }
9
10 override val secondContainer: Container by lazy {
11 SecondSceneViewController(view.secondSceneRoot)
12 }
13 }
This allows Acorn to bind the correct part of the layout to the proper Scenes.
- Displaying as part from a Scene transition from the first to the second Scene
This is the case the user will see most of the time, when you call
ConcurrentPairNavigator#push with your second Scene instance.
When this happens, the navigator will transition from the FirstScene
instance
to a CombinedScene instance.
This CombinedScene takes the key
from the SecondScene
, so this can be used
to define a transition animation.
This transition animation can be implemented as any other transition animation. The only difference is that you don't remove the first Scene's layout at the end of the transition. For example, the transition in the GIF above is implemented as such:
1 object FirstSecondTransition : Transition {
2
3 override fun execute(parent: ViewGroup, callback: Transition.Callback) {
4 // Inflate and add the overlay to the layout
5 val secondScene = parent.inflateView(R.layout.second_scene)
6 parent.addView(secondScene)
7
8 // Immediately create and attach the combined view controller.
9 // This allows our first Scene to keep communicating with the
10 // view seamlessly.
11 val viewController = FirstSecondViewController(parent)
12 callback.attach(viewController)
13
14 parent.doOnPreDraw {
15 // Animate the dark overlay
16 secondScene.overlayView
17 .apply {
18 alpha = 0f
19 animate().alpha(1f)
20 }
21
22 // Animate the card view
23 secondScene.cardView.apply {
24 translationY = height.toFloat()
25 animate().translationY(0f)
26 .withEndAction {
27 // Complete the transition
28 callback.onComplete(viewController)
29 }
30 }
31 }
32 }
33 }
This transition can be registered using the first and second Scene's key as mentioned before, for example using the transitionFactory DSL:
1 transitionFactory(viewControllerFactory) {
2 (FirstScene.key to SecondScene.key) use FirstSecondTransition
3 }
- Displaying from scratch
Next to displaying the combination of the first and second layout through a transition, it can also occur that the entire layout needs to be created from scratch. This happens for example when a fresh Activity is presented, like after an orientation change.
In this case we need to provide Acorn with a single View instance
that contains both layouts.
We can do this using a FrameLayout and the <include>
tag.
In first_and_second_scene.xml
:
1 <?xml version="1.0" encoding="utf-8"?>
2 <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
3 xmlns:tools="http://schemas.android.com/tools"
4 android:id="@+id/firstAndSecondRoot"
5 android:layout_width="match_parent"
6 android:layout_height="match_parent">
7
8 <include
9 android:id="@+id/firstSceneRoot"
10 layout="@layout/first_scene" />
11
12 <include
13 android:id="@+id/secondSceneRoot"
14 layout="@layout/second_scene" />
15
16 </FrameLayout>
This will combine our two scene layouts in a single FrameLayout which we can provide to Acorn:
1 class MyViewControllerFactory : ViewControllerFactory {
2
3 override fun supports(scene: Scene<*>): Boolean {
4 return scene.key == SecondScene.key
5 }
6
7 override fun viewControllerFor(scene: Scene<*>, parent: ViewGroup): ViewController {
8 return FirstSecondViewController(parent)
9 }
10 }
- Displaying as part from a Scene transition from the second to the first Scene
This is also a case the user will see often. However, we now have to take two cases into account: one where the layout is built up using the transition from first to second, and the other where the layout is built up from scratch. In the latter case we have an intermediate extra FrameLayout we need to deal with.
We can define a Transition just as before, but now need to take care that we leave our layout in a proper state. We can do this by checking what elements are present in the layout, in particular whether the intermediate FrameLayout is there:
1 object SecondFirstTransition : Transition {
2
3 override fun execute(parent: ViewGroup, callback: Transition.Callback) {
4 // The firstAndSecondRoot id is an indicator that our intermediate
5 // FrameLayout is present.
6 val firstAndSecondRoot = parent.findViewById<ViewGroup>(R.id.firstAndSecondRoot)
7 if (firstAndSecondRoot != null) {
8 // 'Flatten' the layout to the 'normal' state
9 normalizeLayout(parent)
10 }
11
12 // Now we can execute the transition as usual.
13
14 // Immediately create and attach the combined view controller.
15 // This allows our first Scene to keep communicating with the
16 // view seamlessly.
17 val viewController = FirstSceneViewController(parent)
18 callback.attach(viewController)
19
20 // Animate the dark overlay
21 parent.secondSceneRoot.overlayView
22 .animate()
23 .alpha(0f)
24
25 // Animate the card view
26 val cardView = parent.secondSceneRoot.cardView
27 cardView.animate()
28 .translationY(cardView.height.toFloat())
29 .withEndAction {
30 // Complete the transaction, taking care to remove any
31 // obsolete views.
32 parent.removeView(parent.secondSceneRoot)
33 callback.onComplete(viewController)
34 }
35 }
36
37 /**
38 * 'Normalizes' the layout by removing the intermediate FrameLayout.
39 * The resulting layout in [parent] contains the exact layout as it would
40 * be when transitioning using [FirstSecondTransition].
41 */
42 private fun normalizeLayout(parent: ViewGroup) {
43 val firstAndSecondRoot = parent.firstAndSecondRoot
44
45 firstAndSecondRoot.firstSceneRoot.let {
46 firstAndSecondRoot.removeView(it)
47 parent.addView(it)
48 }
49
50 firstAndSecondRoot.secondSceneRoot.let {
51 firstAndSecondRoot.removeView(it)
52 parent.addView(it)
53 }
54
55 parent.removeView(firstAndSecondRoot)
56 }
57 }
1: This example uses the RxScene class available in the
com.nhaarman.acorn.ext:acorn-rx
artifact.