你可能还不知道 Compose Pager 有多强大

我同事刚开始用 Compose 没多久,就找上我了,问我 ViewPager 有没有什么替代品。我说 Compose 的 Pager 可比那个强多了,你等我写一篇文章,你学学。

开篇

多年来,在传统的 Android UI 开发方面,ViewPager 及其继任者 ViewPager2 一直是 Android 开发中实现可滑动布局的标准方案。

但是这种使用传统的 XML 系统,实现一个轮播图或多标签的引导流程需要编写大量样板代码:

  1. 定义 FragmentStateAdapter 或 PagerAdapter 类;
  2. 管理 Fragment 生命周期;
  3. 小心翼翼地与 XML 布局进行对接。

几乎所有的传统 View 布局,做一件事就是比 Compose 要复杂得多!想想 RecycleView 和 LazyColumn 的区别。

随着 Compose 的兴起,Pager ------ 用灵活的状态驱动模型取代了僵硬的适配器系统。

开发者不再需要与适配器和 Fragment 打交道,而是可以使用简单的 Composable 函数来定义分页内容。这不仅契合了现代响应式编程范式,还让同步动画、自定义页面变换和动态内容加载等功能变得轻而易举。

借助 Compose 天生的灵活性,我们可以用更少的代码实现更多的功能,将原本复杂的架构任务转化为简洁可读的实现。

入门

要在项目中使用 Pager,需要确保使用的是较新版本的 Compose BOM(物料清单)。当前写这篇文章的时候,采用的版本是:

kotlin 复制代码
// 在 build.gradle.kts 中
dependencies {
    val composeBom = "2026.02.00"
    implementation(platform("androidx.compose:compose-bom:$composeBom"))
    implementation("androidx.compose.foundation:foundation")
    // ... 其他依赖
}

为了更好的可扩展性和可读性,在现代 Android 项目中强烈建议使用版本目录(libs.versions.toml)。这种方式可以集中管理依赖,确保多模块项目的一致性。

通过这种方式引入 Foundation 库后,你就可以使用 HorizontalPager 和 VerticalPager 这两个 Composable 函数了。

基础实现

Pager 的核心是 PagerState,它负责追踪当前显示的页面并处理滚动逻辑。你可以通过 rememberPagerState 来初始化它。

下面是一个简单的水平滑动视图的实现:

kotlin 复制代码
@Composable
fun HorizontalPagerView() {
    Box {
        // 定义页面数量(例如 3 页)
        val state = rememberPagerState { 3 }
        
        HorizontalPager(
            modifier = Modifier.fillMaxSize(),
            state = state
        ) { page ->
            PagerItem(
                modifier = Modifier.fillMaxSize(),
                page = page
            )
        }
    }
}

如果你的设计需要垂直滚动 ------ 这在全屏视频流或文档阅读器中很常见 ------ 只需将组件换成 VerticalPager:

kotlin 复制代码
@Composable
fun VerticalPagerView() {
    Box {
        val state = rememberPagerState { 3 }
        
        VerticalPager(
            modifier = Modifier.fillMaxSize(),
            state = state
        ) { page ->
            PagerItem(
                modifier = Modifier.fillMaxSize(),
                page = page
            )
        }
    }
}

理解 PagerState

HorizontalPager 和 VerticalPager 都依赖 PagerState 来运行。

这个状态对象非常强大,它允许你监听当前页面、定义总页面数、设置起始页面,甚至可以通过编程方式触发页面切换。

下面是 rememberPagerState 的函数签名:

less 复制代码
@Composable
fun rememberPagerState(
    initialPage: Int = 0,
    @FloatRange(from = -0.5, to = 0.5) initialPageOffsetFraction: Float = 0f,
    pageCount: () -> Int,
): PagerState

在大多数使用场景中,你只需要提供 initialPage 和 pageCount lambda 表达式即可。除非你对初始滚动对齐有特殊需求,否则 initialPageOffsetFraction 最好保持默认值 0f。

控制 Pager

由于 Compose 是状态驱动的,你可以轻松添加外部控制按钮,比如"上一页"和"下一页"。由于滚动到某个页面是一个"挂起"操作(涉及随时间变化的动画),你需要一个 CoroutineScope 来触发这些操作。

kotlin 复制代码
@Composable
private fun BoxScope.ControlButtons(state: PagerState) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .align(Alignment.BottomCenter)
            .padding(16.dp),
    ) {
        val coroutineScope = rememberCoroutineScope() // 在这里创建一个 CoroutineScope
        IconButton(
            onClick = {
                coroutineScope.launch {
                    state.animateScrollToPage(max(state.currentPage - 1, 0))
                }
            },
        ) {
            Icon(
                painter = painterResource(R.drawable.baseline_arrow_back_24),
                contentDescription = "返回"
            )
        }
        Spacer(modifier = Modifier.weight(1f))
        IconButton(
            onClick = {
                coroutineScope.launch {
                    state.animateScrollToPage(min(state.currentPage + 1, state.pageCount - 1))
                }
            },
        ) {
            Icon(
                painter = painterResource(R.drawable.outline_arrow_forward_24),
                contentDescription = "前进"
            )
        }
    }
}

这里是通过点按屏幕下边的按钮实现的 Pager 滚动。

使用 Key 维护状态

在处理动态内容(即条目可能被添加、删除或重新排序的场景)时,使用 key 参数至关重要。

默认情况下,Pager 会将条目的位置作为其 key。但如果在列表开头插入一个条目,后续所有条目的位置都会发生变化,这可能导致滚动位置跳变或状态丢失。

通过提供一个稳定且唯一的 key,你可以确保滚动位置基于该 key 来维护。如果在当前可见条目之前添加或删除了条目,Pager 会通过这个 key 来保持正确的条目处于可见状态。

kotlin 复制代码
@Composable
fun HorizontalPagerView(data: List<PageData>) {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.White)
    ) {
        // 记住Pager的状态,总页数为10
        val state = rememberPagerState { data.size }
        HorizontalPager(
            modifier = Modifier.fillMaxSize(),
            state = state,
            // 为每个页面提供唯一的key
            key = { page -> "${data[page].id}" },
        ) { page ->
            PagerItem(
                page = page,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(200.dp)
            )
        }
    }
}

修改页面大小

默认情况下,Pager 会认为其内容应该填满滚动轴向上的所有可用空间。

kotlin 复制代码
@Composable
fun HorizontalPagerView() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.White)
    ) {
        val state = rememberPagerState { 50 }
        HorizontalPager(
            modifier = Modifier.fillMaxSize(),
            state = state,
        ) {
            PagerItem(
                page = it,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(200.dp)
            )
        }
    }
}

看起来这像是 fillMaxWidth() 造成的行为,但实际上问题比较复杂。

即使你给页面内容指定了固定宽度(比如 200.dp),Pager 仍然将该元素视为"完整页面"单元,拉伸布局或对齐方式以匹配 Pager 的滑动边界。

kotlin 复制代码
@Composable
fun HorizontalPagerView() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.White)
    ) {
        val state = rememberPagerState { 50 }
        HorizontalPager(
            modifier = Modifier.fillMaxSize(),
            state = state,
        ) {
            PagerItem(
                page = it,
                modifier = Modifier
                    .width(200.dp)
                    .height(200.dp)
            )
        }
    }
}

之所以这样,根源在于 pageSize 参数默认值为 PageSize.Fill。要显示多个项目或特定大小的项目,你需要将该参数改为 PageSize.Fixed:

kotlin 复制代码
HorizontalPager(
  ...
  pageSize = PageSize.Fixed(200.dp),
)

或者使用自定义实现:

kotlin 复制代码
private val threePagesPerViewport = object : PageSize {
    override fun Density.calculateMainAxisPageSize(
        availableSpace: Int,
        pageSpacing: Int
    ): Int {
        return (availableSpace - 2 * pageSpacing) / 3
    }
}

到这里,Pager 入门相关内容,已经讲完了,如果你愿意动动手写几行,你会发现实在是又简单又方便。

如果你想继续学一点更加深的内容,同时自定义一些 Pager 的效果(这正是 Compose 中最强大的地方),那么我们继续往下看。

吸附:对齐卡片

当屏幕上一次显示多个页面时(使用自定义 PageSize),Pager 的"吸附"位置就变得非常重要。

默认情况下,吸附位置是 SnapPosition.Start,意味着当前页面会与 Pager 容器的起始位置对齐。

kotlin 复制代码
@Composable
fun HorizontalPagerView() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.White)
    ) {
        val state = rememberPagerState { 50 }
        HorizontalPager(
            modifier = Modifier.fillMaxSize(),
            state = state,
            pageSize = threePagesPerViewport,
            snapPosition = SnapPosition.Start // 默认行为
        ) { page ->
            PagerItem(
                page = page,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(200.dp)
                    .then(
                        if (state.currentPage == page) {
                            Modifier.border(4.dp, Color.Black)
                        } else {
                            Modifier
                        }
                    )
            )
        }
    }
}

如你所见,吸附位置位于视口的起始位置。不过,Compose 提供了足够的灵活性让你根据设计需求更改这种对齐方式。你可以使用 SnapPosition.Center 将活动页居中,或使用 SnapPosition.End 将其对齐到末尾。

如吸附中间:

或是吸附末尾:

内边距

contentPadding 是另一个重要参数。

根据文档定义,它会在内容被裁剪后,在内容周围添加内边距。这与直接在 Pager 上使用 Modifier.padding() 不同,因为它允许你在可滚动内容的起始或结束位置专门添加间距。

很多类似列表的控件都有 contentPadding,contentPadding 在滑动的时候是能覆盖到的,也就说给你分配了额外的滑动空间,而 Modifier.padding() 你是滑动不到的,这个边距在任何时候都会显示。

contentPadding 最常见的用途之一是让下一个(和上一个)页面能够"窥探"到屏幕上。这向用户传达了一个信号:还有更多内容可以探索。

kotlin 复制代码
@Composable
fun HorizontalPagerWithPeeking() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.White)
    ) {
        val state = rememberPagerState { 5 }
        
        HorizontalPager(
            modifier = Modifier.fillMaxSize(),
            state = state,
            // 在内容起始和结束位置添加 16.dp 的内边距
            contentPadding = PaddingValues(horizontal = 16.dp),
        ) { page ->
            PagerItem(
                page = page,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(200.dp)
            )
        }
    }
}

当你应用类似 16.dp 的水平内边距时,Pager 会减小"当前"页面的可用宽度,刚好让相邻页面在边缘露出一部分。

注意,这种内边距适用于所有页面;如果你使用垂直 Pager,应该应用 vertical 内边距来实现从顶部或底部"窥探"的类似效果。

注意到右边黄色的部分了吗?

页面间距

contentPadding 影响 Pager 内容的外边缘,而 pageSpacing 用于定义单个页面之间的间隙。

这纯粹是卡片之间的内部间距,不会影响第一个或最后一个页面的外边界。

kotlin 复制代码
@Composable
fun HorizontalPagerView() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.White)
    ) {
        val state = rememberPagerState { 5 }
        HorizontalPager(
            modifier = Modifier.fillMaxSize(),
            state = state,
            pageSpacing = 16.dp,
            pageSize = threePagesPerViewport,
        ) { page ->
            PagerItem(
                page = page,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(200.dp)
            )
        }
    }
}

有趣的是,你甚至可以为 pageSpacing 使用负值。

如果你设置为类似 pageSpacing = (-16).dp,页面实际上会相互重叠。这可以用于创意性的堆叠式动画或风格化的卡片布局。

四个字形容这个效果:遥遥领先!

懒加载与性能

HorizontalPager 和 VerticalPager 中的页面都是懒加载的,只在需要时才会进行组合和布局。这是一个核心的性能特性:当用户滚动时,Pager 会移除不再需要的页面,保持低内存占用。

默认情况下,Pager 只加载当前屏幕上可见的页面。

当然,你也可以使用 beyondViewportPageCount 参数来提前加载屏幕外的页面。这可以让滑动体验更加流畅,因为下一页在进入视口前就已经完成组合和测量了。

管理视口外页面

虽然设置一个较高的计数值来确保一切都"准备就绪"很诱人,但读者应该谨慎。每一个加载到视口外的页面仍然在被组合、测量和放置。将这个计数值设置得太高可能导致性能下降,尤其是在页面布局复杂的情况下。

Pager 通过 beyondViewportPageCount 参数支持视口外页面的预加载。甚至支持动态更改视口外页面计数,允许你根据应用状态或用户设置来调整:

kotlin 复制代码
@Composable
fun HorizontalPagerBeyondBounds() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.White)
    ) {
        var beyondPageCount by remember { mutableIntStateOf(1) }
        val state = rememberPagerState(pageCount = { 50 })
        
        HorizontalPager(
            modifier = Modifier.fillMaxSize(),
            state = state,
            beyondViewportPageCount = beyondPageCount 
        ) { page ->
            PagerItem(
                page = page,
                modifier = Modifier.fillMaxSize()
            )
        }
        // 用于演示动态更改的控制界面
        FlowRow(
            modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp)
        ) {
            repeat(3) { index ->
                val count = index + 1
                Button(onClick = { beyondPageCount = count }) {
                    Text("视口外页面数: $count")
                }
            }
        }
    }
}

动态页面数量

由于 PagerState 中的 pageCount 被定义为 lambda 表达式,Pager 能够轻松处理总页面数的动态变化。

你可以从数据源中添加或删除项目,Pager 会随时响应,立即更新其滚动范围。

kotlin 复制代码
@Composable
fun DynamicPagerView() {
    var pages by remember { mutableStateOf<List<String>>(emptyList()) }
    val state = rememberPagerState(pageCount = { pages.size })
    Box(modifier = Modifier.fillMaxSize()) {
            HorizontalPager(state = state) { page ->
                PagerItem(page = page)
            }
            Button(
                modifier = Modifier.align(Alignment.BottomCenter),
                onClick = {
                    pages = pages + "Page: ${pages.size + 1}"
                }
            ) {
                Text("Add Page")
            }
        }
    }

与 Paging 3 配合使用

由此延伸,这种动态能力使得 Pager 控件成为 Paging 3 库的完美搭档。

当处理来自网络或数据库的大量数据集时,Paging 3 负责分块和加载数据,而 Pager 负责提供 UI。

首先,引入依赖:

csharp 复制代码
// libs.versions.toml
paging = "3.3.6"
androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" }

然后,你可以在 Composable 中定义 PagingSource 并收集这些项目:

kotlin 复制代码
@Composable
fun Paging3PagerView(viewModel: HorizontalPagerViewModel = viewModel()) {
    val items = viewModel.items.collectAsLazyPagingItems() // 这里有个方便的扩展
    val state = rememberPagerState(pageCount = { items.itemCount })
    HorizontalPager(
            modifier = Modifier.fillMaxSize(),
            state = state,
        ) { page ->
            items[page]?.let { item ->
                PagerItem(page = item)
            }
        }
    }

这种设置确保你只获取用户实际浏览的页面数据,将 Paging 3 与 Pager 流畅的交互体验完美结合。

变换与动画

PagerState 最强大的功能之一是能够提供实时滚动信息。像 currentPageOffsetFraction 这样的属性让你能够精确监听页面距离其吸附位置有多远。

利用 currentPageOffsetFraction 值,你可以在用户滑动时应用令人惊叹的视觉变换。

基础缩放动画

一种简单但有效的技术是根据页面距离中心的远近来缩放其高度。

kotlin 复制代码
@Composable
fun ScalingPagerView() {
    val state = rememberPagerState { 10 }
    HorizontalPager(
        modifier = Modifier.fillMaxSize(),
        state = state,
        pageSpacing = 16.dp,
        snapPosition = SnapPosition.Center, // 此处就用了 Center 吸附
        pageSize = threePagesPerViewport,
    ) { page ->
        // 计算距离当前吸附位置的绝对距离
        val pageOffset = state.getOffsetDistanceInPages(page).absoluteValue
        
        PagerItem(
            page = page,
            modifier = Modifier
                .fillMaxWidth()
                .height(200.dp * max(1 - pageOffset, 0.5f)) // 在 50% 到 100% 之间缩放
        )
    }
}

高级 3D 变换

为了获得更高级的感觉,你可以使用 graphicsLayer 修饰符同时操作旋转、透明度和缩放。这可以创造出一种径向的、轮播式的特效。

kotlin 复制代码
@Composable
fun RadialPagerView() {
    val state = rememberPagerState { 10 }
    val density = LocalDensity.current
    HorizontalPager(
            modifier = Modifier.fillMaxSize(),
            state = state,
            pageSpacing = 16.dp,
            snapPosition = SnapPosition.Center,
            pageSize = threePagesPerViewport,
        ) { page ->
            PagerItem(
                page = page,
                modifier = Modifier
                    .fillMaxWidth()
                    .graphicsLayer {
                        // 计算包含小数位移的有符号偏移量
                        val pageOffset = ((state.currentPage - page) + state.currentPageOffsetFraction)
                        // 透明度动画 (30% 到 100%)
                        alpha = lerp(
                            start = 0.3f,
                            stop = 1f,
                            fraction = 1f - pageOffset.absoluteValue.coerceIn(0f, 1f)
                        )
                        // 缩放动画 (80% 到 100%)
                        val scale = lerp(
                            start = 0.8f,
                            stop = 1f,
                            fraction = 1f - pageOffset.absoluteValue.coerceIn(0f, 1f)
                        )
                        scaleX = scale
                        scaleY = scale
                        // 3D 旋转和透视
                        rotationY = pageOffset * -30f
                        cameraDistance = 12 * density.density
                    }
            )
        }
    }

关于 graphicsLayer 的用法,看这里能够学到更多。

自定义 Fling 行为

Pager 体验的另一个关键是当用户在屏幕上快速滑动时的感觉。

默认情况下,Pager 的 flingBehavior 配置为 PagerSnapDistance 为 1。这意味着无论你滑得多用力,Pager 每次都只会移动一页。

然而,对于图库视图或快速滚动的列表,你可能希望允许用户在一次手势中跳过多个页面。你可以通过定义自定义的 flingBehavior 来实现:

kotlin 复制代码
val fling = PagerDefaults.flingBehavior(
    state = state,
    pagerSnapDistance = PagerSnapDistance.atMost(3)
)
HorizontalPager(
    state = state,
    flingBehavior = fling,
    // ... 其他参数
) { page ->
    PagerItem(page = page)
}

通过设置 PagerSnapDistance.atMost(3),用户现在可以在一次滑动中最多吸附到三页,这让长列表的导航变得更快、更有吸引力。

总结

从传统的 ViewPager 过渡到 Compose Pager,代表了开发生产力和 UI 灵活性的一次重大飞跃。通过拥抱状态驱动模型,我们摆脱了复杂的适配器层级,转向了一个声明式系统,在这个系统中,动画和数据更新自然发生。

通过本文,我们看到了 PagerState 如何充当组件的大脑 ------ 管理从简单的滚动位置到复杂的 3D 变换的所有内容。

无论你是构建具有动态页面数量的简单引导流程、与 Paging 3 集成大量数据集,还是使用 graphicsLayer 制作高端定制动画,Compose Pager 都提供了一个健壮且可扩展的基础。

当你在自己的项目中实现这些模式时,请记住 Compose 真正的力量在于其可组合性:这些 Pager 可以被嵌套、组合和样式化,以适应你能想象的几乎任何移动端体验。

同时你也会发现,使用 Compose Pager 做复杂的效果和更好的用户体验,比传统的 XML 做法更快、更简单!

相关推荐
千里马学框架4 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台4 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone4 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc4 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo4 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077004 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
ai2work4 天前
ch23 综合复刻:从零做一个最小可用版本(capstone)
kotlin
其实防守也摸鱼4 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
ai2work4 天前
ch21 签名、校验与发版
kotlin
AFinalStone4 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui