Создание двухпанельного макета

Попробуйте способ создания композиций.
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 , чтобы переключиться на новый видимый фрагмент:

Котлин

// 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 для переключения между панелями с подробной информацией в рамках этого самодостаточного графа навигации:

Котлин

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 :

Котлин

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

Котлин

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 :

Котлин

binding.slidingPaneLayout.lockMode = SlidingPaneLayout.LOCK_MODE_LOCKED

Java

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

Узнать больше

Чтобы узнать больше о разработке макетов для различных форм-факторов, см. следующую документацию:

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