- 屏幕旋转与文档方向?如何监听文档方向变化,如何兼容横屏/竖屏模式下的样式布局以及实现强制横屏展示canvas手写电子签名页?
基于实践问题,本文主要覆盖以下至少点:
- 文档方向与屏幕方向
- 不同端APP浏览器的横屏预览支持情况
- 移动端适配
- 横屏布局快速兼容
- canvas响应式适配屏幕旋转带来的问题以及兼容方法
移动端横/竖屏模式下的电子签名
屏幕方向于页面方向
- 屏幕方向:手机的屏幕方向,一般未设置锁定🔒屏幕方向情况下,屏幕方向会随拿手机屏幕对应的方向(竖、横、倒等)自动感应触发旋转
- 页面方向:即网页的方向,取决于app的webview是否设置了跟随系统屏幕旋转方向
一、背景
- 默认应用所有页面只支持竖屏模式,未兼容竖屏模式的浏览和操作
- 有个电子签名页面,为方便用户操作,需要做了横屏模式的布局展示(兼容横竖屏,无论横竖屏下,签字图都需要是横向显示)
- 未考虑到横屏模式,版本一的处理形式是基于固定的竖屏模式做了横屏形式的布局(绝大多数浏览器的网页浏览并不会跟随系统横屏)
二、遇到的问题
- 在实际表现中发现,ios启动自动旋转屏幕模式时,在微信等浏览器中用户签名操作时,会导致自动旋转,导致样式异常
- 在安卓中,启动旋转正常,网页并不会默认进入横屏浏览模式😯
三、网页方向跟随屏幕方向表现探索(忽略的冷知识)
- 针对不同的机型中部分app浏览器在手机横屏模式下的网页方向变化表现得出如下:
安卓(各app规则不一)
不会跟随系统自动旋转、不支持横屏模式(可能我没找到设置的地方)
- i.百度等大部分浏览器
可自定义设置
- 华为自带浏览器、微信等其他浏览器
默认可随系统设置进入横屏模式
- 企业微信等
- IOS(比较统一):
- 微信、Safari等绝大部分浏览器都会跟随系统,暂时未发现可自定义和不支持的
✧总结(各浏览器app在处理支持横屏模式下独立其行):
- ○在ios中基本上所有的app浏览器都支持自动跟随系统横屏预览模式
- ○在安卓中不同app有不同的处理形式,比如微信内不支持横屏、华为自带浏览则是提供设置是否要跟随系统
四、解决方案探索
(一)思路分析
- 强制横屏:移动H5虽可以判断横竖屏,但考虑到多设备访问问题(安卓多数app需要开启横屏模式,但入口比较深),所以强制横屏显示只能放弃;
- 强制竖屏模式:书写签名时必须要横向输入,除非能够关闭手机横屏模式(依赖用户操作-可兜底考虑弹窗提示)或者锁定🔒网页方向
- 横竖屏兼容:通过视觉旋转,或者横竖屏样式布局兼容
(二)方案一锁定网页方向
1、针对特定的浏览器提供了设置可支持
<meta name=”screen-orientation” content=”portrait”> <!--uc强制竖屏-->
<meta name=”x5-orientation” content=”portrait”> <!--QQ强制竖屏-->
2、 screenOrientation API
- ✧文档地址:https://developer.mozilla.org/en-US/docs/Web/API/ScreenOrientation
- ✧对应JS中提供了相应监听文档方向变化的API,(其中包含锁定与取消锁定方向布局)
✧lock() 与 unlock()
const oppositeOrientation = screen.orientation.type.startsWith("portrait") ? "landscape" : "portrait"; screen.orientation .lock(oppositeOrientation) .then(() => { log.textContent = `Locked to ${oppositeOrientation}\n`; }) .catch((error) => { log.textContent += `${error}\n`; });
- ✧可惜兼容性不容乐观(❌)
(三)方案二:横屏模式兼容
横竖屏的样式兼容包括普通DOM以及canvas画布处理
通过rotate视觉旋转方式,在测试过程中由于项目本身vw/vh移动适配,以及旋转后画布落笔精度等问题,相比直接布局适配横屏更为复杂,所以放弃此形式。
1. DOM的横屏适配兼容
- ✧项目页面采用的是VW方案进行移动端适配,由于 vw 单位的特性,适配换算大小是根据屏幕宽度而言的,因此屏幕宽度越大导致容器、文字会越大,还可能导致 DOM 元素超出屏幕外,并不是我们所想要的用户体验
##### 1.1 换用移动端适配方案,如Flexible? - 对整体应用影响太大,并且Flexible方案实质主要是借助JavaScript控制viewport的能力,使用rem模拟vw的特性从而达到适配目的的一套解决方案
- rem是基于根字体大小计算,其存在需要处理横屏兼容问题
1.2 针对横屏通过媒体查询单独设置vw\vh值的换算?
- ✓vw 单位的特点是适配换算大小时是根据屏幕宽度而定的,那么在强制横屏显示时,就可以同理转换为屏幕高度来而定,也就是 vw 单位替换成 vh 单位;
- ✓或者,基于当前使用的375 667 @2x设计稿尺寸(2倍图7501334px),默认vw是按750px进行换算,在横屏时使用1334px进行换算
✓插件postcss-px-to-viewport 发现是支持配置横屏模式的换算规则
module.exports = { plugins: { 'postcss-px-to-viewport': { unitToConvert: 'px', // 需要转换的单位 viewportWidth: 750, // 视口宽度,等同于设计稿宽度 unitPrecision: 5, // 精确到小数点后几位 landscape: true, // 是否自动加入 @media (orientation: landscape),其中的属性值是通过横屏宽度来转换的 - 开启 landscapeUnit: 'vw', // 横屏单位 landscapeWidth: 1334 // 横屏宽度 } } }
- ✓配置后所有的换算过的属性都会同步加上横屏模式的媒体查询样式:
- ✓样式效果(修改后横屏样式好了很多,但部分模块与竖屏相比还是未换算得体比较突兀):
进一步排查发现,上图为转化过来的部分是使用的第三方组件,组件库在样式抽离编译时未配置
postcss-px-to-viewport
的landscape
模式适配或者使用的viewportWidth
基准不一样导致方案一:针对不同的文件做不同的配置
.postcssrc.js
const path = require('path'); module.exports = ({ file }) => { const designWidth = file.dirname.includes(path.join('node_modules', 'vant')) ? 375 : 750; const designHeight = file.dirname.includes(path.join('node_modules', 'vant')) ? 667 : 1334; return { plugins: { 'postcss-px-to-viewport': { unitToConvert: 'px', // 需要转换的单位 viewportWidth: designWidth, // 视口宽度,等同于设计稿宽度 unitPrecision: 5, // 精确到小数点后几位 landscape: true, // 是否自动加入 @media (orientation: landscape),其中的属性值是通过横屏宽度来转换的 - 开启 landscapeUnit: 'vw', // 横屏单位 landscapeWidth: designHeight // 横屏宽度 } } } }
方案二:针对尺寸属性进行样式覆盖,以使用项目自定义的单位转换规则,如
.page-product-sign { --alert-font-size: 24px; --alert-padding: 14px 30px; --button-font-size: 36px; .we-alert-body { min-height: 56px; } }
- 覆盖适配后横屏模式时样式
2. 解决 Canvas 的横屏适配问题
签字板是响应式的,即canvas的css样式宽高全屏铺满100%,而画布尺寸width/height在初始化是确定(默认300*150),为了保持一致加载时会读取容器尺寸复制给画布大小进行初始化。
2.1 问题一:旋转屏幕后签名内容出现扭曲?
canvas元素可以使用CSS来定义大小,但在绘制时图像会伸缩以适应它的框架尺寸:如果 CSS 的尺寸与初始画布的比例不一致,它会出现扭曲;
处理手段:监听屏幕旋转,在网页方向发生变化后重新更新canvas画布的大小
// 设置 canvas 宽高 -- 重新计算 const { width, height } = this.$refs.canvasWrap.getBoundingClientRect() this.$refs.canvas.width = width this.$refs.canvas.height = height // 或者宽高转换 const { width, height } = this.$refs.canvas this.$refs.canvas.width = height this.$refs.canvas.height = width
2.2 问题二:旋转后画布内容被清空了?
改变canvas宽高时,发现画布上的内容被清空了,这是因为canvas的大小改变后会自动清除内容的
2.2.1 方案一:在改变画布大小前将内容保存,更新大小后重新绘制画布
图片旋转参考:https://blog.csdn.net/frgod/article/details/106055830
/* 屏幕旋转在更新画布大小前处理 */ const { width, height } = canvas // 保存绘制的画布内容 const imageData = context.getImageData(0, 0, width, height) /* 屏幕旋转在更新画布大小后处理 */ this.$refs.signatureBoard._initialize() // 旋转图片 -- 由于宽高不一样需要旋转后才能重绘进当前画布 rotateImg = getRotateImage({ imageData }) // 重绘画布 context.putImageData(rotateImg, 0, 0, 0, 0, height, width) // 图片旋转方法 function getRotateImage({ imageData }) { const { data, width, height } = imageData let rotateImg = new ImageData(height, width) let r = 0 let r1 = 0 // index of red pixel in old and new ImageData, respectively for (let y = 0, lenH = height; y < lenH; y++) { for (let x = 0, lenW = width; x < lenW; x++) { r = (x + lenW * y) * 4 r1 = (y + lenH * x) * 4 rotateImg.data[r1 + 0] = data[r + 0] rotateImg.data[r1 + 1] = data[r + 1] rotateImg.data[r1 + 2] = data[r + 2] rotateImg.data[r1 + 3] = data[r + 3] } } return rotateImg }
2.2.2 方案二:绘制路径时记录path,图片旋转后对x/y进行替换并重新绘制
/** 触摸移动绘制 --- 签名组件内调整 */ onTouchMove(event) { const point = { isStart: false, x: event.srcEvent.clientX - this.rect.left, y: event.srcEvent.clientY - this.rect.top } this.paths.push(point) // 记录下此时的绘制路径 this.context.lineTo(point.x, point.y) this.context.stroke() } // 返回实例 getCurrentContext() { return Promise.resolve({ canvas: this.$refs.canvas, context: this.context, paths: this.paths }) } /** 业务项目 */ // 屏幕旋转监听函数 orientationChangeFn({ orientationType }) { // 旋转后的图片 this.$refs.signatureBoard.getCurrentContext().then(({ context, paths }) => { this.$refs.signatureBoard._initialize() // 重绘画布 paths.forEach((point) => { // x/y进行替换 const temp = point.y point.y = point.x point.x = temp // 初始触点 if (point.isStart) { context.moveTo(point.x, point.y) } else { // 移动触点 context.lineTo(point.x, point.y) context.stroke() } }) }
✧两种方案都可行,针对签名场景出于实现的复杂度以及数据处理性能,相对来说方案二较优(并且putImageData处理起来也不如直接绘制Why is putImageData so slow?)
2.2.3 问题三:旋转更新canvas后签名移动触点错位
触摸是通过hammer.js实现,再加载时进行事件绑定和初始化
// 事件绑定 const hammertime = new Hammer(this.$refs.canvas) hammertime.get('pan').set({ direction: Hammer.DIRECTION_ALL, threshold: 1 }) hammertime.get('swipe').set({ enable: false }) hammertime.on('panstart', this.onTouchStart.bind(this)) hammertime.on('panmove', this.onTouchMove.bind(this))
猜测试由于canvas的大小变化,导致屏幕旋转后未重新初始化绑定导致触点出现与实际绘制错位
- 解决方法,旋转屏幕大小变化后对hammer.js重新进行绑定和初始化
五、文档方向监听
1、媒体查询API(MediaQueryList)
- 定义和用法: matchMedia() 返回一个新的 MediaQueryList 对象,表示指定的媒体查询字符串解析后的结果。matchMedia() 方法的值可以是任何一个 CSS @media 规则 的特性, 如 min-height, min-width, orientation 等。
MediaQueryList 对象有以下两个属性:
- media:查询语句的内容。
- matches:用于检测查询结果,如果文档匹配 media query 列表,值为 true,否则为 false
基本使用
this.mediaQueryList = window.matchMedia('screen and (orientation: portrait)') // 识别竖屏媒体查询特性 this.mediaQueryList.addListener(() => { this.orientationType = this.mediaQueryList.matches ? 'portrait' : 'landscape' })
- 兼容性
- 文档:https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList
2、设备方向监听(orientationchange)
orientationchange事件在设备的纵横方向改变时触发
基本使用
/** 90或 - 90横屏; 0或180竖屏 */ function orientationChange(event) { this.orientationType = [0, 180].includes(event?.orientation || window.orientation) ? 'portrait' : 'landscape' }; window.addEventListener("orientationchange",orientationChange);
- 兼容性
- 文档:https://developer.mozilla.org/zh-CN/docs/Web/API/Window/orientationchange_event
3、resize 配合 ( window.innerWidth, window.innerHeight )
基本使用
window.addEventListener("resize", (event) => { const orientation = (window.innerWidth > window.innerHeight) ? "landscape" : "portrait"; if(orientation === 'portrait'){ console.log('竖屏'); } else { console.log('横屏'); } }, false);
缺点❌: 键盘等弹出都会受到干扰,且通过window.innerWidth > window.innerHeight只是一种伪检测,有点不可靠
4、最终方案
考虑大部分主移动设备浏览器,优先orientationchange,不支持则使用matchMedia()
class OrientationChangeClass { constructor() { ... this._init() } _init() { if ('orientation' in window && 'onorientationchange' in window) { this.supportType = 'orientation' } else if ('matchMedia' in window) { this.supportType = 'matchMedia' this.mediaQueryList = window.matchMedia('screen and (orientation: portrait)') } this._listenChangeFn() } _listenChangeFn(event = {}) { switch (this.supportType) { case 'orientation': this.orientationType = [0, 180].includes(event?.orientation || window.orientation) ? 'portrait' : 'landscape' break case 'matchMedia': this.orientationType = this.mediaQueryList.matches ? 'portrait' : 'landscape' break } } addListener(callbackFn) { ... switch (this.supportType) { case 'orientation': window.addEventListener('orientationchange', this.changeBackfn, false) break case 'matchMedia': if (this.mediaQueryList?.addListener) { this.mediaQueryList.addListener(this.changeBackfn) } break } } removeListener() {} }
六、遇到的异常
(一)设置横屏模式转化编译报错
- 报错内容
报错原因与解决方案
- 原因:不支持postcss 8.0+版本
- 解决:降低postcss版本为7.0或者使用
postcss-px-to-viewport-8-plugin
七、总结
大多数应用我们都不会考虑横屏模式的兼容,默认竖屏浏览;针对需要兼容的页面场景需要重点注意考虑以下
- 不同的移动端设备,以及APP浏览器是否支持横屏以及是否支持跟随系统旋转模式表现不一(所以提示用户锁定啥的有点不好找,ps-安卓有些浏览器没找到设置项)
- 锁定文档方向,兼容性很差——起码目前API等做不到
移动端屏幕旋转布局兼容:
- DOM兼容:可使用CSS媒体查询单独两套样式(postcss-px-to-viewport的横屏配置实际也是这种模式);监听横屏变化做响应式处理
- canvas兼容:样式尺寸以及画布尺寸的区别、尺寸变化对绘制内容的还原、触点的变化等需要考虑的细节较多
启程新篇章~~
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。