微信查看图片实现--巨图、动画、手势、回弹

1. 简介

采用SubsamplingScaleImageView做为图片的承载控件,该控件经过文件缓存的方式在不一样的缩放比例下加载不一样分辨率的图片,避免了图片过大致使的内存问题。而且实现了平移、放大缩小等操做。git

在此基础上,添加了一些过渡的动画等,优化查看图片时的交互体验:github

  • 进入动画
  • 退出动画
  • 下拉回弹、退出
  • 双击优化
  • 缩放回弹
  • 加载黑屏优化
查看图片

2. 结构

  • 采用SubsamplingScaleImageView做为图片的承载控件。
  • 将新增的动画逻辑、触控逻辑、加载逻辑放在PhotoFragment中。
  • 将于图片的显示并不强相关的逻辑放入到PhotoActivity,好比指示器,长按等的操做。
  • 采用PhotoPageBuilder做为启动器,主要计算所需的数据,以及提升扩展。
查看图片1

3. 实现

3.1. 动画

3.1.1. 进入动画

看下慢放中的动画:缓存

查看图片-进入动画

将这个动画拆分为三个部分:安全

  1. 图片大小的变化:点击图片的大小->屏幕大小
  2. 图片位置的变化:点击图片的位置->屏幕中心
  3. 背景颜色的变化:透明->黑

将其转换为代码(photoSubsamplingScaleImageView控件,root是父容器):markdown

  1. photo.widthmInImgSize -> root.width
  2. photo.translationmInLocation->[0,0]
  3. root.backgroundColortransparent->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. 退出动画

退出动画的过程大体上是进入动画的反向:网络

  1. photo.scale:目前的缩放比例-> 退出目标尺寸的缩放比例
  2. photo.translation[0,0]->mOutImgLocation
  3. root.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从新赋值,在结束的时候调用下滑的回弹。app

将缩放开始的触发绑定到两根手指触摸屏幕上,将缩放结束的触发绑定到全部手指离开屏幕:ide

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赋值以后,双击的缩放比例也会对应变化,因此将双击也拦截下来:oop

这里有另外一种作法,就是在回弹以后将minScale重置为默认值,可是须要重置的状况会特别多,因此再也不去限制minScale而是去拦截处理双击post

......
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. 加载机制

图片加载须要等待网络,时间上不肯定,其次,网络加载完成后,加载到控件里面也须要必定时间,这个时间受图片大小以及手机性能影响。总体加载流程:

  1. 从本地取到所点击的低清图片的缓存(默认了外部图片加载已经完成),加载进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. 准备工做完成后(准备完成须要同时知足两个条件: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
    }
  }
})
复制代码
  1. 下载完成后,将低清的图片加载进一个ImageView,对photo进行遮挡,而且photo开始载入图片。
  2. 延迟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. 写在最后

把一杯水拿起来喝掉十分简单,但把杯子在桌子上移动一厘米是困难的。把这个不算复杂的功能尽可能去作到最好,不断的去考虑在这短短几百毫秒的时间里,如何让整个过程更加的舒服,更加的流畅,印象最深的就是如何在图片放大的状况下作退出的动画,前先后后试了好几天。但作出来来的东西大多数人甚至感知不到,整个过程是极吃力不讨好的。好在结果总算不差,但愿本身能将这份执拗坚持下去,但行好事莫问前程。

相关文章
相关标签/搜索