Jetpack WindowManager を使用して折りたたみ式デバイスのカメラアプリを最適化する

1. 始める前に

折りたたみ式デバイスの特別な点

折りたたみ式デバイスは一世代に一度のイノベーションであり、ユニークな体験を提供します。ハンズフリー操作のテーブルトップ UI など、差別化された機能でユーザーに喜ばれる貴重な機会をもたらします。

前提条件

  • Android アプリ開発に関する基本的な知識
  • Hilt 依存関係インジェクション フレームワークに関する基本的な知識

作成するアプリの概要

この Codelab では、折りたたみ式デバイス向けに最適化されたレイアウトのカメラアプリを作成します。

c5e52933bcd81859.png

まず、基本的なカメラアプリから始めましょう。このアプリは、デバイスの形状の変化には対応していません。また、背面カメラで高度な自撮りを行うこともできません。このソースコードを更新して、デバイスを広げたときにプレビューを小さな画面に移動し、スマートフォンをテーブルトップ モードに設定するようにします。

この Codelab で扱う API の説明に最も適した使用事例はカメラアプリですが、ここで学習する機能はどのアプリにも応用できます。

学習内容

  • Jetpack Window Manager を使用して、形状の変化に対応できるようにする方法
  • 折りたたみ式デバイスの小さな画面にアプリを移動する方法

必要なもの

  • 最新バージョンの Android Studio
  • 折りたたみ式デバイスまたは折りたたみ式エミュレータ

2. セットアップする

開始用コードを取得する

  1. Git がインストールされている場合は、以下のコマンドをそのまま実行できます。Git がインストールされているかどうかを確認するには、ターミナルまたはコマンドラインで「git --version」と入力し、正しく実行されることを確認します。
git clone https://github.com/android/large-screen-codelabs.git
  1. 省略可: git がない場合は、次のボタンをクリックして、この Codelab のすべてのコードをダウンロードできます。

最初のモジュールを開く

  • Android Studio で、/step1 にある最初のモジュールを開きます。

この Codelab に関連するコードが表示されている Android Studio のスクリーンショット

最新バージョンの Gradle を使用するよう求められた場合は、更新してください。

3.実行して観察する

  1. モジュール step1 のコードを実行します。

ご覧のとおり、これはシンプルなカメラアプリです。前面カメラと背面カメラを切り替え、アスペクト比を調整できます。左側の最初のボタンは、今は何も実行しませんが、これが背面自撮りモードのエントリ ポイントになります。

149e3f9841af7726.png

  1. デバイスを半開きの状態にしてみましょう。ヒンジは完全にフラットな状態でも、閉じている状態でもありません。90 度の角度で開いています。

ご覧のとおり、アプリはデバイスの形状の変化に反応しません。レイアウトは変わらず、ヒンジがビューファインダーの中央に残っています。

4. Jetpack WindowManager について学習する

Jetpack WindowManager は、折りたたみ式デバイス向けに最適化されたエクスペリエンスの開発に役立つライブラリです。この中に、フレキシブル ディスプレイの折りたたみや 2 つの物理ディスプレイ パネル間のヒンジを記述する FoldingFeature クラスがあります。このクラスの API を利用すれば、次のように、デバイスに関する重要な情報にアクセスできます。

FoldingFeature クラスには occlusionType()isSeparating() などの追加情報も含まれますが、この Codelabo では詳しく説明しません。

バージョン 1.2.0-beta01 から、このライブラリは WindowAreaController を使用しています。この API は背面ディスプレイ モードを有効にし、背面カメラ用に調整されたディスプレイに現在のウィンドウを移動します。これは、背面カメラでの自撮りなど、多くの用途に利用できます。

依存関係を追加する

  • アプリで 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 です。これは、デバイス上のディスプレイまたはディスプレイ領域間でのウィンドウの移動に関する情報を提供し、動作を制御します。

この API で、現在操作可能な WindowAreaInfo のリストをクエリできます。

WindowAreaInfo を使用すると、WindowAreaSession(アクティブなウィンドウ領域機能を表すインターフェースと、特定の WindowAreaCapability. の可用性のステータス)にアクセスできます。

  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. [Switch screens now] を選択し、コンテンツが外側のディスプレイに移動したことを確認します。

6. テーブルトップ モードを実装する

次に、アプリを折りたたみ対応にします。折りたたみの方向に応じて、デバイスのヒンジの横または上にコンテンツを移動します。この処理は FoldingStateActor の内部で行います。読みやすくするため、開発するコードを Activity から切り離して示します。

この API のコア部分は WindowInfoTracker インターフェースにあります。これは、Activity を必要とする静的メソッドで作成されています。

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. FoldingStateActor ファイルを開いて、checkFoldingState() メソッドを入力します。

すでに見たように、これは ActivityRESUMED フェーズで実行され、レイアウトの変更をリッスンするために WindowInfoTracker を利用します。

step1/FoldingStateActor.kt

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

WindowInfoTracker インターフェースを使用すると、windowLayoutInfo() を呼び出して、DisplayFeature で使用可能なすべての情報を含む WindowLayoutInfoFlow を収集できます。

最後に、この変化に反応してコンテンツを移動するようにします。この処理は updateLayoutByFoldingState() メソッド内で一度に 1 ステップずつ実行します。

  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 を使用してそれらのロックを解除する方法を学習しました。

これで、カメラアプリに優れたユーザー エクスペリエンスを実装する準備が整いました。

参考資料

リファレンス