myMusic · 折叠屏与双屏 · 设计稿 · 2026-10-31

折痕不压字

平板的宽度断点已经做完,这份稿子不再重复。它只补还没支持的部分:App 现在不知道屏幕上有折痕或铰链。依据只有一篇 Google codelab(使用 Jetpack WindowManager 支持可折叠设备和双屏设备):用 WindowInfoTracker 读 FoldingFeature,按它的 state、orientation、isSeparating、occlusionType、bounds 排版。问题都在 Pixel Fold 模拟器上实测过。

依据 codelab · androidx.window 1.5.1 实测 Pixel Fold AVD · API 34 新增姿态 书本 · 桌面 · 双屏 新增依赖 window + window-testing

书本姿态:左右分页

竖向折痕半开时,播放页、列表-详情、海报墙都按折痕的 bounds 分成两页,内容不跨折痕。

桌面姿态:上看下控

横向折痕半开时,上半屏放歌词 / 封面,下半屏放控制,而且控制不再自动隐藏。

双屏:铰链里不放东西

occlusionType = FULL 的铰链会挡住像素,所以即使展平也按两块屏排版,铰链宽度留空。

小窗:播放页不重叠

折叠屏上下分屏后窗口只有约 400×400dp,手机播放页的控件会盖到封面和标题上,需要补一个矮窗口布局。

01 · 原型

切设备,看折痕压到了什么

上面一排选设备和页面,「现状」按现在的代码排版,「方案」按这份稿子排版。红框是压在折痕上、或被铰链挡住、或互相重叠的关键内容,下面的检视面板列出 App 会从 WindowManager 收到的 FoldingFeature。

尺寸都按 dp 画:大折叠取 Pixel Fold 内屏,小折叠取 codelab 用的 6.7" 横向折叠模拟器,双屏是示意尺寸。

WindowLayoutInfo

选用的布局

压折痕 / 被挡 / 重叠

02 · 实测

Pixel Fold 模拟器上的现状

当前 main(6b9282f)的 debug 包,装在按 Pixel Fold 定义建的 API 34 模拟器上。半开姿态用 adb emu posture 2 切,桌面姿态是把内屏转成竖握后再半开。红色虚线是折痕位置(内屏 2208px 宽的正中,即 420dp)。

书本姿态下的宽屏播放页,歌词栏左缘贴着折痕
① 书本 · 播放页。宽屏播放页左栏取窗口宽的 43.75%(368dp),歌词栏从左栏右边再空 40dp 开始,正好落在 420dp 的折痕上:每一行歌词的第一个字都压在折痕里。
书本姿态下的海报墙,正在播放的大卡居中在折痕上
② 书本 · 海报墙。正在播放的大卡按整个视口居中,中线就是折痕。歌词、进度条和播放键都被折痕从中间劈开。
书本姿态下的我的页,双栏左栏跨过折痕
③ 书本 · 我的。左栏是 96dp 侧栏加 380dp 列表,右缘在 476dp。「最近 / 下载」卡片和「新建歌单」按钮横跨折痕;底部播放条的播放键也贴着折痕。
桌面姿态下的播放页,歌名压在折痕上
④ 桌面 · 播放页。竖握内屏 701×841 走手机播放页。歌名「谁的错」正好在折痕上,封面在立起来的上半屏,按钮在平放的下半屏,是碰巧分开的。
桌面姿态下的海报墙,当前歌词压在折痕上
⑤ 桌面 · 海报墙。大卡竖向也居中,当前这一句歌词落在折痕上。
约 400×400dp 小窗口里,手机播放页的进度条和按钮盖在封面上
⑥ 分屏小窗 · 播放页。用 wm size 1056x1066 模拟约 402×406dp 的窗口:进度条、按钮组、工具栏叠在封面上,歌名被工具栏挡住,歌词出了屏幕。
  • ⑦
    每次折叠 / 展开,Activity 都会重建系统日志里这次配置变化是 orientation | screenLayout | screenSize | smallestScreenSize,而 manifest 的 configChanges 没有 screenLayout,所以走了 relaunch(finishDrawing of relaunch … 938ms)。播放、播放页、歌词模式在重建后都还在,问题是这近一秒的重绘。
  • ⑧
    App 收不到任何折痕信息没有依赖 androidx.window,AppWindow 只有宽高。上面 ①–⑤ 全是「不知道有折痕」造成的。
03 · 姿态

从 FoldingFeature 推出四种情况

codelab 的要点:只有 App 横跨整个显示区时 displayFeatures 才非空;位置一律用 bounds,不写死尺寸;每次 flow 发新值都重新排版。

WindowLayoutInfo姿态处理例子
displayFeatures 为空普通窗口不变,沿用现有宽度断点平板、折叠外屏、分屏或多窗口(codelab:多窗口不算横跨)
FLAT,isSeparating = false展平不变,当平板用,不避折痕Pixel Fold 内屏完全打开
HALF_OPENED,VERTICAL书本左右两页,以折痕 bounds 为界大折叠横握半开
HALF_OPENED,HORIZONTAL桌面上下两块:上看,下控大折叠竖握半开、小折叠半开
occlusionType = FULL双屏不管 state,按铰链分两块,铰链宽度留空;方向决定左右还是上下双屏设备横跨两块屏

是否分页看 isSeparating

书本和桌面都要求 isSeparating = true:半开的折痕,或任何会遮挡像素的铰链。展平的折痕不分页。

坐标换成自己的

bounds 是窗口坐标。照 codelab 的 getFeatureBoundsInWindow:先和自己在窗口里的矩形求交,不相交就当没有;宽或高为 0 的折痕按 1px 算。

折痕两侧留安全距离

半开的折痕不挡像素,但弯着看不清,所以两侧各留 16dp 不放字。铰链本身已经有宽度,不再另加。

小窗另算

小窗口不横跨折痕,收不到 FoldingFeature,按窗口高度判断:高度不到 480dp、宽度不到 600dp 时,播放页改用矮窗口布局。

04 · 页面

三个页面的新排法

只改这些页面:其余页面在书本 / 桌面姿态下照常滚动。列表跨折痕可以接受,前提是折痕不挡像素。

播放页

  • 书本 / 双竖:左页放封面、歌名、进度、按钮组、工具栏,右页整页放歌词。左栏右缘就是折痕左缘,歌词栏从折痕右缘再空 32dp 开始。专注歌词模式只占右页,不再横跨全宽。
  • 桌面 / 双横:上半屏(立着的那一半)放歌词,也可以换成封面或时钟;下半屏(平放的那一半)放小封面、歌名、进度、按钮组、工具栏。下半屏的控制不再 4 秒自动隐藏,因为它是放在桌上用手点的那一面。
  • 矮窗口(约 400×400dp):收起按钮右边放歌词 / 队列 / 定时 / 更多四个图标,取代底部工具栏;封面缩到左边,歌名、歌手和一行歌词放在封面右边,下面是进度和紧凑按钮组。

我的(以及其它列表-详情双栏)

  • 书本 / 双竖:左页是侧栏加列表,列表宽度收到折痕为止;右页是详情。Pixel Fold 的左页扣掉 96dp 侧栏和折痕安全距离后不到 300dp,列表顶上的三张卡片改成一行三张小卡。
  • 底部播放条跟着分:左页放封面、歌名、上一首 / 播放 / 下一首,右页放进度、队列和展开。
  • 双横:上屏列表,下屏详情。半开的桌面姿态折痕不挡像素,列表照常滚动,不改。

海报墙

  • 大卡只放在一边:书本 / 双竖时大卡居中到右页,左页继续铺墙;桌面 / 双横时大卡居中到上半屏。
  • 格子尺寸按那一边选:挑格距用的是大卡所在那一块的宽高,不再用整个窗口;桌面姿态的上半屏只有约 420dp 高,用手机档的格距。
  • 墙上的格子可以跨折痕,那是背景;跨铰链的格子被挡住也无妨。

弹层

  • 平板上的侧边面板本来就靠右,书本 / 双竖时右页放得下(Pixel Fold 右页约 420dp,面板 400dp),只需要贴住右页而不是越过折痕。
  • 居中弹窗(确认框、更新提示)在书本 / 双竖时移到右页正中,桌面 / 双横时移到上半屏正中。
05 · 实现

折痕进 AppWindow,页面只读它

沿用现有的 LocalAppWindow:Activity 收集 WindowLayoutInfo,换算成 dp 后放进 AppWindow.fold。页面不直接碰 WindowManager。

// gradle/libs.versions.toml —— 版本照 codelab
window = "1.5.1"
androidx-window = { module = "androidx.window:window", version.ref = "window" }
androidx-window-testing = { module = "androidx.window:window-testing", version.ref = "window" }
// ui/components/Window.kt
@Immutable
data class Fold(
    val bounds: DpRect,          // 窗口坐标,宽或高为 0 时按 1px 算
    val vertical: Boolean,       // orientation == VERTICAL
    val halfOpened: Boolean,     // state == HALF_OPENED
    val separating: Boolean,     // isSeparating
    val occluding: Boolean,      // occlusionType == FULL
)

enum class Posture { Normal, Flat, Book, Tabletop }

data class AppWindow(val kind: DeviceKind, val widthDp: Int, val heightDp: Int, val fold: Fold? = null) {
    val posture get() = when {
        fold == null -> Posture.Normal
        !fold.separating -> Posture.Flat
        fold.vertical -> Posture.Book          // 半开书本,或双竖
        else -> Posture.Tabletop               // 半开桌面,或双横
    }
    /** 矮窗口:分屏后的小窗,播放页换紧凑布局。 */
    val short get() = kind == DeviceKind.Phone && heightDp < 480
}
// MainActivity:照 codelab 在 STARTED 期间收集,每次发值都重排
private val layoutInfo = MutableStateFlow(WindowLayoutInfo(emptyList()))

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    lifecycleScope.launch {
        lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            WindowInfoTracker.getOrCreate(this@MainActivity)
                .windowLayoutInfo(this@MainActivity)
                .collect { layoutInfo.value = it }
        }
    }
}
// rememberAppWindow(layout, layoutInfo):取第一条 FoldingFeature,bounds 用 density 换成 dp
// ui/adaptive/FoldSplit.kt —— 纯函数,JVM 单测,和 WallGeometry 一样
/** 在自己的窗口矩形 [self] 里,按 [fold] 分出的两块;不相交、或 fold 不分页时返回 null。 */
internal fun foldSplit(self: DpRect, fold: Fold?, margin: Dp = 16.dp): FoldSplit?

// 两个布局原语:用 onPlaced 记下自己在窗口里的位置,交给 foldSplit
@Composable fun FoldRow(start: @Composable () -> Unit, end: @Composable () -> Unit)   // 书本 / 双竖
@Composable fun FoldColumn(top: @Composable () -> Unit, bottom: @Composable () -> Unit) // 桌面 / 双横
文件改动
AndroidManifest.xmlconfigChanges 补 screenLayout,折叠 / 展开不再重建 Activity(⑦)
MainActivity.kt / Window.kt收集 WindowLayoutInfo,AppWindow 加 fold、posture、short
ui/adaptive/FoldSplit.kt(新)foldSplit 纯函数 + FoldRow / FoldColumn
player/WidePlayer.ktwideColumns(width, dp) 加折痕参数:left 取折痕左缘,lyricsX 取折痕右缘 + 32dp(①)
player/TabletopPlayer.kt(新)上看下控;PlayerScreen 在 posture == Tabletop 时选它(④)
player/PhonePlayer.ktshort 时走矮窗口排法(⑥)
library/LibraryScreen.kt 等双栏TabletLibrary、TwoColumns、设置 / 同步 / 详情 / 搜索的双栏改用 FoldRow(③)
adaptive/LargeScreen.ktBottomPlayerBar 书本姿态分两段(③)
poster/WallCamera.kt、PosterWallState.ktcenterTarget 的视口换成大卡所在那一块;wallMetricsFor 用那一块的尺寸(②⑤)
components/Sheets.kt、各弹窗侧边面板贴右页,居中弹窗移到右页 / 上半屏
06 · 测试

单测、仪器测试、模拟器

codelab 给的 window-testing 可以不用真机,直接往 Activity 里塞一条假的 FoldingFeature。

// androidTest:照 codelab 用 WindowLayoutInfoPublisherRule 套在 ActivityScenarioRule 外面
private val activityRule = ActivityScenarioRule(MainActivity::class.java)
private val publisherRule = WindowLayoutInfoPublisherRule()
@get:Rule val testRule: TestRule = RuleChain.outerRule(publisherRule).around(activityRule)

@Test fun lyrics_are_right_of_vertical_fold() {
    activityRule.scenario.onActivity { activity ->
        val fold = FoldingFeature(activity = activity, state = HALF_OPENED, orientation = VERTICAL, size = 0)
        publisherRule.overrideWindowLayoutInfo(TestWindowLayoutInfo(listOf(fold)))
    }
    // 断言:歌词栏的左边界在折痕右边
}
层内容
JVM 单测foldSplit、wideColumns(带折痕)、centerTarget(带分块视口):竖折痕、横折痕、零宽折痕、有宽度的铰链、和自身矩形不相交
仪器测试上面这种 WindowLayoutInfoPublisherRule 用例:播放页歌词在折痕右侧、桌面姿态控件全在下半屏、我的页列表右缘不过折痕、海报墙大卡不跨折痕
折叠屏模拟器这次实测用的 Pixel Fold AVD;codelab 用的 6.7" 横向折叠模拟器测小折叠桌面姿态。adb emu posture 2 半开、3 打开,adb emu fold / unfold 切内外屏,截图要带 screencap -d <显示屏 id>
双屏codelab 提到的 Surface Duo 模拟器;手头没有的话,用仪器测试塞一条有宽度、occlusionType = FULL 的 FoldingFeature
07 · 分期

四步

P1

拿到折痕

  • 加 androidx.window,Activity 收集 WindowLayoutInfo,AppWindow 加 fold / posture / short
  • foldSplit + FoldRow / FoldColumn 和单测
  • manifest 补 screenLayout
P2

播放页

  • 宽屏播放页按折痕分栏(书本、双竖)
  • 新的桌面播放页(桌面、双横),下半屏控制常驻
  • 手机播放页的矮窗口排法
P3

列表-详情和外壳

  • 我的、设置、同步、详情、搜索的双栏改用 FoldRow;双横改上下
  • 底部播放条书本姿态分两段
  • 侧边面板和居中弹窗避开折痕
P4

海报墙 + 验证

  • 大卡居中到所在那一块,格距按那一块选
  • window-testing 仪器测试;Pixel Fold 与 6.7" 横向折叠模拟器逐页过一遍书本 / 桌面 / 外屏
08 · 待定

需要你定的

1. 桌面姿态上半屏默认放什么?歌词 / 封面 / 时钟,原型顶栏可以切着看。建议歌词,封面和时钟放进「播放页」设置里可选。
2. 半开时正在放歌,要不要自动打开播放页?建议不自动打开:只有播放页本来就开着时才切到桌面排法,在列表页半开不打断浏览。
3. 海报墙在书本姿态,大卡放右页还是左页?建议右页,和播放页歌词在右、平板侧边面板在右保持一致。