微信查看圖片實現--巨圖、動畫、手勢、回彈

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. 寫在最後

把一杯水拿起來喝掉十分簡單,但把杯子在桌子上移動一釐米是困難的。把這個不算複雜的功能儘可能去作到最好,不斷的去考慮在這短短几百毫秒的時間裏,如何讓整個過程更加的舒服,更加的流暢,印象最深的就是如何在圖片放大的狀況下作退出的動畫,前先後後試了好幾天。但作出來來的東西大多數人甚至感知不到,整個過程是極吃力不討好的。好在結果總算不差,但願本身能將這份執拗堅持下去,但行好事莫問前程。

相關文章
相關標籤/搜索