XML Jetpack

XML to Compose Migration: Handling Fragment Dismiss Crash in Jetpack Compose

 

Our team is migrating legacy XML-based screens to Jetpack Compose as part of a broader strategy to adopt a modern Android architecture. This transition offers clear advantages, including faster development, simpler UI maintenance, and more declarative code. However, moving to Compose requires a fresh approach to how UI, state, and lifecycle interact, particularly where Compose interfaces with traditional Android components like Fragment.

A particularly challenging issue we encountered was a crash that occurred during fragment dismissal, specifically when Compose was utilized within a BottomSheetDialogFragment. This difficult-to-reproduce edge case was crucial to address for a stable user experience.

The Crash: Compose & Fragment Lifecycle Clash

We encountered an intermittent crash when dismissing a Compose-based BottomSheetDialogFragment. Here’s the common sequence that caused the issue.

A crash occurred due to a fragment attempting to dismiss itself during an active recomposition. This was triggered by two simultaneous events: a user tapping the close button, which called dismiss(), and a UI interaction, such as a state change from input or an item toggle, which triggered recomposition.

Exception Example

java.lang.IllegalStateException: The specified child already has a parent. You must call at android.view.ViewGroup.addViewInner (ViewGroup.java:5200)

at android.view.ViewGroup.addView(ViewGroup.java:5038)

at androidx.compose.ui.platform.AndroidComposeView.onLayout (AndroidComposeView.android at android.view.View.layout(View.java:25053)...

 

The Compose UI tried to recompose content inside a fragment that was in the middle of being dismissed — leading to an invalid view hierarchy.

Root Cause: Mismatch Between State and Lifecycle

Jetpack Compose is designed around state-driven UI, where changes in state automatically drive UI recomposition. Traditional Android views and fragments, however, rely heavily on lifecycle-driven interactions.Jetpack Compose employs a state-driven UI paradigm, automatically triggering UI recomposition in response to state changes. In contrast, traditional Android views and fragments are largely dependent on lifecycle-driven interactions.

This mismatch means that:

  • Compose may attempt to recompose content while the Fragment or its view is in the process of being destroyed 
  • dismiss() can interrupt an ongoing recomposition, leading to inconsistencies and crashes

This presents a particular challenge within modal components such as BottomSheetDialogFragment, where dismissals and state alterations often coincide.

The Fix: State-Driven Dismiss Strategy

To avoid conflicts between recomposition and dismissal, we separated the dismiss() call from the UI layer. Rather than invoking dismiss() directly from an event handler, we implemented a (shouldDismiss) state flag, observed via LaunchedEffect. This ensures Compose completes UI updates before the fragment is dismissed.

Final Implementation

override fun onCreateView(

    inflater: LayoutInflater,

    container: ViewGroup?,

    savedInstanceState: Bundle?,

): View {

    return ComposeView(requireContext()).apply {

        setContent {

            var shouldDismiss by rememberSaveable { mutableStateOf(false) }


            if (!shouldDismiss) {

                MyComposeScreen(

                    onDismissRequest = {

                        shouldDismiss = true

                    }

                )

            } else {

                LaunchedEffect(Unit) {

                    dismiss()

                    onFragmentDismissed?.invoke()
                }
            }
        }
    }
}

How It Works:

  • We use rememberSaveable to persist shouldDismiss across configuration changes
  • The close button in the UI sets shouldDismiss = true
  • On recomposition, the screen content is removed, and LaunchedEffect is triggered dismiss() is safely called after the UI tree is cleared, avoiding lifecycle conflicts

This pattern prevents unsafe overlaps between the fragment lifecycle and Compose recomposition.

Real-World Impact

We applied this fix in our Compose-based BottomSheetDialogFragment components that display editable lists and dynamic content. Previously, rapid user interactions during dismissal would occasionally cause crashes. With this new dismissal flow, those edge cases have been fully resolved.

This pattern has since become a standard in our Compose migration playbook to ensure consistent and crash-free behavior.

Key Takeaways for Compose Migrations

  • Avoid direct lifecycle calls (like dismiss()) inside Compose UI event handlers
  • Use a remember or rememberSaveable flag to signal lifecycle actions
  • Wrap side effects like dismissals inside LaunchedEffect blocks
  • Respect the separation between state and lifecycle
  • Test UI flows where dismissals and recompositions may overlap 

Jetpack Compose enables powerful and modern Android UIs, but it also demands a new approach to managing UI and lifecycle. Embracing state-first patterns like this ensures a smoother transition from XML, reduces crash risk, and results in a more stable and responsive application.

Leave a Reply