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

相关推荐
miao_zz1 小时前
基于react native的锚点
android·react native·react.js
安卓美女1 小时前
Android自定义View性能优化
android·性能优化
Dingdangr2 小时前
Android中的四大组件
android
mg6683 小时前
安卓玩机工具-----无需root权限 卸载 禁用 删除当前机型app应用 ADB玩机工具
android
安卓机器3 小时前
Android架构组件:MVVM模式的实战应用与数据绑定技巧
android·架构
码龄3年 审核中4 小时前
MySQL record 05 part
android·数据库·mysql
服装学院的IT男4 小时前
【Android 13源码分析】WindowContainer窗口层级-1-初识窗口层级树
android
技术无疆5 小时前
Hutool:Java开发者的瑞士军刀
android·java·开发语言·ide·spring boot·gradle·intellij idea
qluka8 小时前
Android 应用安装-提交阶段
android
ansondroider9 小时前
Android MediaPlayer + GLSurfaceView 播放视频
android·opengl·mediaplayer·glsurfaceview