Jetpack Compose and Nested Scrolling Interoperability

As we migrate our apps to Jetpack Compose, we need to ensure that all existing functionality remains intact. This means that not only the way our UI looks, but the way that it behaves when users interact with it.

When recently migrating part of an app to Jetpack Compose, I experienced a broken coordinator layout behaviour – the View System Toolbar was no longer collapsing when the nested compose list was being scrolled. This was due to the lack of a coordinator layout behaviour in place. After a little bit of searching, I learnt how to hook into the parent coordinator using a compose modifier. The solution is very simple and but in the blog post, I want to run through the scenario I encountered, in case it helps you in future!

The TLDR of this approach is to apply the nestedScroll modifier to the parent composable, allowing the composable to hook into the nested scrolling hierarchy of your layout. If you want to read more into this approach, please read the rest of this blog post 🚀

LazyColumn(
    modifier = Modifier.nestedScroll(
        rememberNestedScrollInteropConnection()
    )
)

My new book, CI/CD for Android using GitHub Actions is now available 🚀


Existing View Components

Before we get started, let’s take a look at an example of what our existing app looks like. We can see here that we have two tabs with different content, showing the app bar and bottom bar collapsing/revealing as the content is scrolled.

When it comes to this setup in our apps, it is a pretty traditional approach using the Android View System. We have a CoordinatorLayout that contains several children – an AppBarLayout, fragment and BottomNavigationView, each of which we can see in the demo above.

<androidx.coordinatorlayout.widget.CoordinatorLayout>

    <com.google.android.material.appbar.AppBarLayout/>

    <fragment
        android:id="@+id/fragment_content"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:defaultNavHost="true"
        app:navGraph="@navigation/nav_graph"
        app:layout_behavior="@string/appbar_scrolling_view_behavior" />

    <com.google.android.material.bottomnavigation.BottomNavigationView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

For each of the tabs being shown for the BottomNavigationView, there is a fragment which contains a RecyclerView and corresponding adapter to show the list of content. This is pretty standard for what we would see in applications using the Android View System.

class FirstFragment : Fragment() {

    private var _binding: FragmentFirstBinding? = null
    private val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentFirstBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // setup recycler view data for adapter
    }
}

Moving to Compose

I am starting to convert my application to compose, but I want to do this strategically and avoid massive rewrites. To achieve this, I’m utilising compose interoperability features so that I only need to migrate the content of the fragment, as opposed to the fragment itself and any of the parent content. To achieve this, the onCreateView of my fragment is going to return a ComposeView which composes the equivalent UI for the screen – this moves away from the use of the XML layout for the fragment and any corresponding children (such as the RecyclerView).

Note: For example sake, I am directly composing the content inside of the fragment, splitting this out into a separate composable file would help the readability of my project.

class FirstFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {

        return ComposeView(requireContext()).apply {
            setContent {
                LazyColumn  {
                    ...
                }
            }
        }

    }
}

While we have greatly simplified the layout code of our fragment, we can see now that the behaviour of the coordinator children has broken – the AppBarLayout and BottomNavigationView now longer collapse/reveal as the content is scrolled.

The reason for this is that while the XML declaration for the fragment in our root layout states a layout_behaviour attribute (the BottomNavigationView has something similar), this does not work with Compose out of the box. Because we have migrated the content of the screen to Compose, and in turn removed the RecyclerView that previously hooked into this layout_behaviour, the scroll events are no longer being propagated up and out of our child fragments.

<fragment
        android:id="@+id/fragment_content"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:defaultNavHost="true"
        app:navGraph="@navigation/nav_graph"
        app:layout_behavior="@string/appbar_scrolling_view_behavior" />

Supporting Nested Scrolls

As we can see this is a degraded experience from the existing implementation, but the fix is pretty simple. Luckily for us, we can utilise the nestedScroll modifier which tells our composable to participate in the nested scrolling hierarchy. When using this modifier, we provide a required reference to the NestedScrollConnection class – this enables interoperability between the parent view and the nested composable, enabling the use of the layout_behaviour in the parent layout. This is our bridge between Compose and the View system when it comes to nested scrolling.

class FirstFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return ComposeView(requireContext()).apply {
            setContent {
                LazyColumn(
                    modifier = Modifier.nestedScroll(
                        rememberNestedScrollInteropConnection()
                    )
                ) {
                    ...
                }
            }
        }

    }
}

With this modifier now in place, we can see that the scrolling behaviours now behave as expected for our Compose migration, meaning that now experience here has degraded.


As we can see from this post, the solution to this problem has been a very effort fix – the team behind compose has thought about many different use cases where these bridges are needed between Compose and Views. Good interoperability support is what allows us to take an incremental approach when adopting Jetpack Compose in our apps, as in this case, this would have been a blocker and required more engineering effort to fix using a custom solution. As I migrate more of my apps to compose, I’m looking forward to discovering more features such as this!