前段时间在自己的项目中用到了全屏查看大图的功能,找了一圈没找到特别合适的库,有一些用起来还行但最后发现放在 Pager 中会有滑动冲突的问题,于是打算自己写一个,最终实现效果很好,基本支持了所有的手势操作,并且不依赖任何图片框架。
现在用了小半年了没发现有啥问题,趁着最近时间空了点把查看大图的功能抽一部分出来开源,希望可以帮到大家,目前已经支持如下功能:
- 双击放大/缩小
- 双指缩放
- 下滑关闭
- 点击关闭
- 放大拖动查看
- 嵌入 Pager
使用 ImageViewer
首先添加依赖:
scss
implementation("com.github.0xZhangKe:ImageViewer:1.0.3")
依赖包的仓库在 JitPack,所以可能还需要配置添加 JitPack 仓库,已经有了的直接忽略。
scss
repositories {
maven { setUrl("<https://jitpack.io>") }
}
仓库地址:
最简单的使用方式如下:
ini
ImageViewer {
Image(
painter = painterResource(R.drawable.vertical_demo_image),
contentDescription = "Sample Image",
contentScale = ContentScale.FillBounds,
)
}
ImageViewer 就是本项目唯一提供的 Composable 函数,它是一个图片容器,它会支持上述所有的手势操作。入参包含一个类型为 Composable 的函数 content
,考虑到不同项目中的图片框架都不同,所以这里不依赖任何图片框架,只需要在入参的 content 函数中编写图片代码即可。
比如上面使用的是 Image,也可以换成任何其他的框架,例如 Coil 的 AsyncImage 等。
另外上面的 Image 一般不需要设置大小,内部会按照规则来动态调整,设置了大小之后可能会发生一些奇怪的变化,如果希望控制大小可以给 ImageViewer 设置。contentScale
最好也设置成 FillBounds
。
ImageViewer 接受传入 ImageViewerState
来控制一些行为。
ini
val imageViewerState = rememberImageViewerState(
minimumScale = 1.0F,
maximumScale = 3F,
onDragDismissRequest = {
finish()
},
)
其中的 onDragDismissRequest 是图片下滑时自动退出机制,默认为 null,表示不需要启用此功能,下滑图片也并不会退出,而如果给 onDragDismissRequest 赋值之后,下滑图片超过阈值时会调用改函数。
还需要注意的是,ImageViewer 的 content
只能包含一个 Composable
,超过一个会得到一个 crash。
以上就是 ImageViewer 的使用介绍,非常简单好用,下面大概的介绍一下实现。
实现
ImageViewer 通过两层自定义 Layout 实现,其本身也是个容器。外层的 Layout 用于监听手势,内层的 Layout 用于控制大小和偏移。
手势监听
手势监听共有三部分,第一部分是监听单击和双击事件:
scss
pointerInput(state) {
detectTapGestures(
onDoubleTap = {
if (state.exceed) {
coroutineScope.launch {
state.animateToStandard()
}
} else {
coroutineScope.launch {
state.animateToBig(it)
}
}
},
onTap = {
state.startDismiss()
},
)
}
这个是比较简单的,单击直接结束,双击放大或者缩小。
第二部分用来监听拖动事件以及计算拖动速度实现松手动画。
kotlin
private fun Modifier.draggableInfinity(
enabled: Boolean,
onDrag: (dragAmount: Offset) -> Unit,
onDragStopped: (velocity: Velocity) -> Unit,
): Modifier {
val velocityTracker = VelocityTracker()
return Modifier.pointerInput(enabled) {
if (enabled) {
detectDragGestures(
onDrag = { change, dragAmount ->
velocityTracker.addPointerInputChange(change)
onDrag(dragAmount)
},
onDragEnd = {
val velocity = velocityTracker.calculateVelocity()
onDragStopped(velocity)
},
onDragCancel = {
val velocity = velocityTracker.calculateVelocity()
onDragStopped(velocity)
},
)
} else {
detectVerticalDragGestures(
onVerticalDrag = { change, dragAmount ->
velocityTracker.addPointerInputChange(change)
onDrag(Offset(x = 0F, y = dragAmount))
},
onDragEnd = {
val velocity = velocityTracker.calculateVelocity()
onDragStopped(velocity)
},
onDragCancel = {
val velocity = velocityTracker.calculateVelocity()
onDragStopped(velocity)
},
)
}
} then this
}
这里为了兼容横向 Pager 的事件冲突问题,通过图片状态设置监听不同的 drag 事件,当图片处于标准大小状态,尚未缩放时监听垂直方向的拖动事件,此时可以上下拖动,且不会影响 Pager 横向的滑动事件。当图片处于缩放状态时监听所有方向的拖动事件,此时可以任意方向滑动图片查看细节。
然后拖动时通过 VelocityTracker
计算滑动速度来设置松手后自动滑动的动画。
第三部分的事件是双指缩放事件:
scss
pointerInput(state) {
detectZoom { centroid, zoom ->
state.zoom(centroid, zoom)
}
}
detectZoom 是我自己写的函数,Compose 本身的 detectTransformGestures
虽然也可以监听双指缩放事件,但是会消费事件,也会导致嵌套滑动冲突,所以我自己写一个只用来监听缩放手势的函数。
kotlin
internal suspend fun PointerInputScope.detectZoom(
onGesture: (centroid: Offset, zoom: Float) -> Unit
) {
awaitEachGesture {
var zoom = 1f
var pastTouchSlop = false
val touchSlop = viewConfiguration.touchSlop
awaitFirstDown(requireUnconsumed = false)
do {
val event = awaitPointerEvent()
val canceled = event.changes.any { it.isConsumed }
if (!canceled) {
val zoomChange = event.calculateZoom()
if (!pastTouchSlop) {
zoom *= zoomChange
val centroidSize = event.calculateCentroidSize(useCurrent = false)
val zoomMotion = abs(1 - zoom) * centroidSize
if (zoomMotion > touchSlop) {
pastTouchSlop = true
}
}
if (pastTouchSlop) {
val centroid = event.calculateCentroid(useCurrent = false)
if (zoomChange != 1f) {
onGesture(centroid, zoomChange)
}
event.changes.forEach {
if (it.positionChanged()) {
it.consume()
}
}
}
}
} while (!canceled && event.changes.any { it.pressed })
}
}
使用这个函数可以把 zoom 的比例和中心点返回出去,并且不会导致滑动冲突。
大小和位移
ImageViewer 的所有缩放、偏移和动画都是通过控制第二个 Layout 的大小和 offset
来实现的。
首先第一个 Layout 在布局阶段并不会限制第二层的 Layout 大小,会给他一个无限的约束,使其能够按照自身的需要得到足够的大小,以此支持数倍的放大。
scss
{ measurables, constraints ->
val placeable = measurables.first().measure(infinityConstraints)
layout(constraints.maxWidth, constraints.maxHeight) {
placeable.placeRelative(0, 0)
}
}
可以看到上面的 measure
阶段给的约束是 infinityConstraints
。
其次第二个 Layout 布局阶段会通过给出固定的值获取到内嵌的 Image 实际宽高比。
scss
{ measurables, constraints ->
if (measurables.size > 1) {
throw IllegalStateException("ImageViewer is only allowed to have one children!")
}
val firstMeasurable = measurables.first()
val placeable = firstMeasurable.measure(constraints)
val minWidth = firstMeasurable.minIntrinsicWidth(100)
val minHeight = firstMeasurable.minIntrinsicHeight(100)
if (minWidth > 0 && minHeight > 0) {
state.setImageAspectRatio(minWidth / minHeight.toFloat())
}
layout(constraints.maxWidth, constraints.maxHeight) {
placeable.placeRelative(0, 0)
}
}
这里通过 minIntrinsicWidth/minIntrinsicHeight
可以获取到图片的实际宽高比,因为直接 measure 出来的结果是不准确的,永远会是第二个 Layout 的大小。
然后我们只需要将 ImageViewerState
中的大小和位移应用在第二个 Layout 上即可。
ini
modifier = Modifier
.offset(
x = state.currentOffsetXPixel.pxToDp(density),
y = state.currentOffsetYPixel.pxToDp(density),
)
.width(state.currentWidthPixel.pxToDp(density))
.height(state.currentHeightPixel.pxToDp(density)),
因为 ImageViewerState
中的这几个字段都是 State 类型,所以天然的支持了动画,我们只需要在 ImageViewerState
内部使用 *AnimationState
* 修改这几个数值,UI 部分就会跟着动起来。
可以看到上面基本上都是监听手势和布局信息给到 ImageViewerState,实际上更多的计算和处理逻辑都在 ImageViewerState 内,而其内部总体上也都是在根据 UI 层的信息修改下面这几个数值:
kotlin
private var _currentWidthPixel = mutableFloatStateOf(0F)
private var _currentHeightPixel = mutableFloatStateOf(0F)
private var _currentOffsetXPixel = mutableFloatStateOf(0F)
private var _currentOffsetYPixel = mutableFloatStateOf(0F)
internal val currentWidthPixel: Float by _currentWidthPixel
internal val currentHeightPixel: Float by _currentHeightPixel
internal val currentOffsetXPixel: Float by _currentOffsetXPixel
internal val currentOffsetYPixel: Float by _currentOffsetYPixel
具体内部的细节这里就不介绍了,可以直接去看代码。
以上就是本片文章的所有内容了,感谢大家的阅读,感兴趣或者有需要的欢迎去 GitHub 点个 Star。