Adetunji Dahunsi

Driving Motional Intelligence for Global Persistent UI on Android with a Simple State Store

Navigation destinations in the Reply Material Studies app. Notice UI elements common to various screens

TJ Dahunsi

Aug 05 2019 · 6 min read


Android Q’s debut draws nearer, and with it, the cries and echoes of multiple Android Developer Advocates stressing by way of numerous talks and blog posts that we really ought to embrace edge to edge UIs for our Android apps. Going edge to edge is only step one however; the fruition of that initial step only comes to bear fully if delightful animations and transitions accompany navigation from destination to destination within the app.

At Google IO ’19, Nick Butcher spoke about the concept of

, and how it can be used to build smarter animations. This post is an interpretation of that, but specifically to UI elements that are persistent, only differing slightly from navigation destination to the next. This allows for these pieces of UI to appear like they are morphing in direct response to the user’s actions which in combination with edge to edge UI provides for a UX that is not only cohesive, but delightful to use, giving Material Design a fantastic platform to truly shine. Examples of these persistent UI items include Toolbars and Floating Action Buttons.

To start, let’s define what the global persistent UI State object is. It should summarize what the user can see on the UI at any given point in time and what the screen can do.

1class UiState( 2 private val fabIcon: Int, 3 private val fabText: Int, 4 private val toolBarMenu: Int, 5 private val altToolBarMenu: Int, 6 private val navBarColor: Int, 7 private val showsFab: Boolean, 8 private val showsToolbar: Boolean, 9 private val showsAltToolbar: Boolean, 10 private val insetFlags: InsetFlags, 11 private val toolbarTitle: CharSequence, 12 private val altToolbarTitle: CharSequence, 13 private val fabClickListener: View.OnClickListener? 14) : Parcelable

From that class definition, every nav destination has:

  1. A toolbar that can either be visible or invisible. It displays a title, and has menu items defined by a menu resource Int.

  2. The same as above for an alternate toolbar. This alternate toolbar is used for context menus, for example when items on the screen are selected. It’s a custom implementation of Android’s ContextMenu

  3. A Floating Action Button that can be visible or invisible, with resources for its text and icon. It also carries a nullable reference for the click listener for when it’s tapped.

Next, we’ll need to attach listeners to actually drive animations when any of the fields in the state changes. These animations can be fairly expensive, so care needs to be taken to make sure that a listener is only fired when its field actually changes. only fires if a single field has changed and either fires if either of the referenced fields have changed.

1fun update(force: Boolean, newState: UiState, 2 showsFabConsumer: (Boolean) -> Unit, 3 showsToolbarConsumer: (Boolean) -> Unit, 4 showsAltToolbarConsumer: (Boolean) -> Unit, 5 navBarColorConsumer: (Int) -> Unit, 6 insetFlagsConsumer: (InsetFlags) -> Unit, 7 fabStateConsumer: (Int, Int) -> Unit, 8 toolbarStateConsumer: (Int, CharSequence) -> Unit, 9 altToolbarStateConsumer: (Int, CharSequence) -> Unit, 10 fabClickListenerConsumer: (View.OnClickListener?) -> Unit 11 ): UiState { 12 only(force, newState, { state -> state.showsFab }, showsFabConsumer) 13 only(force, newState, { state -> state.showsToolbar }, showsToolbarConsumer) 14 only(force, newState, { state -> state.showsAltToolbar }, showsAltToolbarConsumer) 15 only(force, newState, { state -> state.navBarColor }, navBarColorConsumer) 16 only(force, newState, { state -> state.insetFlags }, insetFlagsConsumer) 17 18 either(force, newState, { state -> state.fabIcon }, { state -> state.fabText }, fabStateConsumer) 19 either(force, newState, { state -> state.toolBarMenu }, { state -> state.toolbarTitle }, toolbarStateConsumer) 20 either(force, newState, { state -> state.altToolBarMenu }, { state -> state.altToolbarTitle }, altToolbarStateConsumer) 21 22 fabClickListenerConsumer.invoke(newState.fabClickListener) 23 24 return newState 25 }

***Updating UiState objects only fires listeners of properties that have changed

In the above, the respective consumers are only invoked when the pertinent slice of UiState changes.

It’s quite convenient for the current nav destination to be its own source of truth for the current UiState. We can define a BaseFragment that maps to each of the UI fields like this:

1 2abstract class BaseFragment : Fragment() { 3 4 protected open val fabIconRes: Int 5 @DrawableRes get() = 0 6 7 protected open val fabTextRes: Int 8 @StringRes get() = 0 9 10 protected open val navBarColor: Int 11 @ColorInt get() = ContextCompat.getColor(requireContext(), R.color.transparent) 12 13 open val toolBarMenuRes: Int 14 @MenuRes get() = 0 15 16 open val altToolBarRes: Int 17 @MenuRes get() = 0 18 19 protected open val showsFab: Boolean 20 get() = false 21 22 open val showsAltToolBar: Boolean 23 get() = false 24 25 open val showsToolBar: Boolean 26 get() = true 27 28 open val insetFlags: InsetFlags 29 get() = InsetFlags.ALL 30 31 open val toolbarText: CharSequence 32 get() = getText(R.string.app_name) 33 34 open val altToolbarText: CharSequence 35 get() = "" 36 37 protected open val fabClickListener: View.OnClickListener 38 get() = View.OnClickListener { } 39 40 val uiState: UiState 41 get() = UiState( 42 this.fabIconRes, 43 this.fabTextRes, 44 this.toolBarMenuRes, 45 this.altToolBarRes, 46 this.navBarColor, 47 this.showsFab, 48 this.showsToolBar, 49 this.showsAltToolBar, 50 this.insetFlags, 51 this.toolbarText, 52 this.altToolbarText, 53 if (view == null) null else fabClickListener 54 ) 55 56}

Base Fragment definition for creating UiState

Finally, since the activity’s content view is the host of the persistent UI elements, it holds the reference to the current UiState object, feeds the input that mutates each UiState transition, and defines the listeners that drive the animations when a new screen appears.

1<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="" 2 xmlns:app="" 3 xmlns:tools="" 4 android:id="@+id/constraint_layout" 5 android:layout_width="match_parent" 6 android:layout_height="match_parent" 7 tools:clientNsdService="com.tunjid.rcswitchcontrol.activities.MainActivity"> 8 9 <include 10 android:id="@+id/alt_toolbar" 11 layout="@layout/toolbar" 12 android:layout_width="0dp" 13 android:layout_height="wrap_content" 14 android:background="?attr/colorPrimary" 15 android:visibility="invisible" 16 app:layout_constraintLeft_toLeftOf="parent" 17 app:layout_constraintRight_toRightOf="parent" 18 app:layout_constraintTop_toBottomOf="@+id/top_inset" /> 19 20 <include 21 android:id="@+id/toolbar" 22 layout="@layout/toolbar" 23 android:layout_width="0dp" 24 android:layout_height="wrap_content" 25 android:background="?attr/colorPrimary" 26 app:layout_constraintLeft_toLeftOf="parent" 27 app:layout_constraintRight_toRightOf="parent" 28 app:layout_constraintTop_toBottomOf="@+id/top_inset" /> 29 30 <View 31 android:id="@+id/top_inset" 32 android:layout_width="0dp" 33 android:layout_height="0dp" 34 android:background="@color/colorPrimary" 35 app:layout_constraintLeft_toLeftOf="parent" 36 app:layout_constraintRight_toRightOf="parent" 37 app:layout_constraintTop_toTopOf="parent" /> 38 39 <FrameLayout 40 android:id="@+id/main_fragment_container" 41 android:layout_width="0dp" 42 android:layout_height="0dp" 43 app:layout_constraintBottom_toTopOf="@+id/keyboard_padding" 44 app:layout_constraintLeft_toLeftOf="parent" 45 app:layout_constraintRight_toRightOf="parent" 46 47 app:layout_constraintTop_toBottomOf="@+id/top_inset" /> 48 49 <androidx.coordinatorlayout.widget.CoordinatorLayout 50 android:id="@+id/coordinator_layout" 51 android:layout_width="0dp" 52 android:layout_height="0dp" 53 app:layout_constraintBottom_toTopOf="@+id/keyboard_padding" 54 app:layout_constraintLeft_toLeftOf="parent" 55 app:layout_constraintRight_toRightOf="parent" 56 app:layout_constraintTop_toTopOf="parent"> 57 58 < 59 android:id="@+id/fab" 60 style="@style/Widget.MaterialComponents.Button.UnelevatedButton" 61 android:layout_width="wrap_content" 62 android:layout_height="wrap_content" 63 android:layout_gravity="bottom|end" 64 android:layout_margin="@dimen/single_margin" 65 android:backgroundTint="?colorAccent" 66 app:layout_behavior="@string/bottom_transient_bar_behavior" /> 67 68 69 </androidx.coordinatorlayout.widget.CoordinatorLayout> 70 71 <View 72 android:id="@+id/keyboard_padding" 73 android:layout_width="0dp" 74 android:layout_height="0dp" 75 app:layout_constraintBottom_toTopOf="@+id/bottom_inset" 76 app:layout_constraintLeft_toLeftOf="parent" 77 app:layout_constraintLeft_toRightOf="parent" /> 78 79 <View 80 android:id="@+id/bottom_inset" 81 android:layout_width="0dp" 82 android:layout_height="0dp" 83 app:layout_constraintBottom_toBottomOf="parent" 84 app:layout_constraintLeft_toLeftOf="parent" 85 app:layout_constraintRight_toRightOf="parent" /> 86 87 <View 88 android:id="@+id/nav_background" 89 android:layout_width="0dp" 90 android:layout_height="0dp" 91 app:layout_constraintBottom_toBottomOf="parent" 92 app:layout_constraintLeft_toLeftOf="parent" 93 app:layout_constraintRight_toRightOf="parent" /> 94</androidx.constraintlayout.widget.ConstraintLayout>

Layout hierarchy for MainActivity that hosts all persistent UI

To cause animations to run when the destination changes, a FragmentLifeCycleCallback can be attached to the activity’s FragmentManager and the State updated in the onFragmentViewCreated callback.

1private val fragmentViewCreatedCallback: FragmentManager.FragmentLifecycleCallbacks = object : FragmentManager.FragmentLifecycleCallbacks() { 2 3 override fun onFragmentViewCreated(fm: FragmentManager, 4 fragment: Fragment, 5 v: View, 6 savedInstanceState: Bundle?) { 7 uiState = fragment.uiState.update(force, 8 state, 9 this::toggleFab, 10 this::toggleToolbar, 11 this::toggleAltToolbar, 12 this::setNavBarColor, 13 {}, 14 this::setFabIcon, 15 this::updateMainToolBar, 16 this::updateAltToolbar, 17 this::setFabClickListener 18 ) 19 } 20}

If the UiState needs to be updated outside of navigation, the property getters in the BaseFragment can be hooked into reactive values in a ViewModel, say an instance of LiveData that should reflect what the current value of each slice of UiState should be at any given time. Additional hooks can then be written to notify the Activity that it should update itself again with the current fragment. This can be easily done with an Activity scoped ViewModel.

The toggle methods that hide and show the toolbars and FAB, are variations of the following snippet, depending on the direction the View needs to move in.

1ViewPropertyAnimatorCompat animator = ViewCompat.animate(view) 2 .setDuration(duration) 3 .setInterpolator(FAST_OUT_SLOW_IN_INTERPOLATOR) 4 .setListener(listener); 5 6if (direction == LEFT || direction == RIGHT) animator.translationX(displacement); 7else animator.translationY(displacement); 8 9animator.start();

Basic definition for animation that hides and shows elements in a single direction

From the above, all animations are driven by ViewPropertyAnimators, which animate from the View’s current properties satisfying the 3 tenets of good animation defined in Nick’s talk above to an acceptable degree:

  1. Reentrant: If the state changes while an animation is going on, the current running animation changes direction smoothly.

  2. Continuous: There are no stops or stutters, because navigation transitions the whole screen; everything moves in sync with navigation changes.

  3. Smooth: … to a certain degree. There are things that could be better, for example the hiding and showing the Toolbar and floating action buttons could uses a Physics based transition system to make things more natural, rather than just ValuePropertyAnimators. However since the range of motion for both items are relatively short, things look okay as is.

Setting things in motion, we can achieve the following effects:

Animating Persistent UI as a part of navigation or state changes

In the examples above, because there is a single instance of a Toolbar and FAB in the entire app, navigation changes appear clean and smooth. There is no flickering of common UI elements from screen to screen, because all screens share the exact same instance. Also because the properties of these common elements are mutated by a single object, the MainActivity, there is no duplication of animations and no stuttering. There is a strong feeling of cohesion, as pieces of material just comes in and go out when needed.

The above is one of the significant benefits of single Activity Android apps. Shared Element Transitions, UI animations can all be defined and controlled by a single entity: the Activity. Full source for all apps shown above can be found below:

To close, smooth navigation transitions in Android can be achieved with a simple class definition, provided that there is a single driver of global persistent UI, and it uses animators that adhere to the tenets of Motional Intelligence.