📲

Androidでさまざまなデバイスに対応する

2022/12/02に公開

Androidデバイスは、数がものすごく多いのでデバイスの形もサイズもバラバラです。
最近はさらに、折りたたみのデバイスなんか出てきたので、これからさらにさまざまなデバイスに対応する必要が出てきます。
今回は、普段開発しているスマホだけじゃなくFoldableデバイスやChromebookに対応する方法についてみていきます。

大画面に対応する際に気をつけること

そもそも、さまざまなデバイスサイズに対応すると言ってもどういう種類のものがあるのでしょうか?
普段使っているスマホ(PixelやGalaxyS21など)以外にも、AndroidはFoldableやタブレット、Chromebookなど大型のデバイスがあります。
大型のアプリ向けにアプリを開発する際に、気をつけるポイントがいくつかあります。

  • 横向きだけではない。
  • マルチウィンドウに対応する。
    • 複数のアプリを並べて使う場合があり、その時も必ずしも1:1のサイズ感になるとは限らないので、ウィンドウサイズが可変になりそこにも対応する必要がある。
  • タッチだけじゃなく、キーボードやマウスでの操作に対応する。
    • タッチスクリーンがついていないデバイスもある。
  • ウィンドウサイズの変更もConfiguration Changeの一つとして対応する
  • スマホのみでアプリのテストを行うのではなく、タブレットやFoldableデバイスでもテストしてみる。

以上のことを意識して(これ以外にも意識する部分はありますが)、これからはアプリの開発を行っていく必要があります。

大画面デバイスサポートの段階

Androidの公式ページでは、大画面デバイスサポートを以下の3つの段階に分けて紹介しています。

  1. Tier3 (Basic) : 大画面に対応
  2. Tier2 (Better) : 大画面向けに最適化
  3. Tier1 (Best) : 大画面によって差別化

まず、Basicに関しては次のように定義されています。

  • 機能するが理想的でない状態
  • フルスクリーン表示可能
  • Config Changeが起きても状態が保存される
  • 外部入力デバイス(マウスやキーボード)向けの基本的サポート

Betterでは以下のようになっています

  • 大画面デバイスに最適化されたレイアウト
  • 全てのデバイスに対して対応済み
  • レスポンシブ・アダプティブレイアウトのベストプラクティスを実装
    • レスポンシブ・アダプティブレイアウト
    • アダプティブアプリのナビゲーション

最後にBestでは以下のようになっています

  • 大画面デバイスの特性を生かした機能
  • 大画面に対応することで他のアプリと差別化できている
  • マウスでの操作やドラッグ&ドロップにも対応
  • タッチペンへの対応
  • 折りたたみデバイスへの対応

以上の3つの段階になっていて、できるだけこれらを遵守してアプリを開発することがベストではあるが、必ずしも全てを網羅している必要はないそうです。
ただ、さまざまなデバイスサイズに対応する必要がある以上、最低でもレスポンシブ・アダプティブレイアウトには対応した方がいいと思うので、今回はこのレイアウトに対応する方法についてみてみます。

レスポンシブ・アダプティブレイアウトに対応する

レスポンシブ・アダプティブレイアウトに対応する時には、WindowSizeClassを用いて開発を進めていきます。
WindowSizeClassは、デバイス画面のサイズによって決定されるのではなく、現在実行しているアプリで使用可能なウィンドウサイズによって決定されます。
なので、WindowSizeClassはアプリを実行していればいつでも変更される可能性があるので、アプリ側はWindowSizeClassの値をもとに、ナビゲーションであったりUIの変更を行う必要があります。

WindowSizeClassは、横幅と縦幅どちらも、以下のような独自のブレークポイントが設定されています。

横幅

  • Compact : 600dp 未満
  • Medium : 600dp 以上
  • Expanded : 840dp 以上

縦幅

  • Compact : 480dp 未満
  • Medium : 900dp 以下
  • Expanded : 900dp 以上

この3つの値をもとに、レスポンシブ・アダプティブレイアウトに対応していきます。

では、WindowSizeClassの内部をみていきます。
WindowSizeClassは以下のような実装になっています。

WindowSizeClass
@Immutable
class WindowSizeClass private constructor(
    val widthSizeClass: WindowWidthSizeClass,
    val heightSizeClass: WindowHeightSizeClass
) {
    companion object {
        /**
         * Calculates [WindowSizeClass] for a given [size]. Should be used for testing purposes only
         * - to calculate a [WindowSizeClass] for the Activity's current window see
         * [calculateWindowSizeClass].
         *
         * @param size of the window
         * @return [WindowSizeClass] corresponding to the given width and height
         */
        @ExperimentalMaterial3WindowSizeClassApi
        @TestOnly
        fun calculateFromSize(size: DpSize): WindowSizeClass {
            val windowWidthSizeClass = WindowWidthSizeClass.fromWidth(size.width)
            val windowHeightSizeClass = WindowHeightSizeClass.fromHeight(size.height)
            return WindowSizeClass(windowWidthSizeClass, windowHeightSizeClass)
        }
    }
    ....

calculateFromSizeを使って計算を行っているようです。
calculateFromSizeの内部で呼び出しているWindowWidthSizeClass#fromWidthWindowHeightSizeClass#fromHeightを見てみましょう。

WindowWidthSizeClass
@Immutable
@kotlin.jvm.JvmInline
value class WindowWidthSizeClass private constructor(private val value: Int) :
    Comparable<WindowWidthSizeClass> {

    .....

    companion object {
        /** Represents the majority of phones in portrait. */
        val Compact = WindowWidthSizeClass(0)

        /**
         * Represents the majority of tablets in portrait and large unfolded inner displays in
         * portrait.
         */
        val Medium = WindowWidthSizeClass(1)

        /**
         * Represents the majority of tablets in landscape and large unfolded inner displays in
         * landscape.
         */
        val Expanded = WindowWidthSizeClass(2)

        /** Calculates the [WindowWidthSizeClass] for a given [width] */
        internal fun fromWidth(width: Dp): WindowWidthSizeClass {
            require(width >= 0.dp) { "Width must not be negative" }
            return when {
                width < 600.dp -> Compact
                width < 840.dp -> Medium
                else -> Expanded
            }
        }
    }
}
WindowHeightSizeClass
@Immutable
@kotlin.jvm.JvmInline
value class WindowHeightSizeClass private constructor(private val value: Int) :
    Comparable<WindowHeightSizeClass> {
    ......

    companion object {
        /** Represents the majority of phones in landscape */
        val Compact = WindowHeightSizeClass(0)

        /** Represents the majority of tablets in landscape and majority of phones in portrait */
        val Medium = WindowHeightSizeClass(1)

        /** Represents the majority of tablets in portrait */
        val Expanded = WindowHeightSizeClass(2)

        /** Calculates the [WindowHeightSizeClass] for a given [height] */
        internal fun fromHeight(height: Dp): WindowHeightSizeClass {
            require(height >= 0.dp) { "Height must not be negative" }
            return when {
                height < 480.dp -> Compact
                height < 900.dp -> Medium
                else -> Expanded
            }
        }
    }
}

2つともcompanion objectの中のfromWidthfromHeightの引数をもとに計算していました。
では、WindowSizeClass#calculateFromSizeはどこから呼ばれるのでしょうか?
WindowSizeClass#calculateFromSizecalculateWindowSizeClassという関数で呼び出されます。デバイスの幅を計算する時には自分達は、このcalculateWindowSizeClassを使って計算していくことになります。

ではここで、calculateWindowSizeClassの内部をみてましょう。

@ExperimentalMaterial3WindowSizeClassApi
@Composable
fun calculateWindowSizeClass(activity: Activity): WindowSizeClass {
    // Observe view configuration changes and recalculate the size class on each change. We can't
    // use Activity#onConfigurationChanged as this will sometimes fail to be called on different
    // API levels, hence why this function needs to be @Composable so we can observe the
    // ComposeView's configuration changes.
    LocalConfiguration.current
    val density = LocalDensity.current
    val metrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(activity)
    val size = with(density) { metrics.bounds.toComposeRect().size.toDpSize() }
    return WindowSizeClass.calculateFromSize(size)
}

内部で、WindowMetricsCalculatorを使っています。WindowMetricsCalculatorはInterfaceになっており、Activityのウィンドウサイズを計算する役割をになっています。
そして、WindowMetricsCalculatorで取得した値をもとにdpに直してその値を、WindowSizeClass#calculateFromSizeに渡して、実際に自分達が使えるようにしてくれているようです。

WindowSizeClassがどうやって計算されているかなんとなくわかったところで、次はそれ生かして実装をしてみます。

今回は、画面サイズに応じてナビゲーションを変える実装についてみていきたいと思います。
ナビゲーションをレスポンシブ対応させる上で、公式では以下が推奨されています。

  • Compact : Bottom Navigation bar
  • Medium : Navigation Rail
  • Expended : Drawer

これに対応する実施の実装を見てます。
nowinandroidのソースコードを見てましょう。

MainActivity
class MainActivity : ComponentActivity() {

    ....

    override fun onCreate(savedInstanceState: Bundle?) {
        ....
        setContent {
            val systemUiController = rememberSystemUiController()
            val darkTheme = shouldUseDarkTheme(uiState)

            // Update the dark content of the system bars to match the theme
            DisposableEffect(systemUiController, darkTheme) {
                systemUiController.systemBarsDarkContentEnabled = !darkTheme
                onDispose {}
            }

            NiaTheme(
                darkTheme = darkTheme,
                androidTheme = shouldUseAndroidTheme(uiState)
            ) {
                NiaApp(
                    networkMonitor = networkMonitor,
                    windowSizeClass = calculateWindowSizeClass(this),
                )
            }
        }
    }

nowinandroidでは、MainActivity#onCreatecalculateWindowSizeClassが使われており、これを流して行って、UIをレスポンシブ・アダプティブレイアウトに対応していく流れになります。

MainActivity#onCreateNiaAppという関数に対してWindowSizeClassの値を渡していました。NiaAppを見てます。

@Composable
fun NiaApp(
    windowSizeClass: WindowSizeClass,
    networkMonitor: NetworkMonitor,
    appState: NiaAppState = rememberNiaAppState(
        networkMonitor = networkMonitor,
        windowSizeClass = windowSizeClass
    ),
) {
    ....

    background {
        val snackbarHostState = remember { SnackbarHostState() }
        Scaffold(
            modifier = Modifier.semantics {
                testTagsAsResourceId = true
            },
            containerColor = Color.Transparent,
            contentColor = MaterialTheme.colorScheme.onBackground,
            contentWindowInsets = WindowInsets(0, 0, 0, 0),
            snackbarHost = { SnackbarHost(snackbarHostState) },
            topBar = {
                ....
                }
            },
            bottomBar = {
                if (appState.shouldShowBottomBar) {
                    NiaBottomBar(
                        destinations = appState.topLevelDestinations,
                        onNavigateToDestination = appState::navigateToTopLevelDestination,
                        currentDestination = appState.currentDestination
                    )
                }
            }
        ) { padding ->
        .....
            Row(
                Modifier
                    .fillMaxSize()
                    .windowInsetsPadding(
                        WindowInsets.safeDrawing.only(
                            WindowInsetsSides.Horizontal
                        )
                    )
            ) {
                if (appState.shouldShowNavRail) {
                    NiaNavRail(
                        destinations = appState.topLevelDestinations,
                        onNavigateToDestination = appState::navigateToTopLevelDestination,
                        currentDestination = appState.currentDestination,
                        modifier = Modifier.safeDrawingPadding()
                    )
                }
                ....
            }
        }
    }
}

これを見てみると、NiaAppの引数でappStateWindowSizeClassを渡しており、下のbottomBarRowの中を見るとappStateの値によってナビゲーションのレイアウトを変更しているようです。
NiaAppStateの内部をみてみると以下のようになっていました。

@Stable
class NiaAppState(
    val navController: NavHostController,
    val coroutineScope: CoroutineScope,
    val windowSizeClass: WindowSizeClass,
    networkMonitor: NetworkMonitor,
) {
    ....
    val shouldShowBottomBar: Boolean
        get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact ||
            windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact

    val shouldShowNavRail: Boolean
        get() = !shouldShowBottomBar

WindowSizeClassの値によって、レイルで表示するかボトムで表示するか決定しています。
Androidの公式にもありましたが、アプリ内のナビゲーションをデバイスのサイズによって適切に変更することが推奨されています。
もし、CompactならボトムナビゲーションにしてMediumならばレイルで横にナビゲーションを表示させると言ったような、デバイスサイズに応じてナビゲーションの部分も変更していくことが必要になってきそうです。
今回は、nowinandroidのソースコードを参照したので、Jetpack Composeでの実装例になりましたが、実際のアプリでは大半がAndroid Viewを使用していると思うので、Android Viewでの例を見たい方は公式を参照してください🙇‍♂️

最後に

今回は、Androidでのさまざまなデバイスへの対応についてみていきました。まだ全然全部書ききれていないので、気になった方は下の参照から公式を参照して調べてみてください。
大変ですが頑張っていきましょう🙌

参考

https://developer.android.com/guide/topics/large-screens/support-different-screen-sizes#window_size_classes

https://developer.android.com/docs/quality-guidelines/large-screen-app-quality

https://developer.android.com/guide/topics/large-screens/support-different-screen-sizes

https://developer.android.com/guide/topics/large-screens/navigation-for-responsive-uis#views

https://github.com/android/nowinandroid

https://www.youtube.com/watch?v=V7Du1YVo1pA

Discussion