本篇主要介绍微信查看大图时渐入、渐出和下拉返回列表的效果,旨在通过此过程加深对于Compose动画和手势理解和使用。
下面我们先看下实现的具体效果:
在开始之前先理一下需要实现此效果的几个过程:
- 正常的宫格模式展示小图
- 正常的全屏展示大图
- 点击小图逐渐放大直到显示完全大图
- 点击大图逐渐缩放直到大图完全消失
- 下拉大图开始缩放位移直到大图完全消失
那么下面就按照上述五个过程详细介绍下此功能整体的实现流程。
宫格模式
宫格模式比较简单,直接采用Compose提供的LazyVerticalGrid可组合项即可完成,图片来源IconFont❤️
ini
val iconList = listOf(
R.mipmap.icon1,
R.mipmap.icon2,
R.mipmap.icon3,
R.mipmap.icon4,
R.mipmap.icon5,
R.mipmap.icon6,
R.mipmap.icon7,
R.mipmap.icon8,
R.mipmap.icon9,
R.mipmap.icon10,
R.mipmap.icon11,
R.mipmap.icon12,
R.mipmap.icon13,
R.mipmap.icon14
)
LazyVerticalGrid(columns = GridCells.Fixed(3)) {
items(iconList.size) { index ->
Image(
painter = painterResource(id = iconList[index]),
contentDescription = "",
contentScale = ContentScale.FillWidth
)
}
}
显示宫格的代码就不用多加解释了,相信小伙伴们已经很熟悉了,此代码运行的效果如下:
实现好宫格之后,下面开始实现点击展示大图的逻辑
大图模式
大图模式是通过点击宫格的item才会显示,所以我们直接在上面item的点击事件中处理。
ini
@Composable
fun BigImage() {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0F, 0F, 0F, 1F))
) {
Image(
painter = painterResource(id = iconList[BigImageManager.currentClickCellIndex]),
contentDescription = "",
modifier = Modifier.fillMaxSize()
)
}
}
大图模式采用的是外层包裹一个充满全屏背景色为全黑的Box,这里背景色采用的是Color的RGBA模式,方便后续给透明度设置动画,Box内部就是一个Image,暂时设置充满全屏,后续需要根据动画逐渐改变宽高,它的paintResource从iconList中获取,index保存在BigImageManager中。
接着再看下宫格item的点击处理逻辑:
ini
// 记录是否展示大图的状态
val showBigImageStatus = remember {
mutableStateOf(false)
}
LazyVerticalGrid(columns = GridCells.Fixed(3)) {
items(iconList.size) { index ->
Image(
painter = painterResource(id = iconList[index]),
contentDescription = "",
contentScale = ContentScale.FillWidth,
modifier = Modifier.clickable {
if (showBigImageStatus.value) {
return@clickable
}
BigImageManager.currentClickCellIndex = index
showBigImageStatus.value = true
}
)
}
}
if (showBigImageStatus.value) {
BigImage()
}
上面代码需要注意的几点是:
- 定义showBigImageStatus的State变量来保存当前是否展示大图模式的状态;
- 在Image的点击事件中先记录点击的index,存入BigImageManager.currentClickCellIndex中,然后将showBigImageStatus置为true;
- 最后根据showBigImageStatus状态展示BigImage大图。
此时实现的效果见下图GIF
实现到这一步时,只是简单的将小图点击后展示大图的逻辑给处理完了,并没有任何的动画实现,接下来将动画的部分补充完整。
小图动画至大图
从小图至大图的动画的实现思路为:
- 点击时先获取点击小图的坐标和大小;
- 点击之后将大图呈现在小图坐标位置,并且大小和小图一致,大图的背景为完全透明;
- 动画过程中,大图逐渐的从小图位置变为居中显示,大小也变为全屏,背景从全透明变为黑色。
根据以上思路开始编码实现效果。
在Compose中想获取可组合项的坐标位置和大小,可以通过onGloballyPositioned方法实时获取
ini
// 记录列表item的大小
val cellSize = remember {
mutableStateOf(IntSize(0, 0))
}
LazyVerticalGrid(columns = GridCells.Fixed(3)) {
items(iconList.size) { index ->
Image(
painter = painterResource(id = iconList[index]),
contentDescription = "",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.clickable {
if (showBigImageStatus.value) {
return@clickable
}
BigImageManager.currentClickCellIndex = index
showBigImageStatus.value = true
}
.onGloballyPositioned {
val rect = it.boundsInRoot()
val offset = Offset(rect.left, rect.top)
BigImageManager.cellOffsetMap[index] = offset
cellSize.value = it.size
}
)
}
}
这里在Modifier.onGloballyPositioned方法中通过boundsInRoot()获取了Image的坐标位置,然后通过Size获取Image的大小,将大小保存在cellSize中,记录每个Image的位置保存在BigImageManager.cellOffsetMap中。
当我们获取到item的大小和具体位置之后,由小变大的动画就显得非常容易了:
- 先处理下背景透明度的变化,由小变大时,透明度应该是从0变成1,由大变小时则相反,这样我们就可以通过animateFloatAsState直接完成透明度的变化;
- 再处理图片位置的动画,执行动画前图片应该是在小格口位置,执行完动画之后图片左上角坐标就是(0,0)位置,这样就可以采用属性动画结合State实现坐标位置的变化;
- 最后处理图片大小的动画,大小动画和位置类似,最开始大小为小格口的大小,最终是屏幕的大小。
下面看具体代码实现:
ini
// 透明度是否逐渐增大
val alphaIncrease = remember {
mutableStateOf(false)
}
// 透明度动画,当showBigImageStatus为true也就是由小变大时,targetValue应该是1,反之则为0
// animationSpec设置的是2s的时长
val alpha = animateFloatAsState(
targetValue = if (alphaIncrease.value) 1F else 0F,
label = "",
animationSpec = tween(BigImageManager.animatorDuration.toInt())
)
// 大图x轴的偏移量
val bigImageOffsetX = remember {
mutableStateOf(0F)
}
// 大图y轴的偏移量
val bigImageOffsetY = remember {
mutableStateOf(0F)
}
// 大图宽度
val bigImageSizeWidth = remember {
mutableStateOf(0)
}
// 大图高度
val bigImageSizeHeight = remember {
mutableStateOf(0)
}
items(iconList.size) { index ->
Image(
painter = painterResource(id = iconList[index]),
contentDescription = "",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.clickable {
if (showBigImageStatus.value) {
return@clickable
}
BigImageManager.currentClickCellIndex = index
showBigImageStatus.value = true
alphaIncrease.value = true
val currentOffset = BigImageManager.cellOffsetMap[index] ?: Offset(0F, 0F)
animatorOfFloat(state = bigImageOffsetX, currentOffset.x, 0F)
animatorOfFloat(state = bigImageOffsetY, currentOffset.y, 0F)
animatorOfInt(
state = bigImageSizeWidth, cellSize.value.width,
getScreenWidth(context)
)
animatorOfInt(
state = bigImageSizeHeight, cellSize.value.height,
getScreenHeight(context)
)
}
.onGloballyPositioned {
val rect = it.boundsInRoot()
val offset = Offset(rect.left, rect.top)
BigImageManager.cellOffsetMap[index] = offset
cellSize.value = it.size
}
)
}
fun animatorOfFloat(state: MutableState<Float>, vararg offset: Float, onEnd: () -> Unit = {}) {
val valueAnimator = ValueAnimator.ofFloat(*offset)
valueAnimator.duration = BigImageManager.animatorDuration
valueAnimator.addUpdateListener {
state.value = it.animatedValue as Float
}
valueAnimator.addListener(onEnd = { onEnd() })
valueAnimator.start()
}
fun animatorOfInt(state: MutableState<Int>, vararg offset: Int, onEnd: () -> Unit = {}) {
val valueAnimator = ValueAnimator.ofInt(*offset)
valueAnimator.duration = BigImageManager.animatorDuration
valueAnimator.addUpdateListener {
state.value = it.animatedValue as Int
}
valueAnimator.addListener(onEnd = { onEnd() })
valueAnimator.start()
}
上面逻辑就将大图的透明度、大小和位置动画已经处理完成,下面再来看看大图中对这些值的引用。
less
@Composable
fun BigImage(
bigImageSizeWidth: Int,
bigImageSizeHeight: Int,
bigImageOffsetX: Float,
bigImageOffsetY: Float,
alpha: Float,
click: () -> Unit
) {
val context = LocalContext.current
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0F, 0F, 0F, alpha))
) {
Image(
painter = painterResource(id = iconList[BigImageManager.currentClickCellIndex]),
contentDescription = "",
modifier = Modifier
.size(
bigImageSizeWidth.toDp(context).dp,
bigImageSizeHeight.toDp(context).dp
)
.offset(bigImageOffsetX.toDp(context).dp, bigImageOffsetY.toDp(context).dp)
.clickable { click() }
)
}
}
透明度值alpha直接赋给Box的background即可,然后将对应的大小、宽高分别赋给Modifier.size()和Modifier.offset()。这里要注意的一点就是,我们动画执行过程中产生的值都是float和int型的px值,但是size和offset传入的都是Compose中Dp对象,所以我们需要先将px转为dp值,再转成Dp对象。
这时候我们再来看看由小格口变成大图的效果:
大图动画至小图
大图动画至小图的动画其实就是小图至大图相反的过程,只需要将透明度、大小和位置动画反过来执行即可,这里就不多解释具体的过程变化了,直接上代码片段。
ini
if (showBigImageStatus.value) {
BigImage(
bigImageSizeWidth.value, bigImageSizeHeight.value, bigImageOffsetX.value,
bigImageOffsetY.value, alpha.value, click = {
if (!showBigImageStatus.value) {
return@BigImage
}
alphaIncrease.value = false
val currentCellOffset =
BigImageManager.cellOffsetMap[BigImageManager.currentClickCellIndex]
?: Offset(0F, 0F)
animatorOfFloat(state = bigImageOffsetX, bigImageOffsetX.value, currentCellOffset.x)
animatorOfFloat(state = bigImageOffsetY, bigImageOffsetY.value, currentCellOffset.y)
animatorOfInt(
state = bigImageSizeWidth,
getScreenWidth(context),
cellSize.value.width,
onEnd = { showBigImageStatus.value = false })
animatorOfInt(
state = bigImageSizeHeight,
getScreenHeight(context),
cellSize.value.height,
onEnd = { showBigImageStatus.value = false })
}
)
}
BigImage中Image的点击事件提到click参数中,然后在click中先将alpha标志位置为false,此时透明度就会从1变为0,然后从BigImageManager.cellOffsetMap中取出对应小图的位置,最后通过属性动画和State的结合将大小和位置的动画开始执行。
到这位置点击小图动画至大图和点击大图动画至小图效果都已经成型,再看下实现的效果:
下拉缩放
下拉事件可通过Modifier.pointerInput()方法监听,还是和点击事件一样,将下拉事件提到参数中,整个过程中只需要关注下拉开始、下拉结束和下拉的过程,取消事件暂时忽略不计。
ini
.pointerInput(null) {
detectDragGestures(
onDragStart = onDraStart,
onDragEnd = onDragEnd,
onDrag = onDrag
)
}
下拉过程中要记录两个状态,一是下拉的状态表示当前是否处于下拉状态,二是下拉过程中的透明度,都是通过State来记录
scss
// 下拉状态
val dragStatus = remember {
mutableStateOf(false)
}
// 下拉时透明度
val dragAlpha = remember {
mutableStateOf(1F)
}
接下来处理下拉过程中大小、位置和透明度的变化
ini
onDrag = { _, dragAmount ->
val offsetX = bigImageOffsetX.value
val offsetY = bigImageOffsetY.value
// 上滑暂时不处理
if (offsetY < 0) {
return@BigImage
}
bigImageOffsetX.value = offsetX + dragAmount.x
bigImageOffsetY.value = offsetY + dragAmount.y
val scale = 1 - (offsetY + dragAmount.y) / (getScreenHeight(context) / 2)
if (scale > 0.5F) {
dragAlpha.value = scale
BigImageManager.dragEndAlpha = scale
bigImageSizeWidth.value = (getScreenWidth(context) * scale).toInt()
bigImageSizeHeight.value = (getScreenHeight(context) * scale).toInt()
}
}
- 下拉过程中先获取大图当前的位置x和y
- 然后将x和y位置根据下拉的偏移量作出变化
- 接着获取下拉的一个系数,这里将0.5作为最小缩放系数,这里的系数是根据下拉的偏移量和屏幕整体高度的一半计算得出
- 根据下拉缩放系数计算出透明度值和宽高的值,每次都将下拉缩放系数保存至BigImageManager.draEndAlpha中,这个值在松手时会使用到。
到这就将下拉过程中大图的变化处理完成了,最后处理下下拉开始和下拉结束事件就完成整个下拉事件了。
ini
onDraStart = { _ -> dragStatus.value = true },
onDragEnd = {
val currentCellOffset =
BigImageManager.cellOffsetMap[BigImageManager.currentClickCellIndex]
?: Offset(0F, 0F)
animatorOfFloat(state = bigImageOffsetX, bigImageOffsetX.value, currentCellOffset.x)
animatorOfFloat(state = bigImageOffsetY, bigImageOffsetY.value, currentCellOffset.y)
animatorOfInt(
state = bigImageSizeWidth,
bigImageSizeWidth.value,
cellSize.value.width,
onEnd = { showBigImageStatus.value = false })
animatorOfInt(
state = bigImageSizeHeight,
bigImageSizeHeight.value,
cellSize.value.height,
onEnd = { showBigImageStatus.value = false })
animatorOfFloat(state = dragAlpha, BigImageManager.dragEndAlpha, 0F, onEnd = {
dragStatus.value = false
alphaIncrease.value = false
})
},
下拉开始事件中只需要将dragStatus置为true,下拉结束事件的处理逻辑和点击大图的逻辑基本一致,只是点击大图时大小的变化是从充满全屏的宽高开始,而下拉结束时大小是松手时的大小,松手时的大小记录在bigImageSizeWidth和bigImageSizeHeight中,所以大图大小动画的起始点就是这两个值,可以和click事件中动画对比下。
最后我们看下整体的效果
相比于原生的实现,个人感觉Compose实现查看大图的渐入渐出动画实现更加简单,得益于Compose的响应式和animate*AsState,下面是整个效果的代码,感兴趣的小伙伴可以自己运行下代码体会体会整体效果,感谢大家的阅读😆😆
ini
object BigImageManager {
// 动画时长
const val animatorDuration = 2000L
// 记录当前点击宫格的index
var currentClickCellIndex = 0
// 记录所有宫格的位置
var cellOffsetMap = mutableMapOf<Int, Offset>()
// 记录下拉松手时大图的透明度值
var dragEndAlpha = 0F
}
val iconList = listOf(
R.mipmap.icon1,
R.mipmap.icon2,
R.mipmap.icon3,
R.mipmap.icon4,
R.mipmap.icon5,
R.mipmap.icon6,
R.mipmap.icon7,
R.mipmap.icon8,
R.mipmap.icon9,
R.mipmap.icon10,
R.mipmap.icon11,
R.mipmap.icon12,
R.mipmap.icon13,
R.mipmap.icon14
)
@Composable
fun BigImageScaffold() {
val context = LocalContext.current
// 记录是否展示大图的状态
val showBigImageStatus = remember {
mutableStateOf(false)
}
// 记录列表item的大小
val cellSize = remember {
mutableStateOf(IntSize(0, 0))
}
// 透明度是否逐渐增大
val alphaIncrease = remember {
mutableStateOf(false)
}
// 透明度动画,当showBigImageStatus为true也就是由小变大时,targetValue应该是1,反之则为0
// animationSpec设置的是1s的时长
val alpha = animateFloatAsState(
targetValue = if (alphaIncrease.value) 1F else 0F,
label = "",
animationSpec = tween(BigImageManager.animatorDuration.toInt())
)
// 大图x轴的偏移量
val bigImageOffsetX = remember {
mutableStateOf(0F)
}
// 大图y轴的偏移量
val bigImageOffsetY = remember {
mutableStateOf(0F)
}
// 大图宽度
val bigImageSizeWidth = remember {
mutableStateOf(0)
}
// 大图高度
val bigImageSizeHeight = remember {
mutableStateOf(0)
}
// 下拉状态
val dragStatus = remember {
mutableStateOf(false)
}
// 下拉时透明度
val dragAlpha = remember {
mutableStateOf(1F)
}
LazyVerticalGrid(columns = GridCells.Fixed(3)) {
items(iconList.size) { index ->
Image(
painter = painterResource(id = iconList[index]),
contentDescription = "",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.clickable {
if (showBigImageStatus.value) {
return@clickable
}
BigImageManager.currentClickCellIndex = index
showBigImageStatus.value = true
alphaIncrease.value = true
val currentOffset = BigImageManager.cellOffsetMap[index] ?: Offset(0F, 0F)
animatorOfFloat(state = bigImageOffsetX, currentOffset.x, 0F)
animatorOfFloat(state = bigImageOffsetY, currentOffset.y, 0F)
animatorOfInt(
state = bigImageSizeWidth, cellSize.value.width,
getScreenWidth(context)
)
animatorOfInt(
state = bigImageSizeHeight, cellSize.value.height,
getScreenHeight(context)
)
}
.onGloballyPositioned {
val rect = it.boundsInRoot()
val offset = Offset(rect.left, rect.top)
BigImageManager.cellOffsetMap[index] = offset
cellSize.value = it.size
}
)
}
}
if (showBigImageStatus.value) {
BigImage(
bigImageSizeWidth.value, bigImageSizeHeight.value, bigImageOffsetX.value,
bigImageOffsetY.value, if (dragStatus.value) dragAlpha.value else alpha.value,
click = {
if (!showBigImageStatus.value) {
return@BigImage
}
alphaIncrease.value = false
val currentCellOffset =
BigImageManager.cellOffsetMap[BigImageManager.currentClickCellIndex]
?: Offset(0F, 0F)
animatorOfFloat(state = bigImageOffsetX, bigImageOffsetX.value, currentCellOffset.x)
animatorOfFloat(state = bigImageOffsetY, bigImageOffsetY.value, currentCellOffset.y)
animatorOfInt(
state = bigImageSizeWidth,
getScreenWidth(context),
cellSize.value.width,
onEnd = { showBigImageStatus.value = false })
animatorOfInt(
state = bigImageSizeHeight,
getScreenHeight(context),
cellSize.value.height,
onEnd = { showBigImageStatus.value = false })
},
onDraStart = { _ -> dragStatus.value = true },
onDragEnd = {
val currentCellOffset =
BigImageManager.cellOffsetMap[BigImageManager.currentClickCellIndex]
?: Offset(0F, 0F)
animatorOfFloat(state = bigImageOffsetX, bigImageOffsetX.value, currentCellOffset.x)
animatorOfFloat(state = bigImageOffsetY, bigImageOffsetY.value, currentCellOffset.y)
animatorOfInt(
state = bigImageSizeWidth,
bigImageSizeWidth.value,
cellSize.value.width,
onEnd = { showBigImageStatus.value = false })
animatorOfInt(
state = bigImageSizeHeight,
bigImageSizeHeight.value,
cellSize.value.height,
onEnd = { showBigImageStatus.value = false })
animatorOfFloat(state = dragAlpha, BigImageManager.dragEndAlpha, 0F, onEnd = {
dragStatus.value = false
alphaIncrease.value = false
})
},
onDrag = { _, dragAmount ->
val offsetX = bigImageOffsetX.value
val offsetY = bigImageOffsetY.value
// 上滑暂时不处理
if (offsetY < 0) {
return@BigImage
}
bigImageOffsetX.value = offsetX + dragAmount.x
bigImageOffsetY.value = offsetY + dragAmount.y
val scale = 1 - (offsetY + dragAmount.y) / (getScreenHeight(context) / 2)
if (scale > 0.5F) {
dragAlpha.value = scale
BigImageManager.dragEndAlpha = scale
bigImageSizeWidth.value = (getScreenWidth(context) * scale).toInt()
bigImageSizeHeight.value = (getScreenHeight(context) * scale).toInt()
}
}
)
}
}
@Composable
fun BigImage(
bigImageSizeWidth: Int,
bigImageSizeHeight: Int,
bigImageOffsetX: Float,
bigImageOffsetY: Float,
alpha: Float,
click: () -> Unit,
onDraStart: (Offset) -> Unit,
onDragEnd: () -> Unit,
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
) {
val context = LocalContext.current
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0F, 0F, 0F, alpha))
) {
Image(
painter = painterResource(id = iconList[BigImageManager.currentClickCellIndex]),
contentDescription = "",
modifier = Modifier
.size(
bigImageSizeWidth.toDp(context).dp,
bigImageSizeHeight.toDp(context).dp
)
.offset(bigImageOffsetX.toDp(context).dp, bigImageOffsetY.toDp(context).dp)
.clickable { click() }
.pointerInput(null) {
detectDragGestures(
onDragStart = onDraStart,
onDragEnd = onDragEnd,
onDrag = onDrag
)
}
)
}
}
fun animatorOfFloat(state: MutableState<Float>, vararg offset: Float, onEnd: () -> Unit = {}) {
val valueAnimator = ValueAnimator.ofFloat(*offset)
valueAnimator.duration = BigImageManager.animatorDuration
valueAnimator.addUpdateListener {
state.value = it.animatedValue as Float
}
valueAnimator.addListener(onEnd = { onEnd() })
valueAnimator.start()
}
fun animatorOfInt(state: MutableState<Int>, vararg offset: Int, onEnd: () -> Unit = {}) {
val valueAnimator = ValueAnimator.ofInt(*offset)
valueAnimator.duration = BigImageManager.animatorDuration
valueAnimator.addUpdateListener {
state.value = it.animatedValue as Int
}
valueAnimator.addListener(onEnd = { onEnd() })
valueAnimator.start()
}
fun getScreenWidth(context: Context): Int {
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager?
return wm?.defaultDisplay?.width ?: 0
}
fun getScreenHeight(context: Context): Int {
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager?
return wm?.defaultDisplay?.height ?: 0
}
fun Int.toDp(context: Context): Int {
val density = context.resources.displayMetrics.density
return (this / density + 0.5).toInt()
}
fun Float.toDp(context: Context): Int {
val density = context.resources.displayMetrics.density
return (this / density + 0.5).toInt()
}
关于我
我是Taonce**,如果觉得本文对你有所帮助,帮忙关注、赞或者收藏三连一下,谢谢😆😆~**