开源一个 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...

相关推荐
魔芋红茶4 小时前
MySQL 从入门到精通 16:主从复制
android·mysql·adb
2501_915106326 小时前
移动端网页调试实战,iOS WebKit Debug Proxy 的应用与替代方案
android·前端·ios·小程序·uni-app·iphone·webkit
柯南二号7 小时前
【大前端】React Native 调用 Android、iOS 原生能力封装
android·前端·react native
可乐+冰07 小时前
Android 编写高斯模糊功能
android·人工智能·opencv
xzkyd outpaper10 小时前
Android中APK包含哪些内容?
android
蹦极的考拉10 小时前
网站日志里面老是出现{pboot:if((\x22file_put_co\x22.\x22ntents\x22)(\x22temp.php\x22.....
android·开发语言·php
安卓开发者11 小时前
Android Glide最佳实践:高效图片加载完全指南
android·glide
菠萝加点糖12 小时前
Android 使用MediaMuxer+MediaCodec编码MP4视频
android·音视频·编码
雨白13 小时前
手写 MaterialEditText:实现浮动标签(Floating Label)效果
android
CYRUS_STUDIO14 小时前
使用 readelf 分析 so 文件:ELF 结构解析全攻略
android·linux·逆向