두 개의 창 레이아웃 만들기

Compose 방법 사용해 보기
Jetpack Compose는 Android에 권장되는 UI 도구 키트입니다. Compose에서 레이아웃을 사용하는 방법을 알아보세요.

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

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

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

설정

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

Groovy

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

Kotlin

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

XML 레이아웃 구성

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

SlidingPaneLayout의 예를 보여주는 이미지
그림 1. SlidingPaneLayout로 만든 레이아웃의 예

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

이 예에서 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 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();
}

이 코드는 구체적으로 FragmentTransaction에서 addToBackStack()를 호출하지 않습니다. 이는 세부정보 창에 백 스택을 빌드하는 것을 방지합니다.

이 페이지의 예에서는 SlidingPaneLayout를 직접 사용하며 프래그먼트 트랜잭션을 수동으로 관리해야 합니다. 그러나 탐색 구성요소는 목록 및 세부정보 창을 관리하기 위해 내부적으로 SlidingPaneLayout를 사용하는 API 클래스인 AbstractListDetailFragment를 통해 창 두 개 레이아웃의 사전 빌드된 구현을 제공합니다.

이렇게 하면 XML 레이아웃 구성을 단순화할 수 있습니다. SlidingPaneLayout 및 창 두 개를 명시적으로 선언하는 대신, 레이아웃에는 AbstractListDetailFragment 구현만 보유하는 FragmentContainerView만 있으면 됩니다.

<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()를 구현하여 목록 창에 맞춤 뷰를 제공합니다. 세부정보 창의 경우 AbstractListDetailFragmentNavHostFragment를 사용합니다. 즉, 세부정보 창에 표시할 대상만 포함된 탐색 그래프를 정의할 수 있습니다. 그런 다음 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 대상으로 이동한 후 올바른 세부정보 창 대상으로 이동하는 데 도움이 됩니다.

탐색 구성요소를 사용하여 창 두 개 레이아웃의 전체 구현은 TwoPaneFragment 예를 참고하세요.

시스템 뒤로 버튼과 통합

목록 창과 세부정보 창이 겹치는 소형 기기에서는 시스템 뒤로 버튼을 누르면 세부정보 창에서 목록 창으로 다시 이동해야 합니다. 이렇게 하려면 맞춤 뒤로 탐색을 제공하고 OnBackPressedCallbackSlidingPaneLayout의 현재 상태에 연결하면 됩니다.

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

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

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

자세히 알아보기

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

추가 리소스