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 should be responsive and should adapt to the available space. Building a responsive UI with ConstraintLayout can allow a single pane approach to 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 side by side with the details of the currently 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.

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 200dp and the detail pane needs 400dp, then the SlidingPaneLayout automatically shows the two panes side by side as long as it has at least 600dp 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 do not 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 that they are positioned on either side of an overlapping fold or hinge. In this case, the widths set are considered the minimum width that must exist on each side of the folding feature. If there is not 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
        desired width (expressed using android:layout_width) would
        not 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
        the entire window is wide enough to fit both 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 do not see an empty right pane when the app first launches.

Programmatically swap out the detail pane

In the XML example above, tapping on 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 we're 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 we're 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 does not call addToBackStack() on the FragmentTransaction. This avoids building a back stack in the detail pane.

The examples presented so far in this guide used SlidingPaneLayout directly and required 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 allows you to 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>

In your implementation, you need to implement onCreateListPaneView() and onListPaneViewCreated() to provide a custom view for your list pane; but AbstractListDetailFragment uses a NavHostFragment as the detail pane. 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 we're 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 we're 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 should not be present in any outer, app-wide navigation graph. However, any deep links within the detail pane's navigation graph should be attached to the destination that hosts the SlidingPaneLayout. This ensures that external deep links first navigate to the SlidingPaneLayout destination and then navigate to the correct detail pane destination.

See the 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, you should ensure that 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 (i.e., the panes
    // are overlapping) and open (i.e., 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 pressed.
        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 (i.e., the panes
        // are overlapping) and open (i.e., 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 pressed.
        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);
    }
}

Then, 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))

        // Setup the RecyclerView adapter, etc.
    }
}

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()));

        // Setup the RecyclerView adapter, etc.
    }
}

Lock mode

SlidingPaneLayout always allows you to 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 do not 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 guides: