Jetpack Compose Banner即拿即用

6,485 阅读7分钟

Jetpack Compose目前没有官方的Banner控件,所以只能自己写,搜了些资料才完成,非常感谢之前分享过这些内容的大佬们。

效果图

gif图.gif

accompanist组库

accompanist

旨在为Jetpack Compose提供补充功能的组库,里面有非常多很好用的实验性功能,之前用过的加载网络图片的rememberImagePainter就是其中之一,而做Banner的话需要用到的是其中的Pager库。

//导入依赖 
implementation "com.google.accompanist:accompanist-pager:$accompanist_pager"

这里我用的是0.16.1,因为其他库也是这个版本,目前最新是0.18.0

关键代码

1、rememberPagerState

用于记录分页状态的变量,一共有5个参数,我们用到了4个,还有一个是initialPageOffset,可以设置偏移量

val pagerState = rememberPagerState(
    //总页数
    pageCount = list.size,
    //预加载的个数
    initialOffscreenLimit = 1,
    //是否无限循环
    infiniteLoop = true,
    //初始页面
    initialPage = 0
)
2、HorizontalPager

用于创建一个可以横向滑动的分页布局,把上面的rememberPagerState传进去,其他也没啥

HorizontalPager(
    state = pagerState,
    modifier = Modifier
        .fillMaxSize(),
) { page ->
    Image(
        painter = rememberImagePainter(list[page].imageUrl),
        modifier = Modifier.fillMaxSize(),
        contentScale = ContentScale.Crop,
        contentDescription = null
    )
}
3、让HorizontalPager自己动起来

这里有两个方法可以让HorizontalPager动起来,一个是animateScrollToPage,另一个是scrollToPage,从名字上都可以看出来带animate的是有动画效果的方法,也正是我想要的东西。

//自动滚动
LaunchedEffect(pagerState.currentPage) {
    if (pagerState.pageCount > 0) {
        delay(timeMillis)
        pagerState.animateScrollToPage((pagerState.currentPage + 1) % pagerState.pageCount)
    }
}

在控件里添加这行代码就可以让控件自动起来了

但这是一段看起来没问题的代码

假设页面总数pagerState.pageCount为2,当((pagerState.currentPage + 1) % pagerState.pageCount) == 0时跳转到第1个页面,但最后的效果是这样的

gif图2.gif 轮播图往左滑了,而且还出现了轮播图中间页面的画面,页面有点闪烁的感觉。

修改后
//自动滚动
LaunchedEffect(pagerState.currentPage) {
    if (pagerState.pageCount > 0) {
        delay(timeMillis)
        //这里直接+1就可以循环,前提是pagerState的infiniteLoop == true
        pagerState.animateScrollToPage(pagerState.currentPage + 1)
    }
}

只修改了animateScrollToPage参数的值,看到这里可能有人会问:pagerState.currentPage + 1不会报错吗?

确实不会!

因为当rememberPagerState中的infiniteLoop(无限循环)参数设置为true时最大页码其实为Int.MAX_VALUE,而currentPage只是当前页面的索引,并不是真实的页码。

也就是说,当Banner有4个页面,这里传个5的时候,并不会报错,而且animateScrollToPage会自动将这个"5"转换为页面索引,以保证下次使用currentPage不会出错。(菜鸟,我!啊吧啊吧看了好一阵子源码没看到这个是哪里转的)

不过有些地方值得注意:

调用pagerState.animateScrollToPage(target)的时候

  • 当target > pageCount 或 target > currentPage的时候,控件向右滑动
  • 当target < pageCount 且 target < currentPage的时候,控件向左滑动
  • 另外如果currentPage和target当两者相差页面大于4的时候只会在动画中显示(currentPage、currentPage + 1、target - 1、target)四个页面

以此类推,如果改为-1的话就是不断往左自动滑动啦

pagerState.animateScrollToPage(pagerState.currentPage - 1)

Banner中定义了几个参数,indicatorAlignment可以设置指示点的位置,默认为底部居中

/**
 * 轮播图
 * [timeMillis] 停留时间
 * [loadImage] 加载中显示的布局
 * [indicatorAlignment] 指示点的的位置,默认是轮播图下方的中间,带一点padding
 * [onClick] 轮播图点击事件
 */
@ExperimentalCoilApi
@ExperimentalPagerApi
@Composable
fun Banner(
    list: List<BannerData>?,
    timeMillis: Long = 3000,
    @DrawableRes loadImage: Int = R.mipmap.ic_web,
    indicatorAlignment: Alignment = Alignment.BottomCenter,
    onClick: (link: String) -> Unit = {}
)
Alignment.BottomStart

bannerLeft.png

Alignment.BottomEnd

bannerRight.png

发现了个奇怪的问题

//自动滚动
LaunchedEffect(pagerState.currentPage) {
    if (pagerState.pageCount > 0) {
        delay(timeMillis)
        //这里直接+1就可以循环,前提是infiniteLoop == true
        pagerState.animateScrollToPage(pagerState.currentPage - 1)
    }
}

这段代码里,由于ReCompose时机是因为pagerState.currentPage这个值产生变化的时候;当我们触摸着HorizontalPager这个控件期间,动画会挂起取消

所以当我们滑动但是不滑动到上一页或下一页,且在本次跳转页面动画触发后才松开手指的时候,就会导致自动滚动停止的问题发生。

像这样

gif图3.gif

问题解决

问题的解决思路也不复杂,只需要在手指按下时记录当前页面索引,手指抬起时判断当前页面索引是否有所改变,如果没有改变的话就手动触发动画。

PointerInput Modifier

这是用于处理手势操作的Modifier,它为我们提供了PointerInputScope作用域,在这个作用域中我们可以使用一些有关于手势的API。

例如:detectDragGestures

我们可以在detectDragGestures中拿到拖动开始/拖动时/拖动取消/拖动结束的回调,但其中的onDrag(拖动时触发回调)是必传的参数,这会导致HorizontalPager控件拖动手势失效。

suspend fun PointerInputScope.detectDragGestures(
    onDragStart: (Offset) -> Unit = { },
    onDragEnd: () -> Unit = { },
    onDragCancel: () -> Unit = { },
    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
)

所以最后使用了更基础的API - awaitPointerEvent,我们需要在awaitPointerEventScope方法为我们提供的AwaitPointerEventScope作用域内使用它。

HorizontalPager(
    state = pagerState,
    modifier = Modifier.pointerInput(pagerState.currentPage) {
        awaitPointerEventScope {
            while (true) {
                //PointerEventPass.Initial - 本控件优先处理手势,处理后再交给子组件
                val event = awaitPointerEvent(PointerEventPass.Initial)
                //获取到第一根按下的手指
                val dragEvent = event.changes.firstOrNull()
                when {
                    //当前移动手势是否已被消费
                    dragEvent!!.positionChangeConsumed() -> {
                        return@awaitPointerEventScope
                    }
                    //是否已经按下(忽略按下手势已消费标记)
                    dragEvent.changedToDownIgnoreConsumed() -> {
                        //记录下当前的页面索引值
                        currentPageIndex = pagerState.currentPage
                    }
                    //是否已经抬起(忽略按下手势已消费标记)
                    dragEvent.changedToUpIgnoreConsumed() -> {
                        //当pageCount大于1,且手指抬起时如果页面没有改变,就手动触发动画
                        if (currentPageIndex == pagerState.currentPage && pagerState.pageCount > 1) {
                            executeChangePage = !executeChangePage
                        }
                    }
                }
            }
        }
    }
       ...
)

另外,由于轮播图可以点击跳转到详情页面,所以还需要区分单击事件和滑动事件,需要用到pagerState.targetPage(当前页面是否有任何滚动/动画正在执行),如果没有的话就会返回null。

但只要用户拖动了Banner,松手的时候targetPage就不会为null。

//是否已经抬起(忽略按下手势已消费标记)
dragEvent.changedToUpIgnoreConsumed() -> {
    //当页面没有任何滚动/动画的时候pagerState.targetPage为null,这个时候是单击事件
    if (pagerState.targetPage == null) return@awaitPointerEventScope
    //当pageCount大于1,且手指抬起时如果页面没有改变,就手动触发动画
    if (currentPageIndex == pagerState.currentPage && pagerState.pageCount > 1) {
        executeChangePage = !executeChangePage
    }
}

gif图4.gif 解决!(gif图切换的时候卡了一下,真机上没问题)

即拿即用

给小林一个star

import androidx.annotation.DrawableRes
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil.annotation.ExperimentalCoilApi
import coil.compose.rememberImagePainter
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.rememberPagerState
import kotlinx.coroutines.delay

/**
 * 轮播图
 * [timeMillis] 停留时间
 * [loadImage] 加载中显示的布局
 * [indicatorAlignment] 指示点的的位置,默认是轮播图下方的中间,带一点padding
 * [onClick] 轮播图点击事件
 */
@ExperimentalCoilApi
@ExperimentalPagerApi
@Composable
fun Banner(
    list: List<BannerData>?,
    timeMillis: Long = 3000,
    @DrawableRes loadImage: Int = R.mipmap.ic_web,
    indicatorAlignment: Alignment = Alignment.BottomCenter,
    onClick: (link: String) -> Unit = {}
) {

    Box(
        modifier = Modifier.background(MaterialTheme.colors.background).fillMaxWidth()
            .height(220.dp)
    ) {

        if (list == null) {
            //加载中的图片
            Image(
                painterResource(loadImage),
                modifier = Modifier.fillMaxSize(),
                contentDescription = null,
                contentScale = ContentScale.Crop
            )
        } else {
            val pagerState = rememberPagerState(
                //总页数
                pageCount = list.size,
                //预加载的个数
                initialOffscreenLimit = 1,
                //是否无限循环
                infiniteLoop = true,
                //初始页面
                initialPage = 0
            )

            //监听动画执行
            var executeChangePage by remember { mutableStateOf(false) }
            var currentPageIndex = 0

            //自动滚动
            LaunchedEffect(pagerState.currentPage, executeChangePage) {
                if (pagerState.pageCount > 0) {
                    delay(timeMillis)
                    //这里直接+1就可以循环,前提是infiniteLoop == true
                    pagerState.animateScrollToPage(pagerState.currentPage + 1)
                }
            }

            HorizontalPager(
                state = pagerState,
                modifier = Modifier.pointerInput(pagerState.currentPage) {
                    awaitPointerEventScope {
                        while (true) {
                            //PointerEventPass.Initial - 本控件优先处理手势,处理后再交给子组件
                            val event = awaitPointerEvent(PointerEventPass.Initial)
                            //获取到第一根按下的手指
                            val dragEvent = event.changes.firstOrNull()
                            when {
                                //当前移动手势是否已被消费
                                dragEvent!!.positionChangeConsumed() -> {
                                    return@awaitPointerEventScope
                                }
                                //是否已经按下(忽略按下手势已消费标记)
                                dragEvent.changedToDownIgnoreConsumed() -> {
                                    //记录下当前的页面索引值
                                    currentPageIndex = pagerState.currentPage
                                }
                                //是否已经抬起(忽略按下手势已消费标记)
                                dragEvent.changedToUpIgnoreConsumed() -> {
                                    //当页面没有任何滚动/动画的时候pagerState.targetPage为null,这个时候是单击事件
                                    if (pagerState.targetPage == null) return@awaitPointerEventScope
                                    //当pageCount大于1,且手指抬起时如果页面没有改变,就手动触发动画
                                    if (currentPageIndex == pagerState.currentPage && pagerState.pageCount > 1) {
                                        executeChangePage = !executeChangePage
                                    }
                                }
                            }
                        }
                    }
                }
                    .clickable(onClick = { onClick(list[pagerState.currentPage].linkUrl) })
                    .fillMaxSize(),
            ) { page ->
                Image(
                    painter = rememberImagePainter(list[page].imageUrl),
                    modifier = Modifier.fillMaxSize(),
                    contentScale = ContentScale.Crop,
                    contentDescription = null
                )
            }

            Box(
                modifier = Modifier.align(indicatorAlignment)
                    .padding(bottom = 6.dp, start = 6.dp, end = 6.dp)
            ) {

                //指示点
                Row(
                    horizontalArrangement = Arrangement.Center,
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    for (i in list.indices) {
                        //大小
                        var size by remember { mutableStateOf(5.dp) }
                        size = if (pagerState.currentPage == i) 7.dp else 5.dp

                        //颜色
                        val color =
                            if (pagerState.currentPage == i) MaterialTheme.colors.primary else Color.Gray

                        Box(
                            modifier = Modifier.clip(CircleShape).background(color)
                                //当size改变的时候以动画的形式改变
                                .animateContentSize().size(size)
                        )
                        //指示点间的间隔
                        if (i != list.lastIndex) Spacer(
                            modifier = Modifier.height(0.dp).width(4.dp)
                        )
                    }
                }

            }
        }

    }

}

/**
 * 轮播图数据
 */
data class BannerData(
    val imageUrl: String,
    val linkUrl: String
)

特别感谢

RugerMc 手势处理

apk下载链接

项目地址

欢迎Star~PlayAndroid