Androidの折りたたみデバイスに対応する
Androidでは、さまざまなデバイスの種類があり、それぞれに対応するのは大変ですが、その中でも折りたたみ式のデバイスに対応するのは特に大変そうだなと思ったので、今回は折りたたみデバイスに対応する方法について調べてみました。
レスポンシブ・アダプティブデザイン
折りたたみ式デバイスに対応するためには、まずレスポンシブデザインにする必要があります。
ConstraintLayout
を使用するなどして、デバイスサイズの変更にもアプリを対応していくことで、折りたたみ式にも対応することができるようになります。
ただ、折りたたみ式のようにタブレット型になるような場合もあれば、スマホのように縦型の普通のサイズになることもあり、それぞれのサイズに応じてUIを変更していく必要があります。
例えば、ナビゲーションやリストなどはその例かなと思っていて、WindowSizeClass
がCompact
の時は、ボトムナビゲーションで表示させるけどMedium
の場合はRailでナビゲーションを表示させるなどして対応していきます。
リストでは、一覧画面に表示されているリストから詳細画面に遷移する時に、スマホサイズならばそのまま画面遷移すればいいですが、タブレットみたいな大きな画面では、そのまま遷移させるのではなくマルチウィンドウの状態にして、左に一覧画面を表示させ右に詳細画面を表示させると言ったようなUIにすることで対応していくことが推奨されています。
折りたたみ式の詳細
折りたたみ式デバイスは、折りたたむことによって画面が2つに分かれます。
この時に、「ヒンジ」と呼ばれる折り目の部分を境に2つのディスプレイを表示することができます。
折りたたみデバイスの特徴として、さまざまな状態が定義されています。
FLAT
とHALF_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
を見てみると、上で折りたたみデバイスの状態として紹介したFLAT
やHALF_OPENED
がありました。
下の4は何を意味しているのでしょうか?
isSeparating
は、折り目またはヒンジが2つの論理ディスプレイ領域を生成するかどうかを表しています。
occluesionTyep
は、折り目またはヒンジがディスプレイを隠しているかどうかを表しています。
orientation
は、折り目またはヒンジの向きを表します。
state
は、FLAT
やHALF_OPENED
などをデバイスの状態を表しています。
FoldingFeature
のstate
を使用することで、デバイスがトップテーブル状態のなのかブック状態なのかどうかを識別することができるようになっています。
今回はJetcasterという、公式のサンプルアプリを見ながらFoldingFeature
を生かしてどのようにアプリの開発をしているのかを見てます。
まず、JetcasterのMainActivity#onCreate
を見てみます。
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を見てみてください。
最後に
今回は、折りたたみデバイスの特徴や対応する方法についてみていきました。
どんどん新しいデバイスが出てきますが、頑張って対応していきましょう🙌
参考
Discussion