Upgrade to Pro — share decks privately, control downloads, hide ads and more …

MotionLayout & RecyclerView

Jason Pearson
November 17, 2020
330

MotionLayout & RecyclerView

One of the first and easiest things we do as Android Developers is rendering a list of items. Commonly, one of the hardest things we do next is trying to animate items in that list in a variety of ways.

Ever since Hoford & Roard showcased the possibility of animating a RecyclerView with MotionLayout a lot of people have wondered how to actually implement it. I'll be showcasing how my work has evolved in the past year in this specific vein, the issues I encountered, and new routes forward.

Jason Pearson

November 17, 2020
Tweet

Transcript

  1. Widgets Flinging The user needs to be able to not

    just swipe from one item to the next, but fling through several items.
  2. Flinging The user needs to be able to not just

    swipe from one item to the next, but fling through several items.
  3. Heterogeneous Not every ViewHolder is the same size or type

    of content. Some are only text, some are images and some are streaming video.
  4. <?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView android:id="@+id/widgetList" android:layout_width="match_parent" android:layout_height="match_parent" tools:listitem="@layout/widget_photo_item" /> </FrameLayout> activity_main.xml FrameLayout widgetList Component Tree
  5. android:layout_height="match_parent" tools:context=".MainActivity"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/widgetList" android:layout_width="match_parent" android:layout_height="match_parent" tools:listitem="@layout/widget_photo_item" /> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent"

    android:layout_height="match_parent" android:clickable="true" android:focusable="true" > </androidx.constraintlayout.widget.ConstraintLayout> </FrameLayout> activity_main.xml FrameLayout widgetList ConstraintLayout Component Tree
  6. android:layout_height="match_parent" tools:listitem="@layout/widget_photo_item" /> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent" android:clickable="true" android:focusable="true" > <View

    android:id="@+id/overlay" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/overlayOnSurface" /> </androidx.constraintlayout.widget.ConstraintLayout> </FrameLayout> activity_main.xml FrameLayout widgetList ConstraintLayout overlay Component Tree
  7. android:background="@color/overlayOnSurface" /> <include android:id="@+id/photoPlaceholder" layout="@layout/widget_photo_item" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"

    app:layout_constraintBottom_toBottomOf="parent" /> <include android:id="@+id/textPlaceholder" layout="@layout/widget_text_item" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout> </FrameLayout> activity_main.xml FrameLayout widgetList ConstraintLayout overlay photoPlaceholder textPlaceholder Component Tree
  8. android:layout_height="match_parent" tools:listitem="@layout/widget_photo_item" /> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent" android:clickable="true" android:focusable="true" > <View

    android:id="@+id/overlay" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/overlayOnSurface" /> <include android:id="@+id/photoPlaceholder" layout="@layout/widget_photo_item" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent" /> <include activity_main.xml Component Tree FrameLayout widgetList ConstraintLayout overlay photoPlaceholder textPlaceholder
  9. android:layout_height="match_parent" tools:listitem="@layout/widget_photo_item" /> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent" android:clickable="true" android:focusable="true" > <View

    android:id="@+id/overlay" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/overlayOnSurface" /> <include android:id="@+id/photoPlaceholder" layout="@layout/widget_photo_item" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent" /> <include activity_main.xml Component Tree FrameLayout widgetList ConstraintLayout overlay photoPlaceholder textPlaceholder
  10. android:layout_height="match_parent" tools:listitem="@layout/widget_photo_item" /> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent" android:clickable="true" android:focusable="true" > <View

    android:id="@+id/overlay" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/overlayOnSurface" /> <include android:id="@+id/photoPlaceholder" layout="@layout/widget_photo_item" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent" /> <include activity_main.xml Component Tree FrameLayout widgetList ConstraintLayout overlay photoPlaceholder textPlaceholder
  11. android:layout_height="match_parent" tools:listitem="@layout/widget_photo_item" /> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent" android:clickable="true" android:focusable="true" > <View

    android:id="@+id/overlay" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/overlayOnSurface" /> <include android:id="@+id/photoPlaceholder" layout="@layout/widget_photo_item" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent" /> <include activity_main.xml Component Tree FrameLayout widgetList ConstraintLayout overlay photoPlaceholder textPlaceholder
  12. <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent" android:clickable="true" android:focusable="true" > <View android:id="@+id/overlay" android:layout_width="match_parent" android:layout_height="match_parent"

    android:background="@color/overlayOnSurface" /> <include android:id="@+id/photoPlaceholder" layout="@layout/widget_photo_item" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent" /> activity_main.xml Component Tree FrameLayout widgetList ConstraintLayout overlay photoPlaceholder textPlaceholder
  13. <androidx.constraintlayout.motion.widget.MotionLayout android:id="@+id/constraintLayout" android:layout_width="match_parent" android:layout_height="match_parent" android:clickable="true" android:focusable="true" app:layoutDescription="@xml/activity_main_xml_constraintlayout_scene" > <View android:id="@+id/overlay"

    android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/overlayOnSurface" /> <include android:id="@+id/photoPlaceholder" layout="@layout/widget_photo_item" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent" /> activity_main.xml Component Tree FrameLayout widgetList overlay photoPlaceholder textPlaceholder constraintLayout
  14. activity_main.xml <androidx.constraintlayout.motion.widget.MotionLayout android:id="@+id/constraintLayout" android:layout_width="match_parent" android:layout_height="match_parent" android:clickable="true" android:focusable="true" app:layoutDescription="@xml/activity_main_xml_constraintlayout_scene" > <View

    android:id="@+id/overlay" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/overlayOnSurface" /> <include android:id="@+id/photoPlaceholder" layout="@layout/widget_photo_item" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent" />
  15. <ConstraintSet android:id="@+id/list"> </ConstraintSet> <ConstraintSet android:id="@+id/detail"> <Constraint android:id="@id/photoPlaceholder"> <Layout android:layout_width="match_parent" android:layout_height="wrap_content"

    app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintVertical_bias="0.35" /> <Transform android:scaleX="0.9" android:scaleY="0.9" /> <PropertySet app:visibilityMode="ignore" /> </Constraint> <Constraint android:id="@id/textPlaceholder" .../> </ConstraintSet> </MotionScene> main_scene.xml
  16. <ConstraintSet android:id="@+id/list"> </ConstraintSet> <ConstraintSet android:id="@+id/detail"> <Constraint android:id="@id/photoPlaceholder"> <Layout android:layout_width="match_parent" android:layout_height="wrap_content"

    app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintVertical_bias="0.35" /> <Transform android:scaleX="0.9" android:scaleY="0.9" /> <PropertySet app:visibilityMode="ignore" /> </Constraint> <Constraint android:id="@id/textPlaceholder" .../> </ConstraintSet> </MotionScene> main_scene.xml
  17. 0 20 40 60 80 100 ➟ Transition Motion Layout

    end start activity_main.xml main_scene.xml
  18. 0 20 40 60 80 100 ➟ Transition Motion Layout

    end start activity_main.xml main_scene.xml
  19. class MainActivity : AppCompatActivity(R.layout.activity_main) { private val ui: ActivityMainBinding by

    lazy { ... } private var clicks = Channel<Int>(1) private var placeholderSource: ViewBinding? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val view = ui.root setContentView(view) ui.widgetList.adapter = WidgetAdapter(clicks) } override fun onResume() { super.onResume() MainActivity.kt
  20. private var clicks = Channel<Int>(1) private var placeholderSource: ViewBinding? =

    null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val view = ui.root setContentView(view) ui.widgetList.adapter = WidgetAdapter(clicks) } override fun onResume() { super.onResume() clicks.consumeAsFlow() .onEach { onWidgetTapped(getWidgetViewHolder(it)) } .launchIn(lifecycleScope) } private fun getWidgetViewHolder(position: Int): MainActivity.kt
  21. ui.widgetList.adapter = WidgetAdapter(clicks) } override fun onResume() { super.onResume() clicks.consumeAsFlow()

    .onEach { onWidgetTapped(getWidgetViewHolder(it)) } .launchIn(lifecycleScope) } private fun getWidgetViewHolder(position: Int): WidgetViewHolder<*>? { return ui.widgetList.activeViewHolders(onlyVisible = true) .filterIsInstance<WidgetViewHolder<*>>() .firstOrNull { it.adapterPosition == position } } private fun onWidgetTapped(viewHolder: WidgetViewHolder<*>?) { if (viewHolder == null) return MainActivity.kt
  22. .firstOrNull { it.adapterPosition == position } } private fun onWidgetTapped(viewHolder:

    WidgetViewHolder<*>?) { if (viewHolder == null) return val placeholder = when (viewHolder) { is WidgetPhotoViewHolder -> { ui.textPlaceholder.root.isVisible = false ui.photoPlaceholder } is WidgetTextViewHolder -> { ui.photoPlaceholder.root.isVisible = false ui.textPlaceholder } else -> return } ui.motionLayout.enterPlaceholder( MainActivity.kt
  23. } else -> return } ui.motionLayout.enterPlaceholder( listView = ui.widgetList, source

    = viewHolder.binding, placeholder = placeholder, startState = R.id.list, endState = R.id.detail) placeholderSource = viewHolder.binding } override fun onBackPressed() { val source = placeholderSource if (source != null) { ui.motionLayout.exitPlaceholder(source) { placeholderSource = null } MainActivity.kt
  24. placeholderSource = viewHolder.binding } override fun onBackPressed() { val source

    = placeholderSource if (source != null) { ui.motionLayout.exitPlaceholder(source) { placeholderSource = null } ui.motionLayout.transitionToState(R.id.list) } else { super.onBackPressed() } } } MainActivity.kt
  25. fun <T : ViewBinding> MotionLayout.enterPlaceholder( listView: View, source: T, placeholder:

    T, startState: Int, endState: Int, completion: () -> Unit = {}) { // Create Bitmap from source Canvas // TODO: Very inefficient, please fix placeholder.applyBindingToPlaceholder(source) // Updates starting state with constraints that match the source val profileState = calculatePlaceholderConstraints(placeholder.root.id, source, listView) updateState(startState, profileState) setTransition(startState, endState) isVisible = true placeholder.root.isVisible = true MotionExtensions.kt MainActivity.kt
  26. startState: Int, endState: Int, completion: () -> Unit = {})

    { // Create Bitmap from source Canvas // TODO: Very inefficient, please fix placeholder.applyBindingToPlaceholder(source) // Updates starting state with constraints that match the source val profileState = calculatePlaceholderConstraints(placeholder.root.id, source, listView) updateState(startState, profileState) setTransition(startState, endState) isVisible = true placeholder.root.isVisible = true after(completion) transitionToEnd() source.root.alpha = 0f } MotionExtensions.kt MainActivity.kt
  27. // Create Bitmap from source Canvas // TODO: Very inefficient,

    please fix placeholder.applyBindingToPlaceholder(source) // Updates starting state with constraints that match the source val profileState = calculatePlaceholderConstraints(placeholder.root.id, source, listView) updateState(startState, profileState) setTransition(startState, endState) isVisible = true placeholder.root.isVisible = true after(completion) transitionToEnd() source.root.alpha = 0f } MotionExtensions.kt MainActivity.kt
  28. // Create Bitmap from source Canvas // TODO: Very inefficient,

    please fix placeholder.applyBindingToPlaceholder(source) Assessment
  29. LayoutManager detaching views ✅ No delay to transition ✅ Reusable

    ✅ Efficient ❌ Probably can't work with Exoplayer ❌ RecyclerView dependency
  30. fun <T : ViewBinding> MotionLayout.enterPlaceholder( listView: View, source: T, placeholder:

    T, startState: Int, endState: Int, completion: () -> Unit = {}) { // Create Bitmap from source Canvas // TODO: Very inefficient, please fix placeholder.applyBindingToPlaceholder(source) // Updates starting state with constraints that match the source val profileState = calculatePlaceholderConstraints(placeholder.root.id, source, listView) updateState(startState, profileState) setTransition(startState, endState) isVisible = true placeholder.root.isVisible = true MotionExtensions.kt
  31. source: T, placeholder: T, startState: Int, endState: Int, completion: ()

    -> Unit = {}) { // Create Bitmap from source Canvas // TODO: Very inefficient, please fix placeholder.applyBindingToPlaceholder(source) // Updates starting state with constraints that match the source val profileState = calculatePlaceholderConstraints(placeholder.root.id, source, listView) updateState(startState, profileState) setTransition(startState, endState) isVisible = true placeholder.root.isVisible = true MotionExtensions.kt
  32. source: T, placeholder: T, startState: Int, endState: Int, completion: ()

    -> Unit = {}) { // Copy attributes from source placeholder.applyBindingToPlaceholder(source) // Updates starting state with constraints that match the source val profileState = calculatePlaceholderConstraints(placeholder.root.id, source, listView) updateState(startState, profileState) setTransition(startState, endState) isVisible = true placeholder.root.isVisible = true after(completion) MotionExtensions.kt
  33. Motion Layout Assessment ✅ No delay to transition ✅ Robust

    ✅ Reusable ✅ Efficient enough for now