ConcurrentPairNavigator

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:

transitionFactory(viewControllerFactory) {
    (FirstScene.key to SecondScene.key) use FirstSecondTransition
}

- 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.