1. 简介
采用SubsamplingScaleImageView作为图片的承载控件,该控件通过文件缓存的方式在不同的缩放比例下加载不同分辨率的图片,避免了图片过大导致的内存问题。并且实现了平移、放大缩小等操作。
在此基础上,添加了一些过渡的动画等,优化查看图片时的交互体验:
- 进入动画
- 退出动画
- 下拉回弹、退出
- 双击优化
- 缩放回弹
- 加载黑屏优化
2. 结构
- 采用
SubsamplingScaleImageView作为图片的承载控件。 - 将新增的动画逻辑、触控逻辑、加载逻辑放在
PhotoFragment中。 - 将于图片的显示并不强相关的逻辑放入到
PhotoActivity,比如指示器,长按等的操作。 - 采用
PhotoPageBuilder作为启动器,主要计算所需的数据,以及提高扩展。
3. 实现
3.1. 动画
3.1.1. 进入动画
看下慢放中的动画:
将这个动画拆分为三个部分:
- 图片大小的变化:点击图片的大小->屏幕大小
- 图片位置的变化:点击图片的位置->屏幕中心
- 背景颜色的变化:透明->黑
将其转换为代码(photo是SubsamplingScaleImageView控件,root是父容器):
photo.width:mInImgSize->root.widthphoto.translation:mInLocation->[0,0]root.backgroundColor:transparent->black
- 这里的大小通过photo的width和height去设置,而不通过控件本身的缩放去实现,原因是缩放必须等待图片加载完成,而加载会有默认的大小,也就是控件本身的大小,就会闪一下大的图片,然后设置的缩放才会生效。
private fun inAnimation() {
......
val scaleOa1 = ValueAnimator.ofFloat(0f, 1f).apply {
addUpdateListener {
var vaule = it.animatedValue as Float
val mWidth = (root.width - mInImgSize[0]) * vaule + mInImgSize[0]
val mHeight = (root.height - mInImgSize[1]) * vaule + mInImgSize[1]
photo.updateLayoutParams<FrameLayout.LayoutParams> {
width = mWidth.toInt()
height = mHeight.toInt()
}
vaule = 1 - vaule
photo.translationX = vaule * (mInLocation[0] - mInImgSize[0] / 2f)
photo.translationY = vaule * (mInLocation[1] - mInImgSize[1] / 2f)
}
}
val colorOa =
ValueAnimator.ofObject(ArgbEvaluator(), Color.TRANSPARENT, Color.BLACK)
colorOa.addUpdateListener {
root.setBackgroundColor(it.animatedValue as Int)
}
colorOa.duration = 150
scaleOa1.duration = 300
setIn1 = AnimatorSet().apply {
playTogether(scaleOa1, colorOa)
start()
}
}
3.1.2. 退出动画
退出动画的过程大致上是进入动画的反向:
photo.scale:目前的缩放比例-> 退出目标尺寸的缩放比例photo.translation:[0,0]->mOutImgLocationroot.background.alpha:目前透明度->0
- 采用scale而非width控制大小:图像在被放大的情况下退出。
- 不采用scale和width同时控制:动画呈非线性,边缘被切割的比较严重。
- scale不会改变photo的实际大小,所以退出与进入的位置动画并不完全对应,所以将坐标转换为图像的中心点
- 背景色的控制采用透明度,首先是数字先行变换,比较好控制;其次启动动画无法采用透明度,viewpager中多个fragment初始化给root设置初始透明度会有很严重的问题
private fun outAnimation() {
......
val xOa = ObjectAnimator.ofFloat(
photo,
"translationX",
0f,
mOutLocation[0].toFloat() - photo.width / 2
)
val yOa = ObjectAnimator.ofFloat(
photo,
"translationY",
0f,
mOutLocation[1].toFloat() - photo.height / 2
)
val colorOa = ObjectAnimator.ofInt(root.background, "alpha", root.background.alpha, 0)
colorOa.duration = 150
xOa.duration = 300
yOa.duration = 300
if (photo.isReady) {
photo.minScale = min(photo.scale, 1.0f * mOutImgSize[0] / mBitmapSize[0])
photo.animateScale(1.0f * mOutImgSize[0] / mBitmapSize[0])
?.withDuration(300)
?.withInterruptible(false)
?.start()
}
setOut = AnimatorSet().apply {
playTogether(colorOa, xOa, yOa)
start()
}
}
3.2. 下滑
3.2.1. 事件拦截
private fun initEvent() {
val gestureDetector = GestureDetector(
context,
object : GestureDetector.SimpleOnGestureListener() {
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent?,
distanceX: Float,
distanceY: Float
): Boolean {
return onDrag(distanceX, distanceY)
}
}
)
photo.setOnTouchListener { _, event ->
if (currentState == STATE_DRAG) {
currentState = STATE_NOTHING
onDragEnd()
return@setOnTouchListener true
}
return@setOnTouchListener gestureDetector.onTouchEvent(event)
}
}
3.2.2. 滚动过程
- 首先判断当前是否处于没有操作的状态以及是否是最小的缩放比例,以进行初始化,如果是横向滑动,将这个事件交给父控件处理;如果纵向并且向下,将状态更新为Drag。
- 根据dy计算大小以及透明度。
private fun onDrag(dx: Float, dy: Float): Boolean {
if (currentState == STATE_NOTHING && isLessThanScreenScale()) {
if (abs(dy) - abs(dx) < 0.5) {
val parent = photo.parent
parent?.requestDisallowInterceptTouchEvent(false)
return false
} else if (dy < 0 && abs(dy) - abs(dx) > 0.5) {
photo.minScale = 0.6f * mScreenScale
currentState = STATE_DRAG
}
}
if (currentState != STATE_DRAG || !isLessThanScreenScale()) {
return false
}
photo.scrollBy(dx.toInt(), dy.toInt()) // 移动图像
alpha += dy * 0.0005f
intAlpha += (dy * 0.3).toInt()
if (alpha > 1f) {
alpha = 1f
} else if (alpha < 0f) {
alpha = 0f
}
if (intAlpha < 50) {
intAlpha = 50
} else if (intAlpha > 255) {
intAlpha = 255
}
root.background.alpha = intAlpha // 更改透明度
if (alpha >= 0.6 && photo.isReady) {
photo.setScaleAndCenter(alpha * mScreenScale, photo.center)
}
return true
}
3.2.3. 结束后
- 根据背景透明度区分是退出还是回弹。
- 下滑改动了scroll属性,在退出动画中添加反向动画。
- 回滚动画将所有改变值复原即可。
private fun onDragEnd() {
if (photo.scale - mScreenScale > 10e-8f) {
return
}
if (root.background.alpha <= 150) {
outAnimation()
} else {
inAnimation2()
}
}
private fun outAnimation() {
......
val scrollX = photo.scrollX
val scrollY = photo.scrollY
val scrollOa = ValueAnimator.ofFloat(1f, 0f).apply {
addUpdateListener {
val vaule = it.animatedValue as Float
photo.scrollTo(
(vaule * scrollX).toInt(),
(vaule * scrollY).toInt()
)
}
}
......
}
/**
* 下滑回滚动画
*/
private fun inAnimation2() {
if (root.background.alpha == 255 && photo.scrollX == 0 && photo.scrollY == 0 && photo.scale - mScreenScale > 10e-8f) {
return
}
val alphaOa = ObjectAnimator.ofInt(root.background, "alpha", root.background.alpha, 255)
val scrollXOa = ObjectAnimator.ofInt(photo, "scrollX", photo.scrollX, 0)
val scrollYOa = ObjectAnimator.ofInt(photo, "scrollY", photo.scrollY, 0)
alphaOa.duration = 100
scrollXOa.duration = 200
scrollYOa.duration = 200
if (photo.isReady) {
photo.animateScale(mScreenScale)
?.withDuration(200)
?.withInterruptible(false)
?.start()
}
setIn2 = AnimatorSet().apply {
playTogether(alphaOa, scrollXOa, scrollYOa)
start()
}
}
3.3. 缩放回弹及双击
缩放的时候,可以缩放到比最小缩放比例小的尺寸,并在释放后回弹。回弹的过程与下滑的回弹相匹配,可以直接复用。所以实现这个功能只需要在开始缩放的时候将minScale重新赋值,在结束的时候调用下滑的回弹。
将缩放开始的触发绑定到两根手指触摸屏幕上,将缩放结束的触发绑定到所有手指离开屏幕:
photo.setOnTouchListener { v, event ->
if (event.action == MotionEvent.ACTION_UP) {
if (currentState == STATE_DRAG || currentState == STATE_SCALE) {
currentState = STATE_NOTHING
onDragEnd()
return@setOnTouchListener true
}
} else if (event.actionMasked == MotionEvent.ACTION_POINTER_DOWN){
if (currentState == STATE_NOTHING) {
currentState = STATE_SCALE
photo.isPanEnabled = true
photo.isZoomEnabled = true
photo.minScale = 0.8f * mScreenScale
}
}
return@setOnTouchListener gestureDetector.onTouchEvent(event)
}
这会有一个问题,将minScale赋值之后,双击的缩放比例也会对应变化,所以将双击也拦截下来:
这里有另一种做法,就是在回弹之后将minScale重置为默认值,但是需要重置的情况会特别多,所以不再去限制minScale而是去拦截处理双击
......
val gestureDetector = GestureDetector(
context,
object : GestureDetector.SimpleOnGestureListener() {
......
override fun onDoubleTap(e: MotionEvent?): Boolean {
if (photo.scale > 0.9f * mDoubleTapScale && photo.isReady) {
photo.animateScale(mScreenScale)
?.withDuration(200)
?.withInterruptible(false)
?.start()
return true
}
return super.onDoubleTap(e)
}
}
)
3.4. 加载机制
图片加载需要等待网络,时间上不确定,其次,网络加载完成后,加载到控件里面也需要一定时间,这个时间受图片大小以及手机性能影响。整体加载流程:
- 从本地取到所点击的低清图片的缓存(默认了外部图片加载已经完成),加载进
photo。
private fun loadPreviewImage() {
Glide.with(photo)
.asFile()
.load(imageUrl)
.into(object : CustomTarget<File?>() {
......
override fun onResourceReady(
resource: File,
transition: Transition<in File?>?
) {
......
showPreviewImage()
photo.setImage(
ImageSource.uri(Uri.fromFile(resource))
)
}
})
}
- 准备工作完成后(准备完成需要同时满足两个条件:1. 低清图片加载完成。2. 动画播放结束),开始下载高清图片。
//图片加载状态监听
photo.setOnImageEventListener(object : SubsamplingScaleImageView.OnImageEventListener {
override fun onImageLoaded() {
if (isReadyLoadingBig) {
loadBigImage()
} else {
isReadyLoadingBig = true
}
}
})
//进入动画监听
setIn1.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
if (isReadyLoadingBig) {
loadBigImage()
} else {
isReadyLoadingBig = true
}
}
override fun onAnimationCancel(animation: Animator?) {
if (isReadyLoadingBig) {
loadBigImage()
} else {
isReadyLoadingBig = true
}
}
})
- 下载完成后,将低清的图片加载进一个
ImageView,对photo进行遮挡,并且photo开始载入图片。 - 延迟200ms将
ImageView清除。
private fun loadBigImage() {
Glide.with(photo)
.asFile()
.load(bigImageUrl)
.into(object : CustomTarget<File?>() {
......
override fun onResourceReady(
resource: File,
transition: Transition<in File?>?
) {
......
showPreviewImage()
photo.setImage(
ImageSource.uri(Uri.fromFile(resource))
)
}
})
}
private fun showPreviewImage() {
if (currentState != STATE_NOTHING){
previewBitmap = null
return
}
ivPreview.setImageBitmap(previewBitmap)
ivPreview.isVisible = true
handler.postDelayed({
ivPreview.setImageDrawable(null)
ivPreview.isVisible = false
previewBitmap = null
currentState = STATE_NOTHING
}, 200)
}
3.5. 状态隔离
前面很多地方已经使用到了状态,最后确定下来的状态会有这五个,在为了交互体验以及安全的前提下,只有空状态下,才能进行状态的变化,以及触摸事件的处理。一个最直观的情况就是不能在动画的播放过程中不能进行点击,下滑等各种操作。
companion object {
private const val STATE_NOTHING = -1 // 空状态,没有操作
private const val STATE_DRAG = -2 // 下滑状态
private const val STATE_SCALE = -3 // 操作图片状态, 标识为落下过双指并且没有全部离开屏幕
private const val STATE_ANIMATE = -4 // 动画播放状态
private const val STATE_PREVIEW_LOAD = -5 // ImageView显示图片的那段时间,200ms
}
首先是在状态变化时,对不同情况进行不同程度的锁
private var currentState = STATE_NOTHING
set(value) {
when (value) {
STATE_DRAG -> {
/**
* 下滑
*/
if (field != STATE_DRAG) {
onAnimatorListener?.onStart()
}
photo.isZoomEnabled = false
}
STATE_ANIMATE -> {
/**
* 动画
*/
if (field != STATE_ANIMATE) {
onAnimatorListener?.onStart()
}
photo.isPanEnabled = false
photo.isZoomEnabled = false
}
STATE_NOTHING -> {
/**
* 空
*/
if (field == STATE_ANIMATE) {
onAnimatorListener?.onEnd()
}
alpha = 1f
intAlpha = 255
photo.isPanEnabled = true
photo.isZoomEnabled = true
}
STATE_SCALE -> {
/**
* 操作图片
*/
photo.isPanEnabled = true
photo.isZoomEnabled = true
photo.minScale = 0.8f * mScreenScale
}
STATE_PREVIEW_LOAD -> {
photo.isPanEnabled = false
photo.isZoomEnabled = false
}
}
field = value
}
在状态变换时或者操作时进行判断,是否处于空状态
val gestureDetector = GestureDetector(
context,
object : GestureDetector.SimpleOnGestureListener() {
......
override fun onLongPress(e: MotionEvent?) {
if (currentState == STATE_NOTHING) {
longClickListener?.onLongClick(photo, imageUrl)
}
}
override fun onSingleTapConfirmed(e: MotionEvent?): Boolean {
if (currentState == STATE_NOTHING) {
exit()
return true
}
return false
}
}
)
4. 更多的思考
- 如何进行扩展
目前的结构,PhotoFragment存放着交互的逻辑,而交互的数据(起始位置、结束位置、配合显示的控件等等)都存放在Activity中,后续根据不同的场景可设置更多的Activity,在Activity中控制不同的数据以实现不同的交互方式。目前,有很多套的查看图片的组件在项目中运行,后续逐步考虑全部统一接入。
- 如何将翻页同步
在查看图片的时候,先翻动再退出,如果退出的时候可以通知到外面已经翻页了,体验会比较好。最直接的方法就是通过activity退出的时候回传,但这需要在外面的进行接收,需要在外面更改逻辑,不够优雅。
后续思考是否可以通过builder传入一个回调,通过liveData将数据传递出去,这样会有一个问题,不知道什么时候将这个liveData释放掉。
- 流式图片列表的适配
这种情况简单说就是多张图片的动画位置并不一致,处理起来并不困难,在builder中计算屏幕中显示着的图片的位置信息,将不在屏幕中的图片设置一个默认的位置,通过Activity给不同的fragment传入不同的位置信息即可。
- 加载图片时遮挡图的显示时长
目前是直接设置了一个固定值200ms,但受到图片大小以及设备的影响,200ms图片有可能并不能完成载入,还是会出现闪黑屏的情况,这个时长应该如何去调整。
5. 写在最后
把一杯水拿起来喝掉十分简单,但把杯子在桌子上移动一厘米是困难的。把这个不算复杂的功能尽量去做到最好,不断的去考虑在这短短几百毫秒的时间里,如何让整个过程更加的舒服,更加的流畅,印象最深的就是如何在图片放大的情况下做退出的动画,前前后后试了好几天。但做出来来的东西大多数人甚至感知不到,整个过程是极吃力不讨好的。好在结果总算不差,希望自己能将这份固执坚持下去,但行好事莫问前程。