Overlay, Dialog, Modal, Popover 傻傻分不清楚
参考:
Medium – Modal?Dialog?你真的知道他們是什麼嗎?
Popups, dialogs, tooltips, and popovers— UX Patterns #2
掘金 – 对话框、模态框和弹出框看起来很相似,它们有何不同?
傻傻分不清楚是正常的,因为市场上并没有统一的规范。
我个人的理解是这样:
-
Overlay
Overlay 的意思是覆盖,但凡有一个东西覆盖在另一个东西之上,都可以抽象理解为 Overlay。
用于 HTML 的话,只要是 position 定位覆盖在 body / 任何 element 之上,都是 Overlay。
-
Dialog
Material Design 对 Dialog 有明确的定义,Dialog 是一个覆盖在 body 之上的 Overlay,它会强制要求用户与之交互,不然 Dialog 就会一直遮挡在 body 之上。
-
Modal
HTML 对 Dialog 的定义和 Material Design 不同,HTMLDialogElement 有 2 个显示方法,一个是 show 一个是 showModal。
show 只是普通的显示在 body 里,showModal 才是像 Material Dialog 那样以 Overlay 形式显示。
所以对我来说 HTML Modal 和 Material Dialog 是一样的东西,但 HTML Dialog 和 Material Dialog 则是不同的。
-
Popover
Popover 也是一种 Overlay,但是它不像 Dialog 那样抢眼,也不那么强制交互。
Dialog 通常显示在屏幕的正中间大大个,Popover 则出现在 trigger 它的 element 附近小小个。
Dialog 通常会有一层全屏的黑影 (backdrop) 遮挡住后面 body 的内容,而 Popover 通常是没有 backdrop 的。
除了 Dialog,Modal,Popover 以外,其实还有很多的 Overlay,比如说 Snackbar。
但无论如何,本篇主讲是 Overlay,所以我们也不需要分的太清楚先,反正大家都是 Overlay 嘛。
CDK Overlay
CDK Overlay 是 Angular Material 封装的底层功能,用来实现抽象的 Overlay,而具体的 Material Dialog, Menu, Snarbar, Tooltip 等等等都是基于 CDK Overlay 实现的。
我们可以把 CDK Overlay 分成 5 个部分来学习
-
Overlay Dependency Injection Provider
它是一个 Root Level Provider 用来创建 OverlayRef
-
OverlayRef
OverlayRef 是一个普通的 class。
Overlay 和 OverlayRef 的关系类似于 FocusTrapFactory 和 FocusTrap 的关系。
我们可以创建多个 Overlay (遮罩层),每一个对应一个 OverlayRef 对象。
-
PositionStrategy
PositionStrategy 用来控制 Overlay 内容显示的位置,比如说 Dialog 内容通常是显示在屏幕的中心。
Popover 通常显示在 trigger 的附近。
- ScrollStrategy
当 Overlay 显示以后,body scroll 会怎么样?
比如说:
a. close Overlay when body scroll
b. block body scroll
等等 -
Overlay 指令
如同 CdkTrapFocus 指令那样,Overlay 也有类似的指令,它们的目的就只是为了方便开发。
底层依然是 Overlay Provider 和 OverlayRef。
好,接下来我们就一个一个部分学习呗
Overlay Provider
Overlay 是一个 Root Level Provider,我们用它来创建遮罩层。
export class AppComponent { constructor() { // 1. inject Overlay const overlay = inject(Overlay); afterNextRender(() => { // 2. 创建遮罩层 const overlayRef = overlay.create(); }); } }
调用 Overlay.create 会创建一个遮罩层,然后它会返回一个 OverlayRef,我们可以通过这个 OverlayRef 对遮罩层做后续的改动。
注:Overlay.create 是可以传入各种配置的,不过这些配置之后也可以透过 OverlayRef 做设置或修改,所以我把它放到 OverlayRef 的部分一起教,我们先关注 Overlay.create 就好了。
遮罩层的 HTML 结构
遮罩层会被 append 到 body 里 (app-root sibling)。
Overlay Container (.cdk-overlay-container) 是一个 position fixed div
Overlay Pane (cdk-overlay-pane) 是一个 position absolute div
我们再创建一个遮罩层看看 (是的,遮罩层是可以创建多个的)
afterNextRender(() => { // 2. 创建遮罩层 const overlayRef1 = overlay.create(); const overlayRef2 = overlay.create(); });
HTML 结构
Overlay Container 依然只有一个,Overlay Host 和 Pane 则多了一个,后一个创建的遮罩层会在下方,所以它会在比较上层,虽然所有遮罩层 z-index 都是 1000。
此时,虽然遮罩层已经出现了,但它只是两个定了位的空 div,用户啥也看不见。这是因为创建完整的遮罩层需要 2 个步骤,第一个是 Overlay.create,第二个是 OverlayRef.attach (下一 part 会教)。
我们先逛一下 Overlay.create 的源码,它在 overlay.ts。
没什么特别的,就只是创建了 3 个 div 而已 — Overlay Container,Overlay Host,Overlay Pane。
OverlayRef & OverlayConfig
上一 part 我们留了两个点没有解释清楚:
-
OverlayConfig
在 Overlay.create 时我们可以传入一个配置 — OverlayConfig
这个 OverlayConfig 在整个 create 环节里并没有被使用到,它只是转给了 OverlayRef 而已。
OverlayRef 才是真正消费 OverlayConfig 的对象。
-
要创建一个完整的遮罩层需要两个步骤,第一步 Overlay.create 我们已经做了,第二部是 OverlayRef.attach。
attach 环节就会使用到 OverlayConfig 了,虽然有一些 config 在 attach 之后依然可以修改,但有一些是不行的,所以我们要搞清楚 config 和环节的关系。
OverlayRef.attach
上一 part 有提到,Overlay.create 只是创建了几个空的 div,其中一个 div 是 Overlay Pane,它是一个 DomPortalOutlet (不熟悉 Portal 的朋友请看这篇 CDK Portal)。
我们想呈现的具体内容需要透过 ComponentPortal / TemplatePortal / DomPortal 的方式 attach 给 Overlay Pane DomPortalOutlet 才能被呈现出来。
这个 attach 过程便是透过 OverlayRef.attach 方法来完成的。
const overlayRef = overlay.create(); // 1. 创建 Portal (ComponentPortal, TemplatePortal, DomPortal 都可以) const helloWorldPortal = new ComponentPortal(HelloWorldComponent); // 2. 把 Portal attach 到 Overlay Pane const componentRef = overlayRef.attach(helloWorldPortal);
效果
OverlayRef.detech
有 attach 自然就有 detech
const overlayRef = overlay.create(); const helloWorldPortal = new ComponentPortal(HelloWorldComponent); const componentRef = overlayRef.attach(helloWorldPortal); // 1. 销毁 attched 的 HelloWorld 组件 overlayRef.detach();
效果
OverlayRef.attachments, detachments, hasAttached
const overlayRef = overlay.create(); const helloWorldPortal = new ComponentPortal(HelloWorldComponent); // 1. 监听 attach 事件 overlayRef.attachments().subscribe(() => console.log('attached')); // 2. 监听 detach 事件 overlayRef.detachments().subscribe(() => console.log('detached')); const componentRef = overlayRef.attach(helloWorldPortal); console.log(overlayRef.hasAttached()); // true overlayRef.detach(); console.log(overlayRef.hasAttached()); // false
attachments 和 detachments 方法返回 RxJS Observable<void> 用来监听 attach 和 detach 事件。
hasAttached 是一个属性,表示当前是否有 attachment。
OverlayRef.dispose
detach 只是把 attachment 销毁,dispose 是把整个遮罩层通通销毁。
const overlayRef = overlay.create(); const helloWorldPortal = new ComponentPortal(HelloWorldComponent); const componentRef = overlayRef.attach(helloWorldPortal); // 1. 销毁整个 遮罩层 overlayRef.dispose();
效果
HelloWorld 组件,Overlay Pane,Overlay Host 都被销毁了,只剩下一个 Overlay Container。
补充:dispose 时如果当前有 attachment 会先 detach,这会触发 detachments 事件,但没有 displose 事件的哦。
OverlayConfig.disposeOnNavigation
disposeOnNavigation 是一个蛮特别的配置。
在手机,用户习惯使用 back button 来关闭遮罩层。
为了支持这个交互体验,常见的做法是在打开遮罩层时先 push state,然后监听 window popstate 事件,
back button 会触发 window popstate 事件,届时就关闭遮罩层。
disposeOnNavigation 便可以做到这一点
// 1. 开启 disposeOnNavigation 机制 const overlayRef = overlay.create({ disposeOnNavigation: true, }); const helloWorldPortal = new ComponentPortal(HelloWorldComponent); const componentRef = overlayRef.attach(helloWorldPortal);
它的实现方式比较乱,大家要注意几个点:
-
何时起作用
在 Overlay.create 时 disposeOnNavigation 是没有作用的,要等到 OverlayRef.attach 之后才有效果。
-
何事起作用
attach 的时候会透过 Location 监听 window popstate (提醒:只监听 history back 和 forward 而已,pushState 和 replaceState 是不触发事件的哦),
触发时调用 dispose 方法销毁整个遮罩层。
-
不支持的场景
比如说,遮罩层里有一个 anchor link,用户点击它就会开启新的 routing 换页面内容。按常理说,此时遮罩层应该要关掉,
但是 disposeOnNavigation 只监听 history back / forward 而已 push / replace state 是不触发的,所以遮罩层不会关掉。
所以标准做法是监听组件的 destroy 事件,然后 dispose 遮罩层
export class AppComponent { constructor() { const overlay = inject(Overlay); const destroyRef = inject(DestroyRef); afterNextRender(() => { const overlayRef = overlay.create({ disposeOnNavigation: true, }); const helloWorldPortal = new ComponentPortal(HelloWorldComponent); const componentRef = overlayRef.attach(helloWorldPortal); // 1. 监听组件 onDestroy,然后 dispose 遮罩层 destroyRef.onDestroy(() => overlayRef.dispose()); }); } }
OverlayRef.hostElement & overlayElement
const overlayRef = overlay.create();
console.log(overlayRef.hostElement);
console.log(overlayRef.overlayElement);
hostElement 就是 Overlay Host,那个 div。(注:类型是 HTMLElement 而不是 ElementRef 哦) 。
overlayElement 就是 Overlay Pane。
OverlayRef.getConfig
通过 getConfig 方法,我们可以获取当前的 OverlayConfig 配置。
// 1. 创建 OverlayConfig const overlayConfig = new OverlayConfig({ disposeOnNavigation: true, }); // 2. 传入 OverlayConfig const overlayRef = overlay.create(overlayConfig); // 3. 获取当前 OverlayConfig const config = overlayRef.getConfig(); // 4. 拿出来的 OverlayConfig 和传入的 OverlayConfig 并不是同一个 console.log(config === overlayConfig); /// false
两个知识点:
-
OverlayRef.getConfig 返回的 Overlay Config 对象并不是 Overlay.create 时传入的 OverlayConfig 对象,原因是
Overlay.create 传入的 OverlayConfig 对象被用作于初始化 OverlayConfig 的 init value 了。
-
所有 OverlayConfig 的配置都是在第二步 attach 时才被使用的。
Overlay.create 以后,我们可以通过 getConfig 获取到 OverlayConfig 对象,然后肆意的修改它,只要还没有 attach。
attach 以后就不可以再直接修改 OverlayConfig 对象了,如果要修改相关配置,我们需要使用 OverlayRef 提供的各种间接方法 (下面会教)。
Overlay Pane CSS Class
我们可以通过一些方法给 Overlay Pane 添加 CSS class 来做 styling。
OverlayConfig.panelClass
const overlayRef = overlay.create({ panelClass: ['my-pane', 'my-pretty-pane'], }); const helloWorldPortal = new ComponentPortal(HelloWorldComponent); const componentRef = overlayRef.attach(helloWorldPortal);
效果
提醒:所有 OverlayConfig 配置都是在 attach 之后才被使用的。在 attach 之前,虽然 OverlayConfig 已经声明了 panelClass 同时 Overlay Pane div element 也已经 append 了出去,但此时 panelClass 并不会被添加到 div 上。
由于 Overlay Pane 是在 body 而非 App 组件内,要给它添加 styles 我们只能通过全局的 styles.scss。
效果
OverlayRef.addPanelClass & removePanelClass
在 attach 之后,如果我们还想修改 Overlay Pane class 需要使用 OverlayRef 的 add/removePanelClass 方法。
const overlayRef = overlay.create(); const helloWorldPortal = new ComponentPortal(HelloWorldComponent); const componentRef = overlayRef.attach(helloWorldPortal); overlayRef.addPanelClass('my-pane'); // 1. 添加 class 到 Overlay Pane overlayRef.removePanelClass('my-pane'); // 2. 从 Overlay Pane 删除 class
Overlay Pane Dimension
Dimension 指的是 CSS styles width, height, min-width, min-height, max-width, max-height。
Overlay Pane 的 Dimension (min-width, min-height, max-width, max-height) 对 Pane 的 position 定位是有影响的 (下一 part 会详解讲解),
所以我们不可使用 Overlay Pane CSS Class 的方式设置它们,我们需要使用 OverlayConfig 或者 OverlayRef.updateSize 来设置这些 Dimension。
OverlayConfig dimension
const overlayRef = overlay.create({ minWidth: 300, minHeight: 300, maxHeight: '1000px', maxWidth: '100%', width: 350, height: '350px', });
填 number 代表 px,填 string 则可以指定 unit (e.g. px, %, vh 等等)
效果
dimension 会被 apply 到 Overlay Pane 的 styles property 里头。
OverlayRef.updateSize
在 attach 之后,如果我们还想修改 Overlay Pane Dimension 就需要使用 OverlayRef.updateSize 方法。
overlayRef.updateSize({ minWidth: 300, minHeight: 300, maxHeight: '1000px', maxWidth: '100%', width: 350, height: '350px', });
Overlay Backdrop
我们目前掌握的 HTML 结构
画面
Overlay Container 覆盖在 body 之上,它是 position fixed,width height 100%,然后它是透明的,而且是 pointer-events: none。
Overlay Host 我们先不管,因为它和 position 有关系,下一 part 会讲解。
Overlay Pane 是红框,粉色背景 (上一 part addPanelClass 和 updateSize 就是控制它)
HelloWorld 组件是浅蓝色背景
OverlayConfig.hasBackdrop
const overlayRef = overlay.create({ minWidth: 500, minHeight: 500, panelClass: 'my-pane', hasBackdrop: true, });
HTML 结构
画面
灰色的区域就是 Backdrop,它阻挡了原本 Overlay Container 透明的部分。
Backdrop 是 clickable 的,它不像 Overlay Container 那样是 pointer-events: none。
OverlayRef.detachBackdrop
如果我们想显示 Backdrop 那在 attach 之前就要设置 OverlayConfig,attach 以后我们就不能再设置显示 Backdrop 了。
不能设置显示,但是我们可以 detachBackdrop。从 “有 -> 没有” 可以,从 “没有 -> 有” 不行。
const overlayRef = overlay.create({ minWidth: 500, minHeight: 500, panelClass: 'my-pane', // 1. 开启 Backdrop hasBackdrop: true, }); const helloWorldPortal = new ComponentPortal(HelloWorldComponent); const componentRef = overlayRef.attach(helloWorldPortal); // 2. 销毁 Backdrop overlayRef.detachBackdrop();
OverlayConfig.backdropClass
和 panelClass 类似,我们可以添加 CSS class 到 Backdrop 上,这样就可以 override 它的 default background-color (灰色) 等等。
但有一点比较奇葩,有 OverlayRef.add/removePanelClass 但是没有 OverlayRef.add/removeBackdropClass。
OverlayRef.backdropElement
和 hostElement,overlayElement 一样。backdropElement 就是获取 Backdrop HTMLElement。
OverlayRef.backdropClick
backdropClick 用于监听 Backdrop 点击事件
上图灰色的区域就是 Backdrop 的点击范围,Overlay Pane 内 (或狂) 都不算点击区域。
overlayRef.backdropClick().subscribe(() => console.log('Backdrop clicked'));
backdropClick 方法返回的是 RxJS Observable<MouseEvent>。
OverlayRef.keydownEvents
attach 之后,OverlayRef 会监听 document.body keydown 事件。
我们可以通过 OverlayRef.keydownEvents 方法监听这些 keydown 事件。
overlayRef.keydownEvents().subscribe(keyboardEvent => console.log('body keydown', keyboardEvent.key));
它返回 RxJS Observable<KeyboardEvent>。
一个常见的使用场景是,监听 Escape 键,然后关闭遮罩层。
OverlayRef.outsidePointerEvents
outsidePointerEvents 是 Angular Material v10.1.0 发布的新功能,在此之前我们只能用 backdropClick 来模拟 outsidePointer。
所谓 outside 就是指 out of Overlay Pane 的区域,也就是 Backdrop cover 的区别。
Backdrop 要 clickable 就一定要有一个层,这个层对用户是有影响的,哪怕它是透明,但它至少也要是 clickable,用户还是会有一种看不到但却点到了的感觉。
outsidePointerEvents 的实现方式和 backdropClick 不一样。
首先它监听的是 body click event 而且是以 capture 的方式,这样就不怕 stop bubble 了。
接着
小心坑 の 多个 OverlayRef 有些监听 backdrop click 有些监听 outside pointer
假设有两个遮罩层,
第一个监听 outside pointer event,并且它的 Overlay Pane 面积比较大。
第二个监听 backdrop click event,并且它的 Overlay Pane 面积比较小。
第二个的 Backdrop 会覆盖到第一个的 Overlay Pane 上面。
当用户点击到第二个的 Backdrop 时 (第一 Pane 和第二 Backdrop 重叠的那块区域),第二个的 backdrop click event 会触发。
接着依据上面 outside pointer event 的检测规则,每一次点击它会 for loop (逆序) 检查所有的 OverlayRef,第一个是监听 backdrop click event 的遮罩层,它没有监听 outside pointer event 所以会 continue 跳过。
接着来到第二个遮罩层,它有监听 outside pointer event,此时的点击 target 不在这个 Overlay Pane 里 (它是另一个 Overlay Backdrop,自然不在这里),所以算是 outside pointer,结果 outside pointer event 也触发了。(两个都触发,这通常不是我们想要的)
如果我们把第二个遮罩层换成监听 outside pointer event 那结果就不同了,在 for loop 第一个遮罩层时会判断为 outside pointer,然后会触发 outside pointer event,接着第二个会判断为 inside click,不会触发 outside pointer event。(只有一个触发,这是我们要的)
我的经验分享:
我有一个遮罩层,它监听 outside pointer event,遮罩层内容包含了一个 Angular Material Menu,当用户点击打开 Menu 时会产生第二个遮罩层,Menu 遮罩层监听的是 backdrop click event。
假如用户点击 Menu Backdrop,Menu 遮罩层要销毁,我的遮罩层不要销毁。假如用户没有打开 Menu,同时 outside click,那我的遮罩层要销毁。
问题来了,当用户点击 Menu Backdrop 时,由于上面提到的坑,我的遮罩层 outside pointer event 也会触发,结果 2 个遮罩层都销毁了。
我目前是没有 right way 解决方案,只有 hacking way workaround。
当 outside pointer event 触发时,如果 event target element 是 under .cdk-overlay-backdrop element,那要先通过 OverlayOutsideClickDispatcher._attachedOverlays 获取所有的 OverlayRef,
然后判断当前触发的 OverlayRef 是不是在最上层,是的话就可以销毁,不是的话就 skip。
OverlayOutsideClickDispatcher 是一个 Root Level Provider,_attachedOverlays 是 private 属性。
总结
除了 PositionStrategy 和 ScrollStrategy 其它所有 OverlayConfig 和 OverlayRef 的属性方法都讲解完了。
方法虽然多,都逻辑是简单的,只要搞清楚 HTML 结构和几个 div 扮演的角色就没问题了。
GlobalPositionStrategy & ScrollStrategy
目录
上一篇 Angular Material 17+ 高级教程 – CDK Accessibility の ListKeyManager
想查看目录,请移步 Angular 17+ 高级教程 – 目录