🔥

Androidの折りたたみデバイスに対応する

2022/12/03に公開

Androidでは、さまざまなデバイスの種類があり、それぞれに対応するのは大変ですが、その中でも折りたたみ式のデバイスに対応するのは特に大変そうだなと思ったので、今回は折りたたみデバイスに対応する方法について調べてみました。

レスポンシブ・アダプティブデザイン

折りたたみ式デバイスに対応するためには、まずレスポンシブデザインにする必要があります。
ConstraintLayoutを使用するなどして、デバイスサイズの変更にもアプリを対応していくことで、折りたたみ式にも対応することができるようになります。
ただ、折りたたみ式のようにタブレット型になるような場合もあれば、スマホのように縦型の普通のサイズになることもあり、それぞれのサイズに応じてUIを変更していく必要があります。
例えば、ナビゲーションやリストなどはその例かなと思っていて、WindowSizeClassCompactの時は、ボトムナビゲーションで表示させるけどMediumの場合はRailでナビゲーションを表示させるなどして対応していきます。
リストでは、一覧画面に表示されているリストから詳細画面に遷移する時に、スマホサイズならばそのまま画面遷移すればいいですが、タブレットみたいな大きな画面では、そのまま遷移させるのではなくマルチウィンドウの状態にして、左に一覧画面を表示させ右に詳細画面を表示させると言ったようなUIにすることで対応していくことが推奨されています。

折りたたみ式の詳細

公式から参照
foldable

折りたたみ式デバイスは、折りたたむことによって画面が2つに分かれます。
この時に、「ヒンジ」と呼ばれる折り目の部分を境に2つのディスプレイを表示することができます。

折りたたみデバイスの特徴として、さまざまな状態が定義されています。
FLATHALF_OPENEDといったものがあり、それぞれ名前の通り「完全に開いた状態」と「完全に開いた状態と閉じた状態の中間」といったような特徴的な状態があります。

HALF_OPENED状態の場合、折りたたみデバイスは2つの形状を持つことになります。
折り目が水平歩行のテーブルトップ状態と、折り目が垂直状態のブック状態の2つです。

折りたたみデバイスに対応したアプリを開発する際には、この辺りを把握しておくことが重要で、なぜなら折り目の部分に近い部分にアプリ内の重要な情報を表示してしまうと、使いにくく見にくいアプリになってしまうからです。

また、ユーザーのことを考えるともっと考慮すべき点が見えてきます。
例えば、デバイスを折りたたんだり展開したりする際に、configuration changeが起きるので、そこに対しても画面の状態を保つような処理をする必要が出てきます。
入力フィールドに入力されたテキストの保持や、スクロール位置の復元など常にユーザーがデバイスのサイズを変更してくることを想定してアプリの開発を進めていく必要が出てきます。

折りたたみ式デバイスに対応する実装をしてみる

それでは、これから折りたたみデバイスに対応する実装をみていきたいと思います。
まず、折りたたみ式デバイスに対応するためにはWindowManagerを使用します。

WindowManagerは、WindowInfoTrackerというinterfaceを提供しています。

interface WindowInfoTracker {

    fun windowLayoutInfo(activity: Activity): Flow<WindowLayoutInfo>

    companion object {

        private val DEBUG = false
        private val TAG = WindowInfoTracker::class.simpleName

        @Suppress("MemberVisibilityCanBePrivate") // Avoid synthetic accessor
        internal val extensionBackend: WindowBackend? by lazy {
            try {
                val loader = WindowInfoTracker::class.java.classLoader
                val provider = loader?.let {
                    SafeWindowLayoutComponentProvider(loader, ConsumerAdapter(loader))
                }
                provider?.windowLayoutComponent?.let { component ->
                    ExtensionWindowLayoutInfoBackend(component, ConsumerAdapter(loader))
                }
            } catch (t: Throwable) {
                if (DEBUG) {
                    Log.d(TAG, "Failed to load WindowExtensions")
                }
                null
            }
        }

        private var decorator: WindowInfoTrackerDecorator = EmptyDecorator

        @JvmName("getOrCreate")
        @JvmStatic
        fun getOrCreate(context: Context): WindowInfoTracker {
            val backend = extensionBackend ?: SidecarWindowBackend.getInstance(context)
            val repo = WindowInfoTrackerImpl(WindowMetricsCalculatorCompat, backend)
            return decorator.decorate(repo)
        }

        @JvmStatic
        @RestrictTo(LIBRARY_GROUP)
        fun overrideDecorator(overridingDecorator: WindowInfoTrackerDecorator) {
            decorator = overridingDecorator
        }

        @JvmStatic
        @RestrictTo(LIBRARY_GROUP)
        fun reset() {
            decorator = EmptyDecorator
        }
    }
}

内部はこんな感じになっていて、ここにあるwindowLayoutInfo()が、折りたたみ式デバイスの状態についてアプリに通知するWindowLayoutInfoの値を返しています。

WindowLayoutInfoの内部を見てましょう👀

class WindowLayoutInfo @RestrictTo(LIBRARY_GROUP) constructor(
    val displayFeatures: List<DisplayFeature>
) {

    override fun toString(): String {
        return displayFeatures.joinToString(
            separator = ", ",
            prefix = "WindowLayoutInfo{ DisplayFeatures[",
            postfix = "] }"
        )
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other == null || javaClass != other.javaClass) return false
        val that = other as WindowLayoutInfo
        return displayFeatures == that.displayFeatures
    }

    override fun hashCode(): Int {
        return displayFeatures.hashCode()
    }
}

こんな感じの実装になっていました。
WindowLayoutInfoはconstructorでDisplayFeatureのリストを持っているようです。

DisplayFeatureを見てましょう👀

interface DisplayFeature {
    /**
     * The bounding rectangle of the feature within the application window
     * in the window coordinate space.
     */
    val bounds: Rect
}

interfaceになっています。
折りたたみデバイスに対応するには、このDisplayFeatureをもとに開発していく必要があります。折りたたみデバイスでは、DisplayFeatureを継承したFoldingFeatureを使用していきます。

FoldingFeatureを見てみます👀

interface FoldingFeature : DisplayFeature {

    class OcclusionType private constructor(private val description: String) {
        override fun toString(): String {
            return description
        }

        companion object {
            @JvmField
            val NONE: OcclusionType = OcclusionType("NONE")

            @JvmField
            val FULL: OcclusionType = OcclusionType("FULL")
        }
    }

    class Orientation private constructor(private val description: String) {

        override fun toString(): String {
            return description
        }

        companion object {
            @JvmField
            val VERTICAL: Orientation = Orientation("VERTICAL")

            @JvmField
            val HORIZONTAL: Orientation = Orientation("HORIZONTAL")
        }
    }

    class State private constructor(private val description: String) {

        override fun toString(): String {
            return description
        }

        companion object {
            @JvmField
            val FLAT: State = State("FLAT")
            @JvmField
            val HALF_OPENED: State = State("HALF_OPENED")
        }
    }

    val isSeparating: Boolean

    val occlusionType: OcclusionType

    val orientation: Orientation

    val state: State
}

DisplayFeatureを見てみると、上で折りたたみデバイスの状態として紹介したFLATHALF_OPENEDがありました。

下の4は何を意味しているのでしょうか?
isSeparatingは、折り目またはヒンジが2つの論理ディスプレイ領域を生成するかどうかを表しています。
occluesionTyepは、折り目またはヒンジがディスプレイを隠しているかどうかを表しています。
orientationは、折り目またはヒンジの向きを表します。
stateは、FLATHALF_OPENEDなどをデバイスの状態を表しています。

FoldingFeaturestateを使用することで、デバイスがトップテーブル状態のなのかブック状態なのかどうかを識別することができるようになっています。

今回はJetcasterという、公式のサンプルアプリを見ながらFoldingFeatureを生かしてどのようにアプリの開発をしているのかを見てます。

まず、JetcasterのMainActivity#onCreateを見てみます。

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

        // This app draws behind the system bars, so we want to handle fitting system windows
        WindowCompat.setDecorFitsSystemWindows(window, false)

        /**
         * Flow of [DevicePosture] that emits every time there's a change in the windowLayoutInfo
         */
        val devicePosture = getOrCreate(this).windowLayoutInfo(this)
            .flowWithLifecycle(this.lifecycle)
            .map { layoutInfo ->
                val foldingFeature =
                    layoutInfo.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()
                when {
                    isTableTopPosture(foldingFeature) ->
                        DevicePosture.TableTopPosture(foldingFeature.bounds)
                    isBookPosture(foldingFeature) ->
                        DevicePosture.BookPosture(foldingFeature.bounds)
                    isSeparatingPosture(foldingFeature) ->
                        DevicePosture.SeparatingPosture(
                            foldingFeature.bounds,
                            foldingFeature.orientation
                        )
                    else -> DevicePosture.NormalPosture
                }
            }
            .stateIn(
                scope = lifecycleScope,
                started = SharingStarted.Eagerly,
                initialValue = DevicePosture.NormalPosture
            )

        setContent {
            JetcasterTheme {
                JetcasterApp(devicePosture)
            }
        }
    }

下記の部分でWindowLayoutInfoを取得しています。

        val devicePosture = getOrCreate(this).windowLayoutInfo(this)
            .flowWithLifecycle(this.lifecycle)
            .map { layoutInfo ->
                val foldingFeature =
                    layoutInfo.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()
                when {
                    isTableTopPosture(foldingFeature) ->
                        DevicePosture.TableTopPosture(foldingFeature.bounds)
                    isBookPosture(foldingFeature) ->
                        DevicePosture.BookPosture(foldingFeature.bounds)
                    isSeparatingPosture(foldingFeature) ->
                        DevicePosture.SeparatingPosture(
                            foldingFeature.bounds,
                            foldingFeature.orientation
                        )
                    else -> DevicePosture.NormalPosture
                }
            }
            .stateIn(
                scope = lifecycleScope,
                started = SharingStarted.Eagerly,
                initialValue = DevicePosture.NormalPosture
            )

windowLayoutInfoは先ほど見たように、Flowで値を返すようにしていたので、ここではflowWithLifecycleが使われています。さらに、Flowで取得した値に対して処理をしていますね。
val foldingFeature = layoutInfo.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()で、FoldingFeatureを取得しています。
そこから、whenでFoldingFeatureの値によって最終的にdevicePostureに代入しています。
では、whenの中で使われているメソッドがどうなっているか見てましょう。

@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 isSeparatingPosture(foldFeature: FoldingFeature?): Boolean {
    contract { returns(true) implies (foldFeature != null) }
    return foldFeature?.state == FoldingFeature.State.FLAT && foldFeature.isSeparating
}

このように、デバイスがテーブルトップ状態なのかブック状態なのかデュアルスクリーン状態なのかを判断してします。

これを、MainActivityで取得してComposable関数に今、デバイスがどの状態なのかを伝えていきます。
最終的にPlayerContentというComposable関数の中で使われていました。

@Composable
fun PlayerContent(
    uiState: PlayerUiState,
    devicePosture: DevicePosture,
    onBackPress: () -> Unit
) {
    PlayerDynamicTheme(uiState.podcastImageUrl) {
        // As the Player UI content changes considerably when the device is in tabletop posture,
        // we split the different UIs in different composables. For simpler UIs that don't change
        // much, prefer one composable that makes decisions based on the mode instead.
        when (devicePosture) {
            is DevicePosture.TableTopPosture ->
                PlayerContentTableTop(uiState, devicePosture, onBackPress)
            is DevicePosture.BookPosture ->
                PlayerContentBook(uiState, devicePosture, onBackPress)
            is DevicePosture.SeparatingPosture ->
                if (devicePosture.orientation == FoldingFeature.Orientation.HORIZONTAL) {
                    PlayerContentTableTop(
                        uiState,
                        DevicePosture.TableTopPosture(devicePosture.hingePosition),
                        onBackPress
                    )
                } else {
                    PlayerContentBook(
                        uiState,
                        DevicePosture.BookPosture(devicePosture.hingePosition),
                        onBackPress
                    )
                }
            else ->
                PlayerContentRegular(uiState, onBackPress)
        }
    }
}

ここでそれぞれのサイズに合わせたレイアウトを表示するようにしているようです。

この先の詳しい実装に関しては、Jetcasterを見てみてください。

最後に

今回は、折りたたみデバイスの特徴や対応する方法についてみていきました。
どんどん新しいデバイスが出てきますが、頑張って対応していきましょう🙌

参考

https://developer.android.com/guide/topics/large-screens/learn-about-foldables
https://developer.android.com/guide/topics/large-screens/make-apps-fold-aware
https://developer.android.com/guide/topics/large-screens/test-apps-on-foldables
https://github.com/android/compose-samples/tree/main/Jetcaster
https://developer.android.com/codelabs/android-window-manager-dual-screen-foldables#0

Discussion