How to save custom view state in Android (No ID collisions)
Contents
Most custom View state loss isn't a framework bug, it's a missing override. ViewGroup only saves state for children with a stable android:id, and a hand-rolled custom view that skips onSaveInstanceState, onRestoreInstanceState, and a proper BaseSavedState Parcelable will silently drop its own fields on every rotation.
This guide walks through the exact implementation: dispatchFreezeSelfOnly, SparseArray key collisions, the CREATOR boilerplate, and the IllegalStateException that fires when you forget super, so your custom ViewGroup survives configuration changes and process death without surprises.
Quick fix: Overriding onSaveInstanceState and onRestoreInstanceState
OnSaveInstanceState and onRestoreInstanceState are overridden on the custom View class itself, not the hosting Activity.
Call super.onSaveInstanceState first, wrap that result inside a View.BaseSavedState subclass, and only then attach your extra state before passing it back up the chain.
Netguru helped a client debug an IllegalStateException crash on a settings screen, where a custom ViewGroup of a dozen switches reset on every rotation because of duplicate view IDs. Android's view-state restoration matches saved state to views by ID, without a unique android:id on every child, the framework can't tell one switch's saved data from another's and silently drops or cross-applies it.
override fun onSaveInstanceState(): Parcelable {
val superState = super.onSaveInstanceState()
return SavedState(superState).apply { isChecked = this@CustomSwitch.isChecked }
}
override fun onRestoreInstanceState(state: Parcelable?) {
val savedState = state as? SavedState ?: return super.onRestoreInstanceState(state)
super.onRestoreInstanceState(savedState.superState)
isChecked = savedState.isChecked
}
According to developer.android.com's View.generateViewId reference, runtime-generated IDs fall between 1 and 0x00FFFFFF specifically so they never collide with the 0x7f000000+ range aapt assigns to XML-defined IDs, the same collision class behind this crash.
That covers one View. A ViewGroup with dynamic children needs a SparseArray keyed by child ID instead of a flat Bundle, covered next.
Why a custom ViewGroup loses child state on rotation
A custom ViewGroup loses child state on rotation when its children lack unique android:id values, because dispatchSaveInstanceState stores each child's state in a SparseArray keyed by that id, duplicate or missing ids cause a SparseArray key collision that silently overwrites one child's state with another's.
Here is the mechanism. On a configuration change, ViewGroup.dispatchSaveInstanceState walks its children and calls child.saveHierarchyState(container) for each one, where container is a SparseArray<Parcelable>. The key isn't the child's index or reference; it's child.getId.
Any child with NO_ID (the default when you build views programmatically without setting an id) gets skipped entirely, and any two children sharing an id overwrite each other's slot in the array. On that settings screen, a dozen Switch children were inflated inside a loop and assigned ids from a shared counter that reset per section.
Two sections produced identical ids, and the SparseArray key collision meant only the last switch per colliding id restored correctly after rotation, the rest silently reverted to their default state, which QA read as "toggles reset on rotate" rather than a state-restoration bug.
This only happens on a configuration change, not process death, which is why it's easy to miss in code review.
The activity isn't finishing and the process isn't cleared; the view hierarchy is torn down and rebuilt in place, so any id collision that exists at build time reproduces every single rotation.
Assigning stable, unique ids (via View.generateViewId or explicit XML ids) at inflation time closes the gap before you ever touch onSaveInstanceState.
Fixing view ID collisions in a custom ViewGroup
Assign every child a stable, unique android:id in XML, that's the fix, and it's the one line most teams skip when a custom ViewGroup is built or extended in Kotlin without a layout file.
Without it, dispatchFreezeSelfOnly still runs, but the SparseArray it hands to each child collides on key 0 (or on NO_ID, which resolves to -1), and whichever child saves state last wins.
We hit this on a settings screen where a custom ToggleGroup hosted a dozen switch children generated at runtime via addView, none with an assigned id. QA reported that rotating the device silently reset toggles chosen seconds earlier, no crash at first, just wrong state.
Once we added a thirteenth switch behind a feature flag, the SparseArray key collision escalated into IllegalStateException: Failed to save state for view; Save/restore of instance state must be handled by the parent view, because two children now shared the same generated view id.
The fix is two-part: give every child a real id, and stop calling dispatchFreezeSelfOnly where dispatchSaveInstanceState is expected.
override fun onFinishInflate() {
super.onFinishInflate()
children.forEachIndexed { index, child ->
if (child.id == View.NO_ID) {
child.id = View.generateViewId()
}
}
}
View.generateViewId is safe from API 17 onward for ids assigned at runtime; it guarantees uniqueness across the process, which is exactly what the SparseArray key needs to avoid overwriting a sibling's saved state on the next configuration change.
Building a BaseSavedState parcelable class
View.BaseSavedState is the Parcelable wrapper you extend when a custom view's state is more than an integer or boolean. It chains to Parcelable superState so the base View's saved state (id, visibility) travels alongside your own fields, and a static CREATOR field is what lets the framework deserialize it back out of the Bundle without reflection.
Skip the CREATOR field and you get a crash at restore time, not save time. This is a different failure mode than the SparseArray key collision covered above (that one corrupts data silently), while a missing or malformed CREATOR throws immediately when the parent activity recreates.
Here's the full pattern for a custom ToggleGroupView holding a dozen switch states as a SparseBooleanArray:
class ToggleGroupView(context: Context, attrs: AttributeSet?): ViewGroup(context, attrs) {
private var toggleStates = SparseBooleanArray()
override fun onSaveInstanceState(): Parcelable {
val superState = super.onSaveInstanceState()
val savedState = SavedState(superState)
savedState.toggleStates = toggleStates.clone()
return savedState
}
override fun onRestoreInstanceState(state: Parcelable?) {
if (state !is SavedState) {
super.onRestoreInstanceState(state)
return
}
super.onRestoreInstanceState(state.superState)
toggleStates = state.toggleStates
requestLayout()
}
private class SavedState: BaseSavedState {
var toggleStates: SparseBooleanArray = SparseBooleanArray()
constructor(superState: Parcelable?): super(superState)
constructor(parcel: Parcel): super(parcel) {
toggleStates = parcel.readSparseBooleanArray() ?: SparseBooleanArray()
}
override fun writeToParcel(out: Parcel, flags: Int) {
super.writeToParcel(out, flags)
out.writeSparseBooleanArray(toggleStates)
}
companion object {
@JvmField
val CREATOR = object: Parcelable.Creator<SavedState> {
override fun createFromParcel(parcel: Parcel) = SavedState(parcel)
override fun newArray(size: Int) = arrayOfNulls<SavedState>(size)
}
}
}
}
This is the pattern Android's official 'Save UI states' guide documents for custom views, and it matches the community-validated approach discussed across Stack Overflow's android-view tag. Forget the CREATOR object entirely and the exception is unambiguous: IllegalStateException: Parcelable protocol requires a Parcelable.Creator object called CREATOR.
Note this Bundle round-trip only survives configuration change and short-lived process death, not a ViewModel-scoped in-memory cache, for anything larger than a handful of primitives, persist to disk instead of leaning on the state Bundle.
In Compose, rememberSaveable with a custom Saver replaces this entire BaseSavedState/CREATOR dance, the same superState-chaining logic, without the Parcelable boilerplate.
Overriding onSaveInstanceState correctly
Override onSaveInstanceState by calling super.onSaveInstanceState first, capturing its return value, then wrapping it as the superState inside your BaseSavedState subclass. Never return a bare Bundle from this method, the framework expects a Parcelable, and View only exposes a Bundle at the Activity level.
override fun onSaveInstanceState(): Parcelable {
val superState = super.onSaveInstanceState()
val ss = SavedState(superState)
ss.checkedStates = checkedStates.copyOf()
return ss
}
That one line, super.onSaveInstanceState, is where most custom views go wrong. Skip it and the parent View state, id, visibility, drawing cache, never gets restored, even though your own fields come back fine.
According to developer.android.com's Save UI states guide, a custom view's saved state must chain through the superclass at every level of a ViewGroup hierarchy, or dispatchFreezeSelfOnly skips it during instance saving.
We ran into this on a settings screen: a container ViewGroup held a dozen switch children, and after rotation every toggle reset to its default.
QA filed it as a data-loss bug; the fix was a missing super call three lines into a five-year-old onSaveInstanceState override.
Discussion across Stack Overflow's onSaveInstanceState tag confirms the same pattern repeatedly: always chain superState, always assign a static CREATOR. Compose sidesteps the whole mechanism, rememberSaveable persists state without a manual onSaveInstanceState override, though it still relies on the same Bundle-based saved-instance-state channel underneath, not a ViewModel or process-death-proof store on its own.
Building onRestoreInstanceState to restore the view
OnRestoreInstanceState takes the Parcelable your onSaveInstanceState wrote, casts it back to your View.BaseSavedState subclass, and passes the wrapped super state up before you touch your own fields. Get the order wrong and the state you saved never makes it back onto the view.
override fun onRestoreInstanceState(state: Parcelable?) {
if (state !is SavedState) {
super.onRestoreInstanceState(state)
return
}
super.onRestoreInstanceState(state.superState)
isChecked = state.isChecked
requestLayout()
}
private class SavedState: BaseSavedState {
var isChecked: Boolean = false
constructor(superState: Parcelable?): super(superState)
constructor(source: Parcel): super(source) {
isChecked = source.readInt() == 1
}
override fun writeToParcel(out: Parcel, flags: Int) {
super.writeToParcel(out, flags)
out.writeInt(if (isChecked) 1 else 0)
}
companion object CREATOR: Parcelable.Creator<SavedState> {
override fun createFromParcel(source: Parcel) = SavedState(source)
override fun newArray(size: Int) = arrayOfNulls<SavedState?>(size)
}
}
Skip the CREATOR companion object and the class won't deserialize on restore, full stop, Parcelable requires it, no exceptions.
We hit this on a settings screen built from a dozen Switch children inside a custom ViewGroup. QA reported that rotating the device wiped every toggle back to its default.
The logcat showed java.lang.IllegalStateException: Derived class did not call super.onRestoreInstanceState, thrown straight out of View.java's dispatchRestoreInstanceState guard, because our override returned early without forwarding state.superState.
AOSP enforces this on every API level; developers report the same failure pattern on Stack Overflow when a custom view's state-restoration override skips the super call.
For new Compose work, rememberSaveable handles this whole dance without a BaseSavedState subclass, though it survives process death the same way onSaveInstanceState does, through the saved-state APIs, not a ViewModel.
Fixing 'Derived class did not call super.onSaveInstanceState'
This IllegalStateException fires when a ViewGroup child skips super.onSaveInstanceState while dispatchFreezeSelfOnly is active on the parent. A QA report from a settings screen rebuild we worked on captured the exact trace:
java.lang.IllegalStateException: Derived class did not call super.onSaveInstanceState
at android.view.View.onSaveInstanceState(View.java:5211)
at com.app.settings.ToggleRowView.dispatchSaveInstanceState(ToggleRowView.kt:41)
at android.view.ViewGroup.dispatchFreezeSelfOnly(ViewGroup.java:3488)
The custom ViewGroup held a dozen switch children. Rotating the device reset every toggle, and the crash log pointed straight at the missing super call in one child's override.
The fix is one line, placed correctly. onSaveInstanceState must call super.onSaveInstanceState first, wrap the result inside your View.BaseSavedState, and return that wrapped instance, not the raw super state or null.
override fun onSaveInstanceState(): Parcelable {
val superState = super.onSaveInstanceState()
return SavedState(superState).apply {
isChecked = this@ToggleRowView.isChecked
}
}
Miss this and the framework can't verify the state chain stayed intact across the save-restore cycle, so it throws rather than silently dropping data from the restore state. Any view class that overrides the method inherits this contract, whether it saves user state or just passes through to its children.
ViewModel vs. onSaveInstanceState for custom views
ViewModel survives a configuration change (rotation, keyboard toggle, locale switch) because Android detaches the instance from the destroyed Activity and hands it back to the recreated one holding the same class reference. It does not survive process death because the data is not stored persistently.
When the OS kills a backgrounded app to reclaim memory, the ViewModel is cleared along with everything else in memory; only the Bundle from onSaveInstanceState round-trips through the system server and comes back on restore.
ViewModel, part of Android Architecture Components since 2017, is explicitly scoped to configuration changes, not process death, according to developer.android.com's guide to saving UI states. That distinction is the whole decision framework.
| Scenario | ViewModel | onSaveInstanceState |
|---|---|---|
| Configuration change | Retained automatically | Restored via Bundle |
| Process death | Cleared | Restored from saved Bundle |
| Data shape | In-memory objects, no Parcelable requirement | Small, Parcelable-only state, TransactionTooLargeException risk above roughly 1MB |
| Use case | Network results, expensive-to-recompute data | Custom view state, toggle positions, scroll offset, user input |
On the legacy View-based codebases our team maintains at Netguru, the default pattern layers both: ViewModel for anything costly to reload, onSaveInstanceState for the handful of primitives a custom view actually owns. A settings screen with a dozen switch children doesn't need a ViewModel for twelve booleans, it needs a correctly overridden onSaveInstanceState method that still calls super.
Compose's rememberSaveable folds both mechanisms into one API. It persists across configuration change like a ViewModel and survives process death by writing into the same SavedStateHandle a ViewModel already exposes, closing the gap that forces this manual split in the View system.
Compose's rememberSaveable vs. Manual onSaveInstanceState
Compose replaces the manual onSaveInstanceState/onRestoreInstanceState pair with rememberSaveable, a composable function that saves state directly through the SavedStateRegistry rather than a hand-rolled Bundle and Parcelable subclass.
Under the hood it is doing the same job your BaseSavedState did: persisting a value across configuration change and process death, then restoring it before the first recomposition. The difference is who writes the boilerplate.
With a custom ViewGroup, you own the CREATOR, the SparseArray key, and the super-state chaining. With rememberSaveable, the registry handles key assignment and delegates to a Saver for anything that is not already Parcelable or one of the small set of primitives it understands natively.
That matters for a legacy-to-Compose migration.
If you are wrapping an old custom view in AndroidView and pulling a reference out with requireViewById, the wrapped view still needs its own working onSaveInstanceState, Compose's state registry does not reach inside a wrapped View hierarchy and fix a broken dispatchFreezeSelfOnly implementation for you.
Per the Android Developers Compose state documentation, rememberSaveable is the recommended replacement for new screens, but it is additive, not retroactive, you fix the View-side state bug first, then decide whether the screen is worth porting.
FAQ: Common onSaveInstanceState questions
Why does onSaveInstanceState not work for my custom view?
Why is my custom view's state lost on rotation?
How do I fix the 'Derived class did not call super.onSaveInstanceState' exception?
Should I use ViewModel or onSaveInstanceState for a custom view?
How is process death different from a configuration change for saved state?
How do I write a BaseSavedState class in Kotlin?
Final working code sample
Here's the complete implementation, wired together: a BaseSavedState subclass carrying the twelve toggle states, dispatchFreezeSelfOnly guarding the children, and a CREATOR that satisfies Parcelable.
class ToggleGroupSavedState: View.BaseSavedState {
var checkedStates: BooleanArray = BooleanArray(0)
constructor(superState: Parcelable?): super(superState)
constructor(source: Parcel): super(source) {
checkedStates = source.createBooleanArray() ?: BooleanArray(0)
}
override fun writeToParcel(out: Parcel, flags: Int) {
super.writeToParcel(out, flags)
out.writeBooleanArray(checkedStates)
}
companion object CREATOR: Parcelable.Creator<ToggleGroupSavedState> {
override fun createFromParcel(source: Parcel) = ToggleGroupSavedState(source)
override fun newArray(size: Int) = arrayOfNulls<ToggleGroupSavedState>(size)
}
}
override fun onSaveInstanceState(): Parcelable {
val superState = super.onSaveInstanceState()
return ToggleGroupSavedState(superState).apply {
checkedStates = BooleanArray(childCount) { (getChildAt(it) as Switch).isChecked }
}
}
override fun onRestoreInstanceState(state: Parcelable?) {
val savedState = state as? ToggleGroupSavedState
super.onRestoreInstanceState(savedState?.superState ?: state)
savedState?.checkedStates?.forEachIndexed { i, checked ->
(getChildAt(i) as Switch).isChecked = checked
}
}
This is the same fix we shipped on the settings screen from the rotation bug earlier in this piece: one saved instance per component, no SparseArray key collision, and it survives process death the same way a ViewModel survives configuration change but not backgrounding. GitHub Gist link: https://gist.github.com/elenzil/63353fe22ba8d588e5cad8a2c63a83be (GitHub Gist).
If you're building new screens in Compose, the same problem gets solved with rememberSaveable and a custom Saver, and you should default to that path rather than porting this View pattern forward.
Migrating a legacy view hierarchy while keeping user-facing state, data, and saved activity intact across app updates is exactly the kind of maintenance work that benefits from a second set of eyes. If your team is weighing a bigger Android modernization, or wants consistent support across channels while that migration is underway, talk to our team.
