Search code examples
androidandroid-animationandroid-motionlayout

How to restore transition state of MotionLayout without auto-playing the transition?


My code

Activity

class SwipeHandlerActivity : AppCompatActivity(R.layout.activity_swipe_handler){
    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putBundle("Foo", findViewById<MotionLayout>(R.id.the_motion_layout).transitionState)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        savedInstanceState?.getBundle("Foo")?.let(findViewById<MotionLayout>(R.id.the_motion_layout)::setTransitionState)
    }
}

Layout

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layoutDescription="@xml/activity_swipe_handler_scene"
    android:id="@+id/the_motion_layout"
    app:motionDebug="SHOW_ALL">

    <View
        android:id="@+id/touchAnchorView"
        android:background="#8309AC"
        android:layout_width="64dp"
        android:layout_height="64dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.motion.widget.MotionLayout>

Scene

<?xml version="1.0" encoding="utf-8"?>
<MotionScene 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:motion="http://schemas.android.com/apk/res-auto">

    <Transition
        motion:constraintSetEnd="@+id/end"
        motion:constraintSetStart="@id/start"
        motion:duration="1000">

        <OnSwipe
            motion:touchAnchorId="@id/imageView"
            motion:dragDirection="dragUp"
            motion:touchAnchorSide="top" />
    </Transition>

    <ConstraintSet android:id="@+id/start">
    </ConstraintSet>

    <ConstraintSet android:id="@+id/end">
        <Constraint
            android:layout_height="250dp"
            motion:layout_constraintStart_toStartOf="@+id/textView2"
            motion:layout_constraintEnd_toEndOf="@+id/textView2"
            android:layout_width="250dp"
            android:id="@+id/imageView"
            motion:layout_constraintBottom_toTopOf="@+id/textView2"
            android:layout_marginBottom="68dp" />
    </ConstraintSet>
</MotionScene>

Observed behavior

enter image description here

Expected behavior

The motion layout stays at its start state after configuration change

Edit (hacky solution)

I ended up creating these extension functions

  fun MotionLayout.restoreState(savedInstanceState: Bundle?, key: String) {
    viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
      override fun onGlobalLayout() {
        doRestore(savedInstanceState, key)
        viewTreeObserver.removeOnGlobalLayoutListener(this)
      }
    })
  }

  private fun MotionLayout.doRestore(savedInstanceState: Bundle?, key: String) =
    savedInstanceState?.let {
      val motionBundle = savedInstanceState.getBundle(key) ?: error("$key state was not saved")
      setTransition(
        motionBundle.getInt("claptrap.motion.startState", -1)
          .takeIf { it != -1 }
          ?: error("Could not retrieve start state for $key"),
        motionBundle.getInt("claptrap.motion.endState", -1)
          .takeIf { it != -1 }
          ?: error("Could not retrieve end state for $key")
      )
      progress = motionBundle.getFloat("claptrap.motion.progress", -1.0f)
        .takeIf { it != -1.0f }
        ?: error("Could not retrieve progress for $key")
    }

  fun MotionLayout.saveState(outState: Bundle, key: String) {
    outState.putBundle(
      key,
      bundleOf(
        "claptrap.motion.startState" to startState,
        "claptrap.motion.endState" to endState,
        "claptrap.motion.progress" to progress
      )
    )
  }

Then I called them like this:

onCreate, onCreateView

    if (savedInstanceState != null) {
      binding.transactionsMotionLayout.restoreState(savedInstanceState, MOTION_LAYOUT_STATE_KEY)
    }

onSaveInstanceState

    binding.transactionsMotionLayout.saveState(outState, MOTION_LAYOUT_STATE_KEY)

This resulted in the expected behavior for both MotionLayouts in Activitys, and MotionLayouts inside Fragments. But I'm not happy with the amount of code required, so if anyone could suggest a cleaner solution, I would be really happy to hear that :)


Solution

  • I don't see why you haven't done that but you need to extend MotionLayout to properly save the view state.

    Your current solution (aside from requiring extra handling in activity and fragment layer) will fail in case of detachable fragments because they handle views save state internally without actually populating savedInstanceState (quite confusing I know).

    open class SavingMotionLayout @JvmOverloads constructor(
            context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
    ) : MotionLayout(context, attrs, defStyleAttr) {
    
        override fun onSaveInstanceState(): Parcelable {
            return SaveState(super.onSaveInstanceState(), startState, endState, targetPosition)
        }
    
        override fun onRestoreInstanceState(state: Parcelable?) {
            (state as? SaveState)?.let {
                super.onRestoreInstanceState(it.superParcel)
                setTransition(it.startState, it.endState)
                progress = it.progress
            }
        }
    
        @kotlinx.android.parcel.Parcelize
        private class SaveState(
                val superParcel: Parcelable?,
                val startState: Int,
                val endState: Int,
                val progress: Float
        ) : Parcelable
    }
    

    You will need to use this class in your XML instead of MotionLayout however its less prone to errors and will respect proper view state saving mechanisms so you do not need to add any extra code to activities or fragments anymore.

    If you want to disable saving you can do it with android:saveEnabled="false" in XML or isSaveEnabled = false in code.