jetpack compose实战——Banner轮播图的使用和封装

前言

Banner框架介绍和使用

效果

我们首先看下我们今天要做的效果

轮播图效果图.gif
框架使用
Banner(
    data = viewModel.bannerData,//设置数据
    onImagePath = {//设置图片的url地址
        viewModel.bannerData[it].imagePath
    },
    pagerModifier = Modifier
        .padding(horizontal = 16.dp)
        .padding(top = 10.dp)
        .clip(RoundedCornerShape(8.dp)),//HorizontalPager的modifier
    pagerIndicatorModifier = Modifier
        .background(Color(0x90000000))
        .padding(horizontal = 10.dp)
        .padding(top = 10.dp, bottom = 10.dp),//指示器Row的整个样式
    desc = {
        //指示器文本内容,也就是标题一、标题二
        Text(text = viewModel.bannerData[it].desc, color = Color.White)
    }
) {
    //设置item的点击事件
    Log.e("TAG", viewModel.bannerData[it].imagePath)

Banner框架可设置的属性

/**
 * @param data 数据来源
 * @param onImagePath 设置图片的url
 * @param pagerModifier HorizontalPager的Modifier
 * @param ratio 图片宽高压缩比
 * @param contentScale 图片裁剪方式
 * @param isShowPagerIndicator 是否显示指示器
 * @param pagerIndicatorModifier 指示器Row的整个样式
 * @param activeColor 选中的指示器样式
 * @param inactiveColor 未选中的指示器样式
 * @param isLoopBanner 是否自动播放轮播图
 * @param loopDelay 任务执行前的延迟(毫秒)
 * @param loopPeriod 连续任务执行之间的时间(毫秒)。
 * @param horizontalArrangement 指示器Row中文本和指示器的排版样式
 * @param desc 文本内容
 * @param onBannerItemClick Banner的item点击事件
 */

上面是我们已经封装好框架的介绍和使用,那么怎么封装的呢?让我带你一步一步实现它

Banner轮播图的封装实现

def accompanist_version = "0.24.7-alpha"
api "com.google.accompanist:accompanist-pager:${accompanist_version}"
api "com.google.accompanist:accompanist-pager-indicators:${accompanist_version}"
  • 考虑到对数据进行解耦, 我们把耗时的任务和数据放到ViewModel,这时候我们需要另一些库
def lifecycle_version = "2.4.1"

api "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
api "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
api 'androidx.activity:activity-compose:1.4.0'
demo1:HorizontalPager的基本使用
@Composable
fun HomeFragment(viewModel: HomeFragmentViewModel = viewModel()) {
    TopAppBarCenter(title = {
        Text(text = "首页", color = Color.White)
    },
        isImmersive = true,
        modifier = Modifier.background(Brush.linearGradient(listOf(Color_149EE7, Color_2DCDF5)))) {

        Column(Modifier.fillMaxWidth().padding(it)) {
            val pagerState = rememberPagerState()

            HorizontalPager(
                count = viewModel.bannerData.size,
                state = pagerState
            ) { index ->
                AsyncImage(model = viewModel.bannerData[index].imagePath,
                    contentDescription = null,
                    modifier = Modifier
                        .fillMaxWidth()
                        .aspectRatio(7 / 3f),
                    contentScale = ContentScale.Crop)
            }

            Text(text = "我是首页", modifier = Modifier.padding(top = 10.dp))
        }
    }
}
  • 通过viewModel: HomeFragmentViewModel = viewModel()进行数据和UI解耦
  • TopAppBarCenter是我们上篇文章封装的TopAppBar,大家可以看上篇文章,这里不再阐述
  • rememberPagerState记住当前的页面的状态,方法只有一个参数,可以传入初始页面,不传的话默认是0
  • HorizontalPager必须传入两个参数,count代表HorizontalPager的数量,pagerState就是上面的rememberPagerState
  • AsyncImage用到的是coil库,添加依赖
//图片加载
api("io.coil-kt:coil-compose:2.0.0-rc01")

GitHub地址:https://github.com/coil-kt/coil

效果图.gif
demo2:添加循环轮播
  • demo1还是非常简单的,就显示一张图
  • HorizontalPager其实就相当于Android中的ViewPager。
  • 现在我们滑倒最后一张的时候,实际是不可滑动了,现在想让它在最后一张的时候,再向左滑动显示第一张,怎么解决?
  • 官方其实有现成的demo:HorizontalPagerLoopingSample.kt
    修改后的代码
@Composable
fun Banner(vm: HomeFragmentViewModel) {
    val virtualCount = Int.MAX_VALUE

    val actualCount = vm.bannerData.size
    //初始图片下标
    val initialIndex = virtualCount / 2
    val pageState = rememberPagerState(initialPage = initialIndex)
    HorizontalPager(count = virtualCount,
        state = pageState,
        modifier = Modifier
            .padding(horizontal = 16.dp)
            .clip(
                RoundedCornerShape(8.dp))) { index ->
        val actualIndex = (index - initialIndex).floorMod(actualCount)
        AsyncImage(model = vm.bannerData[actualIndex].imagePath,
            contentDescription = null,
            modifier = Modifier
                .fillMaxWidth()
                .aspectRatio(7 / 3f),
            contentScale = ContentScale.Crop)
    }
}

 fun Int.floorMod(other: Int): Int = when (other) {
    0 -> this
    else -> this - floorDiv(other = other) * other
}
  • 我们设置HorizontalPager数量为整型的最大值
  • 初始化显示的位置为整型的最大值的一半,这样数据边界左右就一定有值
  • 大家可能对floorMod方法不是很理解
    • 我们假设Int.MAX_VALUE=200,那么initialIndex=100
    • 图片的大小是4
    • 我们第一次进来的index实际就是initialIndex,也就是100
    • floorDiv的作用是第一个参数/第二个参数,然后向下取整,如125/25=2
    • 如下图,假设我们现在是104,那么它实际下标应该是0,(104-100)-(104-100)/4 * 4,再比如107,下标实际是3=(107-100)-(107-100)/4 * 4
image.png
demo3:轮播图自动轮播
  • 上面的代码我们已经实现了图片的左右轮询滑动,现在再添加一个功能,让它自己动起来

  • 这里我们需要先讲下Compose的生命周期
    生命周期

  • 官方网址:https://developer.android.google.cn/jetpack/compose/lifecycle

  • LanuchedEffect

    • 如果需要在 Compasable 内安全调用挂起函数,可以使用 LaunchedEffect
    • LaunchedEffect 会自动启动一个协程,并将代码块作为参数传递
    • 当 LaunchedEffect 离开 Composable 或 Composable 销毁时,协程也将取消
    • 如果 LaunchedEffect的 key 值改变了,系统将取消现有协程,并在新的协程中启动新的挂起函数
  • rememberCoroutineScope

    • LaunchedEffect是Compose函数,只能在其他Compose中使用
    • 如果想在Compose之外使用协程,并且能够自动取消,我们可以使用rememberCoroutineScope
    • 如果需要手动控制协程的生命周期时,也可以使用 rememberCoroutineScope
  • DisposableEffect

    • 对于需要对于某个值改变时或 Composable 退出后进行销毁或清理操作时,可以使用DisposableEffect
    • 当DisposableEffect的 key 发生改变时,会调用onDispose方法,可以在方法中作清理操作,然后再次调用重启
  • produceState

    • produceState 可让您将非 Compose 状态转换为 Compose 状态

代码实现

基础知识讲完了,那我们就来实现它让它动起来

fun SwipeContent(vm: HomeFragmentViewModel) {
    val virtualCount = Int.MAX_VALUE

    val actualCount = vm.bannerData.size
    //初始图片下标
    val initialIndex = virtualCount / 2
    val pageState = rememberPagerState(initialPage = initialIndex)
    //改变地方在这里
    val coroutineScope= rememberCoroutineScope()
    DisposableEffect(Unit) {
        val timer = Timer()
        timer.schedule(object :TimerTask(){
            override fun run() {
                 coroutineScope.launch {
                     pageState.animateScrollToPage(pageState.currentPage+1)
                 }
            }

        },3000,3000)
        onDispose {
            timer.cancel()
        }
    }
    HorizontalPager(count = virtualCount,
        state = pageState,
        modifier = Modifier
            .padding(horizontal = 16.dp)
            .clip(
                RoundedCornerShape(8.dp))) { index ->
        val actualIndex = (index - initialIndex).floorMod(actualCount)
        AsyncImage(model = vm.bannerData[actualIndex].imagePath,
            contentDescription = null,
            modifier = Modifier
                .fillMaxWidth()
                .aspectRatio(7 / 3f),
            contentScale = ContentScale.Crop)
    }
}
  • 创建了一个coroutineScope用来开启协程
  • 用DisposableEffect对Compose退出的时候做清理动作
  • Timer实际就是定时器,可设置每隔多久执行一次
    是不是很简单,我们看下效果
自动轮播.gif
demo4:添加底部指示器

我们已经实现了Banner的自动轮播,那么我们现在就是开始添加指示器
指示器官方有个现成的

def accompanist_version = "0.24.7-alpha"
implementation "com.google.accompanist:accompanist-pager-indicators:${accompanist_version}"
@Composable
private fun Sample() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text(stringResource(R.string.horiz_pager_with_indicator_title)) },
                backgroundColor = MaterialTheme.colors.surface,
            )
        },
        modifier = Modifier.fillMaxSize()
    ) { padding ->
        Column(Modifier.fillMaxSize().padding(padding)) {
            val pagerState = rememberPagerState()

            // Display 10 items
            HorizontalPager(
                count = 10,
                state = pagerState,
                // Add 32.dp horizontal padding to 'center' the pages
                contentPadding = PaddingValues(horizontal = 32.dp),
                modifier = Modifier
                    .weight(1f)
                    .fillMaxWidth(),
            ) { page ->
                PagerSampleItem(
                    page = page,
                    modifier = Modifier
                        .fillMaxWidth()
                        .aspectRatio(1f)
                )
            }

            HorizontalPagerIndicator(
                pagerState = pagerState,
                modifier = Modifier
                    .align(Alignment.CenterHorizontally)
                    .padding(16.dp),
            )

            ActionsRow(
                pagerState = pagerState,
                modifier = Modifier.align(Alignment.CenterHorizontally)
            )
        }
    }
}
  • 我们主要关注HorizontalPagerIndicator,我们发现代码非常简单,设置了pagerState,而这个pagerState就是HorizontalPager的pagerState。
  • 这时候大家是不是很高心,这么简单,我直接在我们demo3中AsyncImage的下方直接添加
 AsyncImage(
     model = onImagePath(actualIndex),
     contentDescription = null,
     modifier = Modifier
         .layoutId("image")
         .aspectRatio(ratio)
         .clickable {
             onBannerItemClick?.invoke(actualIndex)
         },
     contentScale = contentScale,
 )
     Row(Modifier
         .layoutId("content")
         .fillMaxWidth()
         .then(pagerIndicatorModifier),
         verticalAlignment = Alignment.CenterVertically,
         horizontalArrangement = horizontalArrangement
     ) {
         desc(actualIndex)
         HorizontalPagerIndicator(
             pagerState = pageState
         )

直接运行我们会发现图片展示不出来

image.png

过一会儿程序还崩溃了,what?为什么呢?

  • 我们看HorizontalPagerIndicator源码
@Composable
fun HorizontalPagerIndicator(
    pagerState: PagerState,//①
    modifier: Modifier = Modifier,
    activeColor: Color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current),
    inactiveColor: Color = activeColor.copy(ContentAlpha.disabled),
    indicatorWidth: Dp = 8.dp,
    indicatorHeight: Dp = indicatorWidth,
    spacing: Dp = indicatorWidth,
    indicatorShape: Shape = CircleShape,
) {

    val indicatorWidthPx = LocalDensity.current.run { indicatorWidth.roundToPx() }
    val spacingPx = LocalDensity.current.run { spacing.roundToPx() }

    Box(
        modifier = modifier,
        contentAlignment = Alignment.CenterStart
    ) {
        //②
        Row(
            horizontalArrangement = Arrangement.spacedBy(spacing),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            val indicatorModifier = Modifier
                .size(width = indicatorWidth, height = indicatorHeight)
                .background(color = inactiveColor, shape = indicatorShape)

            repeat(pagerState.pageCount) {
                Box(indicatorModifier)
            }
        }
    //③
        Box(
            Modifier
                .offset {
                    val scrollPosition = (pagerState.currentPage + pagerState.currentPageOffset)
                        .coerceIn(0f, (pagerState.pageCount - 1).coerceAtLeast(0).toFloat())
                    IntOffset(
                        x = ((spacingPx + indicatorWidthPx) * scrollPosition).toInt(),
                        y = 0
                    )
                }
                .size(width = indicatorWidth, height = indicatorHeight)
                .background(
                    color = activeColor,
                    shape = indicatorShape,
                )
        )
    }
}
  • 我们看注释①
    就一个pagerState,而这个实际是HorizontalPagerIndicator的pagerState,还记得之前我们对HorizontalPagerIndicator设置count是多少吗?没错,是Int.MAX_VALUE,我们可能明明就只需要4个点,你给我绘制Int.MAX_VALUE,不肯定崩溃了嘛。

  • 注释②就是绘制所有的点

  • 注释③就是绘制被选中的点

那么问题来了,现在显示失败或者崩溃的原因是我们设置的数量是最大值,现在我们把它写死data.size,运行起来我们发现不会报错,但是选择的点永远不显示。

既然写死不行,那我们就不用它呗,自己写个指示器呗,多大点事。当然这个方案也是可以的,但是我用的是另一种方案,修改HorizontalPagerIndicator源码

手写HorizontalPagerIndicator
  • 我们仔细看HorizontalPagerIndicator源码注释②Row下面有个repeat
repeat(pagerState.pageCount) {
    Box(indicatorModifier) 
}

它既然是因为绘制数量太多崩溃了,那我们就将它写成我们数据的大小不就可以了

  • 再看注释③,大家可以看到scrollPosition
val scrollPosition = (pagerState.currentPage + pagerState.currentPageOffset) 
.coerceIn(0f, (pagerState.pageCount - 1).coerceAtLeast(0).toFloat())

是不是想到了什么?没错pagerState.currentPage就是我们当前选中页面的index,pagerState.pageCount继续改成我们数据的大小。我相信大家肯定已经非常清楚怎么做了,我直接贴源码了

@Composable
fun HorizontalPagerIndicator(
    pagerState: PagerState,
    count:Int,
    modifier: Modifier = Modifier,
    activeColor: Color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current),
    inactiveColor: Color = activeColor.copy(ContentAlpha.disabled),
    indicatorWidth: Dp = 8.dp,
    indicatorHeight: Dp = indicatorWidth,
    spacing: Dp = indicatorWidth,
    indicatorShape: Shape = CircleShape,
) {

    val indicatorWidthPx = LocalDensity.current.run { indicatorWidth.roundToPx() }
    val spacingPx = LocalDensity.current.run { spacing.roundToPx() }

    Box(
        modifier = modifier,
        contentAlignment = Alignment.CenterStart
    ) {
        Row(
            horizontalArrangement = Arrangement.spacedBy(spacing),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            val indicatorModifier = Modifier
                .size(width = indicatorWidth, height = indicatorHeight)
                .background(color = inactiveColor, shape = indicatorShape)

            repeat(count) {
                Box(indicatorModifier)
            }
        }

        Box(
            Modifier
                .offset {
                    val scrollPosition = ((pagerState.currentPage-Int.MAX_VALUE/2).floorMod(count)+ pagerState.currentPageOffset)
                        .coerceIn(0f, (count - 1).coerceAtLeast(0).toFloat())
                    IntOffset(
                        x = ((spacingPx + indicatorWidthPx) * scrollPosition).toInt(),
                        y = 0
                    )
                }
                .size(width = indicatorWidth, height = indicatorHeight)
                .background(
                    color = activeColor,
                    shape = indicatorShape,
                )
        )
    }
}

总结

至此呢,我们对Banner的封装已经全部完成了,大家这时候肯定说:瞎说,pager指示器怎么放到底部的你没说呀,这个就留给大家去思考了,当然,大家可以参考我上篇文章的用到的方法,也可直接看我的源码Banner.kt

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,907评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,546评论 1 289
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,705评论 0 238
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,624评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,940评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,371评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,672评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,396评论 0 195
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,069评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,350评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,876评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,243评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,847评论 3 231
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,004评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,755评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,378评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,266评论 2 259

推荐阅读更多精彩内容