轮播图组件很常用,在移动互联网高速发展的今天,开发者面临着多平台适配、快速迭代、高性能渲染等多重挑战。腾讯开源的Kuikly框架应运而生,作为基于Jetpack Compose深度优化的跨平台开发框架,它重新定义了现代化移动应用的构建方式。
Kuikly框架简介
Kuikly是基于Kotlin MultiPlatform(KMP)构建的跨端开发框架。它利用了KMP逻辑跨平台的能力, 并抽象出通用的跨平台UI渲染接口,复用平台的UI组件,从而达到UI跨平台,具有轻量、高性能、可动态化等优点;同时,KuiklyBase基建同样支持逻辑跨端。
文档地址 :https://kuikly.tds.qq.com/Introduction/arch.html
官网地址 :https://framework.tds.qq.com/
github地址: https://github.com/Tencent-TDS/KuiklyUI
一、组件概述
在传统的 Android View 体系中,实现轮播图通常需要使用 ViewPager 或 ViewPager2 配合 Handler 或 Timer。而在 Jetpack Compose 中,得益于声明式 UI 和强大的 HorizontalPager,我们可以用非常精简的代码实现一个功能完备、自带动画效果的轮播图组件。
本文将带你一步步实现一个支持自动轮播、手指拖拽暂停、无限循环(伪无限)以及指示器的 Carousel 组件。
BannerCarousel是我封装的一个基于Kuikly框架(适配Jetpack Compose)实现的轮播图组件,具备无限循环、自动播放、手势交互等特性。该组件通过使用Compose组件中的HorizontalPager实现横向滚动效果,通过巧妙的列表扩展策略实现无缝循环。
该项目案例地址1 :https://github.com/yangyongzhen/kuiklytest
该项目案例地址2 :https://gitcode.com/qq8864/kuiklytest

二、核心实现原理
最基础的滑动展示图片的 Pager:
kotlin
@Composable
fun SimpleCarousel(
imageUrls: List<String>,
modifier: Modifier = Modifier
) {
val pagerState = rememberPagerState(pageCount = { imageUrls.size })
HorizontalPager(
state = pagerState,
modifier = modifier.fillMaxWidth().height(200.dp)
) { page ->
// 这里可以使用 Coil 或 Glide 加载图片
// AsyncImage(model = imageUrls[page], ...)
Box(
modifier = Modifier
.fillMaxSize()
.background(if (page % 2 == 0) Color.Blue else Color.Red),
contentAlignment = Alignment.Center
) {
Text(text = "Page $page", color = Color.White)
}
}
}
通过在真实数据列表的头部和尾部各添加一份数据的副本,来制造一个可以无缝滚动的" 缓冲区" 。
假设你的真实数据是 [A, B, C]。 处理后的列表会变成 [C, A, B, C, A]。
- Pager 的总页数是 5。
- 初始显示的是索引为 1 的页面(真实的 A)。
- 当用户向右滑动到索引为 3 的页面(副本 C)后,如果再向右滑到索引为 4 的页面(副本 A),我们立即、无动画地瞬移回索引为 1
的页面(真实的 A)。 - 同理,当用户向左滑动到索引为 1 的页面(真实的 A)后,如果再向左滑到索引为 0 的页面(副本 C),我们立即、无动画地瞬移回索引为 3的页面(真实的 C)。 这样就实现了无缝的循环效果。
kotlin
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <T> InfiniteCarousel(
dataList: List<T>,
modifier: Modifier = Modifier,
autoScrollDelay: Long = 3000L,
content: @Composable (T) -> Unit
) {
if (dataList.isEmpty()) return
// 1. 创建一个前后各加一个元素的扩展列表
// 例如 [A, B, C] -> [C, A, B, C, A]
val extendedList = remember(dataList) {
if (dataList.size > 1) {
listOf(dataList.last()) + dataList + listOf(dataList.first())
} else {
dataList
}
}
// 2. Pager 的总页数是扩展列表的大小,初始页是 1 (真实的第一个元素)
val pagerState = rememberPagerState(
initialPage = 1,
pageCount = { extendedList.size }
)
val isDragged by pagerState.interactionSource.collectIsDraggedAsState()
// 3. 监听滚动,在滚动到"缓冲区"时执行瞬移
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
// 当滚动到列表末尾的缓冲区时 (例如滚动到 C 后面的 A)
if (page == extendedList.size - 1) {
// 立即、无动画地跳回真实的第一个元素
pagerState.scrollToPage(1)
}
// 当滚动到列表头部的缓冲区时 (例如滚动到 A 前面的 C)
else if (page == 0) {
// 立即、无动画地跳回真实的最后一个元素
pagerState.scrollToPage(extendedList.size - 2)
}
}
}
// 自动轮播逻辑
LaunchedEffect(isDragged) {
if (!isDragged) {
while (true) {
delay(autoScrollDelay)
// 正常滚动到下一页
pagerState.animateScrollToPage((pagerState.currentPage + 1) % extendedList.size)
}
}
}
Box(modifier = modifier) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { pageIndex ->
// 直接使用扩展列表的数据
content(extendedList[pageIndex])
}
// 指示器逻辑需要调整,因为它只关心原始数据
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// 将 Pager 的页面索引映射回原始列表的索引
val actualIndex = when (pagerState.currentPage) {
0 -> dataList.size - 1 // 在头缓冲区,对应最后一个真实元素
extendedList.size - 1 -> 0 // 在尾缓冲区,对应第一个真实元素
else -> pagerState.currentPage - 1 // 中间部分,索引减 1
}
repeat(dataList.size) { index ->
val isSelected = actualIndex == index
// ... 指示器 UI ...
}
}
}
}
1. 数据扩展策略
kotlin
val extendedList = remember(dataList) {
if (dataList.size > 1) {
listOf(dataList.last()) + dataList + listOf(dataList.first())
} else {
dataList
}
}
- 实现原理 :将原始列表首尾各扩展一个元素
(例如 [A,B,C] → [C,A,B,C,A]) - 目的:创建循环滚动的"缓冲区",用于实现视觉连续性
- 初始定位:设置初始页面为1(指向原始第一个元素):
kotlin
val pagerState = rememberPagerState(
initialPage = 1,
pageCount = { extendedList.size }
)
2. 边界瞬移机制
kotlin
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
when (page) {
extendedList.size - 1 -> pagerState.scrollToPage(1)
0 -> pagerState.scrollToPage(extendedList.size - 2)
}
}
}
- 监听滚动:实时检测当前页码
- 瞬移触发 :
- 滚动到最后一个元素(扩展后的A)→ 瞬移到索引1(真实A)
- 滚动到第一个元素(扩展后的C)→ 瞬移到倒数第二位(真实C)
监听 Pager 的当前页面,当滚动到我们手动添加的"缓冲区" 页面时, 立即、 无动画地跳回到对应的真实页面, 从而实现无缝循环。
pagerState 对象在 Composable 的生命周期内通常是同一个实例,所以这个 key 不会改变。这意味着这个 LaunchedEffect 只会在 Composable 首次进入界面时启动一次。
它启动后,会创建一个 snapshotFlow 来监听 pagerState.currentPage 的变化。
collect 会一直挂起并监听,每当 currentPage 改变,它就会执行 if/else if 判断,检查是否需要执行"瞬移"操作。
当 BannerCarousel 从界面消失时,这个协程会被自动取消,监听也就停止了。
这个 LaunchedEffect 的职责是:设置一个一次性的、持续的页面变化监听器。
3. 自动播放控制
kotlin
LaunchedEffect(isDragged) {
if (!isDragged) {
while (true) {
delay(autoScrollDelay)
pagerState.animateScrollToPage(
(pagerState.currentPage + 1) % extendedList.size
)
}
}
}
- 播放条件 :当用户未拖拽(
!isDragged)时激活 - 暂停机制:通过LaunchedEffect的key值控制协程生命周期
- 滚动动画:使用animateScrollToPage实现平滑过渡
初始状态:isDragged 是 false。LaunchedEffect 启动,进入 if 块,while(true) 循环开始执行,每隔 autoScrollDelay 毫秒,页面就自动滚动一页。
用户开始拖拽:isDragged 的值从 false 变为 true。key 发生了变化!LaunchedEffect 立即取消了正在运行的 while(true) 循环(自动轮播停止)。然后,它用新的 key (true) 重启协程,但因为 if (!isDragged) 条件不满足,所以协程什么也不做就结束了。
在 Jetpack Compose 的世界里,Composable 函数只负责描述 UI,并且可能会在每一帧都重新执行(这个过程叫" 重组" 或 Recomposition)。这意味着, 你不能在 Composable 函数的主体中直接执行耗时操作、网络请求或启动一个无限循环, 因为这会导致每次重组都重新执行, 造成性能灾难和不可预测的行为。
LaunchedEffect 就是为了解决这个问题而生的。它是一个特殊的 Composable 函数,可以让你在 Compose 的生命周期内,安全地启动一个**协程(Coroutine) * * 来执行这些" 副作用" 。
核心特性:
- 关联生命周期:LaunchedEffect 启动的协程会与调用它的那个 Composable "绑定"在一起。当这个 Composable 从界面上消失(离开组合,Leave the Composition)时,LaunchedEffect 会自动取消这个协程。这完美地避免了内存泄漏和不必要的后台工作。
- 受 Key 控制:LaunchedEffect 至少需要一个 key 参数(例如 LaunchedEffect(key1 = someValue))。它的行为完全由这个 key 决定:
当 Composable 首次进入组合时,LaunchedEffect 会启动它的协程。
在后续的重组过程中,如果 key 的值没有发生变化,LaunchedEffect 什么都不会做,协程会继续运行。 - 如果 key 的值发生了变化,LaunchedEffect 会先取消当前正在运行的协程,然后立即用新的 key 值重新启动一个新的协程。
简单比喻:
你可以把 LaunchedEffect 想象成一个拥有"记忆"和"自动开关"的智能机器人。
- 你给它一个任务(协程代码块)和一个指令码( key)。
- 它只在指令码是新的,或者指令码发生变化时, 才会开始执行任务。
- 当你离开这个房间(Composable 离开界面)时,它会自动停止手头的工作。
4. 指示器映射
kotlin
val actualIndex = when (pagerState.currentPage) {
0 -> dataList.size - 1
extendedList.size - 1 -> 0
else -> pagerState.currentPage - 1
}
- 索引转换:将扩展列表的页码映射回原始数据索引
- 视觉反馈:通过大小和透明度差异区分当前页
三、使用示例
1. 基础集成
kotlin
BannerCarousel(
dataList = swiperUiState.data,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
) { swiperItem ->
Card(shape = RoundedCornerShape(16.dp)) {
Image(
painter = rememberAsyncImagePainter(swiperItem.imageUrl),
contentScale = ContentScale.Crop
)
}
}
2. 状态管理
kotlin
when (swiperUiState) {
is Loading -> CircularProgressIndicator()
is Success -> BannerCarousel(...)
is Error -> ErrorPlaceholder()
}
四、实现优势
- 无限循环:通过列表扩展+瞬移机制实现无缝滚动
- 性能优化 :
- 使用remember缓存扩展列表
- 通过协程管理自动播放
- 灵活扩展:通过@Composable lambda开放内容定制
- 交互友好:自动暂停/恢复播放机制
五、完整实现代码
kotlin
package com.example.kuiklytest.app.components
import androidx.compose.runtime.*
import com.tencent.kuikly.compose.foundation.ExperimentalFoundationApi
import com.tencent.kuikly.compose.foundation.background
import com.tencent.kuikly.compose.foundation.interaction.collectIsDraggedAsState
import com.tencent.kuikly.compose.foundation.layout.*
import com.tencent.kuikly.compose.foundation.pager.HorizontalPager
import com.tencent.kuikly.compose.foundation.pager.rememberPagerState
import com.tencent.kuikly.compose.foundation.shape.CircleShape
import com.tencent.kuikly.compose.ui.Alignment
import com.tencent.kuikly.compose.ui.Modifier
import com.tencent.kuikly.compose.ui.draw.clip
import com.tencent.kuikly.compose.ui.graphics.Color
import com.tencent.kuikly.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* 轮播图组件封装
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <T> BannerCarousel(
dataList: List<T>, // 这里可以是任意数据模型
modifier: Modifier = Modifier,
autoScrollDelay: Long = 3000L,
content: @Composable (T) -> Unit // 自定义 Item 内容
) {
if (dataList.isEmpty()) return
// 创建一个前后各加一个元素的扩展列表
// 例如 [A, B, C] -> [C, A, B, C, A]
val extendedList = remember(dataList) {
if (dataList.size > 1) {
listOf(dataList.last()) + dataList + listOf(dataList.first())
} else {
dataList
}
}
// 初始页面定在中间某个位置,保证左右都能滑
//val initialPage = 50 / 2
// 修正初始页,使其对应数据列表的第一个元素 (index 0)
//val startIndex = initialPage - (initialPage % dataList.size)
val pagerState = rememberPagerState(
initialPage = 1,
pageCount = { extendedList.size }
)
// 2. 监听用户是否正在拖拽
val isDragged by pagerState.interactionSource.collectIsDraggedAsState()
// 3. 监听滚动,在滚动到"缓冲区"时执行瞬移
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
// 当滚动到列表末尾的缓冲区时 (例如滚动到 C 后面的 A)
if (page == extendedList.size - 1) {
// 立即、无动画地跳回真实的第一个元素
pagerState.scrollToPage(1)
}
// 当滚动到列表头部的缓冲区时 (例如滚动到 A 前面的 C)
else if (page == 0) {
// 立即、无动画地跳回真实的最后一个元素
pagerState.scrollToPage(extendedList.size - 2)
}
}
}
// // 自动轮播逻辑
// 这里利用 isDragged 作为 key,当用户拖拽时,自动轮播协程会被取消;停止拖拽后重新开始
LaunchedEffect(isDragged) {
if (!isDragged) {
while (true) {
delay(autoScrollDelay)
// 正常滚动到下一页
pagerState.animateScrollToPage((pagerState.currentPage + 1) % extendedList.size)
}
}
}
Box(modifier = modifier) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { pageIndex ->
// 直接使用扩展列表的数据
content(extendedList[pageIndex])
}
// 指示器逻辑需要调整,因为它只关心原始数据
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// 将 Pager 的页面索引映射回原始列表的索引
val actualIndex = when (pagerState.currentPage) {
0 -> dataList.size - 1 // 在头缓冲区,对应最后一个真实元素
extendedList.size - 1 -> 0 // 在尾缓冲区,对应第一个真实元素
else -> pagerState.currentPage - 1 // 中间部分,索引减 1
}
repeat(dataList.size) { index ->
val isSelected = actualIndex == index
Box(
modifier = Modifier
.size(if (isSelected) 8.dp else 6.dp)
.clip(CircleShape)
.background(if (isSelected) Color.White else Color.White.copy(alpha = 0.5f))
)
}
}
}
}
六、如何使用
在page页面内,可以引入该组件并使用,使用举例:
kotlin
package com.example.kuiklytest.app.home
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.example.kuiklytest.app.components.BannerCarousel
import com.example.kuiklytest.common.types.SwiperItem
import com.tencent.kuikly.compose.coil3.rememberAsyncImagePainter
import com.tencent.kuikly.compose.foundation.layout.Box
import com.tencent.kuikly.compose.foundation.layout.fillMaxSize
import com.tencent.kuikly.compose.foundation.layout.padding
import com.tencent.kuikly.compose.foundation.pager.HorizontalPager
import com.tencent.kuikly.compose.foundation.shape.RoundedCornerShape
import com.tencent.kuikly.compose.material3.Card
import com.tencent.kuikly.compose.material3.CircularProgressIndicator
import com.tencent.kuikly.compose.material3.Text
import com.tencent.kuikly.compose.ui.Alignment
import com.tencent.kuikly.compose.ui.Modifier
import com.tencent.kuikly.compose.ui.layout.ContentScale
import com.tencent.kuikly.compose.foundation.Image
import com.tencent.kuikly.compose.foundation.layout.Column
import com.tencent.kuikly.compose.foundation.layout.Spacer
import com.tencent.kuikly.compose.foundation.layout.fillMaxWidth
import com.tencent.kuikly.compose.foundation.layout.height
import com.tencent.kuikly.compose.resources.DrawableResource
import com.tencent.kuikly.compose.resources.InternalResourceApi
import com.tencent.kuikly.compose.resources.painterResource
import com.tencent.kuikly.compose.ui.graphics.Brush
import com.tencent.kuikly.compose.ui.graphics.Color
import com.tencent.kuikly.compose.ui.graphics.SolidColor
import com.tencent.kuikly.compose.ui.graphics.painter.BrushPainter
import com.tencent.kuikly.compose.ui.graphics.painter.ColorPainter
import com.tencent.kuikly.compose.ui.unit.dp
import com.tencent.kuikly.core.base.attr.ImageUri
//import com.tencent.kuikly.lifecycle.compose.collectAsStateWithLifecycle
import com.tencent.kuikly.lifecycle.viewmodel.compose.viewModel
/**
* HomeScreen - 智能容器 (Smart Composable)
* 负责:
* 1. 获取 ViewModel 实例。
* 2. 收集状态 (State)。
* 3. 将状态传递给纯UI组件 (HomeContent)。
*/
@Composable
fun HomeScreen(
// 通过参数注入,方便预览和测试
viewModel: HomeViewModel = viewModel { HomeViewModel() }
) {
// 收集 UI 状态
val swiperUiState by viewModel.swiperUiState.collectAsState()
// 使用 LaunchedEffect 在 Composable 首次进入屏幕时加载数据
LaunchedEffect(Unit) {
viewModel.loadSwiperData()
}
// 将状态传递给纯 UI 组件进行渲染
HomeContent(
swiperUiState = swiperUiState
)
}
@OptIn(InternalResourceApi::class)
private val penguin by lazy(LazyThreadSafetyMode.NONE) {
DrawableResource(ImageUri.commonAssets("penguin2.png").toUrl("app"))
}
/**
* HomeContent - 纯UI组件 (Dumb Composable)
* 负责:
* 1. 接收状态和数据。
* 2. 根据状态渲染不同的 UI。
* 3. 不包含任何业务逻辑。
*/
@Composable
fun HomeContent(
swiperUiState: HomeUiState<List<SwiperItem>>
) {
// 使用 Column 作为根布局,将屏幕垂直划分为多个部分
Column(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
when (swiperUiState) {
is HomeUiState.Loading -> {
CircularProgressIndicator()
}
is HomeUiState.Success -> {
// 成功状态:使用 BannerCarousel 显示轮播图
BannerCarousel(
dataList = swiperUiState.data,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
) { swiperItem ->
// 定义每个轮播项的 UI
Card(
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxSize(),
shape = RoundedCornerShape(16.dp)
) {
//var type by remember { mutableStateOf(0) }
Image(
painter = rememberAsyncImagePainter(
//swiperItem.imageUrl,
"https://wfiles.gtimg.cn/wuji_dashboard/xy/starter/baa91edc.png",
placeholder = painterResource(penguin)
// placeholder = when (type) {
// 0 -> ColorPainter(Color.Gray)
// 1 -> BrushPainter(Brush.verticalGradient(listOf(Color.Magenta, Color.Cyan)))
// 2 -> BrushPainter(SolidColor(Color.Magenta))
// 3 -> painterResource(penguin)
// else -> null
// },
),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().height(200.dp),
)
}
}
}
is HomeUiState.Error -> {
//Text("加载失败: ${swiperUiState.message}")
Image(
painter = rememberAsyncImagePainter(
//swiperItem.imageUrl,
"https://wfiles.gtimg.cn/wuji_dashboard/xy/starter/baa91edc.png",
placeholder = painterResource(penguin)
),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().height(200.dp),
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "其他内容区域",
modifier = Modifier.padding(horizontal = 16.dp)
)
}
}
七、扩展思考
- 垂直滚动支持:可改造为VerticalPager实现
- 动画定制:支持自定义页面切换动画
- 加载优化:结合Kuikly图片加载能力实现渐进式加载
该组件展示了Jetpack Compose声明式UI的优势,通过状态驱动的方式简化了传统View体系下的复杂逻辑实现,可作为学习Compose复杂组件开发的典型案例。