How to prevent custom views from losing state across screen orientation changes

Viewed 110721

I've successfully implemented onRetainNonConfigurationInstance() for my main Activity to save and restore certain critical components across screen orientation changes.

But it seems, my custom views are being re-created from scratch when the orientation changes. This makes sense, although in my case it's inconvenient because the custom view in question is an X/Y plot and the plotted points are stored in the custom view.

Is there a crafty way to implement something similar to onRetainNonConfigurationInstance() for a custom view, or do I need to just implement methods in the custom view which allow me to get and set its "state"?

10 Answers

Easy with kotlin

@Parcelize
class MyState(val superSavedState: Parcelable?, val loading: Boolean) : View.BaseSavedState(superSavedState), Parcelable


class MyView : View {

    var loading: Boolean = false

    override fun onSaveInstanceState(): Parcelable? {
        val superState = super.onSaveInstanceState()
        return MyState(superState, loading)
    }

    override fun onRestoreInstanceState(state: Parcelable?) {
        val myState = state as? MyState
        super.onRestoreInstanceState(myState?.superSaveState ?: state)

        loading = myState?.loading ?: false
        //redraw
    }
}

I had the problem that onRestoreInstanceState restored all my custom views with the state of the last view. I solved it by adding these two methods to my custom view:

@Override
protected void dispatchSaveInstanceState(SparseArray<Parcelable> container) {
    dispatchFreezeSelfOnly(container);
}

@Override
protected void dispatchRestoreInstanceState(SparseArray<Parcelable> container) {
    dispatchThawSelfOnly(container);
}

To augment other answers - if you have multiple custom compound views with the same ID and they are all being restored with the state of the last view on a configuration change, all you need to do is tell the view to only dispatch save/restore events to itself by overriding a couple of methods.

class MyCompoundView : ViewGroup {

    ...

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

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

For an explanation of what is happening and why this works, see this blog post. Basically your compound view's children's view IDs are shared by each compound view and state restoration gets confused. By only dispatching state for the compound view itself, we prevent their children from getting mixed messages from other compound views.

I found that this answer was causing some crashes on Android versions 9 and 10. I think it's a good approach but when I was looking at some Android code I found out it was missing a constructor. The answer is quite old so at the time there probably was no need for it. When I added the missing constructor and called it from the creator the crash was fixed.

So here is the edited code:

public class CustomView extends LinearLayout {

    private int stateToSave;

    ...

    @Override
    public Parcelable onSaveInstanceState() {
        Parcelable superState = super.onSaveInstanceState();
        SavedState ss = new SavedState(superState);

        // your custom state
        ss.stateToSave = this.stateToSave;

        return ss;
    }

    @Override
    protected void dispatchSaveInstanceState(SparseArray<Parcelable> container)
    {
        dispatchFreezeSelfOnly(container);
    }

    @Override
    public void onRestoreInstanceState(Parcelable state) {
        SavedState ss = (SavedState) state;
        super.onRestoreInstanceState(ss.getSuperState());

        // your custom state
        this.stateToSave = ss.stateToSave;
    }

    @Override
    protected void dispatchRestoreInstanceState(SparseArray<Parcelable> container)
    {
        dispatchThawSelfOnly(container);
    }

    static class SavedState extends BaseSavedState {
        int stateToSave;

        SavedState(Parcelable superState) {
            super(superState);
        }

        private SavedState(Parcel in) {
            super(in);
            this.stateToSave = in.readInt();
        }

        // This was the missing constructor
        @RequiresApi(Build.VERSION_CODES.N)
        SavedState(Parcel in, ClassLoader loader)
        {
            super(in, loader);
            this.stateToSave = in.readInt();
        }

        @Override
        public void writeToParcel(Parcel out, int flags) {
            super.writeToParcel(out, flags);
            out.writeInt(this.stateToSave);
        }    
        
        public static final Creator<SavedState> CREATOR =
            new ClassLoaderCreator<SavedState>() {
          
            // This was also missing
            @Override
            public SavedState createFromParcel(Parcel in, ClassLoader loader)
            {
                return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N ? new SavedState(in, loader) : new SavedState(in);
            }

            @Override
            public SavedState createFromParcel(Parcel in) {
                return new SavedState(in, null);
            }

            @Override
            public SavedState[] newArray(int size) {
                return new SavedState[size];
            }
        };
    }
}

Instead of using onSaveInstanceState and onRestoreInstanceState, you can also use a ViewModel. Make your data model extend ViewModel, and then you can use ViewModelProviders to get the same instance of your model every time the Activity is recreated:

class MyData extends ViewModel {
    // have all your properties with getters and setters here
}

public class MyActivity extends FragmentActivity {
    @Override
    public void onCreate(Bundle savedInstanceState) {

        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // the first time, ViewModelProvider will create a new MyData
        // object. When the Activity is recreated (e.g. because the screen
        // is rotated), ViewModelProvider will give you the initial MyData
        // object back, without creating a new one, so all your property
        // values are retained from the previous view.
        myData = ViewModelProviders.of(this).get(MyData.class);

        ...
    }
}

To use ViewModelProviders, add the following to dependencies in app/build.gradle:

implementation "android.arch.lifecycle:extensions:1.1.1"
implementation "android.arch.lifecycle:viewmodel:1.1.1"

Note that your MyActivity extends FragmentActivity instead of just extending Activity.

You can read more about ViewModels here:

Based on @Fletcher Johns answer I came up with:

  • custom layout
  • can inflate from XML
  • is able to save/restore direct and indirect children. I improved @Fletcher Johns' answer to save the ids in String->Id map instead of IntArray.
  • the only small drawback is that you must declare your saveable child views beforehand.

open class AddressView @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = 0,
        defStyleRes: Int = 0
) : LinearLayout(context, attrs, defStyleAttr, defStyleRes) {

    protected lateinit var countryInputLayout: TextInputLayout
    protected lateinit var countryAutoCompleteTextView: CountryAutoCompleteTextView
    protected lateinit var cityInputLayout: TextInputLayout
    protected lateinit var cityEditText: CityEditText
    protected lateinit var postCodeInputLayout: TextInputLayout
    protected lateinit var postCodeEditText: PostCodeEditText
    protected lateinit var streetInputLayout: TextInputLayout
    protected lateinit var streetEditText: StreetEditText
    
    init {
        initView()
    }

    private fun initView() {
        val view = inflate(context, R.layout.view_address, this)

        orientation = VERTICAL

        countryInputLayout = view.findViewById(R.id.countryInputLayout)
        countryAutoCompleteTextView = view.findViewById(R.id.countryAutoCompleteTextView)

        streetInputLayout = view.findViewById(R.id.streetInputLayout)
        streetEditText = view.findViewById(R.id.streetEditText)

        cityInputLayout = view.findViewById(R.id.cityInputLayout)
        cityEditText = view.findViewById(R.id.cityEditText)

        postCodeInputLayout = view.findViewById(R.id.postCodeInputLayout)
        postCodeEditText = view.findViewById(R.id.postCodeEditText)
    }

    // Declare your direct and indirect child views that need to be saved
    private val childrenToSave get() = mapOf<String, View>(
            "coutryIL" to countryInputLayout,
            "countryACTV" to countryAutoCompleteTextView,
            "streetIL" to streetInputLayout,
            "streetET" to streetEditText,
            "cityIL" to cityInputLayout,
            "cityET" to cityEditText,
            "postCodeIL" to postCodeInputLayout,
            "postCodeET" to postCodeEditText,
    )
    private var viewIds: HashMap<String, Int>? = null

    override fun onSaveInstanceState(): Parcelable? {
        // Create a bundle to put super parcelable in
        val bundle = Bundle()
        bundle.putParcelable(SUPER_INSTANCE_STATE, super.onSaveInstanceState())
        // Store viewIds in the bundle - initialize if necessary.
        if (viewIds == null) {
            childrenToSave.values.forEach { view -> view.id = generateViewId() }
            viewIds = HashMap<String, Int>(childrenToSave.mapValues { (key, view) -> view.id })
        }

        bundle.putSerializable(STATE_VIEW_IDS, viewIds)

        return bundle
    }

    override fun onRestoreInstanceState(state: Parcelable?) {
        // We know state is a Bundle:
        val bundle = state as Bundle
        // Get mViewIds out of the bundle
        viewIds = bundle.getSerializable(STATE_VIEW_IDS) as HashMap<String, Int>
        // For each id, assign to the view of same index
        if (viewIds != null) {
            viewIds!!.forEach { (key, id) -> childrenToSave[key]!!.id = id }
        }
        super.onRestoreInstanceState(bundle.getParcelable(SUPER_INSTANCE_STATE))
    }

    companion object {
        private const val SUPER_INSTANCE_STATE = "saved_instance_state_parcelable"
        private const val STATE_VIEW_IDS = "state_view_ids"
    }
}
Related