Jetpack WindowManager로 폴더블 및 듀얼 화면 기기 지원

이 실용적인 Codelab에서는 듀얼 화면 및 폴더블 기기용 앱 개발의 기본사항을 알아봅니다. Codelab을 완료하면 Microsoft Surface Duo와 Samsung Galaxy Z Fold 2와 같은 기기를 지원하도록 앱을 개선할 수 있습니다.

기본 요건

이 Codelab을 완료하려면 다음이 필요합니다.

  • Android 앱 빌드 경험
  • 활동, 프래그먼트, ViewBinding, xml-layout을 사용한 경험
  • 프로젝트에 종속 항목 추가 경험
  • 기기 에뮬레이터 설치 및 사용 경험. 이 Codelab에서는 폴더블 및 듀얼 화면 에뮬레이터를 사용합니다.

실행할 작업

  • 간단한 앱을 만들고 폴더블 및 듀얼 화면 기기를 지원하도록 앱을 개선합니다.
  • Jetpack WindowManager를 사용하여 새로운 폼 팩터 기기로 작업합니다.

필요한 항목

  • Android 스튜디오 4.2 이상
  • 폴더블 기기 또는 에뮬레이터. Android 스튜디오 4.2를 사용하면 아래 이미지처럼 사용할 수 있는 폴더블 에뮬레이터가 몇 가지 있습니다.

7a0db14df3576a82.png

  • 듀얼 화면 에뮬레이터를 사용하려면 여기에서 플랫폼(Windows나 MacOS, GNU/Linux)에 맞는 Microsoft Surface Duo 에뮬레이터를 다운로드하면 됩니다.

폴더블 기기는 사용자에게 이전에 휴대기기에서 사용할 수 있었던 것보다 큰 화면과 더 다양한 사용자 인터페이스를 제공합니다. 또 다른 이점은 접었을 때 이러한 기기가 일반 크기 태블릿보다 작은 경우가 많아서 휴대성과 기능성이 더 뛰어나다는 것입니다.

이 글을 작성하는 시점에 폴더블 기기 유형에는 두 가지가 있습니다.

  • 단일 화면 폴더블 기기(화면 하나를 접을 수 있음). 사용자는 Multi-Window 모드를 사용하여 동시에 같은 화면에서 여러 앱을 실행할 수 있습니다.
  • 듀얼 화면 폴더블 기기(화면 두 개가 힌지로 결합됨). 이러한 기기도 접을 수 있지만 두 가지 다른 논리 디스플레이 영역이 있습니다.

affbd6daf04cfe7b.png

태블릿 및 기타 단일 화면 휴대기기와 마찬가지로 폴더블 기기는 다음 작업을 할 수 있습니다.

  • 한 디스플레이 영역에서 앱 하나를 실행합니다.
  • 다른 디스플레이 영역에서 두 앱을 하나씩 나란히 실행합니다(Multi-Window 모드 사용).

단일 화면 기기와 달리 폴더블 기기는 다양한 상태도 지원합니다. 상태는 다양한 방식으로 콘텐츠를 표시하는 데 사용할 수 있습니다.

f2287b68f32b59e3.png

폴더블 기기는 앱이 전체 디스플레이 영역(듀얼 화면 폴더블 기기의 모든 디스플레이 영역)에 걸쳐 스팬(표시)될 때 다양한 스팬 상태를 제공할 수 있습니다.

폴더블 기기는 접힌 상태로도 사용할 수 있습니다. 탁자 모드처럼 평평한 화면 부분과 사용자를 향해 기울어진 부분 간에 논리적 분할을 할 수 있고 텐트 모드와 같이 기기가 스탠드 가젯을 사용하는 것처럼 콘텐츠를 시각화할 수 있습니다.

Jetpack WindowManager 라이브러리는 개발자가 앱을 조정하여 이러한 기기가 사용자에게 제공하는 새로운 환경을 활용할 수 있도록 설계되었습니다. Jetpack WindowManager는 애플리케이션 개발자가 새로운 기기 폼 팩터를 지원하도록 돕고 이전 플랫폼 버전과 새 플랫폼 버전에서 모두 다양한 WindowManager 기능을 위한 공통 API 표시 영역을 제공합니다.

주요 기능

Jetpack WindowManager 버전 1.0.0-alpha03에는 유연한 디스플레이의 접는 부분 또는 물리적 디스플레이 패널 두 개 사이의 힌지를 설명하는 FoldingFeature 클래스가 포함되어 있습니다. API를 통해 기기와 관련된 중요한 정보에 액세스할 수 있습니다.

기본 WindowManager 클래스를 통해 다음과 같은 중요한 정보에 액세스할 수 있습니다.

  • getCurrentWindowMetrics(): 현재 시스템 상태에 따라 WindowMetrics를 반환합니다. 이 값은 시스템의 현재 윈도잉 상태에 기반합니다.
  • getMaximumWindowMetrics(): 현재 시스템 상태에 따라 가장 큰 WindowMetrics를 반환합니다. 이 값은 시스템의 가장 큰 잠재적인 윈도잉 상태에 기반합니다. 예를 들어 멀티 윈도우 모드에 있는 활동의 경우 반환된 측정항목은 사용자가 전체 화면을 덮도록 창을 확장한 경우의 경계에 기반합니다.

GitHub 저장소를 클론하거나 개선할 앱의 샘플 코드를 다운로드합니다.

git clone https://github.com/googlecodelabs/android-foldable-codelab

종속 항목 선언

Jetpack WindowManager를 사용하려면 이 라이브러리에 종속 항목을 추가해야 합니다.

  1. 먼저 프로젝트에 Google Maven 저장소를 추가합니다.
  2. 앱이나 모듈의 build.gradle 파일에 아티팩트의 종속 항목을 추가합니다.
dependencies {
    implementation "androidx.window:window:1.0.0-alpha03"
}

WindowManager 사용

Jetpack WindowManager는 구성 변경사항을 수신 대기하도록 앱을 등록하여 매우 쉽게 사용할 수 있습니다.

먼저 WindowManager 인스턴스를 초기화하여 API에 액세스할 수 있도록 합니다. WindowManager 인스턴스를 초기화하려면 활동 내에서 다음 코드를 구현합니다.

private lateinit var wm: WindowManager

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        wm = WindowManager(this)
}

기본 생성자는 매개변수(Activity 또는 한 활동 주변의 ContextWrapper와 같은 시각적 컨텍스트) 하나만 허용합니다. 내부적으로 이 생성자는 기본 WindowBackend를 사용합니다. 이 인스턴스의 정보를 제공하는 지원 서버 클래스입니다.

WindowManager 인스턴스를 보유하게 되면 콜백을 등록하여 상태 변경이 발생하는 시점과 기기에서 보유한 기기 기능, 그 기능의 경계(있는 경우)를 알 수 있습니다. 또한 앞서 언급한 것처럼 현재 시스템 상태에 따라 현재 및 최대 측정항목을 확인할 수 있습니다.

  1. Android 스튜디오를 엽니다.
  2. File > New > New Project > Empty Activity를 클릭하여 새 프로젝트를 만듭니다.
  3. Next를 클릭하고 기본 속성 및 값을 허용한 다음 Finish를 클릭합니다.

이제 간단한 레이아웃을 만들어 WindowManager에서 보고할 정보를 확인할 수 있도록 합니다. 이를 위해 레이아웃 폴더와 특정 레이아웃 파일을 만들어야 합니다.

  1. File > New > Android resource directory를 클릭합니다.
  2. 새 창에서 Resource Type layout을 선택하고 OK를 클릭합니다.
  3. 프로젝트 구조로 이동하여 src/main/res/layout에서 activity_main.xml이라는 새 레이아웃 리소스 파일(File > New > Layout resource file)을 만듭니다.
  4. 파일을 열고 이 콘텐츠를 레이아웃으로 추가합니다.

res/layout/activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<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/constraint_layout"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <TextView
       android:id="@+id/window_metrics"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:padding="20dp"
       android:text="@string/window_metrics"
       android:textSize="20sp"
       app:layout_constraintBottom_toTopOf="@+id/layout_change"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent"
       app:layout_constraintVertical_chainStyle="packed" />

   <TextView
       android:id="@+id/layout_change"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:padding="20dp"
       android:text="@string/layout_change_text"
       android:textSize="20sp"
       app:layout_constrainedWidth="true"
       app:layout_constraintBottom_toTopOf="@+id/configuration_changed"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/window_metrics" />

   <TextView
       android:id="@+id/configuration_changed"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:padding="20dp"
       android:text="@string/configuration_changed"
       android:textSize="20sp"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/layout_change" />

</androidx.constraintlayout.widget.ConstraintLayout>

이제 TextViews가 세 개 있는 ConstraintLayout에 기반하여 간단한 레이아웃을 빌드했습니다. 뷰는 상위 뷰 및 화면의 중앙에 정렬되기 위해 뷰 사이에 제한됩니다.

  1. MainActivity.kt 파일을 열고 다음 코드를 추가합니다.

window_manager/MainActivity.kt

class MainActivity : AppCompatActivity() {
  1. 콜백의 결과를 처리하는 데 도움이 되는 내부 클래스를 만듭니다.
inner class LayoutStateChangeCallback : Consumer<WindowLayoutInfo> {
   override fun accept(newLayoutInfo: WindowLayoutInfo) {
       printLayoutStateChange(newLayoutInfo)
   }
}

내부 클래스에서 사용하는 함수는 간단한 함수로, UI 구성요소(TextView)를 사용하여 WindowManager에서 가져오는 정보를 출력합니다.

private fun printLayoutStateChange(newLayoutInfo: WindowLayoutInfo) {
   binding.layoutChange.text = newLayoutInfo.toString()
   if (newLayoutInfo.displayFeatures.size > 0) {
       binding.configurationChanged.text = "Spanned across displays"
   } else {
       binding.configurationChanged.text = "One logic/physical display - unspanned"
   }
}
  1. lateinit WindowManager 변수를 선언합니다.
private lateinit var wm: WindowManager
  1. 이미 만든 내부 클래스를 통해 WindowManager를 사용하여 콜백을 처리할 변수를 만듭니다.
private val layoutStateChangeCallback = LayoutStateChangeCallback()
  1. 다양한 뷰에 액세스할 수 있도록 바인딩을 추가합니다.
private lateinit var binding: ActivityMainBinding
  1. 이제 Executor에서 확장되는 함수를 만들어 콜백에 첫 번째 매개변수로 제공할 수 있도록 합니다. 그러면 콜백이 호출될 때 사용됩니다. 이 경우에는 UI 스레드에서 실행되는 함수를 만듭니다. 필요에 따라 UI 스레드에서 실행되지 않는 다른 함수를 만들 수도 있습니다.
private fun runOnUiThreadExecutor(): Executor {
   val handler = Handler(Looper.getMainLooper())
   return Executor() {
       handler.post(it)
   }
}
  1. MainActivityonCreate에서 WindowManager lateinit를 초기화합니다.
override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   binding = ActivityMainBinding.inflate(layoutInflater)
   setContentView(binding.root)

   wm = WindowManager(this)
}

이제 WindowManager 인스턴스에 유일한 매개변수로 Activity가 있으므로 이 인스턴스가 기본 WindowManager 백엔드 구현을 사용합니다.

  1. 5단계에서 추가한 함수를 찾습니다. 함수 헤더 바로 뒤에 다음 줄을 추가합니다.
binding.windowMetrics.text =
   "CurrentWindowMetrics: ${wm.currentWindowMetrics.bounds.flattenToString()}\n" +
       "MaximumWindowMetrics: ${wm.maximumWindowMetrics.bounds.flattenToString()}"

여기서는 currentWindowMetrics.bounds.flattenToString()maximumWindowMetrics.bounds.flattenToString() 함수에 포함된 값을 사용하여 window_metrics TextView 값을 설정합니다.

이러한 값은 창이 차지하는 영역의 측정항목에 관한 유용한 정보를 제공합니다. 아래 이미지와 같이 듀얼 화면 에뮬레이터에서는 미러링되는 기기의 크기에 맞는 CurrentWindowMetrics를 가져옵니다. 앱이 단일 화면 모드에서 실행될 때도 측정항목을 확인할 수 있습니다.

b032c729d6dce292.png

아래 이미지에서는 앱이 여러 디스플레이에 걸쳐 스팬될 때 측정항목이 어떻게 변경되는지 확인할 수 있습니다. 이제는 측정항목이 앱에서 사용하는 더 큰 창 영역을 반영합니다.

b72ca8a63b65e4c1.png

현재 및 최대 창 측정항목은 모두 동일한 값을 보유합니다. 앱이 단일 및 듀얼 화면에서 모두 항상 실행되고 사용 가능한 전체 디스플레이 영역을 차지하기 때문입니다.

수평형 접이식 폴더블 에뮬레이터에서는 앱이 전체 물리적 디스플레이에 스팬되어 실행되고 멀티 윈도우를 사용할 때 값이 다릅니다.

5cb5270ee0e42320.png

왼쪽 이미지에서 확인할 수 있듯이 두 측정항목의 값은 같습니다. 실행되는 앱이 현재 최대로 사용 가능한 전체 디스플레이 영역을 사용하기 때문입니다.

그러나 오른쪽 이미지에서 멀티 윈도우 모드로 실행되는 앱의 경우 현재 측정항목이 멀티 윈도우 모드의 특정 영역(상단)에서 앱이 실행되는 영역의 크기를 어떻게 표시하는지 확인할 수 있습니다. 또한 최대 측정항목이 기기의 최대 디스플레이 영역을 어떻게 표시하는지 확인할 수 있습니다.

WindowManager에서 제공하는 측정항목은 앱에서 사용하거나 사용할 수 있는 창 영역을 파악하는 데 매우 유용합니다.

이제 레이아웃 변경을 등록합니다. 그러면 기기의 기능(힌지 또는 접이식 기기인지)과 기능의 경계를 알 수 있습니다.

사용해야 하는 함수에는 다음과 같은 서명이 있습니다.

public void registerLayoutChangeCallback (
                Executor executor,
                Consumer<WindowLayoutInfo> callback)

이 함수는 WindowLayoutInfo 유형을 사용합니다. 이 클래스에는 콜백을 호출할 때 살펴봐야 하는 데이터가 있습니다. 내부적으로 이 클래스에는 List< DisplayFeature>가 포함되어 있으며 이 목록에서는 앱과 교차되는 기기에서 발견된 DisplayFeatures 목록을 반환합니다. 앱과 교차되는 디스플레이 기능이 없으면 목록은 비어 있을 수 있습니다.

이 클래스에서 DisplayFeature를 구현하고 결과로 List<DisplayFeature>를 가져오면 항목을 FoldingFeature로 전송할 수 있고 여기서 기기의 상태, 기기 기능 유형, 기능의 경계와 같은 정보를 알 수 있습니다.

이 콜백을 사용하여 콜백이 제공하는 정보를 시각화하는 방법을 살펴보겠습니다. 이전 단계(샘플 앱 빌드)에서 이미 추가한 코드에 다음을 실행합니다.

  1. onAttachedToWindow 메서드를 재정의합니다.
override fun onAttachedToWindow() {
   super.onAttachedToWindow()
  1. 이전에 구현한 실행기를 첫 번째 매개변수로 사용하여 레이아웃 변경 콜백에 등록하는 WindowManager 인스턴스를 사용합니다.
   wm.registerLayoutChangeCallback(
       runOnUiThreadExecutor(),
       layoutStateChangeCallback
   )
}

이 콜백에서 제공하는 정보가 어떤 모습인지 살펴보겠습니다. 듀얼 화면 에뮬레이터에서 이 코드를 실행하면 다음과 같이 표시됩니다.

49a85b4d10245a9d.png

WindowLayoutInfo가 비어 있는 것을 확인할 수 있습니다. 빈 List<DisplayFeature>가 있지만 중간에 힌지가 있는 에뮬레이터가 있다면 WindowManager에서 정보를 가져오는 게 좋습니다.

WindowManager는 앱이 여러 디스플레이(물리적이든 아니든)에 걸쳐 스팬될 때만 LayoutInfo 데이터(기기 기능 유형, 기기 기능 경계, 기기 상태)를 제공합니다. 따라서 이전 그림에서는 앱이 단일 화면 모드에서 실행되어 WindowLayoutInfo가 비어 있습니다.

이를 고려하면 앱이 실행되는 모드(단일 화면 모드 또는 스팬 모드)를 알 수 있으므로 UI/UX를 변경하여 이러한 특정 구성에 맞게 조정된 더 나은 환경을 사용자에게 제공할 수 있습니다.

두 개의 물리적 디스플레이가 없는 기기(일반적으로 물리적 힌지가 없음)에서는 앱이 멀티 윈도우를 사용하여 나란히 실행될 수 있습니다. 이러한 기기의 앱이 멀티 윈도우에서 실행되면 이전 예와 같이 단일 화면에서 실행되는 것처럼 작동하고 앱이 모든 논리 디스플레이를 차지하며 실행되면 앱이 스팬된 것처럼 작동합니다. 다음 그림에서 확인할 수 있습니다.

ecdada42f6df1fb8.png

앱이 멀티 윈도우 모드에서 실행될 때 폴더블 기능과 교차하지 않으므로 WindowManager는 빈 List<LayoutInfo>를 반환하는 것을 확인할 수 있습니다.

요약하면 앱이 기기 기능(접이식 또는 힌지)과 교차할 때만 LayoutInfo 데이터를 얻고 교차하지 않으면 어떤 정보도 얻지 못합니다. 564eb78fc85f6d3e.png

여러 디스플레이에 걸쳐 앱을 스팬하면 어떻게 되나요? 듀얼 화면 에뮬레이터에서 LayoutInfo에 포함되는 FoldingFeature 객체는 기기 기능(HINGE), 기능의 경계(Rect(0, 0- 1434, 1800)), 기기 상태(FLAT)에 관한 데이터를 제공합니다.

13edea3ff94baae4.png

앞서 언급했듯이 기기 유형은 소스 코드에서도 노출되는 것처럼 FOLDHINGE, 두 값을 사용할 수 있습니다.

@IntDef({
       TYPE_FOLD,
       TYPE_HINGE,
})
  • type = TYPE_HINGE. 이 듀얼 화면 에뮬레이터는 물리적 힌지가 있는 실제 Surface Duo 기기를 미러링하고 이는 WindowManager가 보고하는 내용입니다.
  • Rect(0, 0 - 1434, 1800)는 창 좌표 공간에서 애플리케이션 창 내 기능의 경계 직사각형을 나타냅니다. Surface Duo 기기의 크기 사양을 읽으면 힌지가 보고된 이러한 경계(왼쪽, 상단, 오른쪽, 하단)를 충족하는 위치에 있음을 알 수 있습니다.
  • 기기의 기기 상태를 나타내는 값은 3가지가 있습니다.
  • STATE_HALF_OPENED - 폴더블 기기의 힌지가 열린 상태와 닫힌 상태 사이의 중간 위치에 있고 유연한 화면 부분 간 또는 물리적 화면 패널 간에 평평하지 않은 각도가 있습니다.
  • STATE_FLAT - 폴더블 기기가 완전히 열려 있고 사용자에게 표시되는 화면 공간이 평평합니다.
  • STATE_FLIPPED - 폴더블 기기가 유연한 화면 부분 또는 물리적 화면이 반대 방향을 향하며 뒤집혀 있습니다.
@IntDef({
       STATE_HALF_OPENED,
       STATE_FLAT,
       STATE_FLIPPED,
})

에뮬레이터는 기본적으로 180도로 열리므로 WindowManager에서 반환하는 상태는 STATE_FLAT입니다.

Virtual Sensors를 사용하여 에뮬레이터의 상태를 Half Opened 상태로 변경하면 WindowManager에서 새 상태 STATE_HALF_OPENED를 알려 줍니다.

7cfb0b26d251bd1.png

더 이상 필요하지 않을 때 이 콜백에서 등록 취소할 수 있습니다. WindowManager API에서 다음 함수를 호출하기만 하면 됩니다.

public void unregisterDeviceStateChangeCallback (Consumer<DeviceState> callback)

콜백을 등록 취소하기에 좋은 위치는 onDestroy 또는 onDetachedFromWindow 메서드입니다.

override fun onDetachedFromWindow() {
   super.onDetachedFromWindow()
   wm.unregisterLayoutChangeCallback(layoutStateChangeCallback)
}

WindowManager를 사용하여 UI/UX 조정

창 레이아웃 정보를 보여 주는 그림에서 확인했듯이 표시된 정보는 디스플레이 기능으로 인해 잘렸습니다. 여기서 다시 확인할 수 있습니다.

4ee805070989f322.png

사용자에게 제공할 수 있는 최상의 환경이라고 할 수 없습니다. WindowManager에서 제공하는 정보를 사용하여 UI/UX를 조정할 수 있습니다.

이전에 살펴본 것처럼 앱이 여러 디스플레이 영역에 걸쳐 스팬될 때는 앱이 기기 기능과 교차되는 때이기도 하므로 WindowManager는 창 레이아웃 정보를 디스플레이 기능과 디스플레이 경계로 제공합니다. 여기서는 앱이 스팬될 때가 이 정보를 사용하여 UI/UX를 조정해야 하는 때입니다.

다음으로 할 작업은 앱이 스팬되는 런타임에 현재 보유한 UI/UX를 조정하여 중요한 정보가 디스플레이 기능으로 인해 잘리거나 숨겨지지 않도록 하는 것입니다. 기기의 디스플레이 기능을 미러링하여 잘리거나 숨겨진 TextView를 제한하는 참조로 사용할 뷰를 만듭니다. 따라서 더 이상 정보가 누락되지 않습니다.

학습 목적으로 이 새로운 뷰의 색상을 지정합니다. 따라서 뷰가 실제 기기 디스플레이 기능과 정확히 동일한 위치에 있고 크기도 같음을 쉽게 알 수 있습니다.

  1. activity_main.xml에서 기기 기능 참조로 사용할 새로운 뷰를 추가합니다.

res/layout/activity_main.xml

<View
   android:id="@+id/device_feature"
   android:layout_width="0dp"
   android:layout_height="0dp"
   android:background="@android:color/holo_red_dark"
   android:visibility="gone" />
  1. MainActivity.kt에서 WindowManager 콜백의 정보를 표시하는 데 사용한 함수로 이동하여 디스플레이 기능이 있는 if-else 사례에 새 함수 호출을 추가합니다.

window_manager/MainActivity.kt

private fun printLayoutStateChange(newLayoutInfo: WindowLayoutInfo) {
   binding.windowMetrics.text =
       "CurrentWindowMetrics: ${wm.currentWindowMetrics.bounds.flattenToString()}\n" +
           "MaximumWindowMetrics: ${wm.maximumWindowMetrics.bounds.flattenToString()}"

   binding.layoutChange.text = newLayoutInfo.toString()
   if (newLayoutInfo.displayFeatures.size > 0) {
       binding.configurationChanged.text = "Spanned across displays"
       alignViewToDeviceFeatureBoundaries(newLayoutInfo)
   } else {
       binding.configurationChanged.text = "One logic/physical display - unspanned"
   }
}

WindowLayoutInfo를 매개변수로 수신하는 alignViewToDeviceFeatureBoundaries 함수를 추가했습니다.

  1. 새 함수 내에서 ConstraintSet를 만들어 뷰에 새로운 제약 조건을 적용합니다.
private fun alignViewToDeviceFeatureBoundaries(newLayoutInfo: WindowLayoutInfo) {
   val constraintLayout = binding.constraintLayout
   val set = ConstraintSet()
   set.clone(constraintLayout)
  1. 이제 WindowLayoutInfo:를 사용하여 디스플레이 기능 경계를 가져옵니다.
val rect = newLayoutInfo.displayFeatures[0].bounds
  1. 이제 rect 변수에 제공된 WindowLayoutInfo를 사용하여 참조 뷰의 올바른 높이를 설정합니다.
set.constrainHeight(
   R.id.device_feature,
   rect.bottom - rect.top
)
  1. 이제 오른쪽 좌표 - 왼쪽 좌표에 따라 디스플레이 기능의 너비에 맞춰 뷰를 조정하므로 기기 기능의 너비를 파악할 수 있습니다.
set.constrainWidth(R.id.device_feature, rect.right - rect.left)
  1. 정렬 제약 조건을 뷰 참조로 설정하여 시작 및 상단 쪽에서 상위 요소에 정렬되도록 합니다.
set.connect(
   R.id.device_feature, ConstraintSet.START,
   ConstraintSet.PARENT_ID, ConstraintSet.START, 0
)
set.connect(
   R.id.device_feature, ConstraintSet.TOP,
   ConstraintSet.PARENT_ID, ConstraintSet.TOP, 0
)

여기 코드 대신 xml에 뷰 속성으로 직접 추가할 수도 있습니다.

다음은 가능한 모든 기기 기능 배치를 다룹니다. 디스플레이 기능이 수직으로 배치된 기기(예: 듀얼 화면 에뮬레이터)와 디스플레이 기능이 수평으로 배치된 기기(예: 수평형 접이식 폴더블 에뮬레이터)가 포함됩니다.

  1. 첫 번째 시나리오의 경우 top == 0은 기기 기능이 수직으로 배치된다는 것을 나타냅니다(예: 듀얼 화면 에뮬레이터).
if (rect.top == 0) {
  1. 이제 참조 뷰에 여백을 적용하면 실제 디스플레이 기능과 정확히 동일한 위치에 배치됩니다.
  2. 그런 다음 디스플레이 기능을 피하기 위해 더 잘 배치하려는 TextView에 제약 조건을 적용합니다. 그러면 제약 조건에서 기능을 고려합니다.
set.setMargin(R.id.device_feature, ConstraintSet.START, rect.left)
set.connect(
   R.id.layout_change, ConstraintSet.END,
   R.id.device_feature, ConstraintSet.START, 0
)

수평형 디스플레이 기능

사용자 기기는 수평형 접이식 폴더블 에뮬레이터처럼 디스플레이 기능이 수평으로 배치되어 있을 수 있습니다.

UI에 따라 표시할 툴바나 상태 표시줄이 있을 수 있으므로 높이를 가져와서 디스플레이 기능 표현을 조정하여 UI에 완벽하게 맞도록 하는 것이 좋습니다.

샘플 앱에는 상태 표시줄과 툴바가 있습니다.

val statusBarHeight = calculateStatusBarHeight()
val toolBarHeight = calculateToolbarHeight()

이러한 계산을 실행하는 함수의 간단한 구현(현재 함수 외부에 있음)은 다음과 같습니다.

private fun calculateToolbarHeight(): Int {
   val typedValue = TypedValue()
   return if (theme.resolveAttribute(android.R.attr.actionBarSize, typedValue, true)) {
       TypedValue.complexToDimensionPixelSize(typedValue.data, resources.displayMetrics)
   } else {
       0
   }
}

private fun calculateStatusBarHeight(): Int {
   val rect = Rect()
   window.decorView.getWindowVisibleDisplayFrame(rect)
   return rect.top
}

다시 수평형 기기 기능을 처리하는 else 문의 기본 함수에서 여백의 경우 상태 표시줄 높이와 툴바 높이를 사용할 수 있습니다. 디스플레이 기능의 경계가 UI 요소를 전혀 고려하지 않고 (0,0) 좌표에서 가져오기 때문입니다. 이러한 요소를 고려해야 참조 뷰를 올바른 위치에 배치할 수 있습니다.

} else {
   //Device feature is placed horizontally
   val statusBarHeight = calculateStatusBarHeight()
   val toolBarHeight = calculateToolbarHeight()
   set.setMargin(
       R.id.device_feature, ConstraintSet.TOP,
       rect.top - statusBarHeight - toolBarHeight
   )
   set.connect(
       R.id.layout_change, ConstraintSet.TOP,
       R.id.device_feature, ConstraintSet.BOTTOM, 0
   )
}

다음 단계는 참조 뷰의 공개 상태를 visible로 변경하는 것입니다. 그러면 샘플(빨간색이 적용됨)에서 확인할 수 있고 더 중요하게는 제약 조건이 적용됩니다. 뷰가 사라지면 적용할 제약 조건이 없습니다.

set.setVisibility(R.id.device_feature, View.VISIBLE)

마지막 단계는 빌드한 ConstraintSetConstraintLayout,에 적용하여 모든 변경사항과 UI 조정사항을 적용하는 것입니다.

    set.applyTo(constraintLayout)
}

이제 기기 디스플레이 기능과 충돌한 TextView가 기능이 있는 위치를 고려하므로 콘텐츠가 잘리거나 숨겨지지 않습니다.

80993d3695a9a60.png

듀얼 화면 에뮬레이터(왼쪽)에서는 콘텐츠를 여러 디스플레이에 걸쳐 표시하고 힌지로 인해 잘렸던 TextView가 어떻게 더 이상 잘리지 않고 정보가 누락되지 않는지 확인할 수 있습니다.

폴더블 에뮬레이터(오른쪽)에서는 접이식 디스플레이 기능이 배치된 위치를 나타내는 연한 빨간색 선이 표시되고 TextView는 이제 기능 아래에 배치되었습니다. 따라서 기기가 접힐 때(예: 노트북 상태인 90도로) 이 기능으로 영향을 받는 정보가 없습니다.

듀얼 화면 에뮬레이터에서 디스플레이 기능의 위치가 궁금하다면 힌지 유형 기기이므로 기능을 나타내는 뷰가 힌지로 인해 숨겨집니다. 그러나 앱을 스팬에서 스팬 해제로 이동하면 기능과 동일한 위치에 올바른 높이와 너비로 표시됩니다.

4dbe464ac71b498e.png

지금까지 폴더블 기기와 단일 화면 기기의 차이를 알아봤습니다.

폴더블 기기에서 제공하는 기능의 하나는 적은 공간에서 더 많은 작업을 달성할 수 있도록 두 앱을 나란히 실행하는 옵션입니다. 예를 들어 사용자는 한쪽에 이메일 앱을, 다른 쪽에는 캘린더 앱을 표시하거나 한 화면에서 영상 통화를 하고 다른 화면에서는 메모를 할 수 있습니다. 가능성은 무궁무진합니다.

Android 프레임워크에 포함된 기존 API만 사용하여 두 화면을 활용할 수 있습니다. 실행할 수 있는 개선사항을 살펴보겠습니다.

인접한 창에 활동 시작

이 개선사항을 통해 앱이 인접한 창에서 새 활동을 시작할 수 있으므로 많은 작업을 실행할 필요 없이 여러 창 영역을 동시에 활용할 수 있습니다.

클릭하면 앱이 새 활동을 시작하는 버튼이 있다고 가정해 보겠습니다.

  1. 먼저 클릭 이벤트를 처리할 함수를 만듭니다.

intent/MainActivity.kt

private fun openActivityInAdjacentWindow() {
}
  1. 함수 내에서 새 활동을 시작하는 데 사용할 Intent를 만듭니다. 이 사례에서는 SecondActivity라고 합니다. 메시지로 TextView가 있는 간단한 활동입니다.
val intent = Intent(this, SecondActivity::class.java)
  1. 이제 인접한 화면이 비어 있을 때 새 활동을 시작할 플래그를 설정합니다.
intent.addFlags(
   Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT or
       Intent.FLAG_ACTIVITY_NEW_TASK
)

이러한 플래그의 기능은 다음과 같습니다.

  • FLAG_ACTIVITY_NEW_TASK = 설정되면 이 활동은 이 기록 스택에서 새 작업의 시작이 됩니다.
  • FLAG_ACTIVITY_LAUNCH_ADJACENT = 이 플래그는 화면 분할 멀티 윈도우 모드에 사용되고 독립형 물리적 화면이 있는 듀얼 화면 기기에서도 작동합니다. 새 활동은 활동을 시작하는 활동에 인접하여 표시됩니다.

플랫폼은 새 작업을 확인하면 인접한 창을 사용하여 작업을 그 창에 할당하려고 합니다. 새 작업이 현재 작업 위에서 시작되므로 새 활동은 현재 활동 위에서 시작됩니다.

  1. 마지막 단계는 직접 만든 인텐트를 사용하여 새 활동을 간단하게 시작하는 것입니다.
     startActivity(intent)

결과 테스트 앱은 아래 애니메이션과 같이 동작합니다. 버튼을 클릭하면 새 활동이 인접한 빈 창에서 시작됩니다.

듀얼 화면 기기와 멀티 윈도우 모드의 폴더블에서 실행되는 것을 확인할 수 있습니다.

9696f7fa2ee1e35f.gif a2dc98dae26e3045.gif

드래그 앤 드롭

앱에 드래그 앤 드롭을 추가하면 사용자가 만족할 만한 매우 유용한 기능을 제공할 수 있습니다. 이 기능을 통해 앱은 다른 앱에 콘텐츠를 제공하거나(드래그 구현) 다른 앱의 콘텐츠를 허용(드롭 구현)하거나 두 가지 기능을 모두 포함할 수 있습니다. 따라서 앱에서 다른 앱과 앱 자체(예: 같은 앱 내의 다른 위치에 있는 콘텐츠)의 콘텐츠를 제공하고 허용할 수 있습니다.

드래그 앤 드롭은 API 11부터 Android 프레임워크에서 제공되었지만 API 수준 24에서 Multi-Window 지원이 도입되기 전까지는 사용되지 않았습니다. 이 지원을 통해 드래그 앤 드롭이 유용해졌는데 같은 화면에서 나란히 실행되는 앱 간에 요소를 드래그 앤 드롭할 수 있었기 때문입니다.

이제 멀티 윈도우용 영역이 많거나 두 개의 다른 논리 화면까지 있을 수 있는 폴더블 기기가 도입되면서 드래그 앤 드롭이 더 유용해집니다. 유용한 시나리오에는 드롭될 때 새 작업이 되는 텍스트를 허용(드롭)하는 할 일 앱이나 날짜/시간 슬롯에 콘텐츠를 허용(드롭)하여 이벤트가 되는 캘린더 앱 등이 있습니다.

앱은 데이터 소비자가 되기 위해 드래그 동작을 구현하거나 데이터 생산자가 되기 위해 드롭 동작을 구현해야 이 기능을 활용할 수 있습니다.

샘플에서는 한 앱에서 드래그를 구현하고 다른 앱에서 드롭을 구현하지만 물론 같은 앱에서 드래그 앤 드롭을 구현할 수도 있습니다.

드래그 구현

'드래그 앱'은 단순히 TextView를 포함하며 사용자가 TextView를 길게 클릭하면 드래그 작업을 트리거합니다.

  1. 먼저 File > New > New Project > Empty Activity로 이동하여 새 앱을 만듭니다.
  2. 이미 만들어진 activity_main.xml로 이동합니다. 여기에서 기존 레이아웃을 다음으로 바꿉니다.

res/layout/activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:orientation="vertical"
   tools:context=".MainActivity">

   <TextView
       android:id="@+id/drag_text_view"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:layout_margin="20dp"
       android:text="@string/drag_text"
       android:textSize="30sp" />
</LinearLayout>
  1. 이제 MainActivity.kt 파일을 열어 태그를 추가하고 setOnLongClickListener 함수를 호출합니다.

drag/MainActivity.kt

class MainActivity : AppCompatActivity(), View.OnLongClickListener {
   private lateinit var binding: ActivityMainBinding

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       binding = ActivityMainBinding.inflate(layoutInflater)
       setContentView(binding.root)

       binding.dragTextView.tag = "text_view"
       binding.dragTextView.setOnLongClickListener(this)
   }
  1. 이제 onLongClick 함수를 재정의하여 TextView에서 onLongClickListener 이벤트의 재정의된 이 기능을 사용할 수 있도록 합니다.
override fun onLongClick(view: View): Boolean {
  1. 수신자 매개변수가 드래그 기능을 추가하는 View 유형인지 확인합니다. 이 사례에서는 TextView입니다.
return if (view is TextView) {
  1. TextView가 보유하는 텍스트에서 ClipData.item을 만듭니다.
val text = ClipData.Item(view.text)
  1. 이제 사용할 MimeType을 정의합니다.
val mimeType = arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN)
  1. 이전에 만든 항목으로 데이터 공유에 사용할 번들(ClipData 인스턴스)을 만듭니다.
val dataToShare = ClipData(view.tag.toString(), mimeType, text)

사용자에게 피드백을 제공하는 것은 매우 중요하므로 드래그하는 내용에 관한 시각적 정보를 제공하는 것이 좋습니다.

  1. 드래그 상호작용이 실행될 때 사용자가 손가락 아래의 콘텐츠를 볼 수 있도록 드래그하는 콘텐츠의 섀도우를 만듭니다.
val dragShadowBuilder = View.DragShadowBuilder(view)
  1. 이제 다양한 앱 간에 드래그 앤 드롭을 허용하려고 하므로 먼저 이 기능을 사용 설정할 일련의 플래그를 정의해야 합니다.
val flags =
   View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ

문서에 따르면 플래그의 의미는 다음과 같습니다.

  • DRAG_FLAG_GLOBAL: 드래그가 창 경계를 넘을 수 있다고 나타내는 플래그입니다.
  • DRAG_FLAG_GLOBAL_URI_READ: 이 플래그를 DRAG_FLAG_GLOBAL과 함께 사용하면 드래그 수신자는 ClipData 객체에 포함된 콘텐츠 URI 읽기 액세스를 요청할 수 있습니다.
  1. 마지막으로 직접 만든 구성요소로 뷰에서 startDragAndDrop 함수를 호출하면 드래그 상호작용이 시작됩니다.
view.startDragAndDrop(dataToShare, dragShadowBuilder, view, flags)
  1. onLongClick functionMainActivity를 완성하고 닫습니다.
         true
       } else {
           false
       }
   }
}

드롭 구현

샘플에서는 EditText에 연결된 드롭 기능이 있는 간단한 앱을 만듭니다. 이 뷰는 텍스트 데이터(TextView의 드래그 앱에서 가져올 수 있음)를 허용합니다.

EditText(또는 드롭 영역)는 현재 드래그 단계에 따라 배경을 변경하므로 사용자에게 드래그 앤 드롭 상호작용 상태에 관한 정보를 제공할 수 있고 사용자는 언제 콘텐츠 드롭이 허용되는지 확인할 수 있습니다.

  1. 먼저 File > New > New Project > Empty Activity로 이동하여 새 앱을 만듭니다.
  2. 이미 만들어진 activity_main.xml로 이동합니다. 기존 레이아웃을 다음으로 바꿉니다.

res/layout/activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<EditText
   android:id="@+id/drop_edit_text"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:background="@android:color/holo_blue_dark"
   android:gravity="top"
   android:hint="@string/drop_text"
   android:textColor="@android:color/white"
   android:textSize="30sp" />

</RelativeLayout>
  1. 이제 MainActivity.kt 파일을 열고 리스너를 EditText setOnDragListener 함수에 추가합니다.

drop/MainActivity.kt

class MainActivity : AppCompatActivity(), View.OnDragListener {
   private lateinit var binding: ActivityMainBinding

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       binding = ActivityMainBinding.inflate(layoutInflater)
       setContentView(binding.root)
       binding.dropEditText.setOnDragListener(this)
   }
  1. 이제 onDrag 함수를 재정의하여 EditText가 위에 설명한 것처럼 onDragListener 함수의 재정의된 이 콜백을 사용할 수 있도록 합니다.

이 함수는 사용자의 손가락이 드롭 영역에 들어가거나 나올 때, 사용자가 드롭이 실행되도록 드롭 영역에서 손가락을 떼거나 드롭 영역 밖에서 손가락을 떼 드래그 앤 드롭 상호작용을 취소할 때 등 DragEvent가 발생할 때마다 호출됩니다.

override fun onDrag(v: View, event: DragEvent): Boolean {
  1. 트리거되는 다양한 DragEvents에 반응하려면 다양한 이벤트를 처리하는 when 문을 추가합니다.
return when (event.action) {
  1. 드래그 상호작용이 시작될 때 트리거되는 ACTION_DRAG_STARTED를 처리합니다. 이 이벤트가 트리거되면 드롭 영역 색상이 변경되므로 사용자가 EditText가 드롭된 콘텐츠를 허용함을 알 수 있습니다.
DragEvent.ACTION_DRAG_STARTED -> {
       setDragStartedBackground()
       true
}
  1. 손가락이 드롭 영역에 들어올 때 트리거되는 ACTION_DRAG_ENTERED 드래그 이벤트를 처리합니다. 드롭 영역의 배경 색상을 다시 변경하여 드롭 영역이 준비되었다고 사용자에게 나타냅니다. 물론 이 이벤트를 생략하고 배경 이벤트를 변경하지 않을 수도 있습니다. 이 내용은 정보 제공 목적일 뿐입니다.
DragEvent.ACTION_DRAG_ENTERED -> {
   setDragEnteredBackground()
   true
}
  1. 지금 ACTION_DROP 이벤트를 처리합니다. 이 이벤트는 사용자가 드롭 영역에서 손가락으로 드래그 콘텐츠를 놓을 때 트리거되므로 드롭 작업이 실행될 수 있습니다.
DragEvent.ACTION_DROP -> {
   handleDrop(event)
   true
}

드롭 작업 처리 방법은 나중에 살펴봅니다.

  1. 다음으로 ACTION_DRAG_ENDED 이벤트를 처리합니다. 이 이벤트는 ACTION_DROP 후에 트리거되므로 전체 드래그 앤 드롭 작업이 완료되었습니다.

이 시점에서 변경한 내용(예: 드롭 영역의 배경을 원래 값으로 변경)을 복원하는 것이 좋습니다.

DragEvent.ACTION_DRAG_ENDED -> {
   clearBackgroundColor()
   true
}
  1. 다음으로 ACTION_DRAG_EXITED 이벤트를 처리합니다. 이 이벤트는 사용자가 드롭 영역을 벗어날 때(손가락이 드롭 영역에 있다가 벗어날 때) 트리거됩니다.

여기에서 배경을 변경하여 드롭 영역에 들어온 것을 강조표시하면 이전 값으로 복원하기 좋은 시점입니다.

DragEvent.ACTION_DRAG_EXITED -> {
   setDragStartedBackground()
   true
}
  1. 마지막으로 when 문의 else 사례를 처리하고 onDrag 함수를 닫습니다.
      else -> false
   }
}

이제 드롭 작업이 어떻게 처리되는지 살펴보겠습니다. ACTION_DROP 이벤트가 트리거될 때를 확인하기 전에 여기에서 드롭 기능을 처리해야 하므로 이제 그 방법을 알아보겠습니다.

  1. DragEvent를 매개변수로 전달합니다. 드래그 데이터를 보유하는 객체이기 때문입니다.
private fun handleDrop(event: DragEvent) {
  1. 함수 내에서 드래그 앤 드롭 권한을 요청합니다. 이 작업은 다른 앱 간에 드래그 앤 드롭을 실행할 때 필요합니다.
val dropPermissions = requestDragAndDropPermissions(event)
  1. DragEvent 매개변수를 통해 '드래그 단계'에서 이전에 만든 clipData 항목에 액세스할 수 있습니다.
val item = event.clipData.getItemAt(0)
  1. 이제 드래그 항목을 사용하여 항목을 보유하고 공유된 텍스트에 액세스합니다. 드래그 샘플의 TextView에 있던 텍스트입니다.
val dragData = item.text.toString()
  1. 이제 공유된 실제 데이터(텍스트)가 있으므로 코드에서 EditText로 텍스트를 설정할 때 일반적으로 하는 것처럼 드롭 영역(EditText)으로 설정하기만 하면 됩니다.
binding.dropEditText.setText(dragData)
  1. 마지막 단계는 요청된 드래그 앤 드롭 권한을 해제하는 것입니다. 드롭 작업이 완료된 후 해제하지 않으면 활동이 소멸될 때 권한이 자동으로 해제됩니다. 함수와 클래스를 닫습니다.
      dropPermissions?.release()
   }
}

드롭 샘플 앱에서 이 드롭 구현을 구현하고 나면 두 앱을 나란히 실행하여 드래그 앤 드롭이 어떻게 작동하는지 확인할 수 있습니다.

아래 애니메이션에서 어떻게 작동하는지 다양한 드래그 이벤트가 어떻게 트리거되는지 이러한 이벤트를 처리할 때 어떤 작업을 하는지(특정 DragEvent에 따라 드롭 영역 배경 변경 및 콘텐츠 드롭)를 확인하세요.

d66c5c24c6ea81b3.gif

이 콘텐츠 블록에서 살펴본 것처럼 Jetpack WindowManager를 사용하면 폴더블과 같은 새로운 폼 팩터 기기로 작업할 수 있습니다.

제공되는 정보가 앱을 이러한 기기에 맞게 조정하는 데 매우 유용하므로 앱이 이러한 기기에서 실행될 때 더 나은 환경을 제공할 수 있습니다.

다음은 전체 Codelab을 통해 알아본 내용을 요약한 것입니다.

  • 폴더블 기기의 정의
  • 다양한 폴더블 기기의 차이점
  • 폴더블 기기, 단일 화면 기기, 태블릿의 차이점
  • Jetpack WindowManager. 이 API가 제공하는 기능
  • Jetpack WindowManager 사용 및 새로운 폼 팩터 기기에 맞게 앱 조정
  • 인접한 빈 창에서 활동이 시작되도록 최소한의 변경사항을 추가하고 앱 간에 작동하는 드래그 앤 드롭을 구현하여 앱 향상

자세히 알아보기