遥想在Flipboard实习时,我的第一个react项目里曾经有一个小需求就是为一个图集页面实现一个图片查看器。当时能力所限只实现了一版非常丑陋的勉强能用的,sad~如今再回头发现自己已经有能力实现一个体验更好的图片查看器,那么就弥补一下遗憾喽。

Demo

先放一下最后的成果。

demo

功能

  • 类似原生的滑动切换
  • 双击放大和还原
  • 图片拖动

实现

整个项目是用react16实现的,手势控制使用了alloyfinger,相较于另一个非常知名的web手势库hammer.js,alloyfinger更小,虽然功能可配置能力不如hammer.js,但是也足够用了。开发环境使用了storybook,storybook有比较丰富的插件,不过我这里没用(唯一个有迫切需求的移动端模拟插件,还是在下个版本才放出。。。)测试使用jest + enzyme。

实现过程中,有几个需要考虑的要点在这里记录一下。

切换

其实滑动切页是很常见的需求,jQuery的插件也数不胜数,但是这里面有个问题。很多插件会把所有图片都预先渲染好,如果图片数量较大,对于性能还是有一定影响的。理想状态下,应该只需要渲染最多三张,包括当前页,上一页,下一页。每次切换后再更新新的三页。

但是这样会带来新的问题,首先react在进行列表渲染时需要知道每一项的key,来决定如何优化,组件key发生变化会重新渲染整个组件,很多情况下没有必要,此外为了支持渲染两张相同url的图片,只使用url作为key也不合适。所以最后使用url+图片数组index来作为id。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const displayMax = index + 2 > images.length ? images.length : index + 2; //获取下一页的index
const displayMin = index - 1 < 0 ? 0 : index - 1; //获取上一页的index
return (
...
{images.slice(displayMin, displayMax).map((url, ind) => (
<div
key={url + (ind + displayMin)}
className="image-slides-blackboard">
{loaded[url] ? (
<img
className="image-slides-content"
src={url} />
) : Loading}
</div>
))}
)

其中images为url数组,index为当前页数。

另外一个问题是如果我们渲染所有图片,偏移量计算很简单index * window.innerWidth,翻到下一页,index 加一即可,但是如果只渲染3张的话,偏移量始终为1 * window.innerWidth,翻到下一页偏移量变化过程为1 * window.innerWidth -> 1 * window.innerWidth + 手指划过距离(直到触发index + 1) -> 1 * window.innerWidth 这样的话过渡动画会很奇怪,左右闪。所以我这里的实现为1 * window.innerWidth -> 1 * window.innerWidth + 手指划过距离(直到触发index + 1) -> 0 * window.innerWidth + 手指划过距离 -> 1 * window.innerWidth这里会在chrome上又又又碰到一坑。。。chrome在计算transform偏移时,如1 * window.innerWidth + 手指划过距离(直到触发index + 1) -> 0 * window.innerWidth + 手指划过距离 -> 1 * window.innerWidth会做优化将变化合并,即把0 * window.innerWidth + 手指划过距离这一步忽略了,所以我在这一步通过手动修改其他属性,来强制触发。翻到上一页同理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
gesturesManager.on('touchEnd', e => {
const swipeTrigger = window.innerWidth * 0.2; //触发翻页的偏移量
if (this.lastContainerOffsetX > swipeTrigger) { //上一页
if (this.getMedianIndex() > 0 && this.state.index !== 1) {
style.transform = `translate3d(${this.lastContainerOffsetX -
(GUTTER_WIDTH + window.innerWidth) * 2}px, 0, 0)`;
} else if (this.state.index === 1) {
style.transform = `translate3d(${this.lastContainerOffsetX -
(GUTTER_WIDTH + window.innerWidth)}px, 0, 0)`;
}
this.last();
} else if (this.lastContainerOffsetX < -swipeTrigger) { //下一页
style.transform = `translate3d(${this.lastContainerOffsetX}px, 0, 0)`; // 0 * window.innerWidth + 手指划过距离
if (this.state.index === 0) {
style.transition = 'all 0.3s';
}
this.next(); //触发翻页
}
style.transition = 'all 0.3s';
style.transform = `translate3d(${-(
GUTTER_WIDTH + window.innerWidth
) * this.getMedianIndex()}px, 0, 0)`; //恢复为1 * window.innerWidth + 手指划过距离
this.lastContainerOffsetX = 0;
this.isMoving = false;
e.preventDefault();
});

(后来想到另外一种做法是监听transitionend事件,再触发翻页)

拖动

其实这里和滑动翻页纠缠在一起,核心就是如何确定触摸事件触发图片拖动还是滑动翻页。在FLipboard时这个问题通过滚动条交给浏览器来解决,但是滚动条在pc上很丑陋,所以这次决定还是自己控制。控制优先级为:当图片拖动到边界时,控制权交给滑动翻页。用户双击放大控制权交还图片。超出边界判定的规则是图片偏移量大于图片宽度减去视口宽度的1/2.

1
2
3
4
5
6
if (
Math.abs(lastOffsetX + offsetX) > xRange && // xRange为 (clientWidth * scaleMultiples - this.viewPortWidth) / 2,
Math.abs(lastOffsetX + offsetX) > Math.abs(lastOffsetX)
) {
isInLimit = false;
}

优化和兼容

  • 对于图片元素设置will-change提升为合成层使用GPU加速。
  • 针对uc和webview中左滑后退,再touch事件中调用preventDefault。
  • 移动端需要解决滚动穿透的问题的(最简单的方案body高度设为0)

未解决的问题

  1. pinch手势放大缩小的功能没有实现,核心问题在于如何计算放大中心。
  2. 组件进入的时候缺少过渡效果。