平板的宽度断点已经做完,这份稿子不再重复。它只补还没支持的部分:App 现在不知道屏幕上有折痕或铰链。依据只有一篇 Google codelab(使用 Jetpack WindowManager 支持可折叠设备和双屏设备):用 WindowInfoTracker 读 FoldingFeature,按它的 state、orientation、isSeparating、occlusionType、bounds 排版。问题都在 Pixel Fold 模拟器上实测过。
竖向折痕半开时,播放页、列表-详情、海报墙都按折痕的 bounds 分成两页,内容不跨折痕。
横向折痕半开时,上半屏放歌词 / 封面,下半屏放控制,而且控制不再自动隐藏。
occlusionType = FULL 的铰链会挡住像素,所以即使展平也按两块屏排版,铰链宽度留空。
折叠屏上下分屏后窗口只有约 400×400dp,手机播放页的控件会盖到封面和标题上,需要补一个矮窗口布局。
上面一排选设备和页面,「现状」按现在的代码排版,「方案」按这份稿子排版。红框是压在折痕上、或被铰链挡住、或互相重叠的关键内容,下面的检视面板列出 App 会从 WindowManager 收到的 FoldingFeature。
尺寸都按 dp 画:大折叠取 Pixel Fold 内屏,小折叠取 codelab 用的 6.7" 横向折叠模拟器,双屏是示意尺寸。
当前 main(6b9282f)的 debug 包,装在按 Pixel Fold 定义建的 API 34 模拟器上。半开姿态用 adb emu posture 2 切,桌面姿态是把内屏转成竖握后再半开。红色虚线是折痕位置(内屏 2208px 宽的正中,即 420dp)。
wm size 1056x1066 模拟约 402×406dp 的窗口:进度条、按钮组、工具栏叠在封面上,歌名被工具栏挡住,歌词出了屏幕。orientation | screenLayout | screenSize | smallestScreenSize,而 manifest 的 configChanges 没有 screenLayout,所以走了 relaunch(finishDrawing of relaunch … 938ms)。播放、播放页、歌词模式在重建后都还在,问题是这近一秒的重绘。androidx.window,AppWindow 只有宽高。上面 ①–⑤ 全是「不知道有折痕」造成的。codelab 的要点:只有 App 横跨整个显示区时 displayFeatures 才非空;位置一律用 bounds,不写死尺寸;每次 flow 发新值都重新排版。
| WindowLayoutInfo | 姿态 | 处理 | 例子 |
|---|---|---|---|
| displayFeatures 为空 | 普通窗口 | 不变,沿用现有宽度断点 | 平板、折叠外屏、分屏或多窗口(codelab:多窗口不算横跨) |
| FLAT,isSeparating = false | 展平 | 不变,当平板用,不避折痕 | Pixel Fold 内屏完全打开 |
| HALF_OPENED,VERTICAL | 书本 | 左右两页,以折痕 bounds 为界 | 大折叠横握半开 |
| HALF_OPENED,HORIZONTAL | 桌面 | 上下两块:上看,下控 | 大折叠竖握半开、小折叠半开 |
| occlusionType = FULL | 双屏 | 不管 state,按铰链分两块,铰链宽度留空;方向决定左右还是上下 | 双屏设备横跨两块屏 |
书本和桌面都要求 isSeparating = true:半开的折痕,或任何会遮挡像素的铰链。展平的折痕不分页。
bounds 是窗口坐标。照 codelab 的 getFeatureBoundsInWindow:先和自己在窗口里的矩形求交,不相交就当没有;宽或高为 0 的折痕按 1px 算。
半开的折痕不挡像素,但弯着看不清,所以两侧各留 16dp 不放字。铰链本身已经有宽度,不再另加。
小窗口不横跨折痕,收不到 FoldingFeature,按窗口高度判断:高度不到 480dp、宽度不到 600dp 时,播放页改用矮窗口布局。
只改这些页面:其余页面在书本 / 桌面姿态下照常滚动。列表跨折痕可以接受,前提是折痕不挡像素。
沿用现有的 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.xml | configChanges 补 screenLayout,折叠 / 展开不再重建 Activity(⑦) |
| MainActivity.kt / Window.kt | 收集 WindowLayoutInfo,AppWindow 加 fold、posture、short |
| ui/adaptive/FoldSplit.kt(新) | foldSplit 纯函数 + FoldRow / FoldColumn |
| player/WidePlayer.kt | wideColumns(width, dp) 加折痕参数:left 取折痕左缘,lyricsX 取折痕右缘 + 32dp(①) |
| player/TabletopPlayer.kt(新) | 上看下控;PlayerScreen 在 posture == Tabletop 时选它(④) |
| player/PhonePlayer.kt | short 时走矮窗口排法(⑥) |
| library/LibraryScreen.kt 等双栏 | TabletLibrary、TwoColumns、设置 / 同步 / 详情 / 搜索的双栏改用 FoldRow(③) |
| adaptive/LargeScreen.kt | BottomPlayerBar 书本姿态分两段(③) |
| poster/WallCamera.kt、PosterWallState.kt | centerTarget 的视口换成大卡所在那一块;wallMetricsFor 用那一块的尺寸(②⑤) |
| components/Sheets.kt、各弹窗 | 侧边面板贴右页,居中弹窗移到右页 / 上半屏 |
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 |
androidx.window,Activity 收集 WindowLayoutInfo,AppWindow 加 fold / posture / shortfoldSplit + FoldRow / FoldColumn 和单测screenLayoutFoldRow;双横改上下window-testing 仪器测试;Pixel Fold 与 6.7" 横向折叠模拟器逐页过一遍书本 / 桌面 / 外屏