Tạo bố cục hai ngăn

Thử cách sử dụng Compose
Jetpack Compose là bộ công cụ giao diện người dùng được đề xuất cho Android. Tìm hiểu cách sử dụng bố cục trong ứng dụng Compose.

Mọi màn hình trong ứng dụng của bạn đều phải có khả năng thích ứng và thích ứng với không gian có sẵn. Bạn có thể tạo giao diện người dùng thích ứng bằng ConstraintLayout cho phép phương thức một ngăn điều chỉnh tỷ lệ theo nhiều kích thước, nhưng các thiết bị lớn hơn có thể hưởng lợi từ việc chia bố cục thành nhiều ngăn. Ví dụ: có thể bạn muốn một màn hình hiện danh sách các mục bên cạnh danh sách chi tiết của mục đã chọn.

Thành phần SlidingPaneLayout hỗ trợ hiển thị 2 ngăn cạnh nhau trên thiết bị lớn và thiết bị có thể gập lại, đồng thời tự động điều chỉnh để chỉ hiển thị một ngăn tại một thời điểm trên các thiết bị nhỏ hơn (chẳng hạn như điện thoại).

Để biết hướng dẫn dành riêng cho thiết bị, hãy xem bài viết tổng quan về khả năng tương thích với màn hình.

Thiết lập

Để sử dụng SlidingPaneLayout, hãy đưa phần phụ thuộc sau vào tệp build.gradle của ứng dụng:

Groovy

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

Kotlin

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

Cấu hình bố cục XML

SlidingPaneLayout cung cấp bố cục hai ngăn theo chiều ngang để sử dụng ở cấp cao nhất của giao diện người dùng. Bố cục này sử dụng ngăn đầu tiên làm danh sách nội dung hoặc trình duyệt, phụ thuộc vào chế độ xem chi tiết chính để hiện nội dung trong ngăn khác.

Hình ảnh minh hoạ một ví dụ về SlidingPaneLayout
Hình 1. Ví dụ về bố cục được tạo bằng SlidingPaneLayout.

SlidingPaneLayout sử dụng chiều rộng của hai ngăn để xác định xem các ngăn này có hiện cạnh nhau hay không. Ví dụ: nếu kích thước tối thiểu của ngăn danh sách là 200 dp và ngăn chi tiết là 400 dp, thì SlidingPaneLayout sẽ tự động hiển thị hai ngăn cạnh nhau nếu chiều rộng có sẵn tối thiểu cho hai ngăn là 600 dp.

Các chế độ xem con sẽ chồng chéo nhau nếu tổng chiều rộng của chúng vượt quá chiều rộng có sẵn trong SlidingPaneLayout. Trong trường hợp này, các chế độ xem con sẽ mở rộng để lấp đầy chiều rộng có sẵn trong SlidingPaneLayout. Người dùng có thể trượt chế độ xem trên cùng ra ngoài bằng cách kéo nó trở lại từ cạnh của màn hình.

Nếu các khung hiển thị không chồng chéo nhau, SlidingPaneLayout sẽ hỗ trợ việc sử dụng tham số bố cục layout_weight trên các khung hiển thị con để xác định cách phân chia không gian còn lại sau khi đo lường xong. Tham số này chỉ liên quan đến chiều rộng.

Trên một thiết bị có thể gập lại có không gian trên màn hình để hiện cả hai khung hiển thị cạnh nhau, SlidingPaneLayout sẽ tự động điều chỉnh kích thước của hai ngăn để chúng được đặt ở một trong hai bên của đường gập hoặc bản lề chồng chéo. Trong trường hợp này, chiều rộng đã đặt được coi là chiều rộng tối thiểu phải có trên mỗi cạnh của tính năng gập. Nếu không có đủ không gian để duy trì kích thước tối thiểu đó, SlidingPaneLayout sẽ chuyển về chế độ chồng chéo khung hiển thị.

Sau đây là ví dụ về cách sử dụng SlidingPaneLayoutRecyclerView làm ngăn bên trái và FragmentContainerView làm chế độ xem chi tiết chính để hiện nội dung của ngăn bên trái:

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

Trong ví dụ này, thuộc tính android:name trên FragmentContainerView sẽ thêm mảnh ban đầu vào ngăn chi tiết, đảm bảo rằng người dùng trên các thiết bị màn hình lớn không nhìn thấy ngăn bên phải trống khi ứng dụng khởi chạy lần đầu tiên.

Hoán đổi ngăn chi tiết theo phương thức lập trình.

Trong ví dụ XML trước đó, việc nhấn vào một phần tử trong RecyclerView sẽ kích hoạt thay đổi trong ngăn chi tiết. Khi sử dụng mảnh (phải có FragmentTransaction thay thế ngăn bên phải), hãy gọi open() trên SlidingPaneLayout để hoán đổi với mảnh mới hiển thị:

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

Cụ thể, mã này không gọi addToBackStack() trên FragmentTransaction. Điều này giúp bạn tránh tạo ngăn xếp lui trong ngăn chi tiết.

Các ví dụ trong trang này sử dụng trực tiếp SlidingPaneLayout và yêu cầu bạn quản lý giao dịch mảnh theo cách thủ công. Tuy nhiên, Thành phần điều hướng (Navigation component) cung cấp cách triển khai tạo sẵn cho bố cục hai ngăn thông qua AbstractListDetailFragment, một lớp API sử dụng SlidingPaneLayout nâng cao để quản lý danh sách và các ngăn chi tiết.

Việc này giúp bạn đơn giản hoá cấu hình bố cục XML. Thay vì khai báo rõ ràng SlidingPaneLayout và cả hai ngăn, bố cục của bạn chỉ cần có FragmentContainerView để lưu giữ cách triển khai AbstractListDetailFragment của bạn:

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

Triển khai onCreateListPaneView()onListPaneViewCreated() để cung cấp khung hiển thị tuỳ chỉnh cho ngăn danh sách. Đối với ngăn chi tiết, AbstractListDetailFragment sử dụng NavHostFragment. Điều này có nghĩa là bạn có thể xác định một biểu đồ điều hướng chỉ chứa các đích đến sẽ xuất hiện trong ngăn chi tiết. Sau đó, bạn có thể dùng NavController để thay đổi ngăn chi tiết giữa các đích trong sơ đồ điều hướng độc lập:

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

Các đích đến trong biểu đồ điều hướng của ngăn chi tiết không được xuất hiện trong bất kỳ biểu đồ điều hướng bên ngoài nào trên toàn ứng dụng. Tuy nhiên, mọi đường liên kết sâu trong biểu đồ điều hướng của ngăn chi tiết đều phải được đính kèm vào đích đến lưu trữ SlidingPaneLayout. Điều này giúp đảm bảo các đường liên kết sâu bên ngoài sẽ điều hướng tới đích đến SlidingPaneLayout trước, sau đó chuyển tới đích đến chính xác của ngăn chi tiết.

Hãy xem ví dụ về TwoPaneFragment để biết cách triển khai đầy đủ bố cục hai ngăn bằng thành phần Điều hướng.

Tích hợp với nút quay lại của hệ thống

Trên các thiết bị nhỏ hơn, ngăn danh sách và ngăn chi tiết chồng chéo lên nhau, hãy đảm bảo nút quay lại của hệ thống sẽ đưa người dùng từ ngăn chi tiết trở lại ngăn danh sách. Hãy thực hiện việc này bằng cách cung cấp tính năng điều hướng quay lại tuỳ chỉnh và kết nối OnBackPressedCallback với trạng thái hiện tại của 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);
    }
}

Bạn có thể thêm lệnh gọi lại vào OnBackPressedDispatcher bằng cách sử dụng 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.
    }
}

Chế độ khoá

SlidingPaneLayout luôn cho phép bạn gọi open()close() theo cách thủ công để chuyển đổi giữa các ngăn danh sách và ngăn chi tiết trên điện thoại. Các phương thức này sẽ không có tác dụng nếu cả hai ngăn đều hiển thị và không chồng chéo nhau.

Khi ngăn danh sách và ngăn chi tiết chồng chéo nhau, người dùng có thể vuốt theo cả hai hướng theo mặc định, thoải mái hoán đổi giữa hai ngăn ngay cả khi không sử dụng tính năng thao tác bằng cử chỉ. Bạn có thể điều khiển hướng vuốt bằng cách thiết lập chế độ khoá của SlidingPaneLayout:

Kotlin

binding.slidingPaneLayout.lockMode = SlidingPaneLayout.LOCK_MODE_LOCKED

Java

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

Tìm hiểu thêm

Để tìm hiểu thêm về cách thiết kế bố cục cho nhiều hệ số hình dạng, hãy xem tài liệu sau:

Tài nguyên khác