CSS 变量 safe-area-inset-*
iso 提供了一些 css 变量,可以用来避开刘海区域(notch 区域)。这些 css 变量已经成为了 w3c 标准。
env(safe-area-inset-top)
env(safe-area-inset-right)
env(safe-area-inset-bottom)
env(safe-area-inset-left)
最简单的,只要给 body(如果是extjs,则是 #ext-viewport元素) 加上以下样式,即可避开刘海。
padding-top: env(safe-area-inset-top)
padding-right: env(safe-area-inset-right)
padding-bottom: env(safe-area-inset-bottom)
padding-left: env(safe-area-inset-left)
但是这样应用的 沉浸式体验就很不好,如下图,整个应用四周会变成空白的:
ps: 为了演示,我"夸张"地模拟了刘海区域的高度。
理想的效果是下面这样,应用的内容 也能填充到这些区域。只是操作类型的组件(比如按钮、输入框) 需要避开刘海。
实现方法
1、先来写一些 css 样式类
// safe-area
.topinset {
padding-top: env(safe-area-inset-top);
}
.bottominset {
padding-bottom: env(safe-area-inset-bottom);
}
#ext-viewport {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
左右的 notch 区域我就简单处理了。主要是上下的刘海(顶部摄像头传感器,底部横线)。
2、识别出那些 view 需要处理,这是难点之一
一般包括
1)全屏的view
比如 Ext.Viewport 内加入的 view,还有 全屏显示的弹出层 Dialog/Sheet
-
对于 Ext.Viewport 内加入的 view,我们可以监听 Ext.Viewport 的 add 事件,在事件处理函数中,对加入的 view 进行处理。
-
对于全屏的弹出层,先要判断出那些是全屏的弹出层:
- view.getTop() == 0 且 view.getHeight() == 100vh 或 100%
- view.getCentered() == 0 且 view.getHeight() == 100vh 或 100%
- view 限制在可视区域里面(即不能拖拽出可视区域外) 且 view.getHeight() == 100vh 或 100%
这样,我们可以在 updateTop、updateCentered、updateHeight 等方法里面处理。
2)靠底部显示的 Sheet
对于靠底部显示的 Sheet,即 view.getBottom() == 0 的情形,我们可以在 updateBottom 里面处理。
3、如何处理
如果 view 有停靠顶部或底部的标题栏、工具栏等,那么我们应该将 padding 加到这些标题栏、工具栏上,如下:
如果view 没有标题栏、工具栏,那么应该加到可滚动容器 div 内,如下图:
此处必须要考虑到 view 的嵌套,一定要找到最终可以滚动的那个容器 div。
也就是说,如果 container1 用 fit/card 布局加了一个子 container2,那么应该处理 container2,而不是 container1。
当然还有一些细节,比如,可以滚动的那个容器 div 原本 已经有了 padding 样式,我们当然不能去覆盖它,所以需要另辟蹊径,比如给 div 的 :after 伪元素设置个高度:
主要代码
1、写一些 scss 样式类
// safe-area
.topinset {
&:not(.x-button), &.x-button .x-inner-el {
padding-top: env(safe-area-inset-top);
}
}
.bottominset {
&:not(.x-button), &.x-button .x-inner-el {
padding-bottom: env(safe-area-inset-bottom);
}
}
.topinset-margin {
margin-top: env(safe-area-inset-top);
}
.bottominset-margin {
margin-bottom: env(safe-area-inset-bottom);
}
#ext-viewport {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
.topinset-container {
&>.x-body-el:not(.x-layout-fit):not(.x-layout-card),
&>.x-dock > .x-body-el:not(.x-layout-fit):not(.x-layout-card),
&>.x-body-wrap-el > .x-body-el:not(.x-layout-fit):not(.x-layout-card),
&>.x-body-wrap-el > .x-dock > .x-body-el:not(.x-layout-fit):not(.x-layout-card)
{
&:before {
content: '';
display: block;
clear: both;
padding-top: env(safe-area-inset-top);
}
}
}
.bottominset-container {
&:not(.x-list) {
&>.x-body-el:not(.x-layout-fit):not(.x-layout-card),
&>.x-dock > .x-body-el:not(.x-layout-fit):not(.x-layout-card),
&>.x-body-wrap-el > .x-body-el:not(.x-layout-fit):not(.x-layout-card),
&>.x-body-wrap-el > .x-dock > .x-body-el:not(.x-layout-fit):not(.x-layout-card)
{
&:after {
content: '';
display: block;
clear: both;
padding-bottom: env(safe-area-inset-bottom);
}
}
}
&.x-list {
&:not(.x-infinite) > .x-body-el > .x-list-outer-ct > .x-list-inner-ct,
&.x-infinite > .x-body-el > .x-list-outer-ct > .x-scroller-spacer
{
padding-bottom: env(safe-area-inset-bottom);
}
}
}
2、写一个帮助类方法
功能是遍历 某个 view 的 布局结构,找出 需要 inset 的停靠工具栏 或 容器
Ext.define('MyApp.util.Utils', {
alternateClassName: 'Utils',
singleton: true,
/**
* 遍历 view 的层次结构(fit/card layout),给 view 设置 safe-area-inset-*
* @param {Ext.Container} view
* @param {Boolean} needBottomInset 是否需要底部的 inset
* @param {Boolean} needTopInset 是否需要顶部的 inset
*/
iterateFullViewToInset(view, needBottomInset, needTopInset) {
if (!Ext.os.is.iOS) return; // 目前只需要处理 ios
if (!view || view.destroyed || view.destroying || !view.getDockedItems) return;
const need = view.needInset || {};
if (needBottomInset === null) needBottomInset = need.bottom;
if (needTopInset === null) needTopInset = need.top;
view.needInset = {
bottom: needBottomInset,
top: needTopInset
};
if (!view.initialized) { // 等到 view 初始化完毕时 才去处理 padding,避免无谓的消耗
view.un({
initialize: Utils.iterateFullViewToInset
});
view.on({
initialize: Utils.iterateFullViewToInset,
args: [view, null, null]
});
return;
}
let bottomSetted = false;
let topSetted = false;
// 先处理 停靠在顶部或底部的 工具栏、Header 等
const dockedItems = view.getDockedItems();
if (view.getHeader && view.getHeader()) {
dockedItems.unshift(view.getHeader());
}
if (dockedItems.length) {
for (let i = 0; i < dockedItems.length; i++) {
const item = dockedItems[i],
docked = item.getDocked();
if (['bottom', 'top'].includes(docked)) {
if (!item.isHiddenchangeSyncInset) {
item.on({
hiddenchange: Utils.iterateFullViewToInset,
args: [view, null, null]
});
item.isHiddenchangeSyncInset = true;
}
if (docked == 'bottom') {
if (needBottomInset && !item.getHidden() && !bottomSetted) {
item.element.addCls('bottominset');
bottomSetted = true;
} else {
item.element.removeCls('bottominset');
}
}
if (docked == 'top') {
if (needTopInset && !item.getHidden() && !topSetted) {
item.element.addCls('topinset');
topSetted = true;
} else {
item.element.removeCls('topinset');
}
}
}
}
}
const clsb = 'bottominset-container',
clst = 'topinset-container';
// 如果没有停靠顶部底部的工具栏,则给容器内可以滚动的 view 加 inset
const layout = view.getLayout();
let done = false;
if (needBottomInset && !bottomSetted || needTopInset && !topSetted) {
if (layout) {
let items;
if (['fit', 'card', 'hbox'].includes(layout.type)) {
items = view.getInnerItems();
} else if (layout.type == 'vbox') {
const innerItems = view.getInnerItems();
if (innerItems.length == 1 && innerItems[0].getFlex() == 1) {
items = [innerItems[0]];
}
}
if (items) {
items.forEach(x => {
Utils.iterateFullViewToInset(x, needBottomInset && !bottomSetted, needTopInset && !topSetted);
});
done = true;
}
}
if (!done) {
if (needBottomInset && !bottomSetted) {
view.element.addCls(clsb);
} else {
view.element.removeCls(clsb);
}
if (needTopInset && !topSetted) {
view.element.addCls(clst);
} else {
view.element.removeCls(clst);
}
} else {
view.element.removeCls(clsb);
view.element.removeCls(clst);
}
} else {
if (layout) {
let items;
if (['fit', 'card', 'hbox'].includes(layout.type)) {
items = view.getInnerItems();
} else if (layout.type == 'vbox') {
const innerItems = view.getInnerItems();
if (innerItems.length == 1 && innerItems[0].getFlex() == 1) {
items = [innerItems[0]];
}
}
if (items) {
view.getInnerItems().forEach(x => {
Utils.iterateFullViewToInset(x, false, false);
});
done = true;
}
}
if (!done) {
view.element.removeCls(clsb);
view.element.removeCls(clst);
}
}
if (!view.isAddRemoveSyncInset) {
if (view.getHeader) {
const originalUpdateHeader = view.updateHeader;
view.updateHeader = function (header) {
originalUpdateHeader.apply(view, arguments);
Utils.iterateFullViewToInset(view, null, null);
};
}
view.on({
add() {
Utils.iterateFullViewToInset(view, null, null);
},
remove() {
Utils.iterateFullViewToInset(view, null, null);
}
});
view.isAddRemoveSyncInset = true;
}
}
});
3、增加一个 override
放在项目 overrides
目录下
主要功能是统一处理 全屏和靠底部显示的悬浮弹出层,比如 Dialog 和 Sheet
/**
* 底部工具栏添加 bottominset
*/
Ext.define(null, {
override: 'Ext.Panel',
updateTop(top) {
const me = this;
me.callParent(arguments);
me.syncNtochInset();
},
updateCentered(centered) {
const me = this;
me.callParent(arguments);
me.syncNtochInset();
},
updateBottom(bottom) {
const me = this;
me.callParent(arguments);
me.syncNtochInset();
},
updateFloated(floated) {
const me = this;
me.callParent(arguments);
me.syncNtochInset();
},
updateHeight(height) {
const me = this;
me.callParent(arguments);
me.syncNtochInset();
},
updateHeader(header, oldHeader) {
const me = this;
me.callParent(arguments);
if (header && ['bottom', 'top'].includes(header.getDocked())) {
me.syncNtochInset();
}
},
initialize() {
const me = this;
me.on({
initialize: 'syncNtochInset',
scope: me
});
me.callParent(arguments);
},
doAdd(item) {
const me = this;
me.callParent(arguments);
if (item && ['bottom', 'top'].includes(item.getDocked())) {
me.syncNtochInset();
}
},
doRemove(item, index, destroy) {
const me = this;
if (item && ['bottom', 'top'].includes(item.getDocked())) {
me.syncNtochInset();
}
me.callParent(arguments);
},
syncNtochInset() {
const me = this;
if (!Ext.os.is.iOS || !me.initialized || me.destroyed || me.destroying) return;
const floated = me.getFloated();
if (!floated) return;
const centered = me.getCentered(),
top = me.getTop(),
bottom = me.getBottom(),
height = me.getHeight(),
draggable = me.getDraggable();
// 底部是否需要 padding-bottom
const b = floated && ( // 悬浮层
bottom == 0 || // 停靠底部
(top == 0 || centered ||
(me.getConstrainDrag && me.getConstrainDrag()) ||
(draggable && draggable.getConstrain() && draggable.getConstrain().getElement().dom === document.body)) &&
(height == '100vh' || height == '100%') // 或者全屏
);
// 顶部是否需要 padding-top
const t = floated && ( // 悬浮层
top == 0 || // 停靠顶部
(bottom == 0 || centered ||
(me.getConstrainDrag && me.getConstrainDrag()) ||
(draggable && draggable.getConstrain() && draggable.getConstrain().getElement().dom === document.body)) &&
(height == '100vh' || height == '100%') // 或者全屏
);
Utils.iterateFullViewToInset(me, b, t);
}
});
4、统一处理 Ext.Viewport 内加入的 view
在 Application.js
的 launch
方法中监听 add
和 remove
事件,以便处理 view
launch: function () {
this.callParent(arguments);
Ext.Viewport.on({
add(ct, item) {
if(!item.getFloated()) Utils.iterateFullViewToInset(item, true, true);
},
remove(ct, item) {
if(!item.getFloated()) Utils.iterateFullViewToInset(item, false, false);
}
});
}