
一、概念
1.1 生态存在的问题

Android设备以手机为主,有些APP锁死横竖屏(screenOrientation)在平板上运行也只能是竖屏界面以此共用同一套UI、有些APP锁死界面宽高比(maxAspectRatio/minAspectRatio)并拒绝 Activity 根据屏幕尺寸更改界面大小(resizeableActivity)导致即使在大屏上也只能显示手机版界面。
传统根据屏幕大小和方向做适配的方式已不再适用,现在APP的显示方式还可能是:整个设备屏幕或多窗口模式下的可调整大小的窗口、纵向和横向模式,以及可折叠设备的折叠和展开状态下的显示屏,因此需要根据可用显示空间做适配。
基于可用空间而非设备类型来设计自适应布局,实现设备无关性和动态适配性,避免硬编码,以不同形态布局更好的展示内容。将可用的显示区域分别在宽高上划分为紧凑型(Compact)、中等型(Medium)和扩展型(Expanded),由于垂直滚动的普遍性,可用宽度通常比可用高度更为重要。


| 类型 | 宽度 | 展示内容的窗格数 | 常见设备 |
| Compat 紧凑型 | width < 600 | 1 | 手机(竖屏) |
| Medium 中等型 | 600 ≤ width < 840 | 1(推荐)或2 | 平板(竖屏) 折叠(竖屏展开) |
| Expanded 扩展型 | 840 ≤ width < 1200 | 1或2(推荐) | 手机(横屏) 平板(横屏) 折叠(横屏展开) 电脑 |
| Large 大型 | 1200 ≤ width < 1600 | 2或3(推荐) | 外接显示器 |
| Extra-large 超大型 | 1600 ≤ width | 3或4(推荐) | 外接显示器 |
1.2 Android16新规
以下 API(屏幕方向、宽高比、大小调整)在 targetSdk=36 且最小宽度 >600dp 会失效,也就是仅对紧凑型仍然有效。Android16 可以在 AndroidManifest 中配置绕过,但后续高版本不再豁免。 基于 android:appCategory 标志,游戏将不受这些变更的影响。此外用户拥有控制权,他们可以在宽高比设置中明确选择使用应用的默认行为。
| 清单属性 / API | 忽略的值 |
| screenOrientation | portrait, reversePortrait, sensorPortrait, userPortrait, landscape, reverseLandscape, sensorLandscape, userLandscape |
| setRequestedOrientation() | portrait, reversePortrait, sensorPortrait, userPortrait, landscape, reverseLandscape, sensorLandscape, userLandscape |
| resizeableActivity | 所有 |
| minAspectRatio | 所有 |
| maxAspectRatio | 所有 |
<application ...>
<property android:name="android.window.PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY" android:value="true" />
</application>
二、区分可用空间 WindowSizeClasses
implementation("androidx.compose.material3.adaptive:adaptive:1.2.0")
启动 Large 和 Extra-large 需在 Gradle 构建文件中声明使用新的断点即可选择启用。
currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)
1.1 基本使用
已过时
@Composable
fun Demo(
windowWidthSizeClass: WindowWidthSizeClass = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass
) {
//根据判断结果,分别加载不同界面或对变量赋值
val columnCount = when (windowWidthSizeClass) {
WindowWidthSizeClass.COMPACT -> 1
WindowWidthSizeClass.MEDIUM -> 2
WindowWidthSizeClass.EXPANDED -> 3
else -> 3
}
}
推荐(判断顺序必须从大到小)
@Composable
fun Demo(
windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
) {
//根据判断结果,分别加载不同界面或对变量赋值
if (windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND)) {
ExpandedScreen()
} else if (windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)) {
MediumScreen()
} else {
CompactScreen()
}
}
1.2 优化
- 添加动画 AnimatedContent 使界面切换更平滑。
- 换用 rememberSaveable 确保状态健壮,免受Activity重建影响。
AnimatedContent(
targetState = windowSizeClass
) { windowSizeClass ->
if(windowSizeClass...) {...}
}
1.3 针对APP使用了密度适配的情况
UI给的宽高一般都是根据手机设计的,修改密度后,作用域内获取 WindowSizeClass 进行屏幕大小判断的结果总是 COMPACT,因此初始化时通过 CompositionLocal 来提供全局获取。
//无法提供默认值,TopLevel没有Compose作用域
val LocalWindowSizeClass = compositionLocalOf<WindowSizeClass> { error("LocalWindowSizeClass没有默认值") }
@Composable
fun AppTheme() {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
CompositionLocalProvider(
LocalWindowSizeClass provides windowSizeClass
) {
content()
}
}
封装进自定义主题写法
//通过全局入口AppTheme获取更符合直觉,因此设为private
private val LocalWindowSizeClass = compositionLocalOf<WindowSizeClass> { error("LocalWindowSizeClass没有默认值") }
//声明一个单例,用作全局入口
object AppTheme {
//用于获取窗口大小信息
val windowSizeClass: WindowSizeClass
@Composable get() = LocalWindowSizeClass.current
}
@Composable
fun AppTheme() {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
CompositionLocalProvider(
LocalWindowSizeClass provides windowSizeClass
) {
content()
}
}
三、自适应布局

3.1 自适应导航栏 NavigationSuiteScaffold
导航在小窗口上(例如手机全屏显示)一般位于界面底部,在展开窗口上(例如平板全屏显示)一般位于界面侧边。
NavigationSuiteScaffold 会根据 WindowSizeClass 显示适当的导航方式,包括在运行时窗口大小发生变化时动态更改界面。当宽或高较小、或处于桌上模式会显示在底部,其他情况显示在侧边。
有三种:底部(Bar)、侧边(Rail)、抽屉(Drawer,就是更宽的侧边适合更大的屏幕)。
implementation("androidx.compose.material3:material3-adaptive-navigation-suite")
| @Composable 参数layoutType:默认是自动更改导航栏显示的方式,可通过NavigationSuiteType指定为:None不显示、NavigationBar底部、NavigationRail侧边、NavigationDrawer抽屉。 |
3.1.1 定义节点信息
enum class AppDestinations(
val itemIcon: ImageVector,
val itemName: String,
) {
HOME(Icons.Default.Home, "首页"),
MALL(Icons.Default.ShoppingCart, "商城"),
MESSAGE(Icons.Default.Email, "消息"),
MINE(Icons.Default.Person, "个人")
}
3.1.2 定义条目颜色
val navigationItemColors = NavigationSuiteDefaults.itemColors(
//底部导航栏
navigationBarItemColors = NavigationBarItemColors(
selectedIconColor = AppColors.red, //选中后的图标颜色
selectedTextColor = AppColors.red, //选中后的文字颜色
selectedIndicatorColor = AppColors.blue, //选中的指示器颜色
unselectedIconColor = AppColors.black, //未选中的图标颜色
unselectedTextColor = AppColors.black, //未选中的文字颜色
disabledIconColor = AppColors.purple, //被禁用的图标颜色
disabledTextColor = AppColors.purple //被禁用的文字颜色
),
//侧边导航栏
navigationRailItemColors = NavigationRailItemColors(
selectedIconColor = AppColors.red, //选中后的图标颜色
selectedTextColor = AppColors.red, //选中后的文字颜色
selectedIndicatorColor = AppColors.blue, //选中的指示器颜色
unselectedIconColor = AppColors.black, //未选中的图标颜色
unselectedTextColor = AppColors.black, //未选中的文字颜色
disabledIconColor = AppColors.purple, //被禁用的图标颜色
disabledTextColor = AppColors.purple //被禁用的文字颜色
),
//抽屉导航栏(只能这样实现,上面两个也能这样实现)
navigationDrawerItemColors = NavigationDrawerItemDefaults.colors(
selectedIconColor = AppColors.red, //选中后的图标颜色
selectedTextColor = AppColors.red, //选中后的文字颜色
selectedContainerColor = AppColors.blue, //选中的指示器颜色
selectedBadgeColor = AppColors.orange, //选中后的小红点颜色
unselectedIconColor = AppColors.black, //未选中的图标颜色
unselectedTextColor = AppColors.black, //未选中的文字颜色
unselectedContainerColor = AppColors.transparent, //未选中指示器颜色
unselectedBadgeColor = AppColors.orange //未选中的小红点颜色
)
)
3.1.3 配置条目和内容切换
NavigationSuiteScaffold(
navigationSuiteItems = {
AppDestinations.entries.forEach {
item(
icon = { Icon(it.itemIcon, "" ) }, //条目图标
label = { Text(it.itemName) }, //条目名称
selected = it == currentDestination, //是否选中
onClick = { currentDestination = it }, //点击回调
colors = navigationItemColors //条目颜色
)
}
},
navigationSuiteColors = NavigationSuiteDefaults.colors(
navigationBarContainerColor = AppColors.green, //底部导航栏的背景色
navigationRailContainerColor = AppColors.green, //侧边导航栏的背景色
navigationDrawerContainerColor = AppColors.green //抽屉导航栏的背景色
),
containerColor = AppColors.red, //切换区域的背景色
contentColor = AppColors.blue //切换区域的内容颜色(Text文字颜色会被改变)
) {
when (currentDestination) {
AppDestinations.HOME -> HomeScreen()
AppDestinations.MALL -> MallScreen()
AppDestinations.MESSAGE -> MessageScreen()
AppDestinations.MINE -> MineScreen()
}
}
3.2 列表详情布局 NavigableListDetailPaneScaffold

会根据窗口大小自适应:大窗口并排显示列表页和详情页,小窗口只显示列表页点击后显示详情页。
| 框架 | @Composable fun <T> NavigableListDetailPaneScaffold( navigator: ThreePaneScaffoldNavigator<T>, //导航器 listPane: @Composable ThreePaneScaffoldPaneScope.() -> Unit, //列表窗格 detailPane: @Composable ThreePaneScaffoldPaneScope.() -> Unit, //详情窗格 modifier: Modifier = Modifier, extraPane: (@Composable ThreePaneScaffoldPaneScope.() -> Unit)? = null, //额外窗格(提供额外的背景信息) defaultBackBehavior: BackNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange, paneExpansionDragHandle: (@Composable ThreePaneScaffoldScope.(PaneExpansionState) -> Unit)? = null, paneExpansionState: PaneExpansionState? = null, ) |
| 导航器 | @Composable fun <T> rememberListDetailPaneScaffoldNavigator( scaffoldDirective: PaneScaffoldDirective = calculatePaneScaffoldDirective(currentWindowAdaptiveInfo()), adaptStrategies: ThreePaneScaffoldAdaptStrategies = ListDetailPaneScaffoldDefaults.adaptStrategies(), isDestinationHistoryAware: Boolean = true, initialDestinationHistory: List<ThreePaneScaffoldDestinationItem<T>> = DefaultListDetailPaneHistory, ): ThreePaneScaffoldNavigator<T> |
| 动画 | @Composable fun <S, T : PaneScaffoldValue<S>> ExtendedPaneScaffoldPaneScope<S, T>.AnimatedPane( modifier: Modifier = Modifier, enterTransition: EnterTransition = motionDataProvider.calculateDefaultEnterTransition(paneRole), exitTransition: ExitTransition = motionDataProvider.calculateDefaultExitTransition(paneRole), boundsAnimationSpec: FiniteAnimationSpec<IntRect> = PaneMotionDefaults.AnimationSpec, content: (@Composable AnimatedPaneScope.() -> Unit), ) |
val coroutineScope = rememberCoroutineScope()
//数据类型实现Parcelable支持保存和恢复所选列表项
val navigator = rememberListDetailPaneScaffoldNavigator<MyData>()
NavigableListDetailPaneScaffold(
modifier = Modifier.background(AppColors.green), //列表和详情之间会有间隔显示这个颜色
navigator = navigator,
listPane = {
AnimatedPane { //可选,默认的窗格动画
ListPage(
list = dataList,
onItemClick = {
coroutineScope.launch {
//导航到详情窗格
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, it)
}
}
)
}
},
detailPane = {
AnimatedPane {
//currentDestination是当前目的地,contentKey是携带的数据
navigator.currentDestination?.contentKey?.let { it->
DetailPage(it)
} ?: DetailPage(dataList[0]) //还没有被点击就默认展示第一条数据的详情页
}
}
)
3.3 辅助窗格布局 NavigableSupportingPaneScaffold
四、响应式排版


3.1 流式布局 FlowRow
流式布局能实时响应屏幕可用空间进行重排界面。
@Composable
fun AdapterScreen(
windowWidthSizeClass: WindowWidthSizeClass = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass
) {
FlowRow(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.Center,
maxItemsInEachRow = 3 ////可用空间再大一行也只有3个子元素
) {
//大型组件作为第一个直接放
Big()
//对紧凑型来说,小型组件横着放,放不下会自动换行(效果图横着显示2个)
//对中等型和展开型来说,用容器 FlowColumn 包裹小型组件使之成为一个整体
//充分利用纵向空间,在容器中竖着放,放不下会自动换列(效果图竖着显示两个)
if(windowWidthSizeClass == WindowWidthSizeClass.COMPACT) {
Small()
Small()
} else {
FlowColumn {
Small()
Small()
}
}
//中型组件同理小型组件
if(windowWidthSizeClass == WindowWidthSizeClass.COMPACT) {
Medium()
Medium()
} else {
FlowColumn {
Medium()
Medium()
}
}
}
}
3.2 保持子元素内部动画状态 movableContentOf()
当流式布局进行重排的时候,子元素的动画会被打断(重新启动)。借助可移动内容 (Movable content)可以在子元素被移动的时候不丢失动画状态。
val smallContent = remember {
movableContentOf {
Small()
Small()
}
}
FlowRow {
if (windowSizeClass == WindowWidthSizeClass.Compact) {
smallContent()
} else {
FlowColumn { smallContent() }
}
}
3.3 子元素位移动画 Modifier.animateBounds()
当流式布局进行重排的时候,子元素是瞬间移动的,没有流畅自然的感觉。最外层使用 LookaheadScope 包裹能够让 Compose 在布局变化时执行中间测量过程,并告知子元素这些中间状态。使用 Modifier.animateBounds() 构建一个自定义的 Modifier 传递给子元素,构建时可以指定 boundsTransform 参数到自定义的 spring 规范从而定制动画的运行方式。
implementation("androidx.compose.animation:animation:1.8.0-beta01")
//自定义动画
val boundsTransform = { _: Rect, _: Rect->
spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium,
visibilityThreshold = Rect.VisibilityThreshold
)
}
//最外层包裹一下
LookaheadScope {
//单独创建 Modifier 方便多次传参给子元素
val MyModifier = Modifier.animateBounds(
lookaheadScope = this@LookaheadScope,
boundsTransform = boundsTransform
)
val smallContent = remember {
movableContentOf {
small(modifier = MyModifier) //将自定义的 Modifier 传递给子元素
small(modifier = MyModifier)
}
}
FlowRow {
if (windowSizeClass == WindowWidthSizeClass.Compact) {
smallContent()
} else {
FlowColumn { smallContent() }
}
}
}
199

被折叠的 条评论
为什么被折叠?



