Compose 大屏适配别乱做!这几个坑 90% 的人都在踩

0 阅读5分钟

Android App 现在不能只按手机竖屏设计。同一个 App 可能跑在手机、平板、折叠屏、ChromeOS、桌面窗口和分屏模式里。同一台设备的窗口大小,也可能在运行时变化。

Compose 自适应布局的核心问题不是“这是不是平板”,而是“当前 App 窗口还有多少空间”。

Image

先读窗口,不读设备

很多老代码会这样写:

if (isTablet) {
    TabletHomeScreen()
} else {
    PhoneHomeScreen()
}

这类判断在今天很不友好。因为平板可以进入小窗口分屏,折叠屏可以展开或合上,ChromeOS 窗口可以被用户随手拖动。设备类型没变,但 App 可用空间已经变了。

Compose Material 3 Adaptive 提供了 currentWindowAdaptiveInfo()

它读的是当前窗口信息,不是设备型号。

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveMailScreen() {
    val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass

    val width = windowSizeClass.widthSizeClass
    val height = windowSizeClass.heightSizeClass
}

常用宽度分成三档:

WindowWidthSizeClass.Compact
WindowWidthSizeClass.Medium
WindowWidthSizeClass.Expanded

这三档比 isTablet 更接近真实 UI 决策。

Image

双栏还要看高度

很多列表详情页会写成:

val showTwoPane = width == WindowWidthSizeClass.Expanded

只看宽度还不够。横屏手机可能有较宽的窗口,但高度很低。这个时候强行显示列表 + 详情,两个 pane 都会显得挤。

更稳的判断是把高度也放进去:

val showTwoPane =
    width == WindowWidthSizeClass.Expanded &&
        height != WindowHeightSizeClass.Compact

这条规则不是标准答案,但适合大多数 list-detail 页面。

宽度够,说明能放下两个 pane;高度不紧,说明详情页还有可阅读空间。

列表详情是最常见模式

邮件、设置、消息、笔记、文档、订单管理,很多页面都是 list-detail。

小屏上,用户先看到列表,点进去再看详情。大屏上,左边保留列表,右边显示选中项详情。这个模式的价值不是“利用大屏填满空间”,而是减少来回导航。用户切换邮件、设置项或文档时,不需要不断返回列表。

data class Mail(
    val id: Long,
    val sender: String,
    val subject: String,
    val body: String,
)

屏幕根节点只维护一个核心状态:当前选中的 item。

var selectedMailId by rememberSaveable {
    mutableStateOf<Long?>(null)
}

val selectedMail = mails.firstOrNull {
    it.id == selectedMailId
}

不要为手机和平板各维护一套状态。

同一个 selectedMailId,在小屏驱动“列表 / 详情切换”,在大屏驱动“左侧选中态 + 右侧详情”。

Image

根节点决定排列方式

自适应布局最容易失控的地方,是把 phone / tablet 分支写到每个组件里。

更好的方式是:根节点判断布局结构,子组件保持可复用。

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveMailScreen() {
    val info = currentWindowAdaptiveInfo()
    val width = info.windowSizeClass.widthSizeClass
    val height = info.windowSizeClass.heightSizeClass

    val showTwoPane =
        width == WindowWidthSizeClass.Expanded &&
            height != WindowHeightSizeClass.Compact

    val mails = remember { sampleMails() }
    var selectedMailId by rememberSaveable { mutableStateOf<Long?>(null) }
    val selectedMail = mails.firstOrNull { it.id == selectedMailId }

    if (showTwoPane) {
        TwoPaneMailContent(
            mails = mails,
            selectedMail = selectedMail,
            selectedMailId = selectedMailId,
            onMailSelected = { selectedMailId = it }
        )
    } else {
        SinglePaneMailContent(
            mails = mails,
            selectedMail = selectedMail,
            onMailSelected = { selectedMailId = it },
            onBack = { selectedMailId = null }
        )
    }
}

这段代码的重点不是 Row 怎么写。

重点是状态没有分叉,只有布局结构分叉。

小屏只显示一个 pane

小屏里,如果没有选中邮件,就显示列表。

如果已经选中邮件,就显示详情。

@Composable
private fun SinglePaneMailContent(
    mails: List<Mail>,
    selectedMail: Mail?,
    onMailSelected: (Long) -> Unit,
    onBack: () -> Unit,
) {
    if (selectedMail == null) {
        MailList(
            mails = mails,
            selectedMailId = null,
            onMailSelected = onMailSelected,
            modifier = Modifier.fillMaxSize()
        )
    } else {
        MailDetail(
            mail = selectedMail,
            showBackButton = true,
            onBack = onBack,
            modifier = Modifier.fillMaxSize()
        )
    }
}

小屏不需要硬塞一个窄详情 pane。

这种布局里,返回逻辑也要跟着布局走。

详情页可见时,返回应该清空 selectedMailId,让用户回到列表。

if (!showTwoPane && selectedMail != null) {
    BackHandler {
        selectedMailId = null
    }
}

大屏保留上下文

大屏写法可以很直接:

@Composable
private fun TwoPaneMailContent(
    mails: List<Mail>,
    selectedMail: Mail?,
    selectedMailId: Long?,
    onMailSelected: (Long) -> Unit,
) {
    Row(Modifier.fillMaxSize()) {
        MailList(
            mails = mails,
            selectedMailId = selectedMailId,
            onMailSelected = onMailSelected,
            modifier = Modifier.weight(0.4f)
        )

        VerticalDivider()

        MailDetail(
            mail = selectedMail,
            showBackButton = false,
            onBack = null,
            modifier = Modifier.weight(0.6f)
        )
    }
}

0.4f / 0.6f 只是起点。

列表密度高,就给列表更多空间;详情内容长,就给详情更多空间。不要把比例写成设计系统规则。

大屏里还要显示选中态。

小屏打开详情后列表不可见,选中态没有意义;大屏列表一直在左边,选中态能告诉用户右侧内容来自哪一项。

Image

导航也要自适应

页面内容要自适应,App 主导航也要自适应。

小屏一般用 bottom navigation。

中大屏更适合 navigation rail,导航项固定在左侧,不占用底部高度。

手写版本大概是这样:

@Composable
fun AdaptiveAppShell(
    useBottomBar: Boolean,
    content: @Composable (Modifier) -> Unit,
) {
    if (useBottomBar) {
        Scaffold(
            bottomBar = {
                NavigationBar {
                    NavigationBarItem(
                        selected = true,
                        onClick = {},
                        icon = { Icon(Icons.Default.Home, null) },
                        label = { Text("Home") }
                    )
                }
            }
        ) { padding ->
            content(Modifier.padding(padding))
        }
    } else {
        Row(Modifier.fillMaxSize()) {
            NavigationRail {
                NavigationRailItem(
                    selected = true,
                    onClick = {},
                    icon = { Icon(Icons.Default.Home, null) },
                    label = { Text("Home") }
                )
            }
            content(Modifier.weight(1f))
        }
    }
}

真实项目里,可以优先看 NavigationSuiteScaffold

它会根据窗口大小和设备姿态,在 navigation bar、navigation rail 等形态之间切换。

依赖是:

dependencies {
    implementation("androidx.compose.material3:material3-adaptive-navigation-suite")
}

手写逻辑适合少量页面,官方 scaffold 适合整站导航。

Image

官方 scaffold 不是必须一开始就上

Material 3 Adaptive 里还有几类组件:

ListDetailPaneScaffold
NavigableListDetailPaneScaffold
NavigationSuiteScaffold

ListDetailPaneScaffold 负责标准 list-detail 结构。

NavigableListDetailPaneScaffold 在它之上处理 pane 间导航和返回动画。

NavigationSuiteScaffold 处理 App 主导航形态切换。

列表详情相关依赖通常是:

dependencies {
    implementation("androidx.compose.material3.adaptive:adaptive")
    implementation("androidx.compose.material3.adaptive:adaptive-layout")
    implementation("androidx.compose.material3.adaptive:adaptive-navigation")
}

如果页面很简单,手写 if (showTwoPane) Row(...) else ... 可读性更高。

如果详情 pane 里还有自己的导航、返回行为变复杂、需要 pane 级保存恢复,再上 NavigableListDetailPaneScaffold

不要为了“用了官方自适应组件”而让简单页面变复杂。

最后

Compose 自适应布局的基本思路很朴素:读当前窗口,保留同一份状态,只改变布局排列。

小窗口单栏,大窗口双栏;小窗口底部导航,大窗口侧边导航。

真正要避免的是把“平板适配”写成一个单独工程。Android 的窗口形态已经变成动态条件,布局代码也应该跟着窗口走。

#Android #JetpackCompose #自适应布局 #Material3