두 개의 창 레이아웃 만들기

앱의 모든 화면은 반응해야 하고 사용 가능한 공간에 맞게 조정되어야 합니다. ConstraintLayout으로 반응형 UI를 빌드하면 단일 창 접근 방법을 통해 창을 다양한 크기로 조정할 수 있지만, 상대적으로 더 큰 기기에서는 레이아웃을 여러 창으로 분할할 수도 있습니다. 예를 들어 화면에 항목 목록과 현재 선택된 항목의 세부정보를 나란히 표시할 수 있습니다.

SlidingPaneLayout 구성요소를 사용하면 대형 기기와 폴더블에서는 창 두 개를 나란히 표시하고 휴대전화와 같은 소형 기기에서는 창을 한 번에 한 개만 표시하도록 자동 조정할 수 있습니다.

기기별 안내는 화면 호환성 개요를 참고하세요.

설정

SlidingPaneLayout를 사용하려면 앱의 build.gradle 파일에 다음 종속 항목을 포함합니다.

Groovy

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

Kotlin

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

XML 레이아웃 구성

SlidingPaneLayout은 UI의 최상위 수준에서 사용할 수 있는 두 개의 가로 창 레이아웃을 제공합니다. 이 레이아웃은 첫 번째 창을 콘텐츠 목록 또는 브라우저로 사용합니다. 이 창은 다른 창에 콘텐츠를 표시하는 기본 세부정보 뷰에 종속됩니다.

SlidingPaneLayout은 두 창의 너비를 감안해 창을 나란히 표시할지 결정합니다. 예를 들어 목록 창이 최소 크기가 200dp로 측정되고 세부정보 창에 400dp가 필요한 경우 SlidingPaneLayout은 최소 600dp의 너비를 사용할 수 있다면 두 창을 자동으로 나란히 표시합니다.

합산 너비가 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>

이 예에서는 FragmentContainerViewandroid:name 속성이 초기 프래그먼트를 세부정보 창에 추가합니다. 그러면 대화면 기기의 사용자가 앱을 처음 실행할 때 빈 오른쪽 창이 표시되지 않습니다.

프로그래매틱 방식으로 세부정보 창 바꾸기

위 XML 예에서 RecyclerView의 요소를 탭하면 세부정보 창이 바뀝니다. 프래그먼트를 사용하는 경우에는 오른쪽 창을 바꿀 FragmentTransaction이 필요합니다. 이 요소는 SlidingPaneLayout에서 open()를 호출하여 새로 표시되는 프래그먼트로 교체됩니다.

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

자바

// 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와 프래그먼트 트랜잭션을 직접 사용했습니다. 하지만 대신에 탐색 구성요소를 사용하여 세부정보 창을 구현할 수 있습니다. 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()
}

자바

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

자바

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

자바

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

자바

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

자세히 알아보기

다양한 폼 팩터의 레이아웃 디자인에 관한 자세한 내용은 다음 가이드를 참고하세요.