고급 활동 삽입

1. 소개

Android 12L(API 수준 32)에 도입된 활동 삽입을 사용하면 활동 기반 앱이 대형 화면에 여러 활동을 동시에 표시하므로 목록-세부정보와 같은 창 두 개 레이아웃을 만들 수 있습니다.

활동 삽입과 Material Design으로 목록-세부정보 레이아웃 빌드 Codelab에서는 XML 또는 Jetpack WindowManager API 호출을 사용하여 목록-세부정보 레이아웃을 만드는 방법을 알아봤습니다.

이 Codelab에서는 새로 출시된 활동 삽입의 기능을 알아보며 이 기능을 통해 대형 화면 기기에서 앱 환경을 더욱 개선할 수 있습니다. 기능에는 창 확장, 활동 고정, 전체 화면 대화상자 어둡게 처리 등이 있습니다.

기본 요건

학습할 내용

학습할 내용은 다음과 같습니다.

  • 창 확장 사용 설정 방법
  • 분할 창 중 하나를 사용하여 활동 고정 구현 방법
  • 전체 화면 대화상자 어둡게 처리 사용 방법

필요한 항목

  • Android 스튜디오 최신 버전
  • Android 15를 실행하는 Android 휴대전화 또는 에뮬레이터
  • 최소 너비가 600dp를 초과하는 Android 대형 태블릿 또는 에뮬레이터

2. 설정

샘플 앱 가져오기

1단계: 저장소 클론하기

대형 화면 Codelab Git 저장소를 클론합니다.

git clone https://github.com/android/large-screen-codelabs

또는 대형 화면 Codelab ZIP 파일을 다운로드하고 보관 취소합니다.

소스 코드 다운로드

2단계: Codelab 소스 파일 검사하기

activity-embedding-advanced 폴더로 이동합니다.

3단계: Codelab 프로젝트 열기

Android 스튜디오에서 Kotlin 또는 Java 프로젝트를 엽니다.

저장소 및 ZIP 파일의 활동 폴더 파일 목록

저장소 및 ZIP 파일의 activity-embedding-advanced 폴더에는 두 개의 Android 스튜디오 프로젝트가 포함되어 있습니다. 하나는 Kotlin으로 작성된 프로젝트이고, 다른 하나는 Java로 작성된 프로젝트입니다. 원하는 프로젝트를 엽니다. Codelab 스니펫은 두 가지 언어로 모두 제공됩니다.

가상 기기 만들기

API 수준 35 이상의 Android 휴대전화, 소형 태블릿 또는 대형 태블릿이 없는 경우 Android 스튜디오에서 기기 관리도구를 열고 다음의 필수 가상 기기를 만듭니다.

  • 휴대전화: Pixel 8, API 수준 35 이상
  • 태블릿: Pixel Tablet, API 수준 35 이상

3. 앱 실행

샘플 앱은 항목 목록을 표시합니다. 사용자가 항목을 선택하면 앱에 항목 정보가 표시됩니다.

앱은 세 가지 활동으로 구성되어 있습니다.

  • ListActivityRecyclerView의 항목 목록을 포함합니다.
  • DetailActivity - 목록에서 항목이 선택되면 목록 항목의 정보를 표시합니다.
  • SummaryActivity - 요약 목록 항목이 선택되면 정보 요약을 표시합니다.

이전 Codelab에서 계속

활동 삽입과 Material Design으로 목록-세부정보 레이아웃 빌드 Codelab에서는 탐색 레일과 하단 탐색 메뉴를 모두 사용하여 탐색이 지원되는 활동 삽입을 사용하는 목록-세부정보 보기가 포함된 애플리케이션을 개발했습니다.

  1. 대형 태블릿 또는 Pixel 에뮬레이터에서 세로 모드로 앱을 실행합니다. 기본 목록 화면과 하단 탐색 메뉴가 표시됩니다.

74906232acad76f.png

  1. 태블릿을 옆으로 돌립니다(가로 모드). 화면이 분할되어 한쪽에는 목록이, 다른 한쪽에는 세부정보가 표시됩니다. 하단 탐색 메뉴는 세로 탐색 레일로 대체됩니다.

dc6a7d1c02c49cd4.png

활동 삽입을 사용한 새로운 기능

이중 창 레이아웃을 한 단계 업그레이드할 준비가 되셨나요? 이 Codelab에서는 멋진 새 기능을 추가하여 사용자 환경을 개선합니다. 빌드할 항목은 다음과 같습니다.

  1. 창을 동적으로 만들어 보겠습니다. Google에서는 사용자가 창 크기를 조절하거나 창을 확장하여 뷰를 맞춤설정할 수 있도록 창 확장 기능을 구현할 예정입니다.

2ec5f7fd6df5d8cd.gif

  1. 사용자가 우선순위를 지정할 수 있습니다. 활동 고정을 사용하면 사용자가 가장 중요한 작업을 항상 화면에 표시할 수 있습니다.

980d0033972737ed.gif

  1. 특정 작업에 집중해야 하나요? 방해 요소를 부드럽게 페이드 아웃하고 사용자가 가장 중요한 일에 집중할 수 있도록 전체 화면 어둡게 하기 기능이 추가됩니다.

2d3455e0f8901f95.png

4. 창 확장

대형 화면에서 이중 창 레이아웃을 사용할 때 대부분의 경우 사용자는 분할된 창 중 하나에 집중하면서 다른 창을 화면에 유지해야 합니다. 예를 들어 한 창에서는 기사를 읽고 다른 창에서는 채팅 대화 목록을 유지하는 경우입니다. 사용자는 한 활동에 집중하기 위해 창 크기를 조절하려고 하는 경우가 많습니다.

이 목표를 달성하기 위해 활동 삽입에는 사용자가 분할 비율을 변경하고 크기 조절 전환을 맞춤설정할 수 있는 새로운 API가 추가되었습니다.

종속 항목 추가

먼저 build.gradle 파일에 WindowManager 1.4를 추가합니다.

참고: 이 라이브러리의 일부 기능은 Android 15(API 수준 35) 이상에서만 작동합니다.

build.gradle

 implementation 'androidx.window:window:1.4.0-alpha02'

창 구분선 맞춤설정

DividerAttributes 인스턴스를 만들어 SplitAttributes에 추가합니다. 이 객체는 분할 레이아웃의 전반적인 동작을 구성합니다. DividerAttributes의 색상, 너비, 드래그 범위 속성을 사용하여 사용자 환경을 개선할 수 있습니다.

다음과 같이 구분선을 맞춤설정합니다.

  1. WindowManager 확장 프로그램 API 수준을 확인합니다. 창 확장 기능은 API 수준 6 이상에서만 사용할 수 있으므로 이는 나머지 새 기능에도 적용됩니다.
  2. DividerAttributes 만들기: 창 사이의 구분선에 스타일을 지정하려면 DividerAttributes 객체를 만듭니다. 이 객체를 사용하면 다음을 설정할 수 있습니다.
  • color: 앱의 테마에 맞게 구분선의 색상을 변경하거나 시각적으로 구분되도록 합니다.
  • widthDp: 가시성을 높이거나 더 섬세한 모양을 위해 구분선의 너비를 조정합니다.
  1. SplitAttributes에 추가: 구분선을 맞춤설정했으면 DividerAttributes 객체에 추가합니다.
  2. 드래그 범위 설정(선택사항): 사용자가 창 크기를 조절하기 위해 구분선을 얼마나 드래그할 수 있는지 제어할 수도 있습니다.
  • DRAG_RANGE_SYSTEM_DEFAULT: 이 특수 값을 사용하면 시스템이 기기의 화면 크기와 폼 팩터를 기반으로 적절한 드래그 범위를 결정할 수 있습니다.
  • 맞춤 값(0.33~0.66): 자체 드래그 범위를 설정하여 사용자가 창 크기를 조절할 수 있는 범위를 제한합니다. 이 한도를 초과하여 드래그하면 분할 레이아웃이 사용 중지됩니다.

splitAttributes를 다음 코드로 바꿉니다.

SplitManager.kt

val splitAttributesBuilder: SplitAttributes.Builder = SplitAttributes.Builder()
   .setSplitType(SplitAttributes.SplitType.ratio(0.33f))
   .setLayoutDirection(SplitAttributes.LayoutDirection.LEFT_TO_RIGHT)

if (WindowSdkExtensions.getInstance().extensionVersion >= 6) {
   splitAttributesBuilder.setDividerAttributes(
       DividerAttributes.DraggableDividerAttributes.Builder()
           .setColor(getColor(context, R.color.divider_color))
           .setWidthDp(4)
           .setDragRange(
               DividerAttributes.DragRange.DRAG_RANGE_SYSTEM_DEFAULT)
           .build()
   )
}
val splitAttributes: SplitAttributes = splitAttributesBuilder.build()

SplitManager.java

SplitAttributes.Builder splitAttributesBuilder = new SplitAttributes.Builder()
        .setSplitType(SplitAttributes.SplitType.ratio(0.33f))
        .setLayoutDirection(SplitAttributes.LayoutDirection.LEFT_TO_RIGHT);

if (WindowSdkExtensions.getInstance().getExtensionVersion() >= 6) {
    splitAttributesBuilder.setDividerAttributes(
            new DividerAttributes.DraggableDividerAttributes.Builder()
                    .setColor(ContextCompat.getColor(context, R.color.divider_color))
                    .setWidthDp(4)
                    .setDragRange(DividerAttributes.DragRange.DRAG_RANGE_SYSTEM_DEFAULT)
                    .build()
    );
}
SplitAttributes splitAttributes = splitAttributesBuilder.build();

다음 콘텐츠로 res/color 폴더에 divider_color.xml을 만듭니다.

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
   <item android:color="#669df6" />
</selector>

실행해 보기

이상입니다. 샘플 앱을 빌드하고 실행하세요.

창이 확장되고 창을 드래그할 수 있습니다.

2ec5f7fd6df5d8cd.gif

이전 버전에서 분할 비율 변경

중요 호환성 참고사항: 창 확장 기능은 WindowManager 확장 프로그램 6 이상에서만 사용할 수 있으므로 Android 15(API 수준 35) 이상이 필요합니다.

하지만 이전 Android 버전의 사용자에게도 우수한 환경을 제공해야 합니다.

Android 14(API 수준 34) 이하에서는 SplitAttributesCalculator 클래스를 사용하여 동적 분할 비율 조정을 계속 제공할 수 있습니다. 이렇게 하면 창 확장 없이도 레이아웃을 어느 정도 사용자가 제어할 수 있습니다.

a36f8ba4226353c5.gif

이러한 기능을 최적으로 사용하는 방법은 무엇일까요? '권장사항' 섹션에서 모든 권장사항과 전문가 팁을 확인하세요.

5. 활동 고정

화면 분할 보기의 한 부분은 고정하고 다른 부분은 자유롭게 탐색하고 싶었던 적이 있나요? 한쪽에서는 긴 기사를 읽고 다른 쪽에서는 다른 앱 콘텐츠와 상호작용할 수 있다고 생각해 보세요.

여기서 활동 고정 기능이 유용합니다. 분할된 창 중 하나를 고정하여 다른 창 내에서 탐색하는 동안에도 화면에 유지될 수 있습니다. 이렇게 하면 사용자에게 더욱 집중적이고 생산적인 멀티태스킹 환경을 제공할 수 있습니다.

고정 버튼 추가

먼저 DetailActivity.에 버튼을 추가합니다. 사용자가 버튼을 클릭하면 애플리케이션이 이 DetailActivity를 고정합니다.

activity_detail.xml을 다음과 같이 변경합니다.

  1. ConstraintLayout에 ID를 추가합니다.
android:id="@+id/detailActivity"
  1. 레이아웃 하단에 버튼을 추가합니다.
<androidx.appcompat.widget.AppCompatButton
      android:id="@+id/pinButton"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:text="@string/pin_this_activity"
      app:layout_constraintBottom_toBottomOf="parent"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toBottomOf="@id/textViewItemDetail"/>
  1. TextView 하단을 버튼 상단으로 제한합니다.
app:layout_constraintBottom_toTopOf="@id/pinButton"

TextView에서 이 줄을 삭제합니다.

app:layout_constraintBottom_toBottomOf="parent"

방금 추가한 PIN THIS ACTIVITY 버튼을 포함하여 activity_detail.xml 레이아웃 파일의 전체 XML 코드는 다음과 같습니다.

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/detailActivity"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/white"
    tools:context=".DetailActivity">

  <TextView
      android:id="@+id/textViewItemDetail"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:textSize="36sp"
      android:textColor="@color/obsidian"
      app:layout_constraintBottom_toTopOf="@id/pinButton"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toTopOf="parent" />

  <androidx.appcompat.widget.AppCompatButton
      android:id="@+id/pinButton"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:text="@string/pin_this_activity"
      app:layout_constraintBottom_toBottomOf="parent"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toBottomOf="@id/textViewItemDetail"/>

</androidx.constraintlayout.widget.ConstraintLayout>

pin_this_activity 문자열을 res/values/strings.xml에 추가합니다.

<string name="pin_this_activity">PIN THIS ACTIVITY</string>

고정 버튼 연결

  1. 변수 선언: DetailActivity.kt 파일에서 PIN THIS ACTIVITY 버튼의 참조를 보유할 변수를 선언합니다.

DetailActivity.kt

private lateinit var pinButton: Button

DetailActivity.java

private Button pinButton;
  1. 레이아웃에서 버튼을 찾아 setOnClickListener() 콜백을 추가합니다.

DetailActivity.kt/onCreate

pinButton = findViewById(R.id.pinButton)
pinButton.setOnClickListener {
 pinActivityStackExample(taskId)
}

DetailActivity.java/onCreate()

Button pinButton = findViewById(R.id.pinButton);
pinButton.setOnClickListener( (view) => {
        pinActivityStack(getTaskId());

});
  1. DetailActivity 클래스에서 새 메서드 pinActivityStackExample을 만듭니다. 여기에서 실제 고정 로직을 구현합니다.

DetailActivity.kt

private fun pinActivityStackExample(taskId: Int) {

 val splitAttributes: SplitAttributes = SplitAttributes.Builder()
   .setSplitType(SplitAttributes.SplitType.ratio(0.66f))
   .setLayoutDirection(SplitAttributes.LayoutDirection.LEFT_TO_RIGHT)
   .build()

 val pinSplitRule = SplitPinRule.Builder()
   .setSticky(true)
   .setDefaultSplitAttributes(splitAttributes)
   .build()

 SplitController.getInstance(applicationContext).pinTopActivityStack(taskId, pinSplitRule)
}

DetailActivity.java

private void pinActivityStackExample(int taskId) {
    SplitAttributes splitAttributes = new SplitAttributes.Builder()
            .setSplitType(SplitAttributes.SplitType.ratio(0.66f))
            .setLayoutDirection(SplitAttributes.LayoutDirection.LEFT_TO_RIGHT)
            .build();

    SplitPinRule pinSplitRule = new SplitPinRule.Builder()
            .setSticky(true)
            .setDefaultSplitAttributes(splitAttributes)
            .build();

    SplitController.getInstance(getApplicationContext()).pinTopActivityStack(taskId, pinSplitRule);
}

참고:

  1. 한 번에 하나의 활동만 고정할 수 있습니다. 현재 고정된 활동을 다음을 사용하여 고정 해제한 후
unpinTopActivityStack()

다른 활동을 고정합니다.

  1. 활동을 고정할 때 창 확장을 사용 설정하려면 다음 코드를
setDividerAttributes()

새로 생성된 다음 코드에도

SplitAttributes

호출합니다.

뒤로 탐색 변경사항

WindowManager 1.4에서는 뒤로 탐색 동작이 변경되었습니다. 버튼 탐색을 사용할 때 마지막으로 포커스가 맞춰진 활동으로 뒤로 이벤트가 전송됩니다.

버튼 탐색:

  • 버튼 탐색을 사용하면 이제 뒤로 이벤트가 마지막으로 포커스가 맞춰진 활동으로 일관되게 전송됩니다. 이렇게 하면 뒤로 탐색 동작이 간소화되어 사용자가 더 쉽게 예측할 수 있습니다.

동작 탐색:

  • Android 14(API 수준 34) 이하: 뒤로 동작은 동작이 발생한 활동에 이벤트를 전송하므로 화면 분할 시나리오에서는 예기치 않은 동작이 발생할 수 있습니다.
  • Android 15(API 수준 35) 이상:
  • 동일한 앱 활동: 뒤로 동작은 스와이프 방향과 관계없이 상위 활동을 일관되게 종료하여 보다 통합된 환경을 제공합니다.
  • 다른 앱 활동(오버레이): 뒤로 이벤트는 버튼 탐색 동작에 따라 포커스가 있는 마지막 활동으로 이동합니다.

실행해 보기

샘플 앱을 빌드하고 실행하세요.

활동 고정

  • DetailActivity 화면으로 이동합니다.
  • PIN THIS ACTIVITY 버튼을 탭합니다.

980d0033972737ed.gif

6. 전체 화면 대화상자 어둡게 처리

활동 삽입은 화면 분할 레이아웃을 용이하게 하지만 이전 버전의 대화상자는 자체 활동의 컨테이너만 어둡게 처리했습니다. 이는 특히 대화상자를 중앙에 배치하려는 경우 시각적 환경의 일관성이 깨질 수 있습니다.

해결 방법: WindowManager 1.4

  • 걱정하지 마세요. WindowManager 1.4를 사용하면 대화상자가 기본적으로 전체 앱 창을 어둡게 설정하여(DimAreaBehavior.Companion.ON_TASK) 더 몰입도 높고 집중된 느낌을 제공합니다.
  • 이전 동작을 원하시나요? 걱정하지 마세요. ON_ACTIVITY_STACK을 사용하여 활동의 컨테이너만 어둡게 설정할 수 있습니다.

ON_ACTIVITY_STACK

ON_TASK

ActivityEmbeddingController를 사용하여 전체 화면 어둡게 처리 동작을 관리하는 방법은 다음과 같습니다.

참고: 전체 화면 대화상자 어둡게 처리는 WindowManager 확장 프로그램 5 이상에서 사용할 수 있습니다.

SplitManager.kt/createSplit()

with(ActivityEmbeddingController.getInstance(context)) {
   if (WindowSdkExtensions.getInstance().extensionVersion  >= 5) {
       setEmbeddingConfiguration(
           EmbeddingConfiguration.Builder()
               .setDimAreaBehavior(ON_TASK)
               .build()
       )
   }
}

SplitManager.java/createSplit()

ActivityEmbeddingController controller = ActivityEmbeddingController.getInstance(context);
if (WindowSdkExtensions.getInstance().getExtensionVersion()  >= 5) {
    controller.setEmbeddingConfiguration(
        new EmbeddingConfiguration.Builder()
            .setDimAreaBehavior(EmbeddingConfiguration.DimAreaBehavior.ON_TASK)
            .build()
    );
}

전체 화면 어둡게 처리 기능을 보여주기 위해 Google에서는 활동을 고정하기 전에 사용자에게 확인을 요청하는 알림 대화상자를 도입합니다. 이 대화상자가 표시되면 활동이 있는 컨테이너뿐만 아니라 전체 애플리케이션 창이 어두워집니다.

DetailActivity.kt

pinButton.setOnClickListener {
 showAlertDialog(taskId)
}

...
private fun showAlertDialog(taskId: Int) {
 val builder = AlertDialog.Builder(this)
 builder.setTitle(getString(R.string.dialog_title))
 builder.setMessage(getString(R.string.dialog_message))
 builder.setPositiveButton(getString(R.string.button_yes)) { _, _ ->
   if (WindowSdkExtensions.getInstance().extensionVersion  >= 6) {
     pinActivityStackExample(taskId)
   }
 }
 builder.setNegativeButton(getString(R.string.button_cancel)) { _, _ ->
   // Cancel
 }
 val dialog: AlertDialog = builder.create()
 dialog.show()
}

DetailActivity.java

pinButton.setOnClickListener(new View.OnClickListener() {
   @Override
   public void onClick(View v) {
       showAlertDialog(getTaskId());
   }
});

...

private void showAlertDialog(int taskId) {
   AlertDialog.Builder builder = new AlertDialog.Builder(this);
   builder.setTitle(getString(R.string.dialog_title));
   builder.setMessage(getString(R.string.dialog_message));

   builder.setPositiveButton(getString(R.string.button_yes), new DialogInterface.OnClickListener() {
       @Override
       public void onClick(DialogInterface dialog, int which) {
           if (WindowSdkExtensions.getInstance().getExtensionVersion() >= 6) {
               pinActivityStackExample(taskId);
           }
       }
   });
   builder.setNegativeButton(getString(R.string.button_cancel), new DialogInterface.OnClickListener() {
       @Override
       public void onClick(DialogInterface dialog, int which) {
           // Cancel
       }
   });
   AlertDialog dialog = builder.create();
   dialog.show();
}

다음 문자열을 res/values/strings.xml에 추가합니다.

<!-- Dialog information -->
<string name="dialog_title">Activity Pinning</string>
<string name="dialog_message">Confirm to pin this activity</string>
<string name="button_yes">Yes</string>
<string name="button_cancel">Cancel</string>

실행해 보기

샘플 앱을 빌드하고 실행하세요.

활동 고정 버튼을 클릭합니다.

  • 고정 작업을 확인하라는 알림 대화상자가 표시됩니다.
  • 분할된 두 창을 포함한 전체 화면이 어두워져 대화상자에 초점이 맞춰집니다.

2d3455e0f8901f95.png

7. 권장사항

사용자가 이중 창 레이아웃을 사용 중지하도록 허용

새 레이아웃으로 더 원활하게 전환할 수 있도록 이중 창 뷰와 단일 열 뷰 간에 전환할 수 있는 기능을 사용자에게 제공해 보겠습니다. SplitAttributesCalculatorSharedPreferences를 사용하여 사용자 환경설정을 저장하면 됩니다.

Android 14 이하에서 분할 비율 변경

Android 15 이상에서 사용자가 분할 비율을 쉽게 조정할 수 있는 창 확장을 살펴봤습니다. 하지만 이전 Android 버전의 사용자에게도 비슷한 수준의 유연성을 제공하려면 어떻게 해야 할까요?

SplitAttributesCalculator를 사용하여 이를 달성하고 다양한 기기에서 일관된 환경을 제공하는 방법을 자세히 알아보겠습니다.

예를 들면 다음과 같이 표시됩니다.

a87452341434c86d.gif

설정 화면 만들기

먼저 사용자 구성을 위한 전용 설정 화면을 만들어 보겠습니다.

이 설정 화면에서 전체 애플리케이션의 활동 삽입 기능을 사용 설정하거나 중지하는 스위치를 통합합니다. 또한 사용자가 이중 창 레이아웃의 분할 비율을 조정할 수 있는 진행률 표시줄을 포함합니다. 분할 비율 값은 활동 삽입 스위치가 켜진 경우에만 적용됩니다.

사용자가 SettingsActivity에서 값을 설정하면 나중에 애플리케이션의 다른 위치에서 사용할 수 있도록 SharedPreferences에 저장합니다.

build.gradle

환경설정 종속 항목을 추가합니다.

implementation 'androidx.preference:preference-ktx:1.2.1' // Kotlin

또는

implementation 'androidx.preference:preference:1.2.1' // Java

SettingsActivity.kt

package com.example.activity_embedding

import android.os.Bundle
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SeekBarPreference
import androidx.preference.SwitchPreferenceCompat

class SettingsActivity : AppCompatActivity() {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.settings_activity)
    if (savedInstanceState == null) {
      supportFragmentManager
        .beginTransaction()
        .replace(R.id.settings, SettingsFragment())
        .commit()
    }
    supportActionBar?.setDisplayHomeAsUpEnabled(true)
  }

  override fun onOptionsItemSelected(item: MenuItem): Boolean {
    if (item.itemId == android.R.id.home) finishActivity()
    return super.onOptionsItemSelected(item)
  }

  private fun finishActivity() { finish() }

  class SettingsFragment : PreferenceFragmentCompat() {
    override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
      setPreferencesFromResource(R.xml.root_preferences, rootKey)
findPreference<SwitchPreferenceCompat>("dual_pane")?.setOnPreferenceChangeListener { _, newValue ->
        if (newValue as Boolean) {
          this.activity?.let {
            SharePref(it.applicationContext).setAEFlag(true)
          }
        } else {
          this.activity?.let {
            SharePref(it.applicationContext).setAEFlag(false)
          }
        }
        this.activity?.finish()
        true
      }

      val splitRatioPreference: SeekBarPreference? = findPreference("split_ratio")
      splitRatioPreference?.setOnPreferenceChangeListener { _, newValue ->
        if (newValue is Int) {
          this.activity?.let { SharePref(it.applicationContext).setSplitRatio(newValue.toFloat()/100) }
        }
        true
      }
    }
  }
}

SettingsActivity.java

package com.example.activity_embedding;

import android.os.Bundle;
import android.view.MenuItem;

import androidx.appcompat.app.AppCompatActivity;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.SeekBarPreference;
import androidx.preference.SwitchPreferenceCompat;

public class SettingsActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.settings_activity);
        if (savedInstanceState == null) {
            getSupportFragmentManager()
                .beginTransaction()
                .replace(R.id.settings, new SettingsFragment())
                .commit();
        }
        if (getSupportActionBar() != null) {
            getSupportActionBar().setDisplayHomeAsUpEnabled(true);
        }
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        if (item.getItemId() == android.R.id.home) {
            finishActivity();
            return true;
        }
        return super.onOptionsItemSelected(item);
    }

    private void finishActivity() {
        finish();
    }

    public static class SettingsFragment extends PreferenceFragmentCompat {
        @Override
        public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
            setPreferencesFromResource(R.xml.root_preferences, rootKey);

            SwitchPreferenceCompat dualPanePreference = findPreference("dual_pane");
            if (dualPanePreference != null) {
                dualPanePreference.setOnPreferenceChangeListener((preference, newValue) -> {
                    boolean isDualPane = (Boolean) newValue;
                    if (getActivity() != null) {
                        SharePref sharePref = new SharePref(getActivity().getApplicationContext());
                        sharePref.setAEFlag(isDualPane);
                        getActivity().finish();
                    }
                    return true;
                });
            }

            SeekBarPreference splitRatioPreference = findPreference("split_ratio");
            if (splitRatioPreference != null) {
                splitRatioPreference.setOnPreferenceChangeListener((preference, newValue) -> {
                    if (newValue instanceof Integer) {
                        float splitRatio = ((Integer) newValue) / 100f;
                        if (getActivity() != null) {
                            SharePref sharePref = new SharePref(getActivity().getApplicationContext());
                            sharePref.setSplitRatio(splitRatio);
                        }
                    }
                    return true;
                });
            }
        }
    }
}

layout 폴더에 settings_activity.xml 추가

settings_activity.xml

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   android:layout_width="match_parent"
   android:layout_height="match_parent">

   <FrameLayout
       android:id="@+id/settings"
       android:layout_width="match_parent"
       android:layout_height="match_parent" />
</LinearLayout>

매니페스트 파일에 SettingsActivity 추가

<activity
   android:name=".SettingsActivity"
   android:exported="false"
   android:label="@string/title_activity_settings" />

SettingsActivity의 분할 규칙 구성

SplitManager.kt/createSplit()

val settingActivityFilter = ActivityFilter(
   ComponentName(context, SettingsActivity::class.java),
   null
)
val settingActivityFilterSet = setOf(settingActivityFilter)
val settingActivityRule = ActivityRule.Builder(settingActivityFilterSet)
   .setAlwaysExpand(true)
   .build()
ruleController.addRule(settingActivityRule)

SplitManager.java/createSplit()

Set<ActivityFilter> settingActivityFilterSet = new HashSet<>();
ActivityFilter settingActivityFilter = new ActivityFilter(
        new ComponentName(context, SettingsActivity.class),
        null
);
settingActivityFilterSet.add(settingActivityFilter);
ActivityRule settingActivityRule = new ActivityRule.Builder(settingActivityFilterSet)
        .setAlwaysExpand(true).build();
ruleController.addRule(settingActivityRule);

다음은 SharedPreferences에 사용자 설정을 저장하는 코드입니다.

SharedPref.kt

package com.example.activity_embedding

import android.content.Context
import android.content.SharedPreferences

class SharePref(context: Context) {
    private val sharedPreferences: SharedPreferences =
        context.getSharedPreferences("my_app_preferences", Context.MODE_PRIVATE)

    companion object {
        private const val AE_FLAG = "is_activity_embedding_enabled"
        private const val SPLIT_RATIO = "activity_embedding_split_ratio"
        const val DEFAULT_SPLIT_RATIO = 0.3f
    }

    fun setAEFlag(isEnabled: Boolean) {
        sharedPreferences.edit().putBoolean(AE_FLAG, isEnabled).apply()
    }

    fun getAEFlag(): Boolean = sharedPreferences.getBoolean(AE_FLAG, true)

    fun getSplitRatio(): Float = sharedPreferences.getFloat(SPLIT_RATIO, DEFAULT_SPLIT_RATIO)

    fun setSplitRatio(ratio: Float) {
        sharedPreferences.edit().putFloat(SPLIT_RATIO, ratio).apply()
    }
}

SharedPref.java

package com.example.activity_embedding;

import android.content.Context;
import android.content.SharedPreferences;

public class SharePref {
    private static final String PREF_NAME = "my_app_preferences";
    private static final String AE_FLAG = "is_activity_embedding_enabled";
    private static final String SPLIT_RATIO = "activity_embedding_split_ratio";
    public static final float DEFAULT_SPLIT_RATIO = 0.3f;

    private final SharedPreferences sharedPreferences;

    public SharePref(Context context) {
        this.sharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
    }

    public void setAEFlag(boolean isEnabled) {
        SharedPreferences.Editor editor = sharedPreferences.edit();
        editor.putBoolean(AE_FLAG, isEnabled);
        editor.apply();
    }

    public boolean getAEFlag() {
        return sharedPreferences.getBoolean(AE_FLAG, true);
    }

    public float getSplitRatio() {
        return sharedPreferences.getFloat(SPLIT_RATIO, DEFAULT_SPLIT_RATIO);
    }

    public void setSplitRatio(float ratio) {
        SharedPreferences.Editor editor = sharedPreferences.edit();
        editor.putFloat(SPLIT_RATIO, ratio);
        editor.apply();
    }
}

환경설정 화면 레이아웃 xml도 필요합니다. 다음 코드를 사용하여 res/xml 아래에 root_preferences.xml을 만듭니다.

<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:android="http://schemas.android.com/apk/res/android">
   <PreferenceCategory app:title="@string/split_setting_header">

       <SwitchPreferenceCompat
           app:key="dual_pane"
           app:title="@string/dual_pane_title" />

       <SeekBarPreference
           app:key="split_ratio"
           app:title="@string/split_ratio_title"
           android:min="0"
           android:max="100"
           app:defaultValue="50"
           app:showSeekBarValue="true" />
   </PreferenceCategory>
</PreferenceScreen>

그리고 res/values/strings.xml에 다음을 추가합니다.

<string name="title_activity_settings">SettingsActivity</string>
<string name="split_setting_header">Dual Pane Display</string>
<string name="dual_pane_title">Dual Pane</string>
<string name="split_ratio_title">Split Ratio</string>

메뉴에 SettingsActivity 추가

사용자가 앱의 기본 인터페이스에서 쉽게 액세스할 수 있도록 새로 만든 SettingsActivity를 탐색 대상에 연결해 보겠습니다.

  1. ListActivity 파일에서 하단 탐색 메뉴와 왼쪽 탐색 레일의 변수를 선언합니다.

ListActivity.kt

 private lateinit var navRail: NavigationRailView private lateinit var bottomNav: BottomNavigationView

ListActivity.java

 private NavigationRailView navRail;  private BottomNavigationView bottomNav;
  1. ListActivityonCreate() 메서드 내에서 findViewById를 사용하여 이러한 변수를 레이아웃의 해당 뷰에 연결합니다.
  2. 하단 탐색 메뉴와 탐색 레일에 모두 OnItemSelectedListener를 추가하여 항목 선택 이벤트를 처리합니다.

ListActivity.kt/onCreate()

navRail  = findViewById(R.id.navigationRailView)
bottomNav = findViewById(R.id.bottomNavigationView)

val menuListener = NavigationBarView.OnItemSelectedListener { item ->
    when (item.itemId) {
        R.id.navigation_home -> {
            true
        }
        R.id.navigation_dashboard -> {
            true
        }
        R.id.navigation_settings -> {
            startActivity(Intent(this, SettingsActivity::class.java))
            true
        }
        else -> false
    }
}

navRail.setOnItemSelectedListener(menuListener)
bottomNav.setOnItemSelectedListener(menuListener)

ListActivity.java/onCreate()

NavigationRailView navRail = findViewById(R.id.navigationRailView);
BottomNavigationView bottomNav = findViewById(R.id.bottomNavigationView);

NavigationBarView.OnItemSelectedListener menuListener = new NavigationBarView.OnItemSelectedListener() {
   @Override
   public boolean onNavigationItemSelected(@NonNull MenuItem item) {
       switch (item.getItemId()) {
           case R.id.navigation_home:
               // Handle navigation_home selection
               return true;
           case R.id.navigation_dashboard:
               // Handle navigation_dashboard selection
               return true;
           case R.id.navigation_settings:
               startActivity(new Intent(ListActivity.this, SettingsActivity.class));
               return true;
           default:
               return false;
       }
   }
};

navRail.setOnItemSelectedListener(menuListener);
bottomNav.setOnItemSelectedListener(menuListener);

애플리케이션은 SharedPreferences를 읽고 분할 모드 또는 SPLIT_TYPE_EXPAND 모드로 앱을 렌더링합니다.

  • 창 구성이 변경되면 프로그램은 창 분할 제약 조건이 충족되는지 확인합니다(너비가 840dp보다 큰 경우).
  • 앱은 SharedPreferences 값을 확인하여 사용자가 디스플레이에 창 분할을 사용 설정했는지 확인합니다. 사용 설정하지 않은 경우 SPLIT_TYPE_EXPAND 유형으로 SplitAttribute를 반환합니다.
  • 창 분할이 사용 설정된 경우 앱은 SharedPreferences 값을 읽어 분할 비율을 가져옵니다. 이는 WindowSDKExtensions 버전이 6 미만인 경우에만 작동합니다. 버전 6은 이미 창 확장을 지원하고 분할 비율 설정을 무시합니다. 대신 개발자는 사용자가 UI에서 구분선을 드래그하도록 허용할 수 있습니다.

ListActivity.kt/onCreate()

...

SplitController.getInstance(this).setSplitAttributesCalculator{
       params -> params.defaultSplitAttributes
   if (params.areDefaultConstraintsSatisfied) {
       setWiderScreenNavigation(true)

       if (SharePref(this.applicationContext).getAEFlag()) {
           if (WindowSdkExtensions.getInstance().extensionVersion  < 6) {
               // Read a dynamic split ratio from shared preference.
               val currentSplit = SharePref(this.applicationContext).getSplitRatio()
               if (currentSplit != SharePref.DEFAULT_SPLIT_RATIO) {
                   return@setSplitAttributesCalculator SplitAttributes.Builder()
                       .setSplitType(SplitAttributes.SplitType.ratio(SharePref(this.applicationContext).getSplitRatio()))
                     .setLayoutDirection(SplitAttributes.LayoutDirection.LEFT_TO_RIGHT)
                       .build()
               }
           }
           return@setSplitAttributesCalculator params.defaultSplitAttributes
       } else {
           SplitAttributes.Builder()
               .setSplitType(SPLIT_TYPE_EXPAND)
               .build()
       }
   } else {
       setWiderScreenNavigation(false)
       SplitAttributes.Builder()
           .setSplitType(SPLIT_TYPE_EXPAND)
           .build()
   }
}

...

ListActivity.java/onCreate()

...
SplitController.getInstance(this).setSplitAttributesCalculator(params -> {
   if (params.areDefaultConstraintsSatisfied()) {
       setWiderScreenNavigation(true);

       SharePref sharedPreference = new SharePref(this.getApplicationContext());
       if (sharedPreference.getAEFlag()) {
           if (WindowSdkExtensions.getInstance().getExtensionVersion()  < 6) {
               // Read a dynamic split ratio from shared preference.
               float currentSplit = sharedPreference.getSplitRatio();
               if (currentSplit != SharePref.DEFAULT_SPLIT_RATIO) {
                   return new SplitAttributes.Builder()
                           .setSplitType(SplitAttributes.SplitType.ratio(sharedPreference.getSplitRatio()))
                           .setLayoutDirection(SplitAttributes.LayoutDirection.LEFT_TO_RIGHT)
                           .build();
               }
           }
           return params.getDefaultSplitAttributes();
       } else {
           return new SplitAttributes.Builder()
                   .setSplitType(SPLIT_TYPE_EXPAND)
                   .build();
       }
   } else {
       setWiderScreenNavigation(false);
       return new SplitAttributes.Builder()
               .setSplitType(SPLIT_TYPE_EXPAND)
               .build();
   }
});

...

설정이 변경된 후 SplitAttributesCalculator를 트리거하려면 현재 속성을 무효화해야 합니다. 이렇게 하려면 ActivityEmbeddingController;에서 invalidateVisibleActivityStacks()를 호출하면 됩니다. 이 메서드의 이름은 WindowManager 1.4 전에는 다음과 같았습니다.

invalidateTopVisibleSplitAttributes

ListActivity.kt/onResume()

override fun onResume() {
   super.onResume()
   ActivityEmbeddingController.getInstance(this).invalidateVisibleActivityStacks()
}

ListActivity.java/onResume()

@Override
public void onResume() {
    super.onResume();
    ActivityEmbeddingController.getInstance(this).invalidateVisibleActivityStacks();
}

실행해 보기

샘플 앱을 빌드하고 실행하세요.

설정 살펴보기:

  • 설정 화면으로 이동합니다.
  • 창 분할 사용 스위치를 사용 또는 사용 중지합니다.
  • 분할 비율 슬라이더를 조정합니다(기기에서 사용할 수 있는 경우).

레이아웃 변경사항 확인:

  • Android 14 이하를 실행하는 기기: 레이아웃은 스위치에 따라 단일 창 모드와 이중 창 모드 간에 전환되어야 하며, 분할 비율은 슬라이더를 조정하면 변경되어야 합니다.
  • Android 15 이상을 실행하는 기기: 창 확장을 사용하면 슬라이더 설정과 관계없이 창 크기를 동적으로 조절할 수 있습니다.

8. 축하합니다

잘하셨습니다. 활동 삽입과 WindowManager를 사용하여 강력한 새 기능으로 앱을 개선했습니다. 이제 사용자는 Android 버전과 관계없이 대형 화면에서 더 유연하고 직관적이며 몰입도 높은 환경을 즐길 수 있습니다.

9. 자세히 알아보기