
我同事刚开始用 Compose 没多久,就找上我了,问我 ViewPager 有没有什么替代品。我说 Compose 的 Pager 可比那个强多了,你等我写一篇文章,你学学。
开篇
多年来,在传统的 Android UI 开发方面,ViewPager 及其继任者 ViewPager2 一直是 Android 开发中实现可滑动布局的标准方案。
但是这种使用传统的 XML 系统,实现一个轮播图或多标签的引导流程需要编写大量样板代码:
- 定义
FragmentStateAdapter或PagerAdapter类; - 管理
Fragment生命周期; - 小心翼翼地与 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 做法更快、更简单!