本周推荐 | 基于 canvas 实现 H5 丝滑看图体验

简介: 推荐语:随着机器算力及性能的提升,基于原生Web体系的富交互体验也可以媲美原生,本文作者通过Canvas + Web手势从零实现了大图浏览的交互效果,并在体验上不输Native,是一次不错的技术尝试,欢迎阅读。——大淘宝技术客户端开发工程师 楚奕


背景


最近进行汽车图库的建设,需要在 H5 页面中实现图片浏览,包括图片的基本交互(缩放、平移)、滑动切换、复杂手势(双击缩放、快扫切图)等


盘点部门工具库,阿里集团的一个跨端工具库 jsbridge 提供了图片浏览的交互能力,但该能力是端侧的,交互界面独立(无法在详情页直接操作),且业务侧无法获知用户的交互情况(用户切换了图片,但是页面不感知)。最终实现上,使用了框架原生的 Swiper 组件支持业务层的左右滑动,使用 jsbridge 支持大图浏览,实现效果如下:

image.png

从实现效果来看,虽然功能上都具备了,但是交互不友好:用户进入图片详情页后还要再次点击图片才能浏览大图,切换图片还需要退回到详情页进行。

调研社区大图浏览方案,发现现有的方案要么接入难度大,要么改造成本高。为优化用户体验,拓展部门交付能力,决定基于 canvas 能力自行实现纯 H5 侧的大图浏览方案。



基础 API


 图片绘制


canvas 图片浏览的关键 API 是drawImage,用到的方法签名为 drawImage(image, dx, dy, dWidth, dHeight)。该 API 通过指定图片的左上角坐标、图片宽高,控制容器内图片的可视区域。而图片宽高与缩放大小相关(图片等比例缩放),基本参数可进一步抽象为 imgX, imgY, imgScale。

drawImage地址: https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/drawImage

image.png

然而实际上,这些事件并不在移动端事件的标准规范里,标准的移动端事件有以下四种:

  1. touchstart:当在屏幕上按下手指时触发
  2. touchmove:当在屏幕上移动手指时触发
  3. touchend:当在屏幕上抬起手指时触发
  4. touchcancel:当一些更高级别的事件发生的时候(如电话接入或者弹出信息)会取消当前的 touch 操作

标准规范:https://www.w3.org/TR/touch-events/


其它复杂事件都是三方库对基本事件的封装,其中的佼佼者当属 hammerjs,它成熟稳定,兼容鼠标/触摸事件,支持各种复杂手势。但功能强大的同时,也带来较大的体积。

hammerjs地址: https://github.com/hammerjs/hammer.js


由于图片浏览场景下手势需求简单明确,完全可以自行实现。在图片浏览中用到的复杂手势有:Swipe(滑动切图)、Tap(单击图片)、Drag(拖拽)、dblTap(双击放大图片)。拖拽和点击很简单,主要介绍下 Swipe 和 DblTap 的实现思路。


  • Swipe 事件


Swipe 是指手指在屏幕上快速地扫过,这里主要关注水平方向的快扫,以切换图片。快滑事件的实现,通过从按压到抬起的交互时间和滑动距离(水平向),计算滑动速度,判断是否属于快滑。

export interface IQuickCheck {  startTime?: number; // 起始时间  startX?: number; // 起始 x 点}
const quickSlideCheckRef: {current: IQuickCheck} = {  current: {}};export const quickSlideCheckFn = {  tapStart: (startX) => {    quickSlideCheckRef.current = {      startX,      startTime: performance.now(),    }  },  /**   * 双击结束校验   * @param endX 结束点 x 坐标   * @param toLeft 向左滑的回调函数   * @param toRight 向右滑的回调函数   * @returns    */  tapEnd: (endX, toLeft, toRight) => {    // 快滑判断    const { startX, startTime } = quickSlideCheckRef.current;    const endTime = performance.now();    const speed = (endX - startX) / (endTime - startTime);
    // 如果图片比例大于宽度,不要移动    if (Math.abs(speed) > 0.3) {      if (speed > 0) toRight();      else toLeft();      return true;    }
    return false;  }}


  • DblTap 事件


双击事件关键在于判断两次点击的间隔时间

export interface IDoubleTapCheck {  firstTapStart?: number;  firstTapEnd?: number;  secondTapStart?: number;}
const clickTime = 200;const doubleTapCheckRef: {current: IDoubleTapCheck} = {  current: {}};export const doubleClickCheckFn = {  tapStart: () => {    // 双击事件校验    const now = performance.now();    // 如果没有点击,则记录第一次点击    if (!doubleTapCheckRef.current.firstTapStart) {      doubleTapCheckRef.current.firstTapStart = now;    } else { // 有第一次点击,判断这次点击是否与第一次点击连续      if (now - doubleTapCheckRef.current.firstTapEnd < clickTime) {        // 点击连续,记录为第二次点击        doubleTapCheckRef.current.secondTapStart = now;      } else {        // 点击不连续,重置为第一次点击        doubleTapCheckRef.current = {firstTapStart: now};      }    }  },  tapEnd: (callback = () => {}) => {    const now = performance.now();    let isDoubleTap = false;    // 判断是第几次点击    if (!doubleTapCheckRef.current.secondTapStart) { // 如果是第一次点击      if (now - doubleTapCheckRef.current.firstTapStart < clickTime) { // 满足点击特征        doubleTapCheckRef.current.firstTapEnd = now;      } else {        doubleTapCheckRef.current = {}; // 不满足点击特征,直接重置      }    } else { // 如果是第二次点击      if (now - doubleTapCheckRef.current.secondTapStart < clickTime) { // 满足点击特征        // 第二次点击命中,处理双击逻辑        callback();        isDoubleTap = true;      }       // 恢复点击判断状态      doubleTapCheckRef.current = {};    }
    return isDoubleTap;  }}


 单指拖拽


单指拖拽是最基本的图片浏览能力,其实现也较为简单。只需根据拖拽前后 touch 点的坐标变化,更新图片的左上角坐标即可。


 双指缩放


双指缩放是移动端浏览图片的重要功能,其实现也相对复杂。
通过计算双指移动前后的距离,可以确定图片的缩放方向,具体的缩放策略,这里的缩放策略指图片的缩放中心缩放尺度。社区中常见的解决方案是取两指中心(或者干脆就取其中一指)作为缩放中心点,缩放尺度则是固定的步进——这也是 H5 侧看图体验通常不够丝滑的关键原因:响应了,但没有完全响应。
而在成熟的看图实现中,双指初始接触的图片点始终跟随手指移动。当双指初始距离较小,而拉开很大时,图片放大尺度会更大,给人一种丝滑的浏览体验。为达到这种效果,就需要根据双指前后移动,确定图片角点坐标与缩放尺度。将双指移动前后的位置抽象出来如下:

image.png

BC/B'C' 为缩放前后的双指位置,A/A‘ 为缩放前后图片左上角位置。其中,A/B/C/B‘/C' 坐标已知,如何求解 A’ 的坐标?这里有两个隐含条件:1. 移动前后,双指总是在一条直线上;2.移动前后,双指和角点围成的三角形是相似三角形。


一种思路是:

  1. 根据 AB,计算出 A'B' 的斜率;
  2. 根据 B‘坐标,确定 A'B' 的直线公式;
  3. 根据 BC/B'C’ 确定缩放尺度,进而算出 A‘B' 的距离。
  4. 有了直线公式和线段长度,能够得出 A’ 的两个可能坐标点,而 A‘ 一定在 B’ 的左上方,从而确定唯一解。


const handleZoom = (e: TouchEvent) => {  const { touches: curTouches } = e;  const { imgX, imgY, imgScale } = drawParamsRef.current;
  // 双指缩放图片时,图片不是按照固定的倍率缩放,而是两个锚点跟随移动  // 应当根据双指位置,重新计算图片渲染位置  const { clientX, clientY } = curTouches[0];  const pos1 = getPositionInCanvas(clientX, clientY, canvasRef.current); // 第一指移动后  const prePos1 = getPositionInCanvas(    touchesRef.current[0].clientX,    touchesRef.current[0].clientY,    canvasRef.current  ); // 第一指移动前  // 计算缩放倍率  const curDistance = getDistance(curTouches[0], curTouches[1]);  const prevDistance = getDistance(touchesRef.current[0], touchesRef.current[1])  const curScale = curDistance / prevDistance;  // 计算左上角坐标  const newImgXY = getNewImgXY(scalePos(prePos1), scalePos(pos1), {imgX, imgY}, curScale);
  // 缩放倍数不是相加关系,而是相乘(宽度计算应该在上一次的计算上递增)  const newImgScale = imgScale * curScale;
  drawParamsRef.current = {    imgScale: newImgScale,    ...newImgXY,  }
  // 更新缩放起始位置  touchesRef.current = curTouches;}


 双击缩放


厘清双指缩放的逻辑后,双击缩放就简单了。可以将双击缩放看作双指缩放的特殊情况(双指重合),给予固定的缩放尺度,即可复用双指缩放的逻辑。


 滑动切换


滑动切换图片主要通过左右边界与容器宽度的对比,判断是否需要切换图片。


const slideCheck = () => {  let { imgScale, imgX } = drawParamsRef.current;  const { width: canvasWidth } = canvasRef.current;  let { width: imgWidth, height: imgHeight } = curImgRef.current;
  imgWidth = imgWidth * imgScale * devicePixelRatio;  imgHeight = imgHeight * imgScale * devicePixelRatio;
  let doSlide = true;  if (imgX > (canvasWidth * 2 / 5) && preImgRef.current) { // 右滑判断    slideToRight();  } else if (imgX < 0 && ((imgWidth + imgX) < (canvasWidth * 3 / 5)) && nextImgRef.current) { // 左滑判断    slideToLeft();  } else {    doSlide = false;  }
  return doSlide;}


当用户在屏幕上快速扫过时,就算边界未超过阈值也应当切换图片。快扫的事件模拟已经在 “Swipe 事件”章节中有介绍。


交互优化


 图片居中


图片的居中显示,是指图片垂直居中,宽度撑满容器。此时 imgX 设为 0,imgY 根据宽高比动态调整即可。


// 使图片保等宽居中const initDrawParams = () => {  const { width: imgWidth, height: imgHeight } = curImgRef.current;  const { width: canvasWidth, height: canvasHeight } = canvasRef.current;
  // 初始绘制,使图片居中  const imgScale = canvasWidth / imgWidth;  const imgX = 0;  const imgY = (canvasHeight - imgHeight * imgScale) / 2;  drawParamsRef.current = {imgX, imgY, imgScale};}


 滑动切换


当左右边界进入容器时,留白应当被前后图片填充,需要预加载当前图片前后的图片。

image.png


/** * 绘图函数 * @param isInit 参数为 true 时,将图片居中等宽绘制 * @param isBouncing 参数为 ture 时,不做左右图片的滑出 * @returns  */const drawImg = (isInit = false, isBouncing = false) => {  // ......  // 如果拖到边界,需要绘制左右滑动的图片  if (isZoomingRef.current || isBouncing) return; // 缩放/回动场景不加载左右图片  if (imgX >= 0 && preImgRef.current) {    // 向右滑,拉出部分左侧图片    showSideImg(preImgRef, imgX - canvasWidth);  } else if (imgX < 0 && imgX < canvasWidth - curImgWidth && nextImgRef.current){    // 向左滑,拉出部分右侧图片    showSideImg(nextImgRef, imgX + curImgWidth);  }}
/** * 拉出部分图片 * @param imgRef 图片指针 * @param imgX x 坐标 */const showSideImg = (imgRef, imgX) => {  const { width: imgWidth, height: imgHeight } = imgRef.current;  const { width: canvasWidth, height: canvasHeight } = canvasRef.current;  const scale = canvasWidth / imgWidth / devicePixelRatio; // 计算缩放  const imgY = (canvasHeight - imgHeight * scale * devicePixelRatio) / 2; // 计算 y 坐标  ctxRef.current.drawImage(imgRef.current, imgX, imgY, canvasWidth, scale * imgHeight * devicePixelRatio);}


 边界控制


边界控制主要避免左右两侧出现留白,或上下两侧中某侧超出屏幕,而另一侧存在留白等情况。


/** * 回弹场景 * - 图片高度超过容器高度 *   - 上边界大于零 *   - 下边界小于底边 * - 图片高度未超过容器高度 *   - 上边界小于零 *   - 下边界大于底边 * - 图片宽度超过容器宽度 *   - 左边界大于零 *   - 右边界小于右边 * - 图片宽度未超过容器宽度 *   - 左边界小于零 *   - 右边界超过右边 * @param drawParams 待检查的绘图参数 * @returns {imgX, imgY, imgScale} 检查后新的绘图参数  */const boundaryCheck = (drawParams = null) => {  const oldDrawParams = drawParams || drawParamsRef.current;  let { imgScale, imgX, imgY } = oldDrawParams;  const { width: canvasWidth, height: canvasHeight } = canvasRef.current;  let { width: imgWidth, height: imgHeight } = curImgRef.current;
  imgWidth = imgWidth * imgScale * devicePixelRatio;  imgHeight = imgHeight * imgScale * devicePixelRatio;
  // 垂直方向的回弹控制  if (imgHeight >= canvasHeight) {    if (imgY > 0) {      imgY = 0;    } else if (imgY + imgHeight < canvasHeight) {      imgY = canvasHeight - imgHeight;    }  } else {    if (imgY < 0) {      imgY = 0;    } else if (imgY + imgHeight > canvasHeight) {      imgY = canvasHeight - imgHeight;    }  }
  // 水平方向的回弹控制  if (imgWidth >= canvasWidth) {    if (imgX > 0) {      imgX = 0;    } else if (imgX + imgWidth < canvasWidth) {      imgX = canvasWidth - imgWidth;    }  } else {    if (imgX < 0) {      imgX = 0;    } else if (imgX + imgWidth > canvasWidth) {      imgX = canvasWidth - imgWidth;    }  }
  // 将图片居中展示  if (canvasHeight >= imgHeight) {    imgY = (canvasHeight - imgHeight) / 2;  }
  return { imgY, imgX, imgScale };}


 缩放控制


控制图片的缩放比例,图片的最小缩放比例也应当是宽度撑满容器,最大缩放比例下,应当使图片具有一定的信息量,实现上写死了某个经验值,后续优化中也可以根据原图尺寸动态计算。


 过渡动画


以上边界控制/缩放控制/图片居中/图片切换,会在交互完成后闪到限定位置,这种体验上比较糟糕。为交互位 -> 目标位的切换添加过渡。实现效果如下(左侧无过渡,右侧有过渡):



相关文章
|
5月前
|
图形学
【制作100个unity游戏之27】使用unity复刻经典游戏《植物大战僵尸》,制作属于自己的植物大战僵尸随机版和杂交版3(附带项目源码)
【制作100个unity游戏之27】使用unity复刻经典游戏《植物大战僵尸》,制作属于自己的植物大战僵尸随机版和杂交版3(附带项目源码)
126 2
|
5月前
|
图形学
【制作100个unity游戏之28】花半天时间用unity复刻童年4399经典小游戏《黄金矿工》(附带项目源码)
【制作100个unity游戏之28】花半天时间用unity复刻童年4399经典小游戏《黄金矿工》(附带项目源码)
136 0
|
3月前
|
开发者 图形学 前端开发
绝招放送:彻底解锁Unity UI系统奥秘,五大步骤教你如何缔造令人惊叹的沉浸式游戏体验,从Canvas到动画,一步一个脚印走向大师级UI设计
【8月更文挑战第31天】随着游戏开发技术的进步,UI成为提升游戏体验的关键。本文探讨如何利用Unity的UI系统创建美观且功能丰富的界面,包括Canvas、UI元素及Event System的使用,并通过具体示例代码展示按钮点击事件及淡入淡出动画的实现过程,助力开发者打造沉浸式的游戏体验。
79 0
|
5月前
|
图形学
【制作100个unity游戏之29】使用unity复刻经典游戏《愤怒的小鸟》(完结,附带项目源码)(上)
【制作100个unity游戏之29】使用unity复刻经典游戏《愤怒的小鸟》(完结,附带项目源码)
195 2
|
5月前
|
图形学
【制作100个unity游戏之27】使用unity复刻经典游戏《植物大战僵尸》,制作属于自己的植物大战僵尸随机版和杂交版6(附带项目源码)
【制作100个unity游戏之27】使用unity复刻经典游戏《植物大战僵尸》,制作属于自己的植物大战僵尸随机版和杂交版6(附带项目源码)
61 1
|
5月前
|
存储 JSON 关系型数据库
【制作100个unity游戏之27】使用unity复刻经典游戏《植物大战僵尸》,制作属于自己的植物大战僵尸随机版和杂交版13(完结,附带项目源码)
【制作100个unity游戏之27】使用unity复刻经典游戏《植物大战僵尸》,制作属于自己的植物大战僵尸随机版和杂交版13(完结,附带项目源码)
101 0
|
5月前
|
图形学
【制作100个unity游戏之29】使用unity复刻经典游戏《愤怒的小鸟》(完结,附带项目源码)(下)
【制作100个unity游戏之29】使用unity复刻经典游戏《愤怒的小鸟》(完结,附带项目源码)(下)
81 0
|
5月前
|
图形学
【制作100个unity游戏之27】使用unity复刻经典游戏《植物大战僵尸》,制作属于自己的植物大战僵尸随机版和杂交版8(附带项目源码)
【制作100个unity游戏之27】使用unity复刻经典游戏《植物大战僵尸》,制作属于自己的植物大战僵尸随机版和杂交版8(附带项目源码)
33 0
|
5月前
|
图形学
【制作100个unity游戏之27】使用unity复刻经典游戏《植物大战僵尸》,制作属于自己的植物大战僵尸随机版和杂交版10(附带项目源码)
【制作100个unity游戏之27】使用unity复刻经典游戏《植物大战僵尸》,制作属于自己的植物大战僵尸随机版和杂交版10(附带项目源码)
61 0
|
5月前
|
图形学
【制作100个unity游戏之27】使用unity复刻经典游戏《植物大战僵尸》,制作属于自己的植物大战僵尸随机版和杂交版1(附带项目源码)
【制作100个unity游戏之27】使用unity复刻经典游戏《植物大战僵尸》,制作属于自己的植物大战僵尸随机版和杂交版1(附带项目源码)
127 0