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

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

开篇

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

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

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

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

随着 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 库后,你就可以使用 HorizontalPagerVerticalPager 这两个 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

HorizontalPagerVerticalPager 都依赖 PagerState 来运行。

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

下面是 rememberPagerState 的函数签名:

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

在大多数使用场景中,你只需要提供 initialPagepageCount 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() 不同,因为它允许你在可滚动内容的起始或结束位置专门添加间距。

很多类似列表的控件都有 contentPaddingcontentPadding 在滑动的时候是能覆盖到的,也就说给你分配了额外的滑动空间,而 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,页面实际上会相互重叠。这可以用于创意性的堆叠式动画或风格化的卡片布局。

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

懒加载与性能

HorizontalPagerVerticalPager 中的页面都是懒加载的,只在需要时才会进行组合和布局。这是一个核心的性能特性:当用户滚动时,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 3Pager 流畅的交互体验完美结合。

变换与动画

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 体验的另一个关键是当用户在屏幕上快速滑动时的感觉。

默认情况下,PagerflingBehavior 配置为 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 做法更快、更简单!

相关推荐
阿捏利2 小时前
vscode+jadx-mcp-server配置及使用
android·apk·逆向·mcp·jadx
zhangphil2 小时前
Kotlin协程flow缓冲buffer任务流,批次任务中选取优先级最高任务最先运行(八)
kotlin
程知农2 小时前
Android的配置笔记
android·笔记
幸福在路上wellbeing2 小时前
Android 程序员 常用的AI工具有哪些
android·人工智能
阿拉斯攀登2 小时前
【RK3576 安卓 JNI/NDK 系列 03】JNI 核心语法(上):数据类型映射与方法调用
android·安卓ndk入门·jni方法签名·java调用c++·rk3576底层开发
XerCis2 小时前
安卓手机搭建Samba服务器SMB
android·服务器·智能手机
studyForMokey2 小时前
【Android面试】Context专题
android·面试·职场和发展
三少爷的鞋14 小时前
从 MVVM 到 MVI:为什么说 MVVM 的 UI 状态像“网”,而 MVI 像“一条线”?
android
蜡台14 小时前
Flutter 安装配置
android·java·flutter·环境变量