Compose 适配 - 显示空间 WindowSizeClasses(响应式排版 & 自适应布局)

一、概念

1.1 生态存在的问题

        Android设备以手机为主,有些APP锁死横竖屏(screenOrientation)在平板上运行也只能是竖屏界面以此共用同一套UI、有些APP锁死界面宽高比(maxAspectRatio/minAspectRatio)并拒绝 Activity 根据屏幕尺寸更改界面大小(resizeableActivity)导致即使在大屏上也只能显示手机版界面。

        传统根据屏幕大小和方向做适配的方式已不再适用,现在APP的显示方式还可能是:整个设备屏幕或多窗口模式下的可调整大小的窗口、纵向和横向模式,以及可折叠设备的折叠和展开状态下的显示屏,因此需要根据可用显示空间做适配。

        基于可用空间而非设备类型来设计自适应布局,实现设备无关性和动态适配性,避免硬编码,以不同形态布局更好的展示内容。将可用的显示区域分别在宽高上划分为紧凑型(Compact)、中等型(Medium)和扩展型(Expanded),由于垂直滚动的普遍性,可用宽度通常比可用高度更为重要。

类型宽度展示内容的窗格数常见设备

Compat

紧凑型

width < 6001手机(竖屏)

Medium

中等型

600 ≤ width < 8401(推荐)或2

平板(竖屏)

折叠(竖屏展开)

Expanded

扩展型

840 ≤ width < 12001或2(推荐)

手机(横屏)

平板(横屏)

折叠(横屏展开)

电脑

Large

大型

1200 ≤ width < 16002或3(推荐)外接显示器

Extra-large

超大型

1600 ≤ width3或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
fun NavigationSuiteScaffold(
    navigationSuiteItems: NavigationSuiteScope.() -> Unit,        //条目
    modifier: Modifier = Modifier,
    layoutType: NavigationSuiteType =
        NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(WindowAdaptiveInfoDefault),        //默认计算方式
    navigationSuiteColors: NavigationSuiteColors = NavigationSuiteDefaults.colors(),        //导航栏背景色
    containerColor: Color = NavigationSuiteScaffoldDefaults.containerColor,        //切换区域的背景色
    contentColor: Color = NavigationSuiteScaffoldDefaults.contentColor,        //切换区域的内容色(Text文字会变色)
    content: @Composable () -> Unit = {},        //切换区域

参数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() }
        }
    }
}

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值