アプリを折りたたみ対応にする

折りたたみ式デバイスでは、開いた状態の大型ディスプレイと独自の折りたたみ状態により、新しいユーザー エクスペリエンスを実現します。アプリを折りたたみ対応にするには、Jetpack WindowManager ライブラリを使用します。このライブラリは、折りたたみ式デバイスの折り目やヒンジなどのウィンドウ機能用の API サーフェスを提供します。アプリを折りたたみ対応にすると、レイアウトを調整して、重要なコンテンツが折り目またはヒンジの領域に配置されることを回避したり、折り目またはヒンジを自然な区切り要素として使用したりすることができます。

ウィンドウ情報

Jetpack WindowManager の WindowInfoTracker インターフェースは、ウィンドウ レイアウト情報を公開します。このインターフェースの windowLayoutInfo() メソッドは、折りたたみ式デバイスの折りたたみ状態をアプリに通知する WindowLayoutInfo データのストリームを返します。WindowInfoTrackergetOrCreate() メソッドは、WindowInfoTracker のインスタンスを作成します。

WindowManager は、Kotlin Flow と Java コールバックを使用した WindowLayoutInfo データの収集をサポートしています。

Kotlin Flow

WindowLayoutInfo データの収集を開始および停止するには、再実行可能なライフサイクル対応コルーチンを使用します。このコルーチンでは、ライフサイクルが STARTED 以降のときに repeatOnLifecycle コードブロックが実行され、ライフサイクルが STOPPED のときに停止されます。ライフサイクルが再び STARTED になると、自動的にコードブロックの実行が再開されます。次の例では、コードブロックが WindowLayoutInfo データを収集して使用しています。

class DisplayFeaturesActivity : AppCompatActivity() {

    private lateinit var binding: ActivityDisplayFeaturesBinding

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

        binding = ActivityDisplayFeaturesBinding.inflate(layoutInflater)
        setContentView(binding.root)

        lifecycleScope.launch(Dispatchers.Main) {
            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
                WindowInfoTracker.getOrCreate(this@DisplayFeaturesActivity)
                    .windowLayoutInfo(this@DisplayFeaturesActivity)
                    .collect { newLayoutInfo ->
                        // Use newLayoutInfo to update the layout.
                    }
            }
        }
    }
}

Java コールバック

androidx.window:window-java 依存関係に含まれるコールバック互換性レイヤを使用すると、Kotlin Flow を使用せずに WindowLayoutInfo の更新を収集できます。このアーティファクトには WindowInfoTrackerCallbackAdapter クラスが含まれています。このクラスは WindowInfoTracker を適応させて、WindowLayoutInfo の更新を受け取るコールバックの登録(および登録解除)をサポートします。次に例を示します。

public class SplitLayoutActivity extends AppCompatActivity {

    private WindowInfoTrackerCallbackAdapter windowInfoTracker;
    private ActivitySplitLayoutBinding binding;
    private final LayoutStateChangeCallback layoutStateChangeCallback =
            new LayoutStateChangeCallback();

   @Override
   protected void onCreate(@Nullable Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);

       binding = ActivitySplitLayoutBinding.inflate(getLayoutInflater());
       setContentView(binding.getRoot());

       windowInfoTracker =
                new WindowInfoTrackerCallbackAdapter(WindowInfoTracker.getOrCreate(this));
   }

   @Override
   protected void onStart() {
       super.onStart();
       windowInfoTracker.addWindowLayoutInfoListener(
                this, Runnable::run, layoutStateChangeCallback);
   }

   @Override
   protected void onStop() {
       super.onStop();
       windowInfoTracker
           .removeWindowLayoutInfoListener(layoutStateChangeCallback);
   }

   class LayoutStateChangeCallback implements Consumer<WindowLayoutInfo> {
       @Override
       public void accept(WindowLayoutInfo newLayoutInfo) {
           SplitLayoutActivity.this.runOnUiThread( () -> {
               // Use newLayoutInfo to update the layout.
           });
       }
   }
}

RxJava のサポート

RxJava(バージョン 2 または 3)をすでに使用している場合は、Observable または Flowable を使用して、Kotlin Flow を使用せずに WindowLayoutInfo の更新を収集するアーティファクトを利用できます。

androidx.window:window-rxjava2 および androidx.window:window-rxjava3 の依存関係によって提供される互換性レイヤには、WindowInfoTracker#windowLayoutInfoFlowable() メソッドと WindowInfoTracker#windowLayoutInfoObservable() メソッドが含まれています。アプリはこれらを使用して WindowLayoutInfo の更新を受信できます。次に例を示します。

class RxActivity: AppCompatActivity {

    private lateinit var binding: ActivityRxBinding

    private var disposable: Disposable? = null
    private lateinit var observable: Observable<WindowLayoutInfo>

   @Override
   protected void onCreate(@Nullable Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);

       binding = ActivitySplitLayoutBinding.inflate(getLayoutInflater());
       setContentView(binding.getRoot());

        // Create a new observable
        observable = WindowInfoTracker.getOrCreate(this@RxActivity)
            .windowLayoutInfoObservable(this@RxActivity)
   }

   @Override
   protected void onStart() {
       super.onStart();

        // Subscribe to receive WindowLayoutInfo updates
        disposable?.dispose()
        disposable = observable
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe { newLayoutInfo ->
            // Use newLayoutInfo to update the layout
        }
   }

   @Override
   protected void onStop() {
       super.onStop();

        // Dispose the WindowLayoutInfo observable
        disposable?.dispose()
   }
}

折りたたみ式ディスプレイの機能

Jetpack WindowManager の WindowLayoutInfo クラスは、ディスプレイ ウィンドウの機能を DisplayFeature 要素のリストとして利用できるようにします。

FoldingFeatureDisplayFeature の一種であり、次のような折りたたみ式ディスプレイに関する情報を提供します。

  • state: デバイスの折りたたみ状態(FLAT または HALF_OPENED
  • orientation: 折り目またはヒンジの向き(HORIZONTAL または VERTICAL
  • occlusionType: 折り目またはヒンジがディスプレイを隠しているかどうか(NONE または FULL
  • isSeparating: 折り目またはヒンジが 2 つの論理ディスプレイ領域を生成するかどうか(true または false)

HALF_OPENED 状態の折りたたみ式デバイスは、画面が 2 つのディスプレイ領域に分割されているため、常に isSeparating を true として報告します。また、デュアル スクリーン デバイスでは、アプリが両方の画面にまたがっている場合、isSeparating は常に true になります。

FoldingFeaturebounds プロパティ(DisplayFeature から継承される)は、折り目やヒンジなどの折りたたみ機能の境界四角形を表します。この境界を使用して、折りたたみ機能からの相対位置に画面上の要素を配置できます。

FoldingFeaturestate を使用すると、デバイスがテーブルトップとブックのどちらの形状になっているかを識別し、それに応じてアプリのレイアウトをカスタマイズできます。次に例を示します。

Kotlin

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    lifecycleScope.launch(Dispatchers.Main) {
        // The block passed to repeatOnLifecycle is executed when the lifecycle
        // is at least STARTED and is cancelled when the lifecycle is STOPPED.
        // It automatically restarts the block when the lifecycle is STARTED again.
        lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            // Safely collects from windowInfoRepo when the lifecycle is STARTED
            // and stops collection when the lifecycle is STOPPED
            WindowInfoTracker.getOrCreate(this@MainActivity)
                .windowLayoutInfo(this@MainActivity)
                .collect { layoutInfo ->
                    // New posture information
                    val foldingFeature = layoutInfo.displayFeatures
                        .filterIsInstance()
                        .firstOrNull()
                    when {
                            isTableTopPosture(foldingFeature) ->
                                enterTabletopMode(foldingFeature)
                            isBookPosture(foldingFeature) ->
                                enterBookMode(foldingFeature)
                            isSeparating(foldingFeature) ->
                            // Dual-screen device
                            if (foldingFeature.orientation == HORIZONTAL) {
                                enterTabletopMode(foldingFeature)
                            } else {
                                enterBookMode(foldingFeature)
                            }
                            else ->
                                enterNormalMode()
                        }
                }

        }
    }
}

@OptIn(ExperimentalContracts::class)
fun isTableTopPosture(foldFeature : FoldingFeature?) : Boolean {
    contract { returns(true) implies (foldFeature != null) }
    return foldFeature?.state == FoldingFeature.State.HALF_OPENED &&
            foldFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
}

@OptIn(ExperimentalContracts::class)
fun isBookPosture(foldFeature : FoldingFeature?) : Boolean {
    contract { returns(true) implies (foldFeature != null) }
    return foldFeature?.state == FoldingFeature.State.HALF_OPENED &&
            foldFeature.orientation == FoldingFeature.Orientation.VERTICAL
}

@OptIn(ExperimentalContracts::class)
fun isSeparating(foldFeature : FoldingFeature?) : Boolean {
    contract { returns(true) implies (foldFeature != null) }
    return foldFeature?.state == FoldingFeature.State.FLAT && foldFeature.isSeparating
}

Java

private WindowInfoTrackerCallbackAdapter windowInfoTracker;
private final LayoutStateChangeCallback layoutStateChangeCallback =
                new LayoutStateChangeCallback();

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    ...
    windowInfoTracker =
            new WindowInfoTrackerCallbackAdapter(WindowInfoTracker.getOrCreate(this));
}

@Override
protected void onStart() {
    super.onStart();
    windowInfoTracker.addWindowLayoutInfoListener(
            this, Runnable::run, layoutStateChangeCallback);
}

@Override
protected void onStop() {
    super.onStop();
    windowInfoTracker.removeWindowLayoutInfoListener(layoutStateChangeCallback);
}

class LayoutStateChangeCallback implements Consumer<WindowLayoutInfo> {
    @Override
    public void accept(WindowLayoutInfo newLayoutInfo) {
        // Use newLayoutInfo to update the Layout
        List<DisplayFeature> displayFeatures = newLayoutInfo.getDisplayFeatures();
        for (DisplayFeature feature : displayFeatures) {
            if (feature instanceof FoldingFeature) {
                if (isTableTopPosture((FoldingFeature) feature)) {
                    enterTabletopMode(feature);
                } else if (isBookPosture((FoldingFeature) feature)) {
                    enterBookMode(feature);
                } else if (isSeparating((FoldingFeature) feature)) {
                    // Dual-screen device
                    if (((FoldingFeature) feature).getOrientation() ==
                              FoldingFeature.Orientation.HORIZONTAL) {
                        enterTabletopMode(feature);
                    } else {
                        enterBookMode(feature);
                    }
                } else {
                    enterNormalMode();
                }
            }
        }
    }
}

private boolean isTableTopPosture(FoldingFeature foldFeature) {
    return (foldFeature != null) &&
           (foldFeature.getState() == FoldingFeature.State.HALF_OPENED) &&
           (foldFeature.getOrientation() == FoldingFeature.Orientation.HORIZONTAL);
}

private boolean isBookPosture(FoldingFeature foldFeature) {
    return (foldFeature != null) &&
           (foldFeature.getState() == FoldingFeature.State.HALF_OPENED) &&
           (foldFeature.getOrientation() == FoldingFeature.Orientation.VERTICAL);
}

private boolean isSeparating(FoldingFeature foldFeature) {
    return (foldFeature != null) &&
           (foldFeature.getState() == FoldingFeature.State.FLAT) &&
           (foldFeature.isSeparating() == true);
}

デュアル スクリーン デバイスでは、FoldingFeature の状態が FLAT であっても、テーブルトップ形状とブック形状に合わせて設計されたレイアウトを使用します。

isSeparating が true のときに UI コントロールが折り目またはヒンジに近づきすぎないようにしてください。近すぎると、コントロールが操作しづらくなります。occlusionType を使用して、折りたたみ機能の bounds 内にコンテンツを配置するかどうかを決定します。

ウィンドウ サイズの変更

アプリのディスプレイ領域は、デバイス設定の変更に伴って変化する可能性があります。これには、デバイスが折りたたまれたとき、広げられたとき、回転されたとき、マルチウィンドウ モードでウィンドウのサイズが変更されたときなどがあります。

Jetpack WindowManager の WindowMetricsCalculator クラスを使用すると、現在のウィンドウ指標と最大ウィンドウ指標を取得できます。API レベル 30 で導入されたプラットフォームの WindowMetrics と同様に、WindowManager の WindowMetrics はウィンドウ境界を提供しますが、API には API レベル 14 までとの下位互換性があります。

現在のウィンドウ サイズに合わせてアプリのレイアウトを設定するには、アクティビティの onCreate() メソッドまたは onConfigurationChanged() メソッドで WindowMetrics を使用します。次に例を示します。

Kotlin

override fun onConfigurationChanged(newConfig: Configuration) {
    super.onConfigurationChanged(newConfig)
    val windowMetrics = WindowMetricsCalculator.getOrCreate()
        .computeCurrentWindowMetrics(this@MainActivity)
    val bounds = windowMetrics.getBounds()
    ...
}

Java

@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
    super.onConfigurationChanged(newConfig);
    final WindowMetrics windowMetrics = WindowMetricsCalculator.getOrCreate()
        .computeCurrentWindowMetrics(this);
    final Rect bounds = windowMetrics.getBounds();
    ...
}

各種の画面サイズのサポートもご覧ください。

参考情報

サンプル

Codelab