동작 탐색과 더 넓은 화면 환경

Android 버전 10 이상을 사용하는 경우 동작 탐색이 새 모드로 지원됩니다. 동작 탐색을 사용하면 앱이 전체 화면을 사용할 수 있고 몰입도가 더욱 높은 화면 환경을 제공할 수 있습니다. 사용자가 화면의 하단 가장자리에서 위로 스와이프하면 Android 홈 화면으로 이동합니다. 왼쪽이나 오른쪽 가장자리에서 안쪽으로 스와이프하면 사용자가 이전 화면으로 이동합니다.

이러한 두 개의 동작을 사용하여 앱은 화면 하단에서 화면의 실제 공간을 활용할 수 있습니다. 하지만, 앱이 동작을 사용하거나 시스템 동작 영역에 앱이 소유한 컨트롤이 포함되어 있다면 앱 동작은 시스템 전체 동작과 충돌을 일으킬 수도 있습니다.

이 Codelab에서는 인셋을 사용하여 동작 충돌을 피하는 방법을 배웁니다. 또한, 이 Codelab에서는 동작 영역 안에 있어야 하는 컨트롤(예: 드래그 핸들)을 위해 Gesture Exclusion API를 사용하는 방법도 배웁니다.

학습 내용

  • 뷰에서 인셋 리스너를 사용하는 방법
  • Gesture Exclusion API를 사용하는 방법
  • 동작이 활성 상태일 때 몰입형 모드가 동작하는 방식

이 Codelab에서는 앱이 시스템 동작과 호환되도록 하는 방법을 설명합니다. 이 주제와 관련이 없는 개념 및 코드 블록은 자세히 다루지 않으며 복사하여 붙여넣을 수 있도록 제공됩니다.

빌드할 항목

범용 Android 뮤직 플레이어(UAMP)는 Kotlin으로 작성된 Android용 뮤직 플레이어 앱의 예입니다. 여기서는 UAMP에 동작 탐색을 설정합니다.

  • 인셋을 사용하여 동작 영역에서 컨트롤 이동
  • Gesture Exclusion API를 사용하여 충돌하는 컨트롤의 뒤로 동작을 선택 해제
  • 빌드를 사용하여 동작 탐색으로 몰입형 모드 동작의 변경사항 탐색

필요한 항목

범용 Android 뮤직 플레이어(UAMP)는 Kotlin으로 작성된 Android용 뮤직 플레이어 샘플 앱입니다. 이 앱은 백그라운드 재생, 오디오 포커스 처리, 어시스턴트 통합 및 여러 플랫폼(예: Wear, TV, 자동차)을 포함하는 기능을 지원합니다.

그림 1: UAMP 흐름

UAMP는 원격 서버에서 뮤직 카탈로그를 로드하며 사용자가 앨범과 노래를 둘러볼 수 있습니다. 사용자가 노래를 탭하면 연결된 스피커 또는 헤드폰을 통해 노래가 재생됩니다. 앱은 시스템 동작과 호환되지 않습니다. 따라서, Android 10 이상을 실행하는 기기에서 UAMP를 실행하는 경우 처음에는 몇 가지 문제가 발생합니다.

샘플 앱을 다운로드 받으려면 GitHub에서 저장소를 클론하고 starter 브랜치로 전환하세요.

$  git clone https://github.com/googlecodelabs/android-gestural-navigation/


또는, ZIP 파일로 저장소를 다운로드한 다음 압축을 풀고 Android 스튜디오에서 열어도 됩니다.

Zip 파일 다운로드

다음 단계를 완료합니다.

  1. Android 스튜디오에서 앱을 열고 빌드합니다.
  2. 새 가상 기기를 만들고 API level 29를 선택합니다. 또는 API 수준 29 이상을 실행하는 실제 기기를 연결해도 됩니다.
  3. 앱을 실행합니다. RecommendedAlbums에 속하도록 선택된 노래 목록이 표시됩니다.
  4. Recommended를 클릭하고 노래 목록에서 노래를 선택합니다.
  5. 앱이 노래 재생을 시작합니다.

동작 탐색 사용 설정

API 수준 29로 새 에뮬레이터 인스턴스를 실행하면 동작 탐색이 기본 설정되지 않을 수도 있습니다. 동작 탐색을 사용 설정하려면 System settings > System > System Navigation > Gesture Navigation을 선택합니다.

동작 탐색으로 앱 실행

동작 탐색이 사용 설정된 앱을 실행하고 노래 재생을 시작하면 플레이어 컨트롤이 홈과 뒤로 동작 영역에 매우 가까이 있다는 것을 알게 됩니다.

더 넓은 화면이란 무엇인가요?

Android 10 이상을 실행하는 앱은 동작이나 버튼 중 무엇이 탐색용으로 사용 설정되어 있는지와 관계없이 화면 전체를 사용하는 더 넓은 화면 환경을 제공할 수 있습니다. 더 넓은 화면 환경을 제공하려면 앱이 투명한 탐색 메뉴 및 상태 표시줄 뒤에 그려야 합니다.

탐색 메뉴 뒤에 그리기

앱이 탐색 메뉴 아래에 콘텐츠를 렌더링하려면 탐색 메뉴 배경을 투명하게 만들어야 합니다. 그런 다음, 상태 표시줄을 투명하게 해야 합니다. 이렇게 하면 앱이 화면의 전체 높이에 맞게 앱을 표시할 수 있습니다.

탐색 메뉴와 상태 표시줄의 색상을 변경하려면 다음 단계를 따르세요.

  1. 탐색 메뉴: res/values-29/styles.xml을 열고 navigationBarColorcolor/transparent로 설정합니다.
  2. 상태 표시줄: 마찬가지로 statusBarColorcolor/transparent로 설정합니다.

아래에 나와 있는 res/values-29/styles.xml의 코드 샘플을 검토해 보세요.

<!-- change navigation bar color -->
<item name="android:navigationBarColor">
    @android:color/transparent
</item>

<!-- change status bar color -->
<item name="android:statusBarColor">
    @android:color/transparent
</item>

시스템 UI 공개 상태 플래그

시스템 UI 공개 상태 플래그를 설정하여 시스템 표시줄 아래 앱을 배치한다는 것을 시스템에도 알려야 합니다. View 클래스의 systemUiVisibility API를 사용하면 다양한 플래그를 설정할 수 있습니다. 다음 단계를 따르세요.

  1. MainActivity.kt 클래스를 열고 onCreate() 메서드를 찾습니다. fragmentContainer 인스턴스를 가져옵니다.
  2. 다음 플래그를 content.systemUiVisibility에 설정합니다.

아래에 나와 있는 MainActivity.kt의 코드 샘플을 검토해 보세요.

  val content: FrameLayout = findViewById(R.id.fragmentContainer)
  content.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
            View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
            View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION

이러한 플래그를 함께 설정하면 탐색 메뉴와 상태 표시줄이 없는 것처럼 앱을 전체 화면에 표시하고 싶다고 시스템에 알리게 됩니다. 다음 단계를 따르세요.

  1. 앱을 실행하고 플레이어 화면으로 이동하려면 재생할 노래를 선택합니다.
  2. 플레이어 컨트롤이 탐색 메뉴 밑에 위치하고 이로 인해 액세스하기가 어려운지 확인합니다.

  1. 시스템 설정으로 이동하여 버튼 세 개가 포함된 탐색 모드로 다시 전환한 다음, 앱으로 다시 이동합니다.
  2. 버튼 세 개가 있는 탐색 메뉴가 표시될 때 컨트롤을 사용하기가 더 어려워지는지 확인합니다. SeekBar는 탐색 메뉴 뒤에 숨겨지고 Play/Pause는 탐색 메뉴에 의해 거의 가려집니다.
  3. 좀 더 살펴보고 실험해 보세요. 완료된 후에는 시스템 설정으로 이동하여 동작 탐색으로 다시 전환합니다.

이제 앱이 더 큰 화면에 그려지지만, 앱 컨트롤이 충돌하고 겹치는 사용성 문제가 있습니다. 이러한 문제는 해결해야 합니다.

WindowInsets는 시스템 동작이 인앱 동작보다 우선순위가 높은 화면 영역 및 인앱 콘텐츠 위에 시스템 UI가 표시되는 위치를 앱에 알려줍니다. 인셋은 JetpackWindowInsets 클래스와 WindowInsetsCompat 클래스로 표현됩니다. WindowInsetsCompat을 사용하여 모든 API 수준에서 일관된 동작을 하는 것이 좋습니다.

시스템 인셋과 필수 시스템 인셋

다음 인셋 API는 가장 많이 사용되는 인셋 유형입니다.

  • 시스템 윈도우 인셋: 앱 위에 시스템 UI가 표시되는 위치를 알려줍니다. 시스템 표시줄에서 컨트롤을 이동하기 위한 시스템 인셋 사용 방식에 관해 논의합니다.
  • 시스템 동작 인셋: 모든 동작 영역을 반환합니다. 이러한 영역에 있는 모든 인앱 스와이프 컨트롤은 실수로 시스템 동작을 트리거할 수 있습니다.
  • 필수 동작 인셋: 시스템 동작 인셋의 하위 집합이며 재정의될 수 없습니다. 인앱 동작보다 항상 우선순위가 높은 시스템 동작의 화면 위치 영역을 알려줍니다.

인셋을 사용하여 앱 컨트롤 이동

이제 인셋 API에 관해 더 자세히 알게 되었으니 다음 단계에 설명된 것처럼 앱 컨트롤을 고정할 수 있습니다.

  1. view 객체 인스턴스에서 playerLayout 인스턴스를 가져옵니다.
  2. playerViewOnApplyWindowInsetsListener를 추가합니다.
  3. 뷰를 동작 영역에서 일정 간격 이동합니다. 하단의 시스템 인셋 값을 찾아 그 값만큼 뷰의 패딩을 늘립니다. 이에 맞게 뷰의 패딩을 업데이트하려면 [앱의 하단 패딩과 연결된 값]에 [시스템 인셋 하단 값과 연결된 값]을 추가합니다.

아래에 나와 있는 NowPlayingFragment.kt의 코드 샘플을 검토해 보세요.

playerView = view.findViewById(R.id.playerLayout)
playerView.setOnApplyWindowInsetsListener { view, insets ->
   view.updatePadding(
      bottom = insets.systemWindowInsetBottom + view.paddingBottom
   )
   insets
}
  1. 앱을 실행하고 노래를 선택합니다. 재생 컨트롤에서는 변경된 것이 없어 보입니다. 디버그에서 중단점을 추가하고 앱을 실행하면 리스너가 호출되지 않는 것을 알 수 있습니다.
  2. 이 문제를 수정하려면 이 문제를 자동으로 처리하는 FragmentContainerView로 전환합니다. activity_main.xml을 열고 FrameLayoutFragmentContainerView로 변경합니다.

아래에 나와 있는 activity_main.xml의 코드 샘플을 검토해 보세요.

<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/fragmentContainer"
    tools:context="com.example.android.uamp.MainActivity"
    android:layout_width="match_parent"
    android:layout_height="match_parent"/>
  1. 앱을 다시 실행하고 플레이어 화면으로 이동합니다. 하단 재생 컨트롤이 하단 동작 영역에서 일정한 간격만큼 옮겨집니다.

이제 앱 컨트롤은 동작 탐색과 호환되지만, 컨트롤이 예상보다 더 많이 이동합니다. 이 문제를 해결해야 합니다.

현재 패딩과 여백 유지

앱을 닫지 않고 다른 앱으로 전환하거나 홈 화면으로 이동한 후 앱으로 다시 돌아오려면 재생 컨트롤을 매번 위로 이동해야 합니다.

이는 활동이 시작할 때마다 앱이 requestApplyInsets()을 트리거하기 때문입니다. 이 호출이 없더라도 WindowInsets은 뷰의 수명 주기 동안 언제든지 여러 번 전달될 수 있습니다.

인셋 하단 값을 activity_main.xml에 선언된 앱의 하단 패딩 값에 추가하면 처음에는 playerView의 현재 InsetListener가 완벽하게 작동합니다. 그러나, 후속 호출이 인셋 하단 값을 이미 업데이트된 뷰의 하단 패딩에 계속 추가합니다.

이를 해결하려면 다음 단계를 실행하세요.

  1. 초기 뷰 패딩 값을 기록합니다. 리스너 코드 바로 앞에서 새로운 값을 만들고 playerView의 초기 뷰 패딩 값을 저장합니다.

아래에 나와 있는 NowPlayingFragment.kt의 코드 샘플을 검토해 보세요.

   val initialPadding = playerView.paddingBottom
  1. 이 초기값을 사용하여 뷰의 하단 패딩을 업데이트하면 앱의 현재 하단 패딩 값을 사용하지 않아도 됩니다.

아래에 나와 있는 NowPlayingFragment.kt의 코드 샘플을 검토해 보세요.

   playerView.setOnApplyWindowInsetsListener { view, insets ->
            view.updatePadding(bottom = insets.systemWindowInsetBottom + initialPadding)
            insets
        }
  1. 앱을 다시 실행합니다. 앱 간에 이동하고 홈 화면으로 이동하세요. 앱을 반환하면 재생 컨트롤은 동작 영역 위 올바른 위치에 있습니다.

앱 컨트롤 재설계

플레이어의 탐색바는 하단 동작 영역에 너무 가까워서 사용자가 수평으로 스와이프 동작을 완료할 때 실수로 홈 동작을 트리거할 수 있습니다. 패딩을 더 늘리면 이 문제를 해결할 수 있지만 원하는 위치보다 더 높이 플레이어를 이동해야 할 수도 있습니다.

인셋을 사용하면 동작 충돌을 해결할 수 있지만, 때로는 약간의 설계 변경으로 동작 충돌을 완전히 피할 수 있습니다. 동작 충돌을 피하기 위해 재생 컨트롤을 재설계하려면 다음 단계를 실행하세요.

  1. fragment_nowplaying.xml을 엽니다. 디자인 보기로 전환하고 맨 밑에서 SeekBar를 선택합니다.

  1. 코드 보기로 전환합니다.
  2. playerLayout의 상단으로 SeekBar를 이동하려면 탐색바의 layout_constraintTop_toBottomOfparent로 변경합니다.
  3. playerView의 다른 항목이 SeekBar의 하단으로 이동하게 하려면 상위 요소에서 media_button title, position@+id/seekBarlayout_constraintTop_toTopOf를 변경합니다.

아래에 나와 있는 fragment_nowplaying.xml의 코드 샘플을 검토해 보세요.

<androidx.constraintlayout.widget.ConstraintLayout
   android:layout_width="match_parent"
   android:layout_height="wrap_content"
   android:padding="8dp"
   android:layout_gravity="bottom"
   android:background="@drawable/media_overlay_background"
   android:id="@+id/playerLayout">

   <ImageButton
       android:id="@+id/media_button"
       android:layout_width="@dimen/exo_media_button_width"
       android:layout_height="@dimen/exo_media_button_height"
       android:background="?attr/selectableItemBackground"
       android:scaleType="centerInside"
       app:layout_constraintLeft_toLeftOf="parent"
       app:layout_constraintTop_toTopOf="@+id/seekBar"
       app:srcCompat="@drawable/ic_play_arrow_black_24dp"
       tools:ignore="ContentDescription" />

   <TextView
       android:id="@+id/title"
       android:layout_width="0dp"
       android:layout_height="wrap_content"
       android:layout_marginTop="8dp"
       android:layout_marginStart="@dimen/text_margin"
       android:layout_marginEnd="@dimen/text_margin"
       android:ellipsize="end"
       android:maxLines="1"
       android:textAppearance="@style/TextAppearance.Uamp.Title"
       app:layout_constraintTop_toTopOf="@+id/seekBar"
       app:layout_constraintLeft_toRightOf="@id/media_button"
       app:layout_constraintRight_toLeftOf="@id/position"
       tools:text="Song Title" />

   <TextView
       android:id="@+id/subtitle"
       android:layout_width="0dp"
       android:layout_height="wrap_content"
       android:layout_marginStart="@dimen/text_margin"
       android:layout_marginEnd="@dimen/text_margin"
       android:ellipsize="end"
       android:maxLines="1"
       android:textAppearance="@style/TextAppearance.Uamp.Subtitle"
       app:layout_constraintTop_toBottomOf="@+id/title"
       app:layout_constraintLeft_toRightOf="@id/media_button"
       app:layout_constraintRight_toLeftOf="@id/position"
       tools:text="Artist" />

   <TextView
       android:id="@+id/position"
       android:layout_width="0dp"
       android:layout_height="wrap_content"
       android:layout_marginTop="8dp"
       android:layout_marginStart="@dimen/text_margin"
       android:layout_marginEnd="@dimen/text_margin"
       android:ellipsize="end"
       android:maxLines="1"
       android:textAppearance="@style/TextAppearance.Uamp.Title"
       app:layout_constraintTop_toTopOf="@+id/seekBar"
       app:layout_constraintRight_toRightOf="parent"
       tools:text="0:00" />

   <TextView
       android:id="@+id/duration"
       android:layout_width="0dp"
       android:layout_height="wrap_content"
       android:layout_marginStart="@dimen/text_margin"
       android:layout_marginEnd="@dimen/text_margin"
       android:ellipsize="end"
       android:maxLines="1"
       android:textAppearance="@style/TextAppearance.Uamp.Subtitle"
       app:layout_constraintTop_toBottomOf="@id/position"
       app:layout_constraintRight_toRightOf="parent"
       tools:text="0:00" />

   <SeekBar
       android:id="@+id/seekBar"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
  1. 앱을 실행하고 플레이어 및 탐색바와 상호작용합니다.

이러한 최소한의 설계 변경으로 앱의 품질이 크게 향상됩니다.

홈 동작 영역에서 동작 충돌을 일으키던 재생 컨트롤이 수정되었습니다. 뒤로 동작 영역은 앱 컨트롤로 인해 충돌이 발생할 수도 있습니다. 다음 스크린샷은 현재 오른쪽과 왼쪽 뒤로 동작 영역 모두에 있는 플레이어 탐색바를 보여줍니다.

SeekBar는 동작 충돌을 자동으로 처리합니다. 그러나 동작 충돌을 트리거하는 다른 UI 구성요소를 사용해야 할 수도 있습니다. 이러한 경우 Gesture Exclusion API를 사용하여 부분적으로 뒤로 동작을 수신 해제하면 됩니다.

Gesture Exclusion API 사용

동작 제외 영역을 만들려면 rect 객체 목록을 사용하여 뷰에서 setSystemGestureExclusionRects()를 호출합니다. 이러한 rect 객체는 제외된 사각형 영역의 좌표에 매핑됩니다. 이 호출은 뷰의 onLayout() 또는 onDraw() 메서드에서 실행되어야 합니다. 이렇게 하려면 다음 단계를 실행하세요.

  1. view라는 새 패키지를 만듭니다.
  2. 이 API를 호출하려면 MySeekBar라는 새 클래스를 만들고 AppCompatSeekBar를 확장합니다.

아래에 나와 있는 MySeekBar.kt의 코드 샘플을 검토해 보세요.

class MySeekBar @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = android.R.attr.seekBarStyle
) : androidx.appcompat.widget.AppCompatSeekBar(context, attrs, defStyle) {

}
  1. updateGestureExclusion()이라는 새 메서드를 만듭니다.

아래에 나와 있는 MySeekBar.kt의 코드 샘플을 검토해 보세요.

private fun updateGestureExclusion() {

}
  1. API 수준 28 이하에서는 이 호출을 건너뛰도록 검사를 추가합니다.

아래에 나와 있는 MySeekBar.kt의 코드 샘플을 검토해 보세요.

private fun updateGestureExclusion() {
        // Skip this call if we're not running on Android 10+
        if (Build.VERSION.SDK_INT < 29) return
}
  1. Gesture Exclusion API는 200dp 제한이 있으므로 탐색바의 미리보기만 제외합니다. 탐색바의 경계 사본을 가져오고 각 객체를 변경 가능한 목록에 추가합니다.

아래에 나와 있는 MySeekBar.kt의 코드 샘플을 검토해 보세요.

private val gestureExclusionRects = mutableListOf<Rect>()

private fun updateGestureExclusion() {
    // Skip this call if we're not running on Android 10+
    if (Build.VERSION.SDK_INT < 29) return

    thumb?.also { t ->
        gestureExclusionRects += t.copyBounds()
    }
}
  1. 직접 만든 gestureExclusionRects 목록을 사용하여 systemGestureExclusionRects()를 호출합니다.

아래에 나와 있는 MySeekBar.kt의 코드 샘플을 검토해 보세요.

private val gestureExclusionRects = mutableListOf<Rect>()

private fun updateGestureExclusion() {
    // Skip this call if we're not running on Android 10+
    if (Build.VERSION.SDK_INT < 29) return

    thumb?.also { t ->
        gestureExclusionRects += t.copyBounds()
    }
    // Finally pass our updated list of rectangles to the system
    systemGestureExclusionRects = gestureExclusionRects
}
  1. onDraw()onLayout()에서 updateGestureExclusion() 메서드를 호출합니다. onDraw()를 재정의하고 updateGestureExclusion 호출을 추가합니다.

아래에 나와 있는 MySeekBar.kt의 코드 샘플을 검토해 보세요.

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    updateGestureExclusion()
}
  1. SeekBar 참조를 업데이트해야 합니다. 시작하려면 fragment_nowplaying.xml을 엽니다.
  2. SeekBarcom.example.android.uamp.view.MySeekBar로 변경합니다.

아래에 나와 있는 fragment_nowplaying.xml의 코드 샘플을 검토해 보세요.

<com.example.android.uamp.view.MySeekBar
    android:id="@+id/seekBar"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="parent" />
  1. NowPlayingFragment.ktSeekBar 참조를 업데이트하려면 NowPlayingFragment.kt를 열고 positionSeekBar의 유형을 MySeekBar로 변경합니다. 변수 유형을 일치시키려면 findViewById 호출의 SeekBar 제네릭을 MySeekBar로 변경합니다.

아래에 나와 있는 NowPlayingFragment.kt의 코드 샘플을 검토해 보세요.

val positionSeekBar: MySeekBar = view.findViewById<MySeekBar>(
     R.id.seekBar
).apply { progress = 0 }
  1. 앱을 실행하고 SeekBar와 상호작용합니다. 계속 동작 충돌이 발생한다면 MySeekBar의 미리보기 경계를 실험하고 수정하면 됩니다. 이는 다른 동작 제외 호출을 잠재적으로 제한하고 사용자에게 동작의 불일치가 나타나므로 동작 제외 영역을 필요보다 더 크게 만들지 않도록 주의합니다.

축하합니다. 시스템 동작으로 인해 발생하는 충돌을 피하고 해결하는 방식을 알아보았습니다.

더 넓은 화면으로 확장하고 인셋을 사용하여 앱 컨트롤을 동작 영역에서 일정 간격 떨어지도록 이동했다면 앱이 전체 화면을 사용하게 됩니다. 또한, 앱 컨트롤에서 시스템 뒤로 동작을 사용 중지하는 방법도 배웠습니다.

지금까지 앱이 시스템 동작과 호환되도록 하는 데 필요한 주요 단계를 알아보았습니다.

추가 자료

참조 문서