创建双窗格布局

应用中的每个屏幕都应具备自适应能力,还应可以根据可用空间进行调整。通过 ConstraintLayout 构建自适应界面,可以让单窗格根据多种屏幕尺寸进行调整,但大屏幕设备可能会从将布局拆分为多个窗格中获益。例如,您可能希望在一个屏幕中并排显示项列表,并显示当前所选项的详细信息。

SlidingPaneLayout 组件支持在大屏幕设备和可折叠设备上并排显示两个窗格,同时还会自动进行调整,以便在手机等小屏幕设备上一次只显示一个窗格。

如需了解设备专用指南,请参阅屏幕兼容性概览

设置

如需使用 SlidingPaneLayout,请在应用的 build.gradle 文件中添加以下依赖项:

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

XML 布局配置

SlidingPaneLayout 提供一种水平的双窗格布局,以在界面顶层使用。在这种布局中,第一个窗格用作内容列表或浏览器,从属于另一个窗格中用于显示内容的主要详细信息视图。

SlidingPaneLayout 会根据两个窗格的宽度来确定是否并排显示这些窗格。例如,如果测量后发现列表窗格的最小尺寸为 200dp,而详细信息窗格需要 400dp,那么只要可用宽度不小于 600dp,SlidingPaneLayout 就会自动并排显示两个窗格。

如果子视图的总宽度超过了 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
        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>

在此示例中,FragmentContainerView 上的 android:name 属性会向详细信息窗格添加初始 fragment,以确保使用大屏幕设备的用户在应用首次启动时不会看到空白的右侧窗格。

以编程方式更换详细信息窗格

在上面的 XML 布局示例中,点按 RecyclerView 中的某个元素即可触发对详细信息窗格的更改。在使用 fragment 时,这需要一个可替换右侧窗格的 FragmentTransaction,针对 SlidingPaneLayout 调用 open() 来更换为新的可见 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();
}

具体来说,此代码不会针对 FragmentTransaction 调用 addToBackStack()这样可避免在详细信息窗格中构建返回堆栈。

上面的示例直接使用了 FragmentContainerView 和 fragment 事务。不过,您可以改为使用导航组件来实现详细信息窗格。如果您将 NavHostFragment 用作详细信息窗格,则可以使用 NavController 在导航图(仅包含要在详细信息窗格中显示的目的地)中的目的地之间切换:

Kotlin

// A method on the Fragment that owns the SlidingPaneLayout,
// called by the adapter when an item is selected.
fun openDetails(itemId: Int) {
    // Assume the NavHostFragment is added with the +id/detail_container.
    val navHostFragment = childFragmentManager.findFragmentById(
       R.id.detail_container) as NavHostFragment
    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

// A method on the Fragment that owns the SlidingPaneLayout,
// called by the adapter when an item is selected.
void openDetails(int itemId) {
    // Assume the NavHostFragment is added with the +id/detail_container.
    NavHostFragment navHostFragment = (NavHostFragment) getChildFragmentManager()
        .findFragmentById(R.id.detail_container);
    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();
}

详细信息窗格的导航图中的目的地不应存在于任何应用范围的外部导航图中。但是,详细信息窗格的导航图中的任何深层链接都应附加到托管 SlidingPaneLayout 的目的地。这样可确保外部深层链接先导航到 SlidingPaneLayout 目的地,然后再导航到详细信息窗格中正确的目的地。

与系统返回按钮集成

在列表窗格与详细信息窗格重叠的小屏幕设备上,您应确保用户可通过系统返回按钮从详细信息窗格转回到列表窗格。为此,您可以提供自定义返回导航并将 OnBackPressedCallbackSlidingPaneLayout 的当前状态相关联:

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

然后,您可以使用 addCallback() 将回调添加到 OnBackPressedDispatcher

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

锁定模式

SlidingPaneLayout 始终允许您手动调用 open()close(),以便在手机上的列表窗格和详细信息窗格之间转换。如果两个窗格均显示且互不重叠,这些方法不会产生任何影响。

如果列表窗格和详细信息窗格重叠,那么在默认情况下,用户可以向两个方向滑动,这样一来,即使在没有使用手势导航的情况下,也可以随意在两个窗格之间切换。您可以设置 SlidingPaneLayout 的锁定模式来控制滑动方向:

Kotlin

binding.slidingPaneLayout.lockMode = SlidingPaneLayout.LOCK_MODE_LOCKED

Java

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

了解详情

如需详细了解如何针对不同设备类型设计布局,请参阅以下指南: