Jetpack WindowManager로 폴더블 기기에서 카메라 앱 최적화하기

1. 시작하기 전에

폴더블의 특별한 점

폴더블은 새로운 세대의 시작을 알리는 혁신적인 기술입니다. 고유한 환경을 제공하며 핸즈프리 사용을 위한 탁자 UI와 같은 차별화된 기능으로 사용자를 즐겁게 할 수 있는 특별한 기회를 제공합니다.

기본 요건

  • Android 앱 개발에 관한 기본 지식
  • Hilt 종속 항목 삽입 프레임워크에 관한 기본 지식

빌드할 항목

이 Codelab에서는 폴더블 기기용으로 최적화된 레이아웃을 갖춘 카메라 앱을 빌드합니다.

c5e52933bcd81859.png

기기 상태에 반응하지 않는 기본 카메라 앱으로 시작하거나 향상된 셀카를 위해 더 나은 후면 카메라를 활용합니다. 기기를 펼쳤을 때 미리보기를 더 작은 디스플레이로 이동하고 탁자 모드로 설정된 휴대전화에 반응하도록 소스 코드를 업데이트합니다.

카메라 앱이 이 API의 가장 편리한 사용 사례이지만 이 Codelab에서 배우는 두 기능은 모두 어느 앱에나 적용할 수 있습니다.

학습할 내용

  • Jetpack WindowManager를 사용하여 상태 변경에 반응하는 방법
  • 앱을 폴더블의 더 작은 디스플레이로 이동하는 방법

필요한 항목

  • Android 스튜디오 최신 버전
  • 폴더블 기기 또는 폴더블 에뮬레이터

2. 설정

시작 코드 가져오기

  1. Git을 설치했다면 아래 명령어를 실행하면 됩니다. Git이 설치되어 있는지 확인하려면 터미널이나 명령줄에 git --version을 입력하여 올바르게 실행되는지 확인합니다.
git clone https://github.com/android/large-screen-codelabs.git
  1. 선택사항: Git이 없는 경우 다음 버튼을 클릭하여 이 Codelab을 위한 모든 코드를 다운로드할 수 있습니다.

첫 번째 모듈 열기

  • Android 스튜디오에서 /step1 아래 첫 번째 모듈을 엽니다.

이 Codelab과 관련된 코드를 보여주는 Android 스튜디오 스크린샷

최신 Gradle 버전을 사용하라는 메시지가 표시되면 계속 진행하며 업데이트합니다.

3. 실행과 관찰

  1. 모듈 step1에서 코드를 실행합니다.

보시다시피 이것은 간단한 카메라 앱입니다. 전면 카메라와 후면 카메라를 전환할 수 있으며 가로세로 비율을 조정할 수 있습니다. 현재 왼쪽의 첫 번째 버튼은 아무 동작도 하지 않지만 후면 셀카 모드를 시작하는 역할을 하게 됩니다.

149e3f9841af7726.png

  1. 이제 힌지가 완전히 평평하거나 닫히지 않고 90도 각도를 이루는 반쯤 열린 위치가 되도록 기기를 놓아보세요.

보시다시피 앱은 다양한 기기 상태에 반응하지 않으므로 레이아웃이 변경되지 않고 힌지가 뷰파인더 중앙에 유지됩니다.

4. Jetpack WindowManager 알아보기

Jetpack WindowManager 라이브러리는 앱 개발자가 폴더블 기기에 최적화된 환경을 구축하는 데 도움이 됩니다. 여기에는 유연한 디스플레이의 접힘 부분 또는 물리적 디스플레이 패널 두 개 사이의 힌지를 설명하는 FoldingFeature 클래스가 포함되어 있습니다. API를 통해 기기와 관련된 중요한 정보에 액세스할 수 있습니다.

FoldingFeature 클래스에는 occlusionType() 또는 isSeparating()과 같은 추가 정보가 포함되어 있지만 이 Codelab에서는 자세히 다루지 않습니다.

라이브러리 버전 1.2.0-beta01부터 후면 디스플레이 모드에서 현재 창을 후면 카메라와 정렬된 디스플레이로 이동시키는 API인 WindowAreaController를 사용합니다. 이렇게 하면 후면 카메라로 셀카를 찍을 때 및 다양한 여러 사용 사례에서 유용합니다.

종속 항목 추가

  • 앱에서 Jetpack WindowManager를 사용하려면 다음 종속 항목을 모듈 수준 build.gradle 파일에 추가해야 합니다.

step1/build.gradle

def work_version = '1.2.0'
implementation "androidx.window:window:$work_version"
implementation "androidx.window:window-java:$work_version"
implementation "androidx.window:window-core:$work_version"

이제 앱에서 FoldingFeatureWindowAreaController 클래스 모두에 액세스할 수 있습니다. 이 클래스를 사용하여 최고의 폴더블 카메라 환경을 구축합니다.

5. 후면 셀카 모드 구현

후면 디스플레이 모드로 시작합니다.

이 모드를 허용하는 API는 WindowAreaController로, 기기의 디스플레이 또는 디스플레이 영역 간에 창을 이동하는 것과 관련된 정보와 동작을 제공합니다.

이를 통해 현재 상호작용할 수 있는 WindowAreaInfo 목록을 쿼리할 수 있습니다.

WindowAreaInfo를 사용하면 활성 창 영역 기능과 특정 WindowAreaCapability.의 사용 가능 여부 상태를 나타내는 인터페이스인 WindowAreaSession에 액세스할 수 있습니다.

  1. MainActivity에서 다음과 같은 변수를 선언합니다.

step1/MainActivity.kt

private lateinit var windowAreaController: WindowAreaController
private lateinit var displayExecutor: Executor
private var rearDisplaySession: WindowAreaSession? = null
private var rearDisplayWindowAreaInfo: WindowAreaInfo? = null
private var rearDisplayStatus: WindowAreaCapability.Status =
    WindowAreaCapability.Status.WINDOW_AREA_STATUS_UNSUPPORTED
private val rearDisplayOperation = WindowAreaCapability.Operation.OPERATION_TRANSFER_ACTIVITY_TO_AREA
  1. onCreate() 메서드에서 초기화합니다.

step1/MainActivity.kt

displayExecutor = ContextCompat.getMainExecutor(this)
windowAreaController = WindowAreaController.getOrCreate()

lifecycleScope.launch(Dispatchers.Main) {
  lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
    windowAreaController.windowAreaInfos
      .map{info->info.firstOrNull{it.type==WindowAreaInfo.Type.TYPE_REAR_FACING}}
      .onEach { info -> rearDisplayWindowAreaInfo = info }
      .map{it?.getCapability(rearDisplayOperation)?.status?:  WindowAreaCapability.Status.WINDOW_AREA_STATUS_UNSUPPORTED }
      .distinctUntilChanged()
      .collect {
           rearDisplayStatus = it
           updateUI()
      }
  }
}
  1. 이제 updateUI() 함수를 구현하여 현재 상태에 따라 후면 셀카 버튼을 사용 설정하거나 사용 중지합니다.

step1/MainActivity.kt

private fun updateUI() {
    if(rearDisplaySession != null) {
        binding.rearDisplay.isEnabled = true
        // A session is already active, clicking on the button will disable it
    } else {
        when(rearDisplayStatus) {
            WindowAreaCapability.Status.WINDOW_AREA_STATUS_UNSUPPORTED -> {
                binding.rearDisplay.isEnabled = false
                // RearDisplay Mode is not supported on this device"
            }
            WindowAreaCapability.Status.WINDOW_AREA_STATUS_UNAVAILABLE -> {
                binding.rearDisplay.isEnabled = false
                // RearDisplay Mode is not currently available
            }
            WindowAreaCapability.Status.WINDOW_AREA_STATUS_AVAILABLE -> {
                binding.rearDisplay.isEnabled = true
                // You can enable RearDisplay Mode
            }
            WindowAreaCapability.Status.WINDOW_AREA_STATUS_ACTIVE -> {
                binding.rearDisplay.isEnabled = true
                // You can disable RearDisplay Mode
            }
            else -> {
                binding.rearDisplay.isEnabled = false
                // RearDisplay status is unknown
            }
        }
    }
}

이 마지막 단계는 선택사항이지만 WindowAreaCapability.의 가능한 모든 상태를 알아보는 것은 매우 유용합니다.

  1. 이제 기능이 이미 활성 상태인 경우 세션을 닫는 toggleRearDisplayMode 함수를 구현하거나, transferActivityToWindowArea 함수를 호출합니다.

step1/CameraViewModel.kt

private fun toggleRearDisplayMode() {
    if(rearDisplayStatus == WindowAreaCapability.Status.WINDOW_AREA_STATUS_ACTIVE) {
        if(rearDisplaySession == null) {
            rearDisplaySession = rearDisplayWindowAreaInfo?.getActiveSession(rearDisplayOperation)
        }
        rearDisplaySession?.close()
    } else {
        rearDisplayWindowAreaInfo?.token?.let { token ->
            windowAreaController.transferActivityToWindowArea(
                token = token,
                activity = this,
                executor = displayExecutor,
                windowAreaSessionCallback = this
            )
        }
    }
}

MainActivityWindowAreaSessionCallback으로 사용되는 것을 확인할 수 있습니다.

Rear Display API는 리스너 접근 방식과 함께 작동합니다. 콘텐츠를 다른 디스플레이로 이동하도록 요청하면 리스너의 onSessionStarted() 메서드를 통해 반환되는 세션이 시작됩니다. 대신 내부(및 더 큰) 디스플레이로 돌아가려면 세션을 닫고 onSessionEnded() 메서드에서 확인을 받습니다. 이러한 리스너를 만들려면 WindowAreaSessionCallback 인터페이스를 구현해야 합니다.

  1. WindowAreaSessionCallback 인터페이스를 구현하도록 MainActivity 선언을 수정합니다.

step1/MainActivity.kt

class MainActivity : AppCompatActivity(), WindowAreaSessionCallback

이제 MainActivity 내에서 onSessionStarted 메서드와 onSessionEnded 메서드를 구현합니다. 이러한 콜백 메서드는 세션 상태에 관한 알림을 받고 적절하게 앱을 업데이트하는 데 매우 유용합니다.

하지만 이번에는 편의를 위해 함수 본문에서 오류가 있는지 확인하고 상태를 기록합니다.

step1/MainActivity.kt

override fun onSessionEnded(t: Throwable?) {
    if(t != null) {
        Log.d("Something was broken: ${t.message}")
    }else{
        Log.d("rear session ended")
    }
}

override fun onSessionStarted(session: WindowAreaSession) {
    Log.d("rear session started [session=$session]")
}
  1. 앱을 빌드하고 실행합니다. 그런 다음 기기를 펼치고 후면 디스플레이 버튼을 탭하면 다음과 같은 메시지가 표시됩니다.

ba878f120b7c8d58.png

  1. 콘텐츠가 외부 디스플레이로 이동된 것을 보려면 지금 화면 전환을 선택합니다.

6. 테이블탑 모드 구현

이제 앱에서 접힌 상태를 인식하도록 해보겠습니다. 즉 접힘 방향에 따라 기기의 힌지 측면이나 위쪽으로 콘텐츠를 이동하도록 하는 것입니다. FoldingStateActor 내에서 작업을 실행할 코드를 Activity에서 분리하여 가독성을 높입니다.

이 API의 핵심 부분은 Activity가 필요한 정적 메서드로 생성되는 WindowInfoTracker 인터페이스로 구성됩니다.

step1/CameraCodelabDependencies.kt

@Provides
fun provideWindowInfoTracker(activity: Activity) =
        WindowInfoTracker.getOrCreate(activity)

이 코드는 이미 포함되어 있으므로 작성할 필요는 없지만 WindowInfoTracker가 빌드되는 방식을 이해하는 데 도움이 됩니다.

  1. 모든 창 변경사항을 수신 대기하려면 ActivityonResume() 메서드에서 변경사항을 수신 대기합니다.

step1/MainActivity.kt

lifecycleScope.launch {
    foldingStateActor.checkFoldingState(
         this@MainActivity, 
         binding.viewFinder
    )
}
  1. 이제 checkFoldingState() 메서드를 채워야 하므로 FoldingStateActor 파일을 엽니다.

이미 확인한 바와 같이 ActivityRESUMED 단계에서 실행되며 레이아웃 변경을 수신 대기하기 위해 WindowInfoTracker를 활용합니다.

step1/FoldingStateActor.kt

windowInfoTracker.windowLayoutInfo(activity)
      .collect { newLayoutInfo ->
         activeWindowLayoutInfo = newLayoutInfo
         updateLayoutByFoldingState(cameraViewfinder)
      }

WindowInfoTracker 인터페이스를 사용하여 DisplayFeature에서 사용 가능한 모든 정보를 포함하는 WindowLayoutInfoFlow를 수집하기 위해 windowLayoutInfo()를 호출할 수 있습니다.

마지막 단계는 이러한 변경사항에 반응하고 그에 맞춰 콘텐츠를 이동하는 것입니다. updateLayoutByFoldingState() 메서드 내부에서 한 번에 한 단계씩 이 작업을 실행합니다.

  1. activityLayoutInfo에 일부 DisplayFeature 속성이 포함되어 있고 그 중 적어도 하나가 FoldingFeature인지 확인하세요. 그렇지 않으면 어떠한 작업도 원하지 않는 것입니다.

step1/FoldingStateActor.kt

val foldingFeature = activeWindowLayoutInfo?.displayFeatures
            ?.firstOrNull { it is FoldingFeature } as FoldingFeature?
            ?: return
  1. 기기 위치가 레이아웃에 영향을 미치고 계층 구조의 범위를 벗어나지 않도록 접힘 위치를 계산합니다.

step1/FoldingStateActor.kt

val foldPosition = FoldableUtils.getFeaturePositionInViewRect(
            foldingFeature,
            cameraViewfinder.parent as View
        ) ?: return

이제 레이아웃에 영향을 주는 FoldingFeature가 있다고 확신하므로 콘텐츠를 이동해야 합니다.

  1. FoldingFeatureHALF_OPEN인지, 그렇지 않고 콘텐츠의 위치를 복원할지 확인합니다. HALF_OPEN이면 다른 검사를 실행하고 접힘 방향에 따라 다르게 조치를 취해야 합니다.

step1/FoldingStateActor.kt

if (foldingFeature.state == FoldingFeature.State.HALF_OPENED) {
    when (foldingFeature.orientation) {
        FoldingFeature.Orientation.VERTICAL -> {
            cameraViewfinder.moveToRightOf(foldPosition)
        }
        FoldingFeature.Orientation.HORIZONTAL -> {
            cameraViewfinder.moveToTopOf(foldPosition)
        }
    }
} else {
    cameraViewfinder.restore()
}

접힘이 VERTICAL인 경우 콘텐츠를 오른쪽으로 이동하고 그렇지 않은 경우 접힘 위치 위로 이동합니다.

  1. 앱을 빌드 및 실행한 다음 기기를 펼치고 탁자 모드로 두고 콘텐츠가 그에 따라 이동하는지 확인합니다.

7. 축하합니다

이 Codelab에서는 후면 디스플레이 모드 또는 테이블탑 모드와 같은 폴더블 기기의 고유한 기능과 Jetpack WindowManager를 사용하여 이러한 기능을 잠금 해제하는 방법을 알아봤습니다.

이제 카메라 앱을 위한 훌륭한 사용자 환경을 구현할 수 있습니다.

추가 자료

참조