工作总结(6)

这篇工作总结,主要针对移动端网页开发中遇到的软键盘坑来整理的。

最近在做移动端页面,然后遇到一个很多人都遇到的坑:当页面有固定在底部的评论框的情况下,当点击评论框获得聚焦之后,底部评论框会被软键盘顶起,但是不会贴着软键盘顶部,而且后面的页面也会向上滚动一定高度,而且仍然可以滚动。这个问题的结果就是,在评论框获得聚焦时,整个页面会滚动,输入框位置很突兀,如果输入框是textarea,则有可能有一部分推到页面顶部一样,用户体验非常差。

这个问题拆解出来,有几个需要解决的问题:

  1. 背景页面仍然可以滚动。我尝试了增加遮罩层,但是还是不受控制;

  2. 底部评论框顶起的位置不正确。这个问题在安卓系统不会出现,所以是iOS的坑;

  3. 底部评论框获得聚焦的时候,背景页面会被顶起。

这个坑我在网上找了很多帖子,发现真的是一个很难解决的问题。我也有找到一些解决方案,但是实际实现下来效果还是很差。这里我们一个个问题来整理如何解决。

背景页面滚动问题

这个问题不管在安卓还是iOS系统上都会出现。原因应该出现在本身页面高度超过窗口高度,而且移动端有touchmove事件,虽然有遮罩层,但是其实body是遮罩层的根节点,所以存在事件捕获跟冒泡的问题。

因此,主要的问题是要阻止touchmove事件。解决方案需要完成两步操作:

1. 禁止body的touch事件。这样可以确保touchmove事件不会从body元素开始捕获

处理的办法是设置body元素的touchAction属性为none

1
document.body.style.touchAction = 'none';

这样就可以禁止body的touch事件。不过这个只是适用于安卓系统,在safari上并不适用,因为safari不支持该属性

2. 阻止body发生默认的行为。这样可以保证touchmove事件不会触发body元素的滚动

处理的办法是使用e.preventDefault(),同时在绑定addEventListener的时候,需要再传一个options

1
2
3
4
5
document.body.addEventListener('touchmove', this.stopTouch, { passive: false, capture: true });

stopTouch (e) {
e.preventDefault();
}

addEventListener的第三个参数options的写法比较少用到,我们一般用到的是useCapture,而且一般也是使用默认值false。在MDN有详细的说明:

1
2
3
4
5
options
一个指定有关 listener 属性的可选参数对象。可用的选项如下:
capture: Boolean,表示 listener 会在该类型的事件捕获阶段传播到该 EventTarget 时触发。
once: Boolean,表示 listener 在添加之后最多只调用一次。如果是 true, listener 会在其被调用之后自动移除。
passive: Boolean,设置为true时,表示 listener 永远不会调用 preventDefault()。如果 listener 仍然调用了这个函数,客户端将会忽略它并抛出一个控制台警告。

我们在这里使用{ passive: false },这样使用preventDefault()时Chrome等一些浏览器就不会报错;而使用{ capture: true }则可以是事件尽早被阻止。

第一个问题完整的解决方案代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
watch: {
show(newVal) {
this.toggleContainerTouchAction(newVal);
if (newVal) {
document.body.addEventListener('touchmove', this.stopTouch, { passive: false, capture: true });
} else {
document.body.removeEventListener('touchmove', this.stopTouch, { capture: true });
}
}
}

method: {
toggleContainerTouchAction (flag) {
document.body.style.touchAction = flag ? 'none' : 'auto';
},
stopTouch (e) {
e.preventDefault();
},
}

底部评论框顶起的位置

首先,这个问题的根本问题是iOS在处理fixed布局时,当页面输入框获得焦点的时候,会把fixed改成absolute绝对定位,同时计算fixed容器在页面的位置,以此作为绝对定位的位置。正常来说其实会让底部评论框顶到软键盘顶部的位置,虽然如果滚动页面的话底部评论框是会跟软键盘分开,但是上面已经有解决方案了,所以其实还好。问题在于因为在软键盘上滑的同时,页面整体也会被动向上滚动,但评论框的位置是在页面滚动前计算的,所以才会出现顶起后还跟软键盘分离的情况。

因此,首先可以想到的是,不用fixed布局,而改用absolute布局,同时在输入框获得焦点的时候,动态计算输入框容器的高度。

不过直接放弃fixed布局其实是很可惜的,因为如果一开始写布局的时候就用绝对定位,那底部评论框就得一直动态计算位置,而且这个问题只是iOS系统才会出现,安卓系统是没问题的。

所以在处理的时候,我把问题拆分了:

  1. 输入框在布局上,iOS跟安卓是分开处理的;
  2. 其次,iOS系统的布局,也按软键盘调起(等同于输入框获得焦点)与否分开处理;
  3. 因为页面滚动已经被设置为不可滚动,所以动态计算可以控制在一定次数以内。这三个优化可以让方案实施时减少JS的操作,提高页面性能,增加CSS样式操作。
1. 样式布局

样式布局上,我把fixed布局跟absolute布局从原来class.input-container中分离出来:

1
2
3
4
5
6
7
8
9
.input-container-absolute {
position: absolute;
top: 0;
}

.input-container-fixed {
position: fixed;
bottom: 0;
}

然后通过v-bind先把.input-container-fixed绑到输入框容器上。当输入框获得焦点的时候,判断平台是iOS还是安卓,如果是iOS,就改成.input-container-absolute,如果是安卓,就不变:

1
2
3
4
5
6
7
8
handleComment () {
if (this.tp.platformInfo === 'iOS') {
this.inputCls = 'input-container-absolute input-container';
/* ... */
} else {
/*...*/
}
},

当输入框失去焦点的时候,再改回fixed布局:

1
2
3
4
5
6
7
handleBlur () {
if (this.tp.platformInfo === 'iOS') {
/* ... */
this.$refs.inputCon.style.top = '';
this.inputCls = 'input-container-fixed input-container';
}
},

这样对于安卓系统来说,解决iOS系统的js操作跟安卓系统来说一点影响都没有。

2. iOS系统的布局

其实上面的代码也基本说明了,在iOS系统上,布局也不是完全使用绝对布局,就上面的代码,就可以保证在软键盘没有被调起的时候,不需要js控制布局。

3. 动态计算

这个问题是整个问题的核心点。首先,使用什么值来确定输入框的位置。因为使用了绝对定位,而且这个位置问题其实是y轴上滚动造成的,所以问题缩小为topbottom的选择上。一般fixed布局使用bottom,绝对定位也使用bottom可以统一定位方式,但是实际在.input-container-fixed.input-container-absolute的样式上,其实已经说明了绝对定位时,我是选择top来控制输入框的位置的。因为我在尝试使用bottom,发现计算不出来,尴尬…

所以我是计算了top。怎么计算?我们这么考虑,应该是这样的公式:

1
top = 页面滚动高度 + 视窗高度 - 输入框容器高度

首先,页面滚动高度可以通过document.documentElement.scrollTopdocument.body.scrollTop获得;输入框是在软键盘的顶部,而且其实当软键盘调起之后,实际的视窗高度是原来视窗高度减去软键盘高度。这部分不需要计算,直接使用window.innerHeight;输入框容器高度可以通过offsetHeight获得。因此,公式转换成:

1
top = document.documentElement.scrollTop/document.body.scrollTop + window.innerHeight - this.$refs.input.offsetHeight

这里简单说一下,bottom的计算我就是用上面的公式,就是不减去容器高度而已,但是显示效果就是不对…

所以就有下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
getScrollTop () {
let scroll_top = 0;
if (document.documentElement && document.documentElement.scrollTop) {
scroll_top = document.documentElement.scrollTop;
} else if (document.body) {
scroll_top = document.body.scrollTop;
}
return scroll_top;
},

handleScroll () {
if (this.commentVisible) {
this.scrollHeight = this.getScrollTop();
// +5px是为了确保输入框跟软键盘是贴在一起的,所以让输入框有一点点藏在软键盘下面是最保险的做法
this.$refs.inputCon.style.top = `${this.scrollHeight + window.innerHeight - this.$refs.inputCon.offsetHeight + 5}px`;
}
},

其实直接使用上面的代码也不能解决,因为软键盘弹出是有时间的,也就是如果一获取焦点就计算,那就会导致输入框还在软键盘弹出前视窗的底部。所以要动态计算,即在一定时间内,需要使用定时器setInterval不断计算输入框的位置,这样在软键盘调起的过程,输入框也能一直附在软键盘顶部:

1
2
3
4
5
6
7
8
9
10
11
scrollContent () {
clearInterval(this.interval);
let i = 0;
this.interval = setInterval(() => {
this.handleScroll();
i++;
if (i > 20) {
this.clearSrcoll();
}
}, 50)
},

这里控制了只计算20次,是因为软键盘被调起的时间其实很短,测试情况大概1秒内就可以完成,不用一直动态计算,而且定时器间隔时间是50,所以就计算为20次。

因此,样式布局的方法就得增加一些代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
clearSrcoll() {
clearInterval(this.interval);
},

handleComment () {
if (this.tp.platformInfo === 'iOS') {
this.inputCls = 'input-container-absolute input-container';
setTimeout(() => {
this.scrollContent();
}, 10)
} else {
/*...*/
}
},

handleBlur () {
if (this.tp.platformInfo === 'iOS') {
/* ... */
this.clearSrcoll();
this.$refs.inputCon.style.top = '';
this.inputCls = 'input-container-fixed input-container';
}
},

这里要说明的是,跟业务代码是有不同的,因为实际上页面一开始放的评论框容器没有input标签,只是放了一个像输入框的底部容器,加了点击事件后,会控制显示输入框容器的显示,然后在DOM刷新之后(this.$nextTick)使用focus()触发输入框获得焦点。所以就不放完整代码了。

页面顶起

这个问题其实没有解决,考虑到影响不大,而且当页面高度只有一屏时,也不会出现(主要是没有找到很好的解决方案),所以就不处理了。但是这个问题在其他情况下有解决方案,详情可以看工作总结(五)中关于iOS下微信H5页面的坑解决方案的整理。

上面就是全部内容。