流畅的Web移动端图片预览(PhotoPreview)

4,049 阅读7分钟

愉快的一天 从少写两个bug开始

之前在移动端做了一个的样片征集的活动,其中最主要的就是查看大图了。因为是移动端的活动。在产品的要求下,需要支持手势相关功能还有类似ins的缩放功能。先上Github搜索一番PhotoPreview、PhotoSwiper, 并没有完全符合功能的轮子,较为符合的就有PhotoSwiper但是其中有部分手势是不同的。因为只需要支持移动端的项目,在一番考虑之后决定重新写一个比现有改造来的更快。

🤔为什么要做这个组件(没有匹配的轮子,生产轮子就是第一生产力)
😏为什么没有我想要的功能(看完这篇分享 你也能(写/改)一个自己想要的轮子了)

先来看一下项目中最终的效果(PC使用移动端调试查看) 链接

主要支持图片打开关闭过渡上滑滑动关闭图片左右滑动翻转图片双指放大图片双指放大后单指移动图片放大完后自动回弹

当然在UI上是因为项目需求改造这这个样式,普通情况下的样式都是这样的。

代码分析

基础局部&打开关闭动画

首先先从整体的dom结构开始

<div class="preview">
    <div class="preview-card">
        <div class="preview-image"><img></div>
    </div>
    <div class="preview-mask"></div> 
</div>

.preview-card主要是放大图,.preview-mask主要是做背景遮罩。在一般使用的情况下默认情况是.preview默认是display:none ,为了做背景色渐变就把mask单独出一个dom,至于display&transtion的问题可以去看CSS3 transition doesn't work with display property,在.preview display:block 之后延迟给.preview-mask设置transtion即可。

  // 打开大图
  async open (config) {
    ...
    this.previewDom.classList.add('is-show')
    await this.sleep(50)
    this.previewDom.classList.add('is-fade-in-mask')
    ...
  }
  // css 
  .is-show{
    display: block;
    .preview-card {
        transition: transform 400ms cubic-bezier(0.4, 0, 0.22, 1);
    }
  }
  .is-fade-in-mask {
     .preview-mask {
        opacity: 1;
     }
  }

在点击mask区域的时候要给到关闭动画,原理和打开一样

  async close () {
    ...
    this.previewDom.classList.remove('is-fade-in-mask')
    await this.sleep(500)
    this.previewDom.classList.remove('is-show')
    ...
  }

接着就是打开大图时图片的变化,完整图解如图1,首先将红色框缩小至蓝色方框的位置,再从蓝色方块的位置,放大至蓝色方框的位置就完成全部的变化。

  • 蓝块 : list中img的位置
  • 蓝框 : preview中img在变换完后居中的位置
  • 红框 : preview中img初始dom的位置

其中要获取到小图(蓝块的位置),通过 getBoundingClientRecth()获取到从红框缩小至蓝块的信息this.thumb,在通过计算获取到蓝框的距离顶部的高度this.originY,将缩小至蓝块位置的红框再变回蓝框位置。

  getDomRect (target) {
    const rect = target.getBoundingClientRect()
    const zoom = rect.width / document.documentElement.clientWidth
    this.originY = (document.documentElement.clientHeight - rect.height/zoom) / 2
    this.thumb = { 
      x: rect.left, 
      y: rect.topw: rect.width, 
      h: rect.height, 
      zoom 
    }
  }

注意:默认的transform-origin是center,这里的计算都是根据transform-origin: left top进行

结合上面的mask的动画就变为

  async open (config) {
    ...
    this.previewDom.classList.add('is-show')
    this.setCardTransform({ x: this.thumb.x, y: this.thumb.y, zoom: this.thumb.zoom })
    await this.sleep(50)
    this.previewDom.classList.add('is-fade-in-mask')
    this.setCardTransform({ x: 0, y: this.originY, zoom: 1 })
    document.querySelector('.preview-mask').addEventListener('click',this.close)
    ...
  }
  async close () {
    ...
    this.previewDom.classList.remove('is-fade-in-mask')
    this.setCardTransform({ x: this.thumb.x, y: this.thumb.y, zoom: this.thumb.zoom })
    await this.sleep(500)
    this.previewDom.classList.remove('is-show')
    this.previewMaskDom.removeEventListener('click', this.close)
    document.querySelector('.preview-mask').removeEventListener('click',this.close)
    ...
  }
  setCardTransform (config) {
    this.previewCardDom.style.transform = (
      `translate3d(${config.x}px, ${config.y}px, 0px)` +
      `scale(${config.zoom})`
    )
  }

这样基础的打开和关闭动画就已经做好了。接下来是手势部分

手势逻辑判断

在手势库上选择了使用alloyfinger,其中要同时实现单指和双指功能,就需对此进行区分。先从单指部分开始

  • 在touchStart的时候要记录下第一个手指的初始位置,
  • 在touchMove的时候要判断此时运动的方向
  • 在touchEnd的时候要清除数据,并调用close方法进行关闭动画
// 单指部分功能逻辑判断
this.finger = new AlloyFinger(this.previewDom, {
    touchStart: (evt) => {
        if (currentNum === 1) {
          this.fingerNum = 1 // 记录手指数量
          this.startX = evt.touches[0].pageX // 手指初始X位置
          this.startY = evt.touches[0].pageY // 手指初始Y位置      
        }
        ...
    },
    touchMove:(evt) => {
        const currentNum = evt.touches.length
        ...
        if (this.fingerNum === 1 && currentNum === 1) {
          evt.preventDefault()
          this.isMoving = true
          this.singlePointMove(evt) 
        }
        ...
    },
    touchEnd:() => {
        ...
        if (this.fingerNum === 1) {
          if (this.isMoving) {
            this.singlePointEnd(evt)
            this.isMoving = false
            this.type = ''
            return
          }
        }
        this.fingerNum--
    }
})

上下滑动

singlePointMove (evt) {
    const currentX = evt.changedTouches[0].pageX // 当前手指位置X坐标
    const currentY = evt.changedTouches[0].pageY // 当前手指位置Y坐标
    const moveX = currentX - this.startX  // X轴方向移动距离
    const moveY = currentY - this.startY  // Y轴方向移动距离
    // 左右
    if (this.type === 'turn' || (!this.type && Math.abs(moveX) > Math.abs(moveY))) {
      this.type = 'turn'
      ...
    }
    // 上下
    if (this.type === 'leave' || (!this.type && Math.abs(moveY) > Math.abs(moveX) )) {
      this.type = 'leave'
      const screenheight = this.previewDom.offsetHeight / 2
      const opacity = Math.abs(moveY) / screenheight
      
      this.previewDom.classList.add('is-moving')
      this.previewCardDom.style.transform =  (`translate3d(0px, ${this.originY + moveY}px, 0px) scale(1)`)
      this.previewMaskDom.style.opacity = 1 - opacity   
    }
  }

加了个this.type 是为了确保动作的连贯性,执行上下滑动的时候不能左右滑动,还有就是当拖动回到居中的范围内时也能连贯滑动。如不加则会有很明显的抖动的情况发生。

拖动时背景颜色也是需要进行透明度的变化的,因为图片是居中的,所以我们只需要计算半屏高度,当手指从屏幕中间拖动到屏幕底部就刚好是opacity从1->0变化。

在结束调用之前的close方法就完整连接整个动画流程。

async singlePointEnd (evt) {
    const currentY = evt.changedTouches[0].pageY
    if(this.type === 'turn') {
        ...
    }
    if ((currentY - this.startY > this.leaveHight || currentY - this.startY < -this.leaveHight)) {
      this.previewDom.classList.remove('is-moving')
      this.previewCardDom.style.transform =  (`translate3d(${this.thumb.x}px, ${this.thumb.y}px, 0px) scale(${this.thumb.zoom})`this.previewDom.classList.remove('is-fade-in-mask')
      this.previewMaskDom.style.opacity = 0
      await this.sleep(500)
      this.previewDom.classList.remove('is-show')
      this.previewMaskDom.removeEventListener('click', this.close)
      this.previewMaskDom.style.opacity = null
    } else {
      this.previewDom.classList.remove('is-moving')
      this.previewCardDom.style.transform =  (`translate3d(0px, ${this.originY}px, 0px) scale(1)`)
      this.previewMaskDom.style.opacity = 1
    }
  }

当松开手指的时候还需要进行移动高度的判断,如果移动的高度不足,则需要给图片做回弹到居中位置的动画,目前this.leaveHight是固定200px,当然也可以根据自己的需要进行修改。

左右滑动

singlePointMove (evt) {
    const currentX = evt.changedTouches[0].pageX // 当前手指位置X坐标
    const currentY = evt.changedTouches[0].pageY // 当前手指位置Y坐标
    const moveX = currentX - this.startX  // X轴方向移动距离
    const moveY = currentY - this.startY  // Y轴方向移动距离
    // 左右
    if (this.type === 'turn' || (!this.type && Math.abs(moveX) > Math.abs(moveY))) {
      this.type = 'turn'
       moveX > 0 ?  this.$onEvent.left() : this.$onEvent.right()
    }
    // 上下
    if (this.type === 'leave' || (!this.type && Math.abs(moveY) > Math.abs(moveX) )) {
      this.type = 'leave'
      ...
  }

判断左右滑动只需要判断moveX是否大于0就可以了。考虑到左右手势的可能在不同的项目有不一样的要求,所以现在的做法是把方法通知出去,在外部做处理。

双指放大

双指和单指的区别在于,所有双指都是从单指变为双指,所以双指松开一个手指之后是会被识别为单指的。这就需要对双指的状态进行记录。 流程如下

// 双指部分功能判断
this.finger = new AlloyFinger(this.previewDom, {
    touchStart: (evt) => {
        ...
        // 判断当前是否为双指一起移动
        if (currentNum === 2 && this.isMoving) {
          this.fingerNum = 2
          return
        }
        // 判断是否移动过img,若无则开始初始双指操作
        if (currentNum === 2 && !this.scaleStartX) {
          this.fingerNum = 2
          this.doublePointStart(evt)
        }
        // 判断是否移动过img,若有则不需要初始
        if (currentNum === 2 && this.scaleStartX) {
          this.fingerNum = 2
        }
    },
     touchMove: (evt) => {
        const currentNum = evt.touches.length
        // 双指放大
        if (this.firstTouch && currentNum === 2 && !this.scaleStartX) {         
          this.doublePointMove(evt)
          return
        }
        // 双指后的单指移动
        if (this.firstTouch && currentNum === 1 && this.scaleStartX) {
          this.scalePointMove(evt)
          return
        }
        // 双指情况下 松开第一个手指  拦截返回 避免误操作普通单指操作
        if (this.firstTouch && currentNum === 1 && !this.scaleStartX) return
        ...
     },
     touchEnd: (evt) => {
         // 双指情况下 松开第二个手指
          if (this.fingerNum === 2 && this.firstTouch) {
          this.fingerNum--
          if (this.scaleStartX) return
          const finger = evt.touches|| evt.targetTouches
          if (finger.length >= 1 && finger[0].pageX) {
            this.scaleStartX = finger[0].pageX
            this.scaleStartY = finger[0].pageY
          } else {
            this.doublePointEnd(evt)
            this.fingerNum--
          }
          return
        }
        // 双指情况下 松开第一个手指
        if (this.fingerNum === 1 && this.firstTouch) {
          this.doublePointEnd(evt)
          return
        }
        ... 
        this.fingerNum--
     }
})

this.firstTouch 是初始的时候记录当前手指的evt.touches,以及img的基础信息。用是否存在this.firstTouch 判断进行的操作为单指还是双指,就能很好将逻辑区分清楚。

双指放大的动画则是通过计算两手指之间的中间点和放大的倍数,同时设置transform-origin和 transform:scale3d 就能实现放大功能,放大同时移动原理也是一致,因为都是时刻获取touch1和touch2,在设置transform带上translate3d就可以一边放大一边移动。

  doublePointStart (evt) {
    ...
    const touch1 = evt.touches[0]
    const touch2 = evt.touches[1]
    // 获取中间点
    const middleTouchOnElement = this.getMiddleTouchOnElement(
      evt.touches,
      this.initialBoundingRect
    )
    //  初始双指两点之间的距离
    this.initialPinchLength = this.getLengthOfLine(touch1, touch2)
    // 设置transform-origin
    this.setTransformOrigin(`${middleTouchOnElement.clientX}px`, `${middleTouchOnElement.clientY}px`)
    // 记录手指信息
    this.firstTouch = middleTouchOnElement
  }

  doublePointMove (evt) {
    // 获取中间点
    const middleTouchOnElement = this.getMiddleTouchOnElement(
      evt.touches,
      this.initialBoundingRect
    )
    // 当前双指两点之间的距离
    this.currentPinchLength = this.getLengthOfLine(evt.touches[0], evt.touches[1])
  
    //  scale = 当前双指两点之间的距离 / 初始双指两点之间的距离
    const scale = this.currentPinchLength / this.initialPinchLength
    const translateX = middleTouchOnElement.clientX - this.firstTouch.clientX
    const translateY = middleTouchOnElement.clientY - this.firstTouch.clientY
    this.config.scale = scale
    this.config.translateX = translateX
    this.config.translateY = translateY

    this.setTransform(scale, translateX, translateY)
    ...
  }

其中放大倍数的计算是通过touch1和touch2两点之间的距离在放大前和放大后的比例确定的。 需要注意的是现在是进行了双指之后的单指移动后是无法回到双指之前的状态需要完全走完所有操作才能再次进行双指放大。这是后续需要进行优化的地方。

双指后的单指移动就是用touchEnd时候获取到的this.scaleStartX,和this.scaleStartY进行位移计算。

至此,查看大图的中的所有功能点都已经说完了。

多图预览

因为项目中功能只需要做到单图预览,这以后可能也会有需要做多图预览的需求出现,就在单图的情况进行改造,让其支持多图预览。

相比单张预览首先需要对dom进行修改,.preview-card是在.preview-list中float:left,通过设置.preview-list的left来做左右移动的效果。

<div class="preview">
    <div class='preview-list'>
        <div class="preview-card"> <div class="preview-image"><img></div></div>
        <div class="preview-card"> <div class="preview-image"><img></div></div>
        ....
    </div>
    <div class="preview-mask"></div> 
</div>

开关的动画需要的点击List中img的信息,所以左右滑动之后或点击之后记录当前List的index即可让this.previewCardDom获取到当前展示的图片,并执行正常的开关动画。双指放大的获取的this.previewImageDom 也是同理

  async open (config) {
    ...
   if (this.isSingle) {
      this.previewImageDom.src = config.url || ''
    } else {
      const left = `${(config.index - 1 )* -780}`
      this.previewCardDom = this.previewCardDomAll[config.index - 1]
      this.previewImageDom = this.previewImageDomAll[config.index - 1]
      this.previewListDom.style.left = `${left}px`
      this.currentMove = Number(left)
    }
    ...
  }

左右手势的则是在原来的基础设置.preview-list的left值即可

singlePointMove (evt) {
 ...
 if (this.type === 'turn' || (!this.type && Math.abs(moveX) > Math.abs(moveY))) {
    this.type = 'turn'
    if (!this.isSingle) {
        const sc = 750 / window.innerWidth
        document.querySelector('.preview-list').style.left = `${this.currentMove + (moveX)/sc}px`
        return
    }
    moveX > 0 ?  this.$onEvent.left() : this.$onEvent.right()
    return
 }
 ...
}

这样多图预览也改造完成。

仓库

GitHub: PhotoPreview
npm: lego-photo-preview

快速体验

import PhotoPreview from 'lego-photo-preview'
import 'lego-photo-preview/photopreview.min.css'

// 单图模式
this.photoPreview = new PhotoPreview()
const target = xxx // 被点击的img dom
this.photoPreview.open({
  target,
  url:url // 被点击dom的url
})
this.photoPreview.$on('left', ()=>{
  console.log('从左向右划 →');
})
this.photoPreview.$on('right', ()=>{
  console.log('从右向左划 ←');
})

// 多图模式
imgList: [
    'https://xxx.com',
    'https://xxx.com',
    ...
]
this.photoPreview = new PhotoPreview()
this.photoPreview = new PhotoPreview({
  mode:"multitude",
  imgList:this.imgList 
})

this.photoPreview.open({
  index: index + 1 // index为this.imgList中的第几张
})

后话

目前为止都是预览主要功能的分享,至于像其他上一页、下一页这种功能可以自行根据自己的需要来进行添加。毕竟每个项目的要求都不一样,我这只是给大家提供一个最小可行版本,让大家都能简单实现一个交互更好的图片预览。

当然目前的这个组件还有很多可以优化地方,例如多图预览实现无限轮播的效果、提供可选的过渡效果动画、 多图懒加载等等这些。还有像文档说明、api这些都会在后续慢慢更新啦。当然文章上有什么建议也可交流一下😊。代码上如果有什么问题,欢迎提 issues。