Jetpack WindowManager による折りたたみ式デバイスとデュアル スクリーン デバイスのサポート

この実践的な Codelab では、デュアル スクリーン デバイスと折りたたみ式デバイス向けの開発の基本について学習します。演習を完了すると、アプリを拡張して Microsoft Surface Duo や Samsung Galaxy Z Fold 2 などのデバイスをサポートできるようになります。

前提条件

この Codelab を完了するには、以下が必要です。

  • Android アプリを作成した経験
  • アクティビティフラグメントViewBindingxml-layout を使用した経験
  • プロジェクトに依存関係を追加した経験
  • デバイス エミュレータをインストールして使用した経験この Codelab では、折りたたみ式エミュレータとデュアル スクリーン エミュレータを使用します。

演習内容

  • シンプルなアプリを作成し、折りたたみ式デバイスとデュアル スクリーン デバイスをサポートするように拡張します。
  • Jetpack WindowManager を使用して、新しいフォーム ファクタ デバイス向けの機能を追加します。

必要なもの

  • Android Studio 4.2 以上
  • 折りたたみ式のデバイスまたはエミュレータ: Android Studio 4.2 を使用している場合は、下記の画像に示すようないくつかの折りたたみ式エミュレータを使用できます。

7a0db14df3576a82.png

  • デュアル スクリーン エミュレータを使用する場合は、使用しているプラットフォーム(Windows、MacOS、GNU/Linux)向けの Microsoft Surface Duo エミュレータをこちらからダウンロードできます。

折りたたみ式デバイスでは、従来のモバイル デバイスで利用できた画面よりも大きい画面と、より幅広い用途を持つユーザー インターフェースが提供されます。もう 1 つの利点は、折りたたむと一般的なサイズのタブレットより小さくなるため、持ち運びが簡単で、実用的であることです。

現時点では、折りたたみ式デバイスには次の 2 つのタイプがあります。

  • シングル スクリーン折りたたみ式デバイス。折りたたみ可能なスクリーンが 1 つあります。ユーザーは Multi-Window モードを使用して、同じ画面で複数のアプリを同時に実行できます。
  • デュアル スクリーン折りたたみ式デバイス。ヒンジで接合された 2 つのスクリーンがあります。このタイプのデバイスも折りたたみ可能ですが、2 つの異なる論理ディスプレイ領域があります。

affbd6daf04cfe7b.png

折りたたみ式デバイスでは、タブレットや他のシングル スクリーン モバイル デバイスと同様に、以下のことが可能です。

  • 一方のディスプレイ領域で 1 つのアプリを実行する。
  • 2 つのアプリを並べて実行し、それぞれを別のディスプレイ領域に表示する(Multi-Window モードを使用)。

シングル スクリーン デバイスとは異なり、折りたたみ式デバイスではさまざまな形状もサポートされます。形状は、さまざまな方法でコンテンツを表示するために使用されます。

f2287b68f32b59e3.png

折りたたみ式デバイスは、アプリがディスプレイ領域全体にまたがって展開(表示)されるとき、(デュアル スクリーン折りたたみ式デバイスのすべてのディスプレイ領域を使用して)さまざまな展開形状を提供できます。

また、折りたたみ式デバイスは折りたたみ形状も提供できます。たとえば、テーブルトップ モードでは、平らに置いたスクリーンと見やすい角度に傾けたスクリーンを論理的に分割できます。テントモードでは、デバイスを卓上スタンドのように折り曲げてコンテンツを表示できます。

Jetpack WindowManager ライブラリは、デベロッパーがアプリを調整して、この種のデバイスがユーザーに提供する新しいエクスペリエンスを活用できるように設計されています。Jetpack WindowManager を利用すると、アプリのデベロッパーは新しいデバイス フォーム ファクタをサポートし、古いプラットフォーム バージョンと新しいプラットフォーム バージョンの両方で、異なる WindowManager 機能について共通の API サーフェスを提供できます。

主な機能

Jetpack WindowManager のバージョン 1.0.0-alpha03 には、フレキシブル ディスプレイの折りたたみ領域、または 2 つの物理ディスプレイ パネルを接合するヒンジを記述する 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 インスタンスを初期化するには、Activity 内に次のコードを実装します。

private lateinit var wm: WindowManager

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

        wm = WindowManager(this)
}

プライマリ コンストラクタには 1 つのパラメータのみを指定できます。それは、Activity か、1 つの Activity をラップする ContextWrapper のような視覚的コンテキストです。内部的には、このコンストラクタはデフォルトの WindowBackend を使用します。これは、このインスタンス用の情報を提供するバッキング サーバークラスです。

WindowManager インスタンスを用意した後、コールバックを登録すると、形状が変化したタイミング、デバイス接合部、その接合部の境界(存在する場合)を認識できます。また、前述のように、現在のシステム状態に応じて、現在の指標と最大の指標を確認できます。

  1. Android Studio を開きます。
  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>

これにより、ConstraintLayout に基づいて、3 つの TextViews を含む単純なレイアウトが作成されます。ビューは、親(および画面)の中心に配置されるように、お互いに制約を受けます。

  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 から拡張して関数を作成します。これを 1 つ目のパラメータ(コールバックが呼び出されたときに使用されます)としてコールバックに提供できます。ここでは、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> が含まれています。このリストは、アプリがスクリーンを横断するデバイスで見つかった DisplayFeature のリストを返します。アプリが横断するディスプレイ接合部がない場合、リストは空である可能性があります。

このクラスは DisplayFeature を実装するので、List<DisplayFeature> を結果として取得したときは(アイテムを)FoldingFeature にキャストできます。FoldingFeature により、デバイスの形状、デバイス接合部タイプ、接合部の境界などの情報を確認できます。

このコールバックを使用する方法とコールバックが提供する情報を視覚化する方法を見てみましょう。前のステップ(サンプルアプリの作成)で追加したコードで、以下を行います。

  1. onAttachedToWindow メソッドをオーバーライドします。
override fun onAttachedToWindow() {
   super.onAttachedToWindow()
  1. 前に 1 つ目のパラメータとして実装したエグゼキュータを使用して、レイアウト変更コールバックに登録する WindowManager インスタンスを使用します。
   wm.registerLayoutChangeCallback(
       runOnUiThreadExecutor(),
       layoutStateChangeCallback
   )
}

このコールバックがどのように情報を返すかを見てみましょう。デュアル スクリーン エミュレータでこのコードを実行すると、次のようになります。

49a85b4d10245a9d.png

ご覧のように、WindowLayoutInfo は空です。これには空の List<DisplayFeature> が含まれていますが、中央にヒンジがあるエミュレータを使用している場合は、WindowManager から情報を取得できます。

WindowManager は、アプリが両方のディスプレイ(物理ディスプレイかどうかを問わない)に展開されたときにのみ、LayoutInfo データ(デバイス接合部タイプ、デバイス接合部の境界、デバイスの形状)を提供します。つまり、上の図では、アプリがシングル スクリーン モードで実行されており、WindowLayoutInfo は空です。

これを考慮すると、アプリが実行されているモード(シングル スクリーン モードまたは展開されている状態)がわかるので、UI / UX を変更して、その特定の構成に適したより良いエクスペリエンスをユーザーに提供できます。

2 つの物理ディスプレイを持たない(通常は物理ヒンジがない)デバイスでは、マルチウィンドウ モードでアプリを並べて実行できます。このタイプのデバイスでは、アプリがマルチウィンドウで実行される場合、前述の例のようにアプリはシングル スクリーンで動作します。また、アプリがすべての論理ディスプレイを占有して動作する場合、アプリはそれらのディスプレイに展開されて動作します。次の図をご覧ください。

ecdada42f6df1fb8.png

ご覧のように、アプリがマルチウィンドウ モードで実行された場合、アプリは折りたたみ可能な接合部を横断しません。したがって、WindowManager は空の List<LayoutInfo> を返します。

要約すると、アプリがデバイス接合部(折りたたみ領域またはヒンジ)を横断する場合にのみ LayoutInfo データが取得され、横断しない場合、情報は取得されません。564eb78fc85f6d3e.png

アプリを両方のディスプレイに展開するとどうなるでしょうか?デュアル スクリーン エミュレータでは、LayoutInfo に含まれる FoldingFeature オブジェクトによってデバイスに関するデータが提供されます。次の例では、デバイス接合部は HINGE、接合部の境界は Rect (0, 0- 1434, 1800)、デバイスの形状(状態)は FLAT です。

13edea3ff94baae4.png

前述のように、デバイス接合部タイプには、ソースコードでも公開されているとおり、FOLDHINGE, の 2 つの値があります。

@IntDef({
       TYPE_FOLD,
       TYPE_HINGE,
})
  • このデュアル スクリーン エミュレータは物理ヒンジがある実際の Surface Duo デバイスを反映しており、type = TYPE_HINGE によって 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 です。

仮想センサーを使用してエミュレータの形状を半開きに変更すると、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

これは、ユーザーに提供するエクスペリエンスとして不適切です。UI / UX を調整するために、WindowManager から提供される情報を利用できます。

前に見たように、アプリがすべての異なるディスプレイ領域に展開されている場合は、アプリがデバイス接合部を横断している場合でもあります。そのため、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. 渡された WindowLayoutInfo を rect 変数に指定して、参照ビューの正しい高さのサイズを設定します。
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 を適切な位置に配置するために、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 ステートメント内のメイン関数に戻ると、(0,0) 座標から取得される現在の UI 要素がディスプレイ接合部の境界によって考慮されていないため、マージンにステータスバーの高さとツールバーの高さを使用できます。参照ビューを正しい位置に配置するには、以下の要素を考慮する必要があります。

} 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
   )
}

次のステップとして、参照ビューの表示設定を表示状態に変更します。これにより、サンプルで参照ビューが見えるようになり(赤く色付けされています)、さらに重要な点として、制約が適用されます。ビューが削除されると、適用される制約はなくなります。

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

最後のステップとして、作成した ConstraintSetConstraintLayout, に適用して、すべての変更と UI の調整を反映させます。

    set.applyTo(constraintLayout)
}

以上により、デバイスのディスプレイ接合部と競合していた TextView で、その接合部が位置する場所が考慮されるようになり、コンテンツが切り取られたり隠されたりすることがなくなります。

80993d3695a9a60.png

デュアル スクリーン エミュレータ(左側)では、両方のディスプレイにまたがってコンテンツを表示する TextView が今ではヒンジによって切り取られておらず、情報の欠落が解消されています。

折りたたみ式エミュレータ(右側)では、折りたたみ領域(ディスプレイ接合部)の場所を表す明るい赤の線が表示されており、TextView は今では接合部の下に配置されているため、デバイスが折りたたまれたとき(たとえば、ノートパソコンの形状を 90 度開いた状態にしたとき)、情報は接合部によって影響を受けません。

デュアル スクリーン エミュレータでディスプレイ接合部の場所が不明な場合、これはヒンジタイプのデバイスなので、接合部を表すビューはヒンジによって隠されています。しかし、アプリを移動して両方のディスプレイに展開した状態を解除すると、接合部が存在するのと同じ位置に正しい高さと幅で表示されます。

4dbe464ac71b498e.png

ここまでは、折りたたみ式デバイスとシングル スクリーン デバイスの相違を学びました。

折りたたみ式デバイスの特長の 1 つは、2 つのアプリを並べて実行できるため、多くのタスクを容易に実行できることです。たとえば、メールアプリとカレンダー アプリを並べて表示できます。また、一方の画面でビデオ通話を行い、もう一方の画面でメモを取ることができます。他にもたくさんの利用方法が考えられます。

Android フレームワークに含まれている既存の API を使用するだけで、2 つの画面を活用できます。実現可能な機能拡張をいくつか見てみましょう。

隣接するウィンドウでアクティビティを起動する

この機能拡張では、アプリは隣接するウィンドウで新しい Activity を起動できます。これにより、多くの操作をしなくても、複数のウィンドウ領域を同時に利用できます。

クリックするとアプリが新しい Activity を起動するボタンがあるとします。

  1. まず、クリック イベントを処理する関数を作成します。

intent/MainActivity.kt

private fun openActivityInAdjacentWindow() {
}
  1. 関数内に、新しい Activity(ここでは SecondActivity という名前です)の起動に使用する Intent を作成します。これは、TextView をメッセージとして表示するだけの単純な Activity です。
val intent = Intent(this, SecondActivity::class.java)
  1. 次に、隣接する画面が空のときに新しい Activity を起動するフラグを設定します。
intent.addFlags(
   Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT or
       Intent.FLAG_ACTIVITY_NEW_TASK
)

フラグの意味は次のとおりです。

  • FLAG_ACTIVITY_NEW_TASK = 設定すると、このアクティビティはこの履歴スタックにおける新しいタスクの開始ポイントになります。
  • FLAG_ACTIVITY_LAUNCH_ADJACENT = このフラグは、分割画面マルチウィンドウ モードに使用されます(独立した物理スクリーンを持つデュアル スクリーン デバイスでも機能します)。新しいアクティビティを起動したアクティビティの隣に、新しいアクティビティが表示されます。

プラットフォームは、新しいタスクを確認すると、隣接するウィンドウを使用してそこにタスクを割り当てようと試みます。新しいタスクは現在のタスクの上で起動されます。そのため、新しい Activity は現在のタスクの上で起動されます。

  1. 最後のステップは、作成したインテントを使用して新しいアクティビティを起動するだけです。
     startActivity(intent)

結果として、テストアプリは以下のアニメーションのように動作します。つまり、ボタンをクリックすると、空の隣接するウィンドウで新しい Activity が起動します。

次の画像では、デュアル スクリーン デバイスと、マルチウィンドウ モードの折りたたみ式デバイスでアクティビティが実行されています。

9696f7fa2ee1e35f.gif a2dc98dae26e3045.gif

ドラッグ&ドロップ

アプリにドラッグ&ドロップを追加すると、ユーザーが好む非常に便利な機能を提供できます。この機能により、アプリは他のアプリにコンテンツを提供することも(ドラッグを実装)、他のアプリからコンテンツを受け取ることも(ドロップを実装)、その両方を行うこともできます。つまり、アプリは他のアプリのコンテンツおよび自分自身のコンテンツ(たとえば、同じアプリ内の異なる場所にあるコンテンツ)をやり取りできます。

Android フレームワークでドラッグ&ドロップが利用可能になったのは API レベル 11 以降ですが、その重要性が増したのは、Multi-Window サポートが導入された API レベル 24 以降です。それは、同じ画面上で並べて実行しているアプリ間で、要素をドラッグ&ドロップできるようになったためです。

今では、マルチウィンドウの用途に合った複数の領域や、2 つの論理スクリーンを持つ折りたたみ式デバイスが導入されたことで、ドラッグ&ドロップの重要性はさらに増しました。有用なシナリオとしては、テキストをドロップすると新しいタスクとして受け入れる To-Do アプリや、コンテンツを日付 / 時刻スロットにドロップするとイベントとして受け入れるカレンダー アプリなどがあります。

この機能を活用するには、アプリがデータ コンシューマーになるためにドラッグ動作を実装し、データ プロデューサーになるためにドロップ動作を実装する必要があります。

サンプルでは、1 つのアプリにドラッグを実装し、もう 1 つのアプリにドロップを実装しますが、同じアプリにドラッグとドロップの両方を実装することももちろん可能です。

ドラッグを実装する

ここで作成する「ドラッグアプリ」は 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. receiver パラメータの型が、ドラッグ機能を追加する 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 ファイルを開いて、EditTextsetOnDragListener 関数にリスナーを追加します。

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. 最後のステップとして、リクエストされたドラッグ&ドロップ権限を解放します。ドロップ アクションの終了後にこれを行わないと、Activity が破棄されたときに権限が自動的に解放されます。関数とクラスを閉じます。
      dropPermissions?.release()
   }
}

このドロップをこの例のシンプルなドロップアプリに実装すると、両方のアプリを並べて実行して、ドラッグ&ドロップの動作を確認できます。

ドラッグ&ドロップがどのように動作するか、各種のドラッグ イベントがどのようにトリガーされるか、それらを処理するときに何を行うか(特定の DragEvent に応じてドロップ領域の背景を変更し、コンテンツをドロップする)を以下のアニメーションでご覧ください。

d66c5c24c6ea81b3.gif

このコンテンツ ブロックで見てきたように、Jetpack WindowManager を使用すると、折りたたみ式デバイスなどの新しいフォーム ファクタ デバイス向けの開発が容易になります。

WindowManager が提供する情報は、アプリをその種のデバイスに適応させるために役立ちます。つまり、その種のデバイスでアプリが実行される際に、より良いエクスペリエンスをユーザーに提供できます。

この Codelab 全体を通して学習したことの概要を次に示します。

  • 折りたたみ式デバイスとは何か
  • 各種の折りたたみ式デバイスの相違
  • 折りたたみ式デバイス、シングル スクリーン デバイス、タブレットの相違
  • Jetpack WindowManager: この API は何を提供するか
  • Jetpack WindowManager を使用してアプリを新しいフォーム ファクタ デバイスに適応させる方法
  • アプリにわずかな変更を加えて空の隣接するウィンドウでアクティビティを起動できるように拡張する方法と、アプリ間で機能するドラッグ&ドロップを実装する方法

詳細