llunnn

2018-10-08 20:48

写一个H5图片预览组件

本文作者:IMWeb llunnn 原文出处:IMWeb社区 未经同意,禁止转载

最近将一个照片墙从小程序迁移到了h5上,h5使用React开发。

这里需要实现的一点是点击照片墙上的小图时预览大图,小程序中提供了API: wx.previewImage预览图片,非常方便。但没有找到比较满意的React实现,于是仿小程序写了一个PhotoPreview组件。

效果预览 (移动端访问)

组件功能

首先分析一下组件功能啦~

模态框

最基本的是一个模态框,单击照片时显示,再次单击时隐藏。

手势部分

  • 双指缩放图片
  • 单指移动图片
  • 左右滑动切换图片

实现细节

props

hidePreview: Function 控制模态框显隐的方法

urls: Array 所有将要预览的图片链接

initIndex: Number 初始预览的图片下标

模态框

模态框部分比较常见,为了减少模态框受父组件的影响,这里使用了Portal,将其直接添加到body下。

import React from 'react'
import { createPortal } from 'react-dom';

import './index.css'

export default class PhotoPreview extends React.PureComponent {
  constructor(props) {
    super(props);
    const { hidePreview } = this.props;
    this.root = document.createElement('div'); // 创建一个容器放置模态框
    this.root.classList.add('preview-modal-wrapper'); // 设置一些样式
    this.root.addEventListener('click', hidePreview);
    document.body.appendChild(this.root); // 将容器添加到body下
    this.root.addEventListener('touchmove', this.preventTouchMove);
  }

  preventTouchMove = e => e.preventDefault(); // 阻止模态框上的touchMove事件影响到下方的元素

  componentWillUnmount() {
    // 模态框销毁后移除事件和外层容器
    this.root.removeEventListener('touchmove', this.preventTouchMove);
    document.body.removeChild(this.root);
  }

  render() {
    return createPortal( // 创建一个Portal,将模态框添加到我们新创建的this.root容器上
      (
        <div>
          {/* TODO.. */}
        </div>
      ), this.root);
  }
}

手势部分

工具:AlloyFinger

这里借助了一个手势库AlloyFinger帮助捕获一些手势事件。

主要用到的事件如下:

  • onPinch(e) 双指缩放时触发,e.zoom为缩放倍数
  • onMultipointStart(e) 多点触摸时触发
  • onPressMove(e) 手指按下并移动时触发,e.deltaX, e.deltaY为两个方向上移动的距离
  • onTouchEnd(e) 触摸停止时触发
<AlloyFinger
  onPinch={this.onPinch}
  onPressMove={this.onPressMove}
  onMultipointStart={this.onMultipointStart}
  onTouchEnd={this.onTouchEnd}
 >
  <div className="img-wrapper">
    {/* TODO.. */}
  </div>
</AlloyFinger>

根据上面分析的功能,考虑用transform属性的scale和translate来控制图片随手势的变化。

<div 
  className="img-wrapper"
  >
  <img 
    src={urls[curIndex]}
    ref={this.imgRef}
    style={{
      // eslint-disable-next-line
      transform: `scale(${scale}) translate(${translate.x}px, ${translate.y}px)`,
    }}
    />
</div>

接下来就要根据功能一一定制各种手势下的行为了:

双指缩放

这里比较简单,直接使用onPinch获得的zoom去改变this.state.scale。

需要注意的是这里的zoom是相对于每一次缩放手势开始时的放大倍数,因此需要监听onMultipointStart事件,在开始缩放时记录下原始的scale值。

onMultipointStart() {
  this.setState({
    base: this.state.scale,
  })
}

onPinch(evt) {
  const nextScale = evt.zoom * this.state.base;
  this.setState({
    scale: nextScale < 1 ? 1 : nextScale, // 禁止小于原尺寸
  });
  evt.preventDefault();
}
单指移动图片

移动比较简单,根据onPressMove获得的deltaX, deltaY改变图片translate属性就可以了,另外在onTouchEnd判断一下图片有没有被移出屏幕,我们要保持图片最大程度地填充屏幕空间。

onPressMove(evt) {
  // transform里scale放在translate前面,手指移动的距离要除以scale
  let transX = this.state.translate.x + evt.deltaX / this.state.scale;
  let transY = this.state.translate.y + evt.deltaY / this.state.scale;
  if (this.state.scale <= 1) { // 缩放倍数小于1时使y方向上的移动失效
    transY = 0;
  }
  this.setState({
    translate: {
      x: transX,
      y: transY,
    },
  });
  evt.preventDefault();
}

onTouchEnd() {
  const {left, right, top, bottom, width, height} = this.imgRef.current.getBoundingClientRect();
  let translate = {};
  // 保持图片在屏幕中央
  if (width < screenWidth) {
    translate.x = 0;
  } else if (left > 0) {
    translate.x = (width - screenWidth) * 0.5 / this.state.scale;
  } else if (right < screenWidth) {
    translate.x = (screenWidth - width) * 0.5 / this.state.scale;
  } else {
    translate.x = this.state.translate.x;
  }
  if (height < screenHeight) {
    translate.y = 0;
  } else if (top > 0) {
    translate.y = (height - screenHeight) * 0.5 / this.state.scale;
  } else if (bottom < screenHeight) {
    translate.y = (screenHeight - height) * 0.5 / this.state.scale;
  } else {
    translate.y = this.state.translate.y;
  }
  this.setState({
    translate,
  });
}
左右滑动切换图片

这大概就是大魔王吧...思考了几种实现的方式,最终使用的方法是这样的:

提前加载前后两张图片,并在onPressMove时同步更改左右两张图片的位置,那么当前图片的左右两侧有空隙时,前后的图片就可以显示出来。这里用了shiftBeforeshiftAfter来记录前后两张图的偏移。

<AlloyFinger
  onPinch={this.onPinch}
  onPressMove={this.onPressMove}
  onMultipointStart={this.onMultipointStart}
  onTouchEnd={this.onTouchEnd}
  >
  {/*当前图片....*/}
</AlloyFinger>
<div
  className="img-wrapper"
  style={{
    transform: `translate(${shiftBefore - screenWidth}px, -50%)`,
      position: 'absolute',
        top: '50%',
  }}
  >
  <img src={urls[curIndex-1]} />
</div>
<div
  className="img-wrapper"
  style={{
    transform: `translate(${shiftAfter + screenWidth}px, -50%)`,
      position: 'absolute',
        top: '50%',
  }}
  >
  <img src={urls[curIndex+1]} />
</div>

接下来就需要在onPressMove的时候同步修改shiftBefore和shiftAfter两个state了:

onPressMove(evt) {
  let transX = this.state.translate.x + evt.deltaX / this.state.scale;
  let transY = this.state.translate.y + evt.deltaY / this.state.scale;
  let shiftAfter, shiftBefore;
  if (this.state.scale <= 1) {
    // 图片没有缩放时shiftAfter, shiftBefore和图片的translate相同
    shiftAfter = transX > 0 ? 0 : transX; 
    shiftBefore = transX < 0 ? 0 : transX;
    transY = 0;
  } else {
    // 图片被放大则将图片边缘与屏幕边缘比较
    const {left, right} = this.imgRef.current.getBoundingClientRect();
    shiftAfter = right < screenWidth ? right - screenWidth : 0;
    shiftBefore = left > 0 ? left : 0;
  }
  this.setState({
    translate: {
      x: transX,
      y: transY,
    },
    shiftBefore,
    shiftAfter,
  });
  evt.preventDefault();
}

另外,在onTouchEnd时判断当前手指移动的距离是否足够大,判断是否切换到下一张图片。

若切换图片,完成下一张图片滑动到屏幕中央的动画后,替换当前图片、前一张和后一张图片的src。

onTouchEnd() {
  const {shiftAfter, shiftBefore, curIndex} = this.state;
  if (Math.abs(shiftAfter) < screenWidth * 0.1 && Math.abs(shiftBefore) < screenWidth * 0.1) {
    // 不切换图片
    // 同上计算translate
    // ...
    this.setState({
      translate,
      shiftAfter: 0,
      shiftBefore: 0,
    })
  } else { // 切换下一张图
    const prevImage =  (shiftAfter === 0);
    let nextIndex = prevImage ? curIndex - 1 : curIndex + 1;
    if (nextIndex < 0) {
      nextIndex = 0;
    } else if (nextIndex >= this.props.urls.length) {
      nextIndex = this.props.urls.length - 1;
    }
    const self = this;

    // 这里是下一张图滑到页面中的效果
    // 用了setTimeout不断改变translate值,应该可以优化
    function moveToCenter() {
      if (prevImage && screenWidth - Math.abs(self.state.shiftBefore) < 20 || 
          !prevImage && screenWidth - Math.abs(self.state.shiftAfter) < 20) {
        // 滑动动画完成,改变当前图片的下标curIndex
        self.setState({
          curIndex: nextIndex,
          shiftAfter: 0,
          shiftBefore: 0,
          translate: {
            x: 0,
            y: 0,
          },
          scale: 1,
          base: 1,
        });
      } else {
        // 继续滑动动画
        setTimeout(moveToCenter, 10);
        self.setState({
          translate: {
            x: self.state.translate.x + 20 * (prevImage ? 1 : -1),
            y: self.state.translate.y,
          },
          shiftBefore: self.state.shiftBefore + 20 * (prevImage ? 1 : -1),
          shiftAfter: self.state.shiftAfter + 20 * (prevImage ? 1 : -1),
        });
      }
    }
    moveToCenter();
  }
}

总结

在上面几步之后基本就实现了一个基础的图片预览组件,比较复杂的还是图片位置的计算吧,以及还需要增加一些优化来使得动作更加流畅。另外,现在的做法预加载了当前图片前后的两张图片,可以考虑增加更多的图片预加载,使得切换时更加流畅。

0条评论

    您需要 注册 一个IMWeb账号或者 才能进行评论。