本周推荐 | 基于 canvas 实现 H5 丝滑看图体验
推荐语:随着机器算力及性能的提升,基于原生Web体系的富交互体验也可以媲美原生,本文作者通过Canvas + Web手势从零实现了大图浏览的交互效果,并在体验上不输Native,是一次不错的技术尝试,欢迎阅读。
——大淘宝技术客户端开发工程师 楚奕
背景
最近进行汽车图库的建设,需要在 H5 页面中实现图片浏览,包括图片的基本交互(缩放、平移)、滑动切换、复杂手势(双击缩放、快扫切图)等。
盘点部门工具库,阿里集团的一个跨端工具库 jsbridge 提供了图片浏览的交互能力,但该能力是端侧的,交互界面独立(无法在详情页直接操作),且业务侧无法获知用户的交互情况(用户切换了图片,但是页面不感知)。最终实现上,使用了框架原生的 Swiper 组件支持业务层的左右滑动,使用 jsbridge 支持大图浏览,实现效果如下:
从实现效果来看,虽然功能上都具备了,但是交互不友好:用户进入图片详情页后还要再次点击图片才能浏览大图,切换图片还需要退回到详情页进行。
调研社区大图浏览方案,发现现有的方案要么接入难度大,要么改造成本高。为优化用户体验,拓展部门交付能力,决定基于 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
技术目标是通过监听用户交互事件,动态计算这三个参数,改变图片的可视区域,达到图片浏览的目的。
▐ 移动端事件
2007年,乔布斯在一块小玻璃屏上的轻轻一触,开启了数十载移动互联网的蓬勃发展。
多年移动互联网的发展,用户对移动端的手势交互已经形成了如下心智:
然而实际上,这些事件并不在移动端事件的标准规范里,标准的移动端事件有以下四种:
touchstart:当在屏幕上按下手指时触发
touchmove:当在屏幕上移动手指时触发
touchend:当在屏幕上抬起手指时触发
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 侧看图体验通常不够丝滑的关键原因:响应了,但没有完全响应。
而在成熟的看图实现中,双指初始接触的图片点始终跟随手指移动。当双指初始距离较小,而拉开很大时,图片放大尺度会更大,给人一种丝滑的浏览体验。为达到这种效果,就需要根据双指前后移动,确定图片角点坐标与缩放尺度。将双指移动前后的位置抽象出来如下:
BC/B'C' 为缩放前后的双指位置,A/A‘ 为缩放前后图片左上角位置。其中,A/B/C/B‘/C' 坐标已知,如何求解 A’ 的坐标?这里有两个隐含条件:1. 移动前后,双指总是在一条直线上;2.移动前后,双指和角点围成的三角形是相似三角形。
一种思路是:
根据 AB,计算出 A'B' 的斜率;
根据 B‘坐标,确定 A'B' 的直线公式;
根据 BC/B'C’ 确定缩放尺度,进而算出 A‘B' 的距离。
有了直线公式和线段长度,能够得出 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};
}
▐ 滑动切换
当左右边界进入容器时,留白应当被前后图片填充,需要预加载当前图片前后的图片。
/*** 绘图函数* @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 };
}
▐ 缩放控制
控制图片的缩放比例,图片的最小缩放比例也应当是宽度撑满容器,最大缩放比例下,应当使图片具有一定的信息量,实现上写死了某个经验值,后续优化中也可以根据原图尺寸动态计算。
▐ 过渡动画
以上边界控制/缩放控制/图片居中/图片切换,会在交互完成后闪到限定位置,这种体验上比较糟糕。为交互位 -> 目标位的切换添加过渡。实现效果如下(左侧无过渡,右侧有过渡):
▐ 提高图片清晰度
起初 canvas 宽高默认撑满容器,发现图片比较模糊。原因是 canvas 宽高决定了其中的像素值,未能充分利用高清的手机屏幕。在初始化时引入设备像素比 devicePixelRatio,将 canvas 宽高放大,再 scale 到容器尺寸,提高图片清晰度。
更改了容器空间的尺寸后,前面所有的交互中图片尺寸和定位计算都需要考虑这个 devicePixelRatio ,重新计算。
▐ PC 端的兼容
组件使用 canvas ,兼容性表现是比较好的。对 PC 端的兼容,主要是对不同事件触发的处理。如在缩放控制优化时,需要在缩放后进行缩放判断。移动端可以在 touchend 中执行,但 PC 端的 wheel 事件不存在类似 end 提示,就需要对其进行特殊处理。这里采用了定时器方案,保证滚动后的控制。
const mouseWheelWatcher = (e: WheelEvent & { wheelDelta: number }) => {mouseWheel(e);// 滚动结束后,进行缩放和边界判断if (wheelWatcherRef.current) {clearTimeout(wheelWatcherRef.current)}wheelWatcherRef.current = window.setTimeout(() => {rescaleImg({ clientX: e.clientX, clientY: e.clientY });}, 100)
}
最终实现的看图效果如下:
团队介绍
我们是阿里巴巴大淘宝技术汽车技术团队,是一支集研发、数据、算法一体的部门,利用互联网+数字化垂直整合汽车行业,打造消费者线上看车、买车、养车的极致体验。在这里,你会接触到新零售核心技术,交易、供应链、结算、阵地运营等。在这里,团队氛围融洽,业务发展迅猛,技术挑战多多,让你有商业思考,又有技术深度。阿里汽车高速发展的路上,期待您的加入!
¤ 拓展阅读 ¤
3DXR技术 | 终端技术 | 音视频技术
服务端技术 | 技术质量 | 数据算法
本周推荐 | 基于 canvas 实现 H5 丝滑看图体验相关推荐
- 强强联手,丝滑办公新体验!IdeaHub+华为云会议实测
目录 1. 前言 2. 软硬结合,天生一对 3. 全方位功能体验 3.1 IdeaHub接入华为云会议 3.2 随时随地一键开会 3.3 天生高清 3.3.1 高清视频 3.3.2 纯净音质 3.3. ...
- uni-app - 电子签字板组件(签名专用写字画板,支持调整写字板 “横纵“ 方向,可调整线条粗细颜色等,Canvas 绘制非常丝滑流畅)完美兼容 H5 APP 小程序,最好用的画板签字教程插件源码
前言 网上的教程代码非常乱且都有 BUG 存在,非常难移植到自己的项目中,本文代码干净整洁注释详细. 本文实现了 全端兼容,签名专用的写字板组件,真机流畅丝滑且无 BUG, 您直接复制组件源码,按照详 ...
- 基于三菱运动控制系统生成丝滑无比的凸轮曲线(上)
随着甲方对设备速度,机械寿命,系统稳定性等有了更高的要求,而传统的梯形加减速,七段S型曲线等再高速运动的过程中经常出现冲击电流等问题,对机械损耗也较大,所以乙方很有必要对控制算法进行一番研究. 本研究 ...
- 基于canvas的H5小游戏之一款风格简约跳跃小游戏
该游戏采用的是html5中的canvas技术进行制作,并采用了html5中的viewport来初始化在移动端设备的不同屏幕大小下的页面大小.下边为viewpoert的详解: <meta name ...
- Spring Boot使用Spring DeferredResult实现长轮询,纵享新丝滑让你体验丝滑般的感觉 - 第414篇
相关历史文章(阅读本文前,您可能需要先看下之前的系列
- 揭秘双11丝滑般剁手之路背后的网络监控技术
简介:本篇将重点介绍Hologres在阿里巴巴网络监控部门成功替换Druid的最佳实践,并助力双11实时网络监控大盘毫秒级响应. 概要:刚刚结束的2020天猫双11中,MaxCompute交互式分析( ...
- 阿里云数据库专家白宸:Redis带你尽享丝滑!(图灵访谈)
访谈嘉宾: 本名郑明杭,现阿里云NoSQL数据库技术专家.先后从事Tair分布式系统.Memcached云服务及阿里云Redis数据库云服务开发,关注分布式系统及NoSQL存储技术前沿. 作为嘉宾,曾 ...
- 阿里云数据库专家白宸:Redis带你尽享丝滑!
本文仅用于学习和交流目的,不得用于商业目的.非商业转载请注明作译者.出处,并保留本文的原始链接:http://www.ituring.com.cn/art... 访谈嘉宾: 本名郑明杭,现阿里云NoS ...
- 基于 高德 + Windvane 的H5选址工具,纵享丝滑,对高德选址组件说:走你
一.背景 1.1 需求 **多个产品和业务方反应:**高德的选址组件不好用,跟demo一样,能不能换一个? 秉承着,作为一名即将优秀的程序员不能 Say NO 的原则,我接了下来 1.2 现有方案缺失 ...
最新文章
- python web开发-flask中response,cookies,session对象使用详解
- 团体程序设计天梯赛-练习集 L1-002 打印沙漏
- Java 正则表达式匹配模式[贪婪型、勉强型、占有型]
- linux 分步编译命令,GCC分步编译C++程序(汇总版)
- js正则函数match、exec、test、search、replace、split使用集合
- 不规则对话框的又一实现
- Laravel中优雅的验证日期需要大于今天
- ubuntu 配置python,Redis,Mysql
- linux如何配置本地yum,Linux配置本地yum源配置方法
- IQtree:使用 SNP 数据构建 有根 系统发育树及踩坑
- 苹果手机通过扫描二维码下载APP
- 基于FPGA的DHT11数字温湿度传感器测试
- opencv 在图片上打印字符。
- ipad的正确使用方法视频,ipad的正确使用方法图解
- Paperreading 之二 多人人体姿态估计COCO2017冠军—CPN
- CMD打开IIS,重启iis等
- Nexus搭建私服(记录)
- python 多线程卡死跳出_解决python线程卡死的问题
- 快速提升SEO关键词搜索排名的5大伎俩
- 跟着老猫来搞GO——启程
热门文章
- 海康设备对接sdk错误码汇总 v6.0
- 链表操作eeeeeeeeee
- 微信小程序使用setData方法修改data中对象或数组的属性值
- 扯淡 | 程序员和豪车及阿玛尼
- yarn和npm区别
- 电脑C语言软件怎样拷贝到u盘,禁止U盘复制电脑文件、禁止电脑文件复制到U盘、禁止复制计算机文件到U盘的方法...
- 读《透过结构看世界》
- 2022年茶艺师(中级)考试模拟100题及在线模拟考试
- 联通无线网卡人工服务器,联通无线上网卡怎么用 联通无线上网卡使用步骤【详解】...
- impdp oracle 只导入表结构_Oracle数据导入导出(expdp impdp)