Androidでさまざまなデバイスに対応する
Androidデバイスは、数がものすごく多いのでデバイスの形もサイズもバラバラです。
最近はさらに、折りたたみのデバイスなんか出てきたので、これからさらにさまざまなデバイスに対応する必要が出てきます。
今回は、普段開発しているスマホだけじゃなくFoldableデバイスやChromebookに対応する方法についてみていきます。
大画面に対応する際に気をつけること
そもそも、さまざまなデバイスサイズに対応すると言ってもどういう種類のものがあるのでしょうか?
普段使っているスマホ(PixelやGalaxyS21など)以外にも、AndroidはFoldableやタブレット、Chromebookなど大型のデバイスがあります。
大型のアプリ向けにアプリを開発する際に、気をつけるポイントがいくつかあります。
- 横向きだけではない。
- マルチウィンドウに対応する。
- 複数のアプリを並べて使う場合があり、その時も必ずしも1:1のサイズ感になるとは限らないので、ウィンドウサイズが可変になりそこにも対応する必要がある。
- タッチだけじゃなく、キーボードやマウスでの操作に対応する。
- タッチスクリーンがついていないデバイスもある。
- ウィンドウサイズの変更もConfiguration Changeの一つとして対応する
- スマホのみでアプリのテストを行うのではなく、タブレットやFoldableデバイスでもテストしてみる。
以上のことを意識して(これ以外にも意識する部分はありますが)、これからはアプリの開発を行っていく必要があります。
大画面デバイスサポートの段階
Androidの公式ページでは、大画面デバイスサポートを以下の3つの段階に分けて紹介しています。
- Tier3 (Basic) : 大画面に対応
- Tier2 (Better) : 大画面向けに最適化
- 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
は以下のような実装になっています。
@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#fromWidth
とWindowHeightSizeClass#fromHeight
を見てみましょう。
@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
}
}
}
}
@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
の中のfromWidth
、fromHeight
の引数をもとに計算していました。
では、WindowSizeClass#calculateFromSize
はどこから呼ばれるのでしょうか?
WindowSizeClass#calculateFromSize
はcalculateWindowSizeClass
という関数で呼び出されます。デバイスの幅を計算する時には自分達は、この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のソースコードを見てましょう。
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#onCreate
でcalculateWindowSizeClass
が使われており、これを流して行って、UIをレスポンシブ・アダプティブレイアウトに対応していく流れになります。
MainActivity#onCreate
でNiaApp
という関数に対して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
の引数でappState
にWindowSizeClass
を渡しており、下のbottomBar
やRow
の中を見ると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でのさまざまなデバイスへの対応についてみていきました。まだ全然全部書ききれていないので、気になった方は下の参照から公式を参照して調べてみてください。
大変ですが頑張っていきましょう🙌
参考
Discussion