本文作者:IMWeb llunnn 原文出处:IMWeb社区 未经同意,禁止转载
最近将一个照片墙从小程序迁移到了h5上,h5使用React开发。
这里需要实现的一点是点击照片墙上的小图时预览大图,小程序中提供了API: wx.previewImage预览图片,非常方便。但没有找到比较满意的React实现,于是仿小程序写了一个PhotoPreview组件。
效果预览 (移动端访问)
首先分析一下组件功能啦~
最基本的是一个模态框,单击照片时显示,再次单击时隐藏。
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时同步更改左右两张图片的位置,那么当前图片的左右两侧有空隙时,前后的图片就可以显示出来。这里用了shiftBefore
和shiftAfter
来记录前后两张图的偏移。
<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();
}
}
在上面几步之后基本就实现了一个基础的图片预览组件,比较复杂的还是图片位置的计算吧,以及还需要增加一些优化来使得动作更加流畅。另外,现在的做法预加载了当前图片前后的两张图片,可以考虑增加更多的图片预加载,使得切换时更加流畅。