Jetpack Compose Banner即拿即用

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


作者:木木沐目
链接:https://juejin.cn/post/7006230365467574302
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册