Create a two-pane layout

Try the Compose way
Jetpack Compose is the recommended UI toolkit for Android. Learn how to work with layouts in Compose.

Every screen in your app must be responsive and adapt to the available space. You can build a responsive UI with ConstraintLayout that lets a single-pane approach scale to many sizes, but larger devices might benefit from splitting the layout into multiple panes. For example, you might want a screen to show a list of items next to a list of details of the selected item.

The SlidingPaneLayout component supports showing two panes side by side on larger devices and foldables while automatically adapting to show only one pane at a time on smaller devices such as phones.

For device-specific guidance, see the screen compatibility overview.

Setup

To use SlidingPaneLayout, include the following dependency in your app's build.gradle file:

Groovy

dependencies {
    implementation "androidx.slidingpanelayout:slidingpanelayout:1.2.0"
}

Kotlin

dependencies {
    implementation("androidx.slidingpanelayout:slidingpanelayout:1.2.0")
}

XML layout configuration

SlidingPaneLayout provides a horizontal, two-pane layout for use at the top level of a UI. This layout uses the first pane as a content list or a browser, subordinate to a primary detail view for displaying content in the other pane.

An image showing an example of SlidingPaneLayout
Figure 1. An example of a layout created with SlidingPaneLayout.

SlidingPaneLayout uses the width of the two panes to determine whether to show the panes side by side. For example, if the list pane is measured to have a minimum size of 200 dp and the detail pane needs 400 dp, then the SlidingPaneLayout automatically shows the two panes side by side as long as it has at least 600 dp of width available.

Child views overlap if their combined width exceeds the available width in the SlidingPaneLayout. In this case, the child views expand to fill the available width in the SlidingPaneLayout. The user can slide the topmost view out of the way by dragging it back from the edge of the screen.

If the views don't overlap, SlidingPaneLayout supports the use of the layout parameter layout_weight on child views to define how to divide leftover space after measurement is complete. This parameter is only relevant for width.

On a foldable device that has space on the screen to show both views side by side, SlidingPaneLayout automatically adjusts the size of the two panes so they are positioned on either side of an overlapping fold or hinge. In this case, the set widths are considered the minimum width that must exist on each side of the folding feature. If there isn't enough space to maintain that minimum size, SlidingPaneLayout switches back to overlapping the views.

Here is an example of using a SlidingPaneLayout that has a RecyclerView as its left pane and a FragmentContainerView as its primary detail view to display content from the left pane:

<!-- two_pane.xml -->
<androidx.slidingpanelayout.widget.SlidingPaneLayout
   xmlns:android="http://schemas.android.com/apk/res/android"
   android:id="@+id/sliding_pane_layout"
   android:layout_width="match_parent"
   android:layout_height="match_parent">

   <!-- The first child view becomes the left pane. When the combined needed
        width, expressed using android:layout_width, doesn't fit on-screen at
        once, the right pane is permitted to overlap the left. -->

   <androidx.recyclerview.widget.RecyclerView
             android:id="@+id/list_pane"
             android:layout_width="280dp"
             android:layout_height="match_parent"
             android:layout_gravity="start"/>

   <!-- The second child becomes the right (content) pane. In this example,
        android:layout_weight is used to expand this detail pane to consume
        leftover available space when the entire window is wide enough to fit
        the left and right pane.-->
   <androidx.fragment.app.FragmentContainerView
       android:id="@+id/detail_container"
       android:layout_width="300dp"
       android:layout_weight="1"
       android:layout_height="match_parent"
       android:background="#ff333333"
       android:name="com.example.SelectAnItemFragment" />
</androidx.slidingpanelayout.widget.SlidingPaneLayout>

In this example, the android:name attribute on FragmentContainerView adds the initial fragment to the detail pane, ensuring that users on large-screen devices don't see an empty right pane when the app first launches.

Programmatically swap out the detail pane

In the preceding XML example, tapping an element in the RecyclerView triggers a change in the detail pane. When using fragments, this requires a FragmentTransaction that replaces the right pane, calling open() on the SlidingPaneLayout to swap to the newly visible fragment:

Kotlin

// A method on the Fragment that owns the SlidingPaneLayout,called by the
// adapter when an item is selected.
fun openDetails(itemId: Int) {
    childFragmentManager.commit {
        setReorderingAllowed(true)
        replace<ItemFragment>(R.id.detail_container,
            bundleOf("itemId" to itemId))
        // If it's already open and the detail pane is visible, crossfade
        // between the fragments.
        if (binding.slidingPaneLayout.isOpen) {
            setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
        }
    }
    binding.slidingPaneLayout.open()
}

Java

// A method on the Fragment that owns the SlidingPaneLayout, called by the
// adapter when an item is selected.
void openDetails(int itemId) {
    Bundle arguments = new Bundle();
    arguments.putInt("itemId", itemId);
    FragmentTransaction ft = getChildFragmentManager().beginTransaction()
            .setReorderingAllowed(true)
            .replace(R.id.detail_container, ItemFragment.class, arguments);
    // If it's already open and the detail pane is visible, crossfade
    // between the fragments.
    if (binding.getSlidingPaneLayout().isOpen()) {
        ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE);
    }
    ft.commit();
    binding.getSlidingPaneLayout().open();
}

This code specifically doesn't call addToBackStack() on the FragmentTransaction. This avoids building a back stack in the detail pane.

The examples in this page use SlidingPaneLayout directly and require you to manage fragment transactions manually. However, the Navigation component provides a prebuilt implementation of a two-pane layout through AbstractListDetailFragment, an API class that uses a SlidingPaneLayout under the hood to manage your list and detail panes.

This lets you simplify your XML layout configuration. Instead of explicitly declaring a SlidingPaneLayout and both of your panes, your layout only needs a FragmentContainerView to hold your AbstractListDetailFragment implementation:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/two_pane_container"
        <!-- The name of your AbstractListDetailFragment implementation.-->
        android:name="com.example.testapp.TwoPaneFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        <!-- The navigation graph for your detail pane.-->
        app:navGraph="@navigation/two_pane_navigation" />
</FrameLayout>

Implement onCreateListPaneView() and onListPaneViewCreated() to provide a custom view for your list pane. For the detail pane, AbstractListDetailFragment uses a NavHostFragment. This means you can define a navigation graph that only contains the destinations to be shown in the detail pane. Then, you can use NavController to swap your detail pane between the destinations in the self-contained navigation graph:

Kotlin

fun openDetails(itemId: Int) {
    val navController = navHostFragment.navController
    navController.navigate(
        // Assume the itemId is the android:id of a destination in the graph.
        itemId,
        null,
        NavOptions.Builder()
            // Pop all destinations off the back stack.
            .setPopUpTo(navController.graph.startDestination, true)
            .apply {
                // If it's already open and the detail pane is visible,
                // crossfade between the destinations.
                if (binding.slidingPaneLayout.isOpen) {
                    setEnterAnim(R.animator.nav_default_enter_anim)
                    setExitAnim(R.animator.nav_default_exit_anim)
                }
            }
            .build()
    )
    binding.slidingPaneLayout.open()
}

Java

void openDetails(int itemId) {
    NavController navController = navHostFragment.getNavController();
    NavOptions.Builder builder = new NavOptions.Builder()
            // Pop all destinations off the back stack.
            .setPopUpTo(navController.getGraph().getStartDestination(), true);
    // If it's already open and the detail pane is visible, crossfade between
    // the destinations.
    if (binding.getSlidingPaneLayout().isOpen()) {
        builder.setEnterAnim(R.animator.nav_default_enter_anim)
                .setExitAnim(R.animator.nav_default_exit_anim);
    }
    navController.navigate(
        // Assume the itemId is the android:id of a destination in the graph.
        itemId,
        null,
        builder.build()
    );
    binding.getSlidingPaneLayout().open();
}

The destinations in the detail pane's navigation graph must not be present in any outer, app-wide navigation graph. However, any deep links within the detail pane's navigation graph must be attached to the destination that hosts the SlidingPaneLayout. This helps ensure that external deep links first navigate to the SlidingPaneLayout destination and then navigate to the correct detail pane destination.

See the TwoPaneFragment example for a full implementation of a two-pane layout using the Navigation component.

Integrate with the system back button

On smaller devices where the list and detail panes overlap, make sure the system back button takes the user from the detail pane back to the list pane. Do this by providing custom back navigation and connecting an OnBackPressedCallback to the current state of the SlidingPaneLayout:

Kotlin

class TwoPaneOnBackPressedCallback(
    private val slidingPaneLayout: SlidingPaneLayout
) : OnBackPressedCallback(
    // Set the default 'enabled' state to true only if it is slidable, such as
    // when the panes overlap, and open, such as when the detail pane is
    // visible.
    slidingPaneLayout.isSlideable && slidingPaneLayout.isOpen
), SlidingPaneLayout.PanelSlideListener {

    init {
        slidingPaneLayout.addPanelSlideListener(this)
    }

    override fun handleOnBackPressed() {
        // Return to the list pane when the system back button is tapped.
        slidingPaneLayout.closePane()
    }

    override fun onPanelSlide(panel: View, slideOffset: Float) { }

    override fun onPanelOpened(panel: View) {
        // Intercept the system back button when the detail pane becomes
        // visible.
        isEnabled = true
    }

    override fun onPanelClosed(panel: View) {
        // Disable intercepting the system back button when the user returns to
        // the list pane.
        isEnabled = false
    }
}

Java

class TwoPaneOnBackPressedCallback extends OnBackPressedCallback
        implements SlidingPaneLayout.PanelSlideListener {

    private final SlidingPaneLayout mSlidingPaneLayout;

    TwoPaneOnBackPressedCallback(@NonNull SlidingPaneLayout slidingPaneLayout) {
        // Set the default 'enabled' state to true only if it is slideable, such
        // as when the panes overlap, and open, such as when the detail pane is
        // visible.
        super(slidingPaneLayout.isSlideable() && slidingPaneLayout.isOpen());
        mSlidingPaneLayout = slidingPaneLayout;
        slidingPaneLayout.addPanelSlideListener(this);
    }

    @Override
    public void handleOnBackPressed() {
        // Return to the list pane when the system back button is tapped.
        mSlidingPaneLayout.closePane();
    }

    @Override
    public void onPanelSlide(@NonNull View panel, float slideOffset) { }

    @Override
    public void onPanelOpened(@NonNull View panel) {
        // Intercept the system back button when the detail pane becomes
        // visible.
        setEnabled(true);
    }

    @Override
    public void onPanelClosed(@NonNull View panel) {
        // Disable intercepting the system back button when the user returns to
        // the list pane.
        setEnabled(false);
    }
}

You can add the callback to the OnBackPressedDispatcher using addCallback():

Kotlin

class TwoPaneFragment : Fragment(R.layout.two_pane) {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val binding = TwoPaneBinding.bind(view)

        // Connect the SlidingPaneLayout to the system back button.
        requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner,
            TwoPaneOnBackPressedCallback(binding.slidingPaneLayout))

        // Set up the RecyclerView adapter.
    }
}

Java

class TwoPaneFragment extends Fragment {

    public TwoPaneFragment() {
        super(R.layout.two_pane);
    }

    @Override
    public void onViewCreated(@NonNull View view,
             @Nullable Bundle savedInstanceState) {
        TwoPaneBinding binding = TwoPaneBinding.bind(view);

        // Connect the SlidingPaneLayout to the system back button.
        requireActivity().getOnBackPressedDispatcher().addCallback(
            getViewLifecycleOwner(),
            new TwoPaneOnBackPressedCallback(binding.getSlidingPaneLayout()));

        // Set up the RecyclerView adapter.
    }
}

Lock mode

SlidingPaneLayout always lets you manually call open() and close() to transition between the list and detail panes on phones. These methods have no effect if both panes are visible and don't overlap.

When the list and detail panes overlap, users can swipe in both directions by default, freely switching between the two panes even when not using gesture navigation. You can control the swipe direction by setting the lock mode of the SlidingPaneLayout:

Kotlin

binding.slidingPaneLayout.lockMode = SlidingPaneLayout.LOCK_MODE_LOCKED

Java

binding.getSlidingPaneLayout().setLockMode(SlidingPaneLayout.LOCK_MODE_LOCKED);

Learn more

To learn more about designing layouts for different form factors, see the following documentation:

Additional resources