开源一个 Compose 图片查看框架ImageViewer,支持多种手势。

前段时间在自己的项目中用到了全屏查看大图的功能,找了一圈没找到特别合适的库,有一些用起来还行但最后发现放在 Pager 中会有滑动冲突的问题,于是打算自己写一个,最终实现效果很好,基本支持了所有的手势操作,并且不依赖任何图片框架。

现在用了小半年了没发现有啥问题,趁着最近时间空了点把查看大图的功能抽一部分出来开源,希望可以帮到大家,目前已经支持如下功能:

  • 双击放大/缩小
  • 双指缩放
  • 下滑关闭
  • 点击关闭
  • 放大拖动查看
  • 嵌入 Pager

使用 ImageViewer

首先添加依赖:

scss 复制代码
implementation("com.github.0xZhangKe:ImageViewer:1.0.3")

依赖包的仓库在 JitPack,所以可能还需要配置添加 JitPack 仓库,已经有了的直接忽略。

scss 复制代码
repositories {
	maven { setUrl("<https://jitpack.io>") }
}

仓库地址:

github.com/0xZhangKe/I...

最简单的使用方式如下:

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。

github.com/0xZhangKe/I...

相关推荐
阿巴斯甜1 小时前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker2 小时前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq95273 小时前
Andorid Google 登录接入文档
android
黄林晴4 小时前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab16 小时前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿19 小时前
Android MediaPlayer 笔记
android
Jony_20 小时前
Android 启动优化方案
android
阿巴斯甜20 小时前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇20 小时前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_1 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android