掘金迁移地址
最近在学习Jetpack Compose
,想着能否用Jetpack Compose
实现微信一些重要界面以及功能。好消息是已经实现了微信聊天界面相关功能以及交互,最近又搞了搞朋友圈的整体交互,网上看了看,关于compose动画相关知识比较少,所以打算通过最近学习的compose手势动画相关知识实现该功能。
本文主要讲述如何通过compose手势动画实现微信大图缩放、切换、预览功能。
废话不多说,先上动图
由于图片或gif上传限制为10M,所以此处无法查看预览图,如想查看预览动图,请移步开头的 掘金迁移地址
在实现上述功能时首先我们需要了解一下 Compose
为我们提供的一些手势动画。
使用 PointerInput Modifier
对于所有手势操作的处理都需要封装到这个 Modifier
中,我们知道 Modifier 时用来修饰 UI 组件的,所以将手势操作的处理封装在 Modifier 符合开发者设计直觉,这同时也做到了手势处理逻辑与 UI 视图的解耦,从而提高复用性。
Modifier 为我们提供了很多手势事件,比如:Transformer Modifier
、Draggable Modifier
、Rotation Modifier
以及滚动事件
、点击事件
等等都能看到PointerInput Modifier
的身影。因为这类上层的手势处理 Modifier 都是基于基础Modifier.pointInput()
来实现的,所以自定义手势必然要在这个 Modifier 中进行。
//Transformer Modifier
fun Modifier.transformable(
state: TransformableState,
lockRotationOnZoomPan: Boolean = false,
enabled: Boolean = true
) = composed(
factory = {
...
if (enabled) Modifier.pointerInput(Unit, block) else Modifier
},
)
//Draggable Modifier
internal fun Modifier.draggable(
stateFactory: @Composable () -> PointerAwareDraggableState,
canDrag: (PointerInputChange) -> Boolean,
orientation: Orientation,
enabled: Boolean = true,
interactionSource: MutableInteractionSource? = null,
startDragImmediately: () -> Boolean,
onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {},
onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {},
reverseDirection: Boolean = false
): Modifier = composed(
) {
...
Modifier.pointerInput(orientation, enabled, reverseDirection) {
...
}
}
通过 PointerInput Modifier
实现我们可以看出,我们所定义的自定义手势处理流程均发生在 PointerInputScope
中,suspend 关键字也告知我们自定义手势处理流程是发生在协程中。这其实是无可厚非的,在探索重组工作原理的过程中我们也经常能够看到协程的身影。
fun Modifier.pointerInput(
key1: Any?,
block: suspend PointerInputScope.() -> Unit
): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "pointerInput"
properties["key1"] = key1
properties["block"] = block
}
) {
val density = LocalDensity.current
val viewConfiguration = LocalViewConfiguration.current
remember(density) { SuspendingPointerInputFilter(viewConfiguration, density) }.apply {
val filter = this
LaunchedEffect(this, key1) {
filter.coroutineScope = this
block()
}
}
}
接下来我们重点看看 PointerInputScope
作用域,本文将着重解释一下部分我们用到的API,有想了解更全的可以移步大神文章:# 使用Jetpack Compose完成自定义手势处理。
点击类型基础 API
API介绍
API名称 | 作用 |
---|---|
detectTapGestures | 监听点击手势 |
我们知道,Clickable Modifier
是compose给我们提供的单击事件,
与 Clickable Modifier
不同的是,detectTapGestures
可以监听更多的点击事件。作为手机监听的基础 API,必然不会存在 Clickable Modifier
所拓展的涟漪效果。
detectTapGestures包括四个函数回调,分别为:
onDoubleTap (可选):双击时回调
onLongPress (可选):长按时回调
onPress (可选):按下时回调
onTap (可选):轻触时回调
suspend fun PointerInputScope.detectTapGestures(
onDoubleTap: ((Offset) -> Unit)? = null,
onLongPress: ((Offset) -> Unit)? = null,
onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
onTap: ((Offset) -> Unit)? = null
) = coroutineScope {
...
}
Tips
onPress 普通按下事件
onDoubleTap 前必定会先回调 2 次 Press
onLongPress 前必定会先回调 1 次 Press(时间长)
onTap 前必定会先回调 1 次 Press(时间短)
例子如下:
@Composable
fun TapGestureSample() {
var boxSize = 100.dp
Box(
Modifier.fillMaxSize()
) {
Text(text = "detectTapGestures\t监听点击手势", fontSize = 30.sp)
Text(
text = "",
fontSize = 16.sp,
modifier = Modifier.align(Alignment.BottomCenter)
)
Box(
Modifier
.size(boxSize)
.align(Alignment.Center)
.background(Color.Green)
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = { offset: Offset ->
//双击时回调
println("detectTapGestures obDoubleTap[双击时回调] offset:$offset")
},
onLongPress = { offset: Offset ->
//长按时回调
println("detectTapGestures onLongPress[长按时回调] offset:$offset")
},
onPress = { offset: Offset ->
//按下时回调
println("detectTapGestures onPress[按下时回调] offset:$offset")
},
onTap = { offset: Offset ->
//轻触时回调
println("detectTapGestures onTap[轻触时回调] offset:$offset")
}
)
}
)
}
}
将上述例子运行一下就明白了,此处就不录gif了。
手势检测
transformable 修饰符
接下来我们通过rememberTransformableState
检测用于平移、缩放和旋转的多点触控手势,我们可以使用transformable
修饰符。此修饰符本身不会转换元素,只会检测手势。
rememberTransformableState
内部是通过协程作用域来事实检测触控手势改变的。
例子如下:
@Composable
fun TransformableSample() {
// set up all transformation states
var scale by remember { mutableStateOf(1f) }
var rotation by remember { mutableStateOf(0f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
scale *= zoomChange
rotation += rotationChange
offset += offsetChange
}
Box(
Modifier
// apply other transformations like rotation and zoom
// on the pizza slice emoji
.graphicsLayer(
scaleX = scale,
scaleY = scale,
rotationZ = rotation,
translationX = offset.x,
translationY = offset.y
)
// add transformable to listen to multitouch transformation events
// after offset
.transformable(state = state)
.background(Color.Blue)
.fillMaxSize()
)
}
需要部分如下依赖
def accompanist_version = "0.24.3-alpha"
//compose的viewpager库
implementation "com.google.accompanist:accompanist-pager:$accompanist_version"
//指示器
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
//CoilImage是google推荐我们去使用的加载网络图片的开源库
implementation "com.google.accompanist:accompanist-coil:0.13.0"
功能实现
我们回到我们的项目中,
如上图所示,我们拆分一下该功能的实现。
1: 实现图片横向水平滚动
HorizontalPager
2: 底部的水平切换的指示器:
HorizontalPagerIndicator
3: 双击放大和缩小
4: 双指缩放
5: 图片如有放大,切换时放大图还原至原始大小
HorizontalPager
HorizontalPager
是其中一种布局,他将所有子项摆放在一条水平行上,允许用户在子项之间水平滑动。
[图片上传失败...(image-2367d7-1651050770637)]
/**
* 界面状态变更
*/
val pageState = rememberPagerState(initialPage = currentIndex)
HorizontalPager(
count = 图片数量,
state = pageState, //图片状态
contentPadding = PaddingValues(horizontal = 0.dp), //图片间的间距
modifier = Modifier.fillMaxSize()
) { page ->
println("ImageBrowserItem current page: $page")
ImageBrowserItem(images[page], page, this)
}
如果你想跳转到某一个特定页面,你可以在 CoroutineScope
中选择使用
rememberPagerState(initialPage = currentIndex)
或
pagerState.scrollToPage(index)
、 pagerState.animateScrollToPage(index)
选一即可。
HorizontalPagerIndicator
HorizontalPagerIndicator
用来标识 HorizontalPager
或 VerticalPager
的水平布局指示器,表示当前活动页面和使用 Shape 绘制的总页面。需要通过pageState绑定。
HorizontalPagerIndicator(
pagerState = pageState, //需要通过pageState绑定
activeColor = Color.White,
inactiveColor = WeComposeTheme.colors.onBackground,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(60.dp)
)
双击放大和缩小
对于我们要实现的双击事件来说,当双击时获取到已经缩放的scale
,,则将当前图片缩放至原始图的两倍,也就是双击放大两倍,再次双击还原到原图大小,并且偏移量Offset
恢复到中心点位置。如下部分代码:
...
Modifier.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
println("ImageBrowserItem detectTapGestures onDoubleTap offset: $it")
scale = if (scale <= 1f) {
2f
} else {
1f
}
offset = Offset.Zero
},
onTap = {
}
)
双指缩放
对于我们要实现的双指缩放来说,我们只需要处理缩放大小即可。当我们监听rememberTransformableState
变换时,scale
放大的5倍时就停止继续放大。
如下部分代码:
/**
* 监听手势状态变换
*/
var state =
rememberTransformableState(onTransformation = { zoomChange, panChange, rotationChange ->
scale = (zoomChange * scale).coerceAtLeast(1f)
scale = if (scale > 5f) {
5f
} else {
scale
}
println("ImageBrowserItem detectTapGestures rememberTransformableState scale: $scale")
})
...
Modifier
.transformable(state = state)
.graphicsLayer{ //布局缩放、旋转、移动变换
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
}
切换恢复图片大小
在 pager 组件的 content scope 中允许开发者很轻松地拿到 currentPage
与 currentPageOffset
引用。可以使用这些值来计算效果。我们提供了 calculateCurrentOffsetForPage()
扩展函数去计算某一个特定页面的偏移量。
例子如下:
@OptIn(ExperimentalPagerApi::class)
@Composable
private fun Sample() {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.horiz_pager_with_transition_title)) },
backgroundColor = MaterialTheme.colors.surface,
)
},
modifier = Modifier.fillMaxSize()
) { padding ->
HorizontalPagerWithOffsetTransition(Modifier.padding(padding))
}
}
@OptIn(ExperimentalPagerApi::class, ExperimentalCoilApi::class)
@Composable
fun HorizontalPagerWithOffsetTransition(modifier: Modifier = Modifier) {
HorizontalPager(
count = 10,
// Add 32.dp horizontal padding to 'center' the pages
contentPadding = PaddingValues(horizontal = 32.dp),
modifier = modifier.fillMaxSize()
) { page ->
Card(
Modifier
.graphicsLayer {
// Calculate the absolute offset for the current page from the
// scroll position. We use the absolute value which allows us to mirror
// any effects for both directions
val pageOffset = calculateCurrentOffsetForPage(page).absoluteValue
// We animate the scaleX + scaleY, between 85% and 100%
lerp(
start = 0.85f,
stop = 1f,
fraction = 1f - pageOffset.coerceIn(0f, 1f)
).also { scale ->
scaleX = scale
scaleY = scale
}
// We animate the alpha, between 50% and 100%
alpha = lerp(
start = 0.5f,
stop = 1f,
fraction = 1f - pageOffset.coerceIn(0f, 1f)
)
}
.fillMaxWidth()
.aspectRatio(1f)
) {
Box {
Image(
painter = rememberImagePainter(
data = rememberRandomSampleImageUrl(width = 600),
),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
ProfilePicture(
Modifier
.align(Alignment.BottomCenter)
.padding(16.dp)
// We add an offset lambda, to apply a light parallax effect
.offset {
// Calculate the offset for the current page from the
// scroll position
val pageOffset =
[email protected](page)
// Then use it as a multiplier to apply an offset
IntOffset(
x = (36.dp * pageOffset).roundToPx(),
y = 0
)
}
)
}
}
}
}
@OptIn(ExperimentalCoilApi::class)
@Composable
private fun ProfilePicture(modifier: Modifier = Modifier) {
Card(
modifier = modifier,
shape = CircleShape,
border = BorderStroke(4.dp, MaterialTheme.colors.surface)
) {
Image(
painter = rememberImagePainter(rememberRandomSampleImageUrl()),
contentDescription = null,
modifier = Modifier.size(72.dp),
)
}
}
我们可以在pager切换时通过calculateCurrentOffsetForPage(page).absoluteValue
拿到当前pager的偏移量,当pageOffet == 1.0f
时证明pager已切换至下一页,此时我们恢复scale = 1f
到原始大小即可,部分代码如下:
Modifier
.transformable(state = state)
.graphicsLayer{ //布局缩放、旋转、移动变换
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
val pageOffset = pagerScope.calculateCurrentOffsetForPage(page = page).absoluteValue
if (pageOffset == 1.0f) {
scale = 1.0f
}
println("ImageBrowserItem pagerScope calculateCurrentOffsetForPage pageOffset: $pageOffset")
}
到这里我们就整个实现了大图缩放、切换、预览功能,完整代码如下:
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.Surface
import androidx.compose.material.swipeable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.eegets.wechatcompose.ui.find.model.ImageBrowserModel
import com.eegets.wechatcompose.ui.theme.WeComposeTheme
import com.google.accompanist.coil.rememberCoilPainter
import com.google.accompanist.pager.*
import kotlinx.coroutines.InternalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlin.math.absoluteValue
/**
* 大图预览
*/
@OptIn(ExperimentalPagerApi::class, InternalCoroutinesApi::class)
@Composable
fun ImageBrowserScreen(images: List, selectImage: Image) {
var currentIndex = 0
images.forEachIndexed { index, image ->
if (image.url == selectImage.url) {
currentIndex = index
return@forEachIndexed
}
}
/**
* 界面状态变更
*/
val pageState = rememberPagerState(initialPage = currentIndex)
Box {
HorizontalPager(
count = images.size,
state = pageState,
contentPadding = PaddingValues(horizontal = 0.dp),
modifier = Modifier.fillMaxSize()
) { page ->
println("ImageBrowserItem current page: $page")
ImageBrowserItem(images[page], page, this)
}
HorizontalPagerIndicator(
pagerState = pageState,
activeColor = Color.White,
inactiveColor = WeComposeTheme.colors.onBackground,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(60.dp)
)
LaunchedEffect(pageState) {
snapshotFlow { pageState }.collect { pageState ->
println("ImageBrowserItem LaunchedEffect pageState currentPageOffset: $pageState.currentPageOffset")
}
}
}
}
@OptIn(ExperimentalPagerApi::class)
@Composable
fun ImageBrowserItem(image: Image, page: Int = 0, pagerScope: PagerScope) {
/**
* 缩放比例
*/
var scale by remember { mutableStateOf(1f) }
/**
* 偏移量
*/
var offset by remember { mutableStateOf(Offset.Zero) }
/**
* 监听手势状态变换
*/
var state =
rememberTransformableState(onTransformation = { zoomChange, panChange, rotationChange ->
scale = (zoomChange * scale).coerceAtLeast(1f)
scale = if (scale > 5f) {
5f
} else {
scale
}
println("ImageBrowserItem detectTapGestures rememberTransformableState scale: $scale")
})
Surface(
modifier = Modifier
.fillMaxSize(),
color = Color.Black,
) {
Image(
painter = rememberCoilPainter(
request = image.url
),
contentDescription = "",
contentScale = ContentScale.Fit,
modifier = Modifier
.transformable(state = state)
.graphicsLayer{ //布局缩放、旋转、移动变换
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
val pageOffset = pagerScope.calculateCurrentOffsetForPage(page = page).absoluteValue
if (pageOffset == 1.0f) {
scale = 1.0f
}
println("ImageBrowserItem pagerScope calculateCurrentOffsetForPage pageOffset: $pageOffset")
}
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
println("ImageBrowserItem detectTapGestures onDoubleTap offset: $it")
scale = if (scale <= 1f) {
2f
} else {
1f
}
offset = Offset.Zero
},
onTap = {
}
)
}
)
}
}
@Preview
@Composable
fun ImageBrowserScreenPreview() {
ImageBrowserScreen(
images = mutableListOf(),
selectImage = Image(url = "https://wx4.sinaimg.cn/orj360/001YqBPrly1h0ha9bxk27j63do52iu0y02.jpg")
)
}
调用
val images = mutableListOf(
Image("https://wx4.sinaimg.cn/orj360/001YqBPrly1h0ha93zif8j63gg56ob2c02.jpg"),
Image("https://wx3.sinaimg.cn/orj360/001YqBPrly1h0ha99r0vej652i3dox6r02.jpg"),
Image("https://wx2.sinaimg.cn/orj360/001YqBPrly1h0ha96rjbij63do52inpf02.jpg"),
Image("https://wx1.sinaimg.cn/orj360/001YqBPrly1h0ha9hek4cj652i3do1l202.jpg"),
Image("https://wx4.sinaimg.cn/orj360/001YqBPrly1h0ha9bxk27j63do52iu0y02.jpg"),
Image("https://wx3.sinaimg.cn/orj360/001YqBPrly1h0ha9e50phj652u3dwhdv02.jpg"),
Image("https://wx4.sinaimg.cn/orj360/001YqBPrly1h0ha91e0jxj63gg56o7wk02.jpg"),
Image("https://wx4.sinaimg.cn/orj360/001YqBPrly1h0ha9jfmouj635x4rgb2b02.jpg"),
Image("https://wx1.sinaimg.cn/orj360/001YqBPrly1h0ha9lzkk3j656o3gge8402.jpg")
)
val selectImage = Image("https://wx4.sinaimg.cn/orj360/001YqBPrly1h0ha93zif8j63gg56ob2c02.jpg")
ImageBrowserScreen(images = images, selectImage = selectImage)
该功能只是仿Jetpack Compose
微信的小部分功能,所以暂无源码,所后期会将仿微信全部代码上传至Github。
参考资料
# 使用Jetpack Compose完成自定义手势处理
# 多点触控:平移、缩放、旋转
# Jetpack Navigation Compose Animation