Как создать макет с двумя панелями

Попробуйте Compose
Jetpack Compose – это рекомендуемый набор инструментов для создания пользовательского интерфейса в Android. Узнайте, как работать с макетами в Compose.

Каждый экран в приложении должен быть адаптивным и подстраиваться под доступное пространство. Вы можете создать адаптивный интерфейс с помощью ConstraintLayout, чтобы масштабировать однопанельный макет для разных размеров экрана. Однако на больших устройствах лучше использовать многопанельный макет. Например, вы можете захотеть, чтобы на экране отображался список элементов рядом со списком сведений о выбранном элементе.

Компонент SlidingPaneLayout поддерживает показ двух панелей рядом на больших и складных устройствах, а на меньших устройствах, например телефонах, автоматически адаптируется для показа только одной панели за раз.

Инструкции для разных устройств можно найти в обзоре совместимости с экранами.

Настроить

Чтобы использовать SlidingPaneLayout, добавьте в файл build.gradle своего приложения следующую зависимость:

классный

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

Котлин

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

Конфигурация макета XML

SlidingPaneLayout предоставляет горизонтальный макет с двумя панелями для использования в верхней части пользовательского интерфейса. В этом макете первая панель используется как список контента или браузер, подчиненный основному представлению сведений для отображения контента на другой панели.

Изображение, показывающее пример SlidingPaneLayout
Рисунок 1. Пример макета, созданного с помощью SlidingPaneLayout.

SlidingPaneLayout использует ширину двух панелей, чтобы определить, показывать ли их рядом. Например, если минимальный размер панели списка составляет 200 dp, а панели подробностей – 400 dp, то SlidingPaneLayout автоматически показывает две панели рядом, если доступная ширина составляет не менее 600 dp.

Дочерние представления перекрываются, если их общая ширина превышает доступную ширину в SlidingPaneLayout. В этом случае дочерние представления расширяются, чтобы заполнить доступную ширину в SlidingPaneLayout. Пользователь может убрать верхний экран, перетащив его обратно от края экрана.

Если области просмотра не перекрываются, SlidingPaneLayout поддерживает использование параметра макета layout_weight в дочерних областях просмотра, чтобы определить, как разделить оставшееся пространство после завершения измерения. Этот параметр относится только к ширине.

На складном устройстве, где на экране можно разместить оба представления рядом, SlidingPaneLayout автоматически корректирует размер двух панелей, чтобы они располагались по обе стороны от сгиба или шарнира. В этом случае заданная ширина считается минимальной шириной, которая должна быть с каждой стороны складки. Если места недостаточно, SlidingPaneLayout снова перекрывает окна.

Ниже приведен пример использования SlidingPaneLayout, у которого в качестве левой панели используется RecyclerView, а в качестве основного подробного представления для отображения контента с левой панели – FragmentContainerView.

<!-- 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>

В этом примере атрибут android:name в FragmentContainerView добавляет начальный фрагмент на панель подробностей, чтобы пользователи устройств с большим экраном не видели пустую правую панель при первом запуске приложения.

Как программно заменить панель подробностей

В приведенном выше примере XML нажатие на элемент в RecyclerView вызывает изменение на панели подробностей. При использовании фрагментов для этого требуется FragmentTransaction, который заменяет правую панель, вызывая open() в SlidingPaneLayout, чтобы переключиться на новый видимый фрагмент:

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

В этом коде не вызывается метод addToBackStack() для объекта FragmentTransaction. Это позволяет избежать создания обратного стека на панели сведений.

В примерах на этой странице используется SlidingPaneLayout, поэтому вам нужно управлять транзакциями фрагментов вручную. Однако компонент Navigation предоставляет готовую реализацию макета с двумя панелями через AbstractListDetailFragment – класс API, который использует SlidingPaneLayout для управления панелями списка и панелями подробностей.

Это позволяет упростить конфигурацию макета XML. Вместо явного объявления SlidingPaneLayout и обеих панелей в макете достаточно указать FragmentContainerView, чтобы разместить реализацию AbstractListDetailFragment:

<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>

Реализуйте onCreateListPaneView() и onListPaneViewCreated(), чтобы настроить представление для панели списка. Для панели подробностей в AbstractListDetailFragment используется NavHostFragment. Это означает, что вы можете определить граф навигации, который содержит только пункты назначения, которые будут показаны на панели подробностей. Затем вы можете использовать NavController, чтобы переключать панель подробностей между пунктами назначения в автономном графе навигации:

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

Пункты назначения в графе навигации панели подробностей не должны присутствовать ни в одном внешнем графе навигации на уровне приложения. Однако любые ссылки на контент в графе навигации панели сведений должны быть прикреплены к целевому объекту, на котором размещен SlidingPaneLayout. Это гарантирует, что внешние ссылки на контент сначала будут перенаправлять на целевую страницу SlidingPaneLayout, а затем – на нужную панель подробностей.

Полный пример реализации макета с двумя панелями с помощью компонента Navigation можно найти в разделе Пример TwoPaneFragment.

Интеграция с системной кнопкой "Назад"

На небольших устройствах, где список и панель подробностей перекрывают друг друга, убедитесь, что системная кнопка "Назад" возвращает пользователя с панели подробностей на панель списка. Для этого настройте собственную обратную навигацию и подключите OnBackPressedCallback к текущему состоянию 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);
    }
}

Добавить обратный вызов в OnBackPressedDispatcher можно с помощью 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.
    }
}

Режим блокировки

SlidingPaneLayout всегда позволяет вручную вызвать open() и close(), чтобы переключаться между списком и панелью подробностей на телефонах. Эти методы не действуют, если обе панели видны и не перекрывают друг друга.

Когда панели списка и сведений перекрываются, пользователи могут по умолчанию проводить пальцем в обоих направлениях, свободно переключаясь между двумя панелями, даже если не используют навигацию жестами. Вы можете управлять направлением пролистывания, задав режим блокировки для SlidingPaneLayout:

Kotlin

binding.slidingPaneLayout.lockMode = SlidingPaneLayout.LOCK_MODE_LOCKED

Java

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

Подробнее…

Дополнительную информацию о создании макетов для разных типов устройств можно найти в следующих статьях:

Дополнительные ресурсы