Search code examples
androidkotlinviewandroid-view

How to Correctly Save the State of a Custom View in Android


At the very beginning, let's try to answer the question - why should we actually save the state of the views?

Imagine a situation in which you fill a large questionnaire in a certain application on your smartphone . At some point, you accidentally rotate the screen or you want to check something in another application and, after returning to the questionnaire, it turns out that all the fields are empty. Such a situation effectively discourage users from using the application. For the purposes of this example, let's create a view:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal">

    <androidx.appcompat.widget.SwitchCompat
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <EditText
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1" />
</LinearLayout>

And its corresponding class, that inflates the xml:

class CustomSwitchViewNoId @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {

    init {
        LayoutInflater.from(context).inflate(R.layout.view_custom_switch_no_id, this)
    }
}

Instead of rotating the screen every time to check the effect, we can enable the appropriate option in the developer settings of the phone (Settings -> Developer options -> Don’t keep activities -> turn it on).

enter image description here

As you can see - the state has not been saved. Let’s recall the hierarchy that is called by Android to save and read the state:

Save state:

  • saveHierarchyState(SparseArray container)
  • dispatchSaveInstanceState(SparseArray container)
  • onSaveInstanceState()

Restore state:

  • restoreHierarchyState(SparseArray container)
  • dispatchRestoreInstanceState(SparseArray container)
  • onRestoreInstanceState(Parcelable state)

Let’s see the internal implementation of saveHierarchyState:

public void saveHierarchyState(SparseArray container) {
    dispatchSaveInstanceState(container);
}

Which leads us to dispatchSaveInstanceState:

@Override
protected void dispatchSaveInstanceState(SparseArray<Parcelable> container) {
    super.dispatchSaveInstanceState(container);
    final int count = mChildrenCount;
    final View[] children = mChildren;
    for (int i = 0; i < count; i++) {
        View c = children[i];
        if ((c.mViewFlags & PARENT_SAVE_DISABLED_MASK) != PARENT_SAVE_DISABLED) {
            c.dispatchSaveInstanceState(container);
        }
    }
}

That calls for the container and every child dispatchSaveInstanceState(container):

protected void dispatchSaveInstanceState(SparseArray<Parcelable> container) {
    if (mID != NO_ID && (mViewFlags & SAVE_DISABLED_MASK) == 0) {
        mPrivateFlags &= ~PFLAG_SAVE_STATE_CALLED;
        Parcelable state = onSaveInstanceState();
        if ((mPrivateFlags & PFLAG_SAVE_STATE_CALLED) == 0) {
            throw new IllegalStateException(
                    "Derived class did not call super.onSaveInstanceState()");
        }
        if (state != null) {
            // Log.i("View", "Freezing #" + Integer.toHexString(mID)
            // + ": " + state);
            container.put(mID, state);
        }
    }
}

As we can see in the 3rd line, there is check if the view has an ID assigned to call onSaveInstanceState() and put the state into the container, where the ID is a key.

Let's add the needed ID and see what happens:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal">

    <androidx.appcompat.widget.SwitchCompat
        android:id="@+id/customViewSwitchCompat"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <EditText
        android:id="@+id/customViewEditText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1" />
</LinearLayout>

enter image description here

It works! Great, let’s add two more our custom views. In the end, we created this whole thing so as not to have to duplicate the code and check if everything is still working.

enter image description here

So the problems is the when including multiple view, their state is not correctly restored. And it looks as if all views retrieve the state of the last child. How can we solve this problem?


Solution

  • Well, looking at the implementation of saveHierarchyState and dispatchSaveInstanceState, we can observe that every state is stored in one container and is shared for the entire view hierarchy. Let's draw up the current hierarchy:

    enter image description here

    As you can see, the tags @+id/customViewSwitch and @+id/customViewEditText and are repeated, so the generated sparse array saves the children of @+id/switch1, then @+id/switch2, and finally @+id/switch3.

    From the documentation, we are able to learn that SparseArray is:

    SparseArray maps integers to Objects and, unlike a normal array of Objects, its indices can contain gaps. SparseArray is intended to be more memory-efficient than a HashMap , because it avoids auto-boxing keys and its data structure doesn’t rely on an extra entry object for each mapping.

    Let's check how the container behaves when we pass the same key again:

    val array = SparseArray()
    array.put(1, "test1")
    array.put(1, "test2")
    array.put(1, "test3")
    Log.i("TAG", array.toString())
    

    The result is:

    I/TAG: {1=test3}
    

    So new values overwrite the previous ones. Considering the fact that the key in the previous case is ID, this explains why each of the views received the state of the last child.

    enter image description here


    Here is one solution to this problem.

    At the beginning, we should overwrite 2 callbacks: dispatchSaveInstanceState and dispatchRestoreInstanceState:

    override fun dispatchSaveInstanceState(container: SparseArray<Parcelable>) {
        dispatchFreezeSelfOnly(container)
    }
    
    override fun dispatchRestoreInstanceState(container: SparseArray<Parcelable>) {
        dispatchThawSelfOnly(container)
    }
    

    This way, super.onSaveInstanceState() will only return the super state, avoiding children views.

    We are now ready to handle the state inside onSaveInstanceState and onRestoreInstanceState. Let’s start with the saving process:

    override fun onSaveInstanceState(): Parcelable? {
        return saveInstanceState(super.onSaveInstanceState())
    }
    

    Now It’s time to restore the state:

    override fun onRestoreInstanceState(state: Parcelable?) {
        super.onRestoreInstanceState(restoreInstanceState(state))
    }
    

    And here are the Viewgroup extenstion functions

    fun ViewGroup.saveChildViewStates(): SparseArray<Parcelable> {
        val childViewStates = SparseArray<Parcelable>()
        children.forEach { child -> child.saveHierarchyState(childViewStates) }
        return childViewStates
    }
    
    fun ViewGroup.restoreChildViewStates(childViewStates: SparseArray<Parcelable>) {
        children.forEach { child -> child.restoreHierarchyState(childViewStates) }
    }
    
    fun ViewGroup.saveInstanceState(state: Parcelable?): Parcelable? {
        return Bundle().apply {
            putParcelable("SUPER_STATE_KEY", state)
            putSparseParcelableArray("SPARSE_STATE_KEY", saveChildViewStates())
        }
    }
    
    fun ViewGroup.restoreInstanceState(state: Parcelable?): Parcelable? {
        var newState = state
        if (newState is Bundle) {
            val childrenState = newState.getSparseParcelableArray<Parcelable>("SPARSE_STATE_KEY")
            childrenState?.let { restoreChildViewStates(it) }
            newState = newState.getParcelable("SUPER_STATE_KEY")
        }
        return newState
    }
    

    In this example, we call the restoreHierarchyState function for each child to which I pass the previously saved SparseArray. Let’s check how the hierarchy looks now:

    enter image description here

    And now here is the result:

    enter image description here

    Everything works well! This is one of the ways to save the state of your own view, but there is another interesting solution using the BaseSavedState class. You can learn more from the original blog post by Marcin Stramowski