Angular Material 18+ 高级教程 – CDK Overlay
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 显示以后,document scroll 会怎么样?
比如说:
a. close Overlay when document scroll
b. block document 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 和 attch 环节的关系。
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);
效果
extend ComponentPortal injector
上面例子中,HelloWorldComponent 是 Dynamic Component,它默认使用的是 Root Injector。
这个 Injector 来自 Overlay Pane DomPortalOutlet
OverlayRef.attach 的源码在 overlay-ref.ts
_portalOutlet 是在 Overlay.create 时创建的
这个 Injector 就是 Root Injector
如果我们想 pass 一些资料给 HelloWorldComponent 可以通过 extend 这个 injector。
App 组件
export class AppComponent { constructor() { const overlay = inject(Overlay); const overlayRef = overlay.create(); const appInjector = inject(Injector); // create an injector for HelloWorld 组件 const helloWorldInjector = Injector.create({ parent: appInjector, // pass in a value providers: [{ provide: HELLO_VALUE_TOKEN, useValue: 'hello world !!!' }], }); const helloWorldPortal = new ComponentPortal(HelloWorldComponent, null, helloWorldInjector); const componentRef = overlayRef.attach(helloWorldPortal); } }
HelloWorld 组件
export class HelloWorldComponent { constructor() { console.log('value', inject(HELLO_VALUE_TOKEN)); // 'hello world !!!' } }
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 事件。另外,OverlayRef 只有 detach 事件,没有 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 键,然后关闭遮罩层。
注:当有多个 Overlay 时,只有最顶层有监听 keydownEvents 的那一个会发布事件,比如说打开了三层 Overlay,此时 keydown 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 了。
接着
小细节 の 移位点击
试想,用户点击了 inside 遮罩层,然后按住键不放,然后移动到 outside 遮罩层才放,这时会触发 body click 事件,target 是 body (不明白原理看这篇)。
请问,这算 outside pointer 吗?
当然不算咯,但要怎么防止呢?
CDK Overlay 监听了 pointerdown 和 click 两个事件,然后以 pointerdown 的 target 为准,这样用户点击时就已经确认的 target,即便后来它移位也无妨了。
提醒:可惜的是,我们监听的 OverlayRef.outsidePointerEvents() 只能拿到 click event 对象,使用不到这个 _pointerDownEventTarget,第一它是 private property,第二它被 set to null 了。
小心坑 の 多个 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。(只有一个触发,这是我们要的)
经验分享:我有一个遮罩层 A,它监听 outside pointer event,遮罩层 A 内容包含了一个 Angular Material Select,当用户点击打开 Select 时会产生第二个遮罩层,Select 遮罩层监听的是 backdrop click event。
假如用户点击 Select Backdrop,Select 遮罩层要销毁,我的遮罩层 A 不要销毁。假如用户没有打开 Select,outside click,那我的遮罩层 A 要销毁。
问题来了,当用户点击 Select Backdrop 时,由于上面提到的坑,我的遮罩层 A outside pointer event 也会触发,结果 2 个遮罩层都销毁了。
解决方法有很多,不同场景可能有所不同,这里给一个例子就好
当 outside pointer event 触发时,如果 event target element 是带有 class ".cdk-overlay-backdrop" 的 element (表示点击的是一个 Backdrop),
那我们可以 skip 掉,不处理。(注:当然,前提是遮罩层 A 没有 Backdrop,只有 outside pointer event)
如果需要更多资讯,可以透过 OverlayOutsideClickDispatcher._attachedOverlays 获取所有的 OverlayRef,
OverlayOutsideClickDispatcher 是一个 Root Level Provider,_attachedOverlays 是 private 属性,里面记入了每一个 OverlayRef,最后一个 _attachedOverlay 就是最上层的 OverlayRef。
keydown enter 导致的 outside pointer 事件
App Template
<button (keydown.enter)="openOverlay()">click me</button> <ng-template #template> <h1>Hello World</h1> </ng-template>
focus button > keydown enter > open overlay
App 组件
export class AppComponent { private readonly overlay = inject(Overlay); private readonly templateRef = viewChild.required('template', { read: TemplateRef }); private readonly viewContainerRef = viewChild.required('template', { read: ViewContainerRef }); openOverlay() { const ref = this.overlay.create({ positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically() }); ref.outsidePointerEvents().subscribe(e => console.log('clicked')); const templatePortal = new TemplatePortal(this.templateRef(), this.viewContainerRef()); ref.attach(templatePortal); } }
问:outsidePointerEvents 会直接触发吗?
答:会
button enter 会触发 keydown 和 click 两个事件,keydown 时我们创建了 overlay 并且监听了 outsidePointerEvents。
keydown > overlay 之后,游览器才触发 click 事件。
此时 outsidePointerEvents 会监听到这个 click 事件,所以它会直接触发。
如果这不是我们预期的效果,那我们可以做一个过滤
ref.outsidePointerEvents().pipe(filter(e => e.screenX !== 0)).subscribe(e => console.log('clicked'));
screenX === 0 表示是 keyboard 导致的 click 事件,借由这个小特性我们就可以过滤掉 enter 导致的 outside pointer event。
或者在 keydown enter 后执行 event.preventDefault() 平仓掉 click 事件。
总结
除了 PositionStrategy 和 ScrollStrategy 其它所有 OverlayConfig 和 OverlayRef 的属性方法都讲解完了。
方法虽然多,但逻辑都是简单的,只要搞清楚 HTML 结构和几个 div 扮演的角色就没问题了。
GlobalPositionStrategy & ScrollStrategy
PositionStrategy 用来表达 Overlay Pane 要定在屏幕的哪个位置。
ScrollStrategy 用来表达当 document scroll 时候遮罩层要有什么反应。
我们直接看例子学习呗 🚀。
GlobalPositionStrategy
GlobalPositionStrategy 常用于 Dialog,Snackbar 这类的遮罩层。
它的效果是 Overlay Pane 对着 Overlay Container 定位。
const overlayRef = overlay.create({ // 1. 通过 OverlayConfig 设置定位策略 positionStrategy: overlay.position().global().top('50px').left('50px'), }); const helloWorldPortal = new ComponentPortal(HelloWorldComponent); overlayRef.attach(helloWorldPortal); // 2. 刷新定位 overlayRef.updatePosition();
Overlay.position().global() 会返回一个 GlobalPositionStrategy 对象。
这个对象可以设置基本的 positions:top, right, bottom, left, start, end。
start 和 end 是 Logical Properties,在 ltr (left to right) 情况下,start = left,end = right。
overlay.position().global().top('50px').left('50px') 的效果是:
它和 CSS Position 用法是一样的。HTML style
定位并不是用 CSS Position 配 top left 实现的,它用的是 margin-left 和 margin-top 来实现。
centerHorizontally & centerVertically
除了 top, right, bottom, left,当然也少不了 center 居中。
positionStrategy: overlay.position().global().centerHorizontally().centerVertically()
效果
cdk-global-overlay-wrapper 就是上面提过的 Overlay Host,在设置 PositionStrategy 后它有了一些小变化。
居中是透过 Overlay Host display flex center 实现的。
OverlayRef.updatePosition
上面代码结尾处调用了 OverlayRef.updatePosition,这是因为我的测试环境使用了 Zoneless ChangeDetection,不调用它不会自动渲染。
Angular Material 高度依赖 Zone.js,它们对 Signal 完全没有准备,所以如果你想用 Angular Material 搭配最新潮流的 Signal,那你的道行一定要够,不然经常掉坑里上不来就惨了。
OverlayRef.updatePositionStrategy
attach 之后如果想换 PositionStrategy 可以透过 OverlayRef.updatePositionStrategy 方法
const overlayRef = overlay.create(); const helloWorldPortal = new ComponentPortal(HelloWorldComponent); overlayRef.attach(helloWorldPortal); overlayRef.updatePositionStrategy(overlay.position().global().top('50px').left('50px'));
updatePositionStrategy 内部会调用 updatePosition。
ScrollStrategy
一共有 4 种 ScrollStrategy:
-
BlockScrollStrategy
const overlayRef = overlay.create({ positionStrategy: overlay.position().global().top('50px').left('50px'), // 1. 通过 OverlayConfig 设置 BlockScrollStrategy scrollStrategy: overlay.scrollStrategies.block(), });
Block 的意思是,遮罩层 attach 之后 document scrollbar 就不允许 scroll 了。
-
CloseScrollStrategy
scrollStrategy: overlay.scrollStrategies.close()
Close 的意思是,当 document scroll 时自动调用 OverlayRef.detach。(注:只是 detach 不是 displose 哦)
-
NoopScrollStrategy
scrollStrategy: overlay.scrollStrategies.noop()
Noop 就是不管的意思,遮罩层 attach 后,document scrollbar 任然可以 scroll,这也是默认的 ScrollStrategy。
-
RepositionScrollStrategy
RepositionScrollStrategy 不是配 GlobalPositionStrategy 的,因为 GlobalPositionStrategy 的 Position 位置是固定的,不会被 document scroll 影响。
RepositionScrollStrategy 要配的是另一个 PositionStrategy -- FlexibleConnectedPositionStrategy。因为它的 Position 位置会随着 document scroll 而变化。
FlexibleConnectedPositionStrategy 下一 part 会教。
OverlayRef.updateScrollStrategy
overlayRef.updateScrollStrategy(overlay.scrollStrategies.block());
和 updatePositionStrategy 是一样的用法。
FlexibleConnectedPositionStrategy
FlexibleConnectedPositionStrategy 是专门用来做 Popover 的。
FlexibleConnectedPositionStrategy 和 GlobalPositionStrategy 最大的区别是 FlexibleConnectedPositionStrategy 在定位时需要一个参照物 (术语叫 Origin)。
GlobalPositionStrategy 不需要参照物,我们通常定位上,下,左,右,中间,等等。
FlexibleConnectedPositionStrategy 则一定有参照物,比如:
点击 create button 后,遮罩层定位在 create button 的左上角,准准覆盖在 create button 之上。
create button 就是它的定位 Origin (参照物)。
再一个例子
遮罩层定位在 yellow bar 的左下方。
yellow bar 就是 Origin,不管 yellow bar 在 body 的哪一个角落,遮罩层都会出现在它的附近。
Create FlexibleConnectedPositionStrategy with Origin
const positionStrategy = this.overlay.position().flexibleConnectedTo();
这是创建 FlexibleConnectedPositionStrategy 的方法,它需要一个 Origin 作为参数。
Origin 可以是一个 Element 或者一个 x, y, width, height 对象。
从这个接口我们大致也可以推测出来,要做定位嘛,element.getBoundingClientRect 获取 x, y, width, height 一定少不了这一步。
Preferred Positions
灰色是 Viewport,红色是 Origin,蓝色是遮罩层。
它们身上各自有 9 个定位点,这代表我们有 9 x 9 = 81 种定位方式。
举几个例子:
-
Origin 右下角 vs 遮罩层左上角
-
Orgin 中间 vs 遮罩层左中间
-
Origin 左中间 vs 遮罩层左上角
代码
const positionStrategy = this.overlay .position() .flexibleConnectedTo(buttonElementRef().nativeElement) .withPositions([{ originX: 'end', originY: 'bottom', overlayX: 'start', overlayY: 'top' }]);
4 个属性,originX originY 就是 Origin 定位点的坐标,overlayX overlayY 就是遮罩层定位点的坐标。
X 表示 horizontal,属性值可以是 start, center, end。start end 是 CSS – Logical Properties 的叫法,等价于 left right。
Y 表示 vertical,属性值可以是 top, center, bottom。
Multiple Preferred Positions
我们可以设置超过 1 个 Preferred Positions。那为什么需要设置多个呢?
因为用户会 scroll body,我们无法控制 Origin 的位置。
假设我们的定位点是 Origin center-bottom vs 遮罩层 center-top,但是碰巧 Origin 在 Viewport 的右下方,结果就是遮罩层显示不完整。
如果此时可以把定位点换成 Origin center-top vs 遮罩层 center-bottom,遮罩层就可以完整显示。
代码
const positionStrategy = this.overlay .position() .flexibleConnectedTo(buttonElementRef().nativeElement) .withPositions([ { originX: 'center', originY: 'bottom', overlayX: 'center', overlayY: 'top' }, { originX: 'center', originY: 'top', overlayX: 'center', overlayY: 'bottom' }, ]);
Choose the most suitable Preferred Position
最终选择哪一个 Preferred Position 是 Overlay 经过一些算法决定的 (主要是看显示面积),我们只要多给几个 Preferred Positions 确保它能应对不同的情况就可以了。
虽然 Overlay 有 formula 但是 parameter 任然需要我们提供,所以我们有必要了解它的判断逻辑和大致算法。
相关源码在 flexible-connected-position-strategy.ts,由于大部分都是加减乘除算法,我们就不一行一行逛了,我讲个流程大概一下就好。
for loop Preferred Positions
首先是 for loop Preferred Positions,如果这个定位可以完整显示遮罩层,那么就直接用它,不用再看其它的了,结束。
check is Flexible Dimension
如果这个定位不能完整显示遮罩层,那么查看遮罩层是否支持 Flexible Dimension。
Flexible Dimension 的意思是,Overlay Pane 是否允许被 resize。
比如说上图,遮罩层超出了 Viewport,为了要完整显示,是否可以强行把遮罩层的 width 变小,像这样
提醒:Response Design 减少 width 通常会导致 height 增加,不过无论如何最终可以完整显示遮罩层就好。
是否支持 Flexible Dimension 有两个依据
第一个是 _hasFlexibleDimensions,它默认是 true,第二个是 OverlayConfig 需要设置 minWidth 和 minHeight。
因为 resize 总不可能变小到 1px 嘛,得要有个下限。
如果遮罩层支持 Flexible Dimension,那么会把当前 Preferred Positions 记入起来先,然后继续 for loop 其它 Preferred Positions。
find the best Flexible Dimension Preferred Positions
for loop 完 Preferred Positions 发现没有任何一个定位可以完整显示遮罩层,那么就退而求其次看看 Flexible Dimension Preferred Positions。
这里有一个比分数算法,挺复杂的,我就不讲细节了,总之最终会选出一个 best Preferred Positions 来使用。
choose the biggest visible
如果遮罩层不支持 Flexible Dimension,那么就退而求其次选择可显示最大面积遮罩层的 Preferred Positions。
withPush
在讲 Flexible Dimension 配置之前,我们先了解一下什么是 Push。
withPush 是一个方法,Push 是一种机制。
const positionStrategy = this.overlay .position() .flexibleConnectedTo(buttonElementRef().nativeElement) // 1. withFlexibleDimensions 默认是 true,暂时关掉它,不要影响我们的测试 .withFlexibleDimensions(false) // 2. withPush 默认是 true,我们先看 false 的效果 .withPush(false) .withPositions([{ originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'bottom' }]);
效果
白色是 Viewport,红色是 Origin,蓝色是遮罩层。
我只设置了一个 Preferred Position,它无法完整显示遮罩层。
开启 Push 机制
.withPush(true)
效果
遮罩层被 “Push” 进 Viewport 了,代价是定位点跑掉了。
提醒:Push 机制默认是开启的哦。
Flexible Dimension & Push 机制详解
和 Push 机制类似,Flexible Dimension 也是一种机制。
withPush 用来开关 Push 机制,withFlexibleDimensions 用来开关 Flexible Dimension 机制。
提醒:by default 两个机制都是默认开启的。
without Flexible Dimension & Push 机制
首先,我们把 Flexible Dimension 和 Push 机制都关掉。
const positionStrategy = this.overlay .position() .flexibleConnectedTo(buttonElementRef().nativeElement) // 1. withFlexibleDimensions 默认是 true,我们先看 false 的效果 .withFlexibleDimensions(false) // 2. withPush 默认是 true,暂时关掉它,不要影响我们的测试 .withPush(false) .withPositions([{ originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'bottom' }]);
效果
HTML 结构
Overlay Container 是 position fixed
Overlay Host 是 position absolute,top 0 left 0 width height 100% 算是覆盖了整个 Overlay Container。
Overlay Pane 是 position absolute,它的 bottom 和 left 是通过 Preferred Positions 计算出来的,它的 width 是 hug content (依据内容)。
图中可以看到蓝色的字因为不够空间,所以掉下来变成了三行。
这个是 CSS 的基本规则
红框 position relative,文字 position absolute,当文字不够空间时就会往下掉。
好,我们加入一些设置,看看它的玩法。
with Push 机制
开启 Push 机制
.withPush(true)
效果
这个效果有一点点不精准,原因是我的文字 max-content width 有小数点,Angular Material 在计算的时候应该是没有进位之类的 (我懒得去研究了)
总之精准的效果应该是下面这样
由于开启了 Push 机制,Overlay Panel 的定位从原本的 left 1700px 变成了 left 1367px
这个定位点已经不是我们设置的 Preferred Position 了。
可以看到,Push 机制的核心是 -- 尽可能让遮罩层完整显示在 Viewport 内,连文字 overflow-wrap 它都会尽量避免。
with Flexible Dimension 机制 but without minimum dimension
.withFlexibleDimensions(true) .withPush(false)
效果
表面上效果是一样的,但其实 Styles 已经不同了。
Overlay Host 原本是 width 100% 现在变成了 203px,这 203px 来自 Origin 和 Viewport 的距离。
Overlay Panel 不再是 position absolute,变成了 position static。
由于它的 parent Overlay Host 的 width 是 203px 所以文字依旧不够空间往下掉。
可以看到,Flexible Dimension 机制是我们告诉 PositionStrategy 算法,Overlay Pane size 是可以 flexible 的,于是 PositionStrategy 会在 Overlay Host 添加 width 来限制 Overlay Pane。
with Flexible Dimension 机制 with minimum dimension
const overlayRef = overlay.create({ positionStrategy, minWidth: 350, });
minimum dimension 对 Flexible Dimension 机制的影响只在 chose the most suitable Preferred Position 的时候,这一点上一 part 已经讲过了。
只是 Overlay Pane 多了一个 min-width,Overlay Host 的 width 并没有改变,依然是 203px。
withTransformOriginOn
withTransformOriginOn 是一个专门用作 transform animation 的设置。
注意看,上面这个 popover 出现时的 scale animation。它是从 popover 的中心点开始 scale up 的。
下面是添加了 withTransformOriginOn
的效果
本来是从 popover 的中心点开始 scale up,现在变成了从 popover center bottom 开始 scale up。
它的原理是,CDK Overlay 会依据 popover 的最终位置 (the most suitable preferred position)
去 query element
例子中,我们 query 的 element 就是 popover (也就是那个 scale up 的 element)。
然后它会给这个 element 添加 transform-origin
这样就改变了 scale up 的方向。
Get to know the most suitable preferred position
通过 FlexibleConnectedPositionStrategy.positionChanges 属性,我们可以获知最终位置 (the most suitable preferred position)。
positionChanges 属性是一个 RxJS Observable。
效果
Material Tooltip 里就用到了这个 positionChanges。
蓝色的小区域就是获知了最终位置 (the most suitable preferred position) 才有办法做出来。
相关源码在 tooltip.ts
监听 positionChanges 然后 add specify class based on position。
这些 class 对应的 styles 都已经写在 tooltip.scss 里了。
Apply position 的时机
假设我 OverlayRef.attach(TestComponent)。
请问是 Test 组件 OnInit 先执行还是 FlexibleConnectedPositionStrategy.positionChanges 先发布。
答案是 OnInit 先执行。
我们看看相关源码,OverlayRef.attach 的源码在
如果我们想在 TestComponent 里得知最终选到的 preferred position,我们可以注册 afterNextRender + AfterRenderPhase.Read。
afterNextRender(() => console.log('get preferred position'), { phase: AfterRenderPhase.Read });
这个时机点,positionChanges 已经发布了。
RepositionScrollStrategy
RepositionScrollStrategy 通常是搭配 FlexibleConnectedPositionStrategy 一起用的。
当用户 scroll body 以后,遮罩层就慢慢远离 Origin 了。
我们可以通过 RepositionScrollStrategy 让它始终保持和 Origin 的定位
const overlayRef = overlay.create({
positionStrategy,
scrollStrategy: overlay.scrollStrategies.reposition(),
});
效果
每当 document scroll 它都会重新计算重新定位。
autoClose
scrollStrategy: overlay.scrollStrategies.reposition({ autoClose: true, scrollThrottle: 0 }),
autoClose 指的是,当 document scroll 到遮罩层离开 Viewport 以后是否要自动 detach 遮罩层。
withPush
Push 机制的宗旨是让遮罩层始终在 Viewport 内,配上 RepositionScrollStrategy 的效果是这样
文字被 push 进了 Viewport。
if not document scroll
上面例子里,我们都是使用 document scroll,如果我们的 scroll 不是 document 而是 body 或某个 div,那我们需要做一些额外处理。
ScrollStrategy 内部依靠 ScrollDispatcher 监听 scroll (不熟悉 CDK Scrolling 的朋友可以看这篇 CDK Scrolling),所以我们需要把 scrollable element 注册到 ScrollDispatcher,
可以通过 CdkScrollable 指令,或者调用 ScrollDispatcher.register 方法。
小心坑 の withFlexibleDimensions 和 withPush
我们看一个简单有 bug 的例子
App Template
<button #origin (click)="popupModal(origin, modalTemplateRef)">click me</button> <ng-template #modalTemplateRef> <div class="card"> <h1>Hello World</h1> <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Corporis, illo.</p> </div> </ng-template>
App 组件
export class AppComponent { private readonly overlay = inject(Overlay); private readonly viewContainerRef = inject(ViewContainerRef); popupModal(origin: HTMLButtonElement, modalTemplateRef: TemplateRef<any>) { const overlayRef = this.overlay.create({ positionStrategy: this.overlay.position().flexibleConnectedTo(origin).withPositions([ { originX: 'end', originY: 'center', overlayX: 'start', overlayY: 'center' } ]), scrollStrategy: this.overlay.scrollStrategies.reposition({ scrollThrottle: 0 }) }); const modalPortal = new TemplatePortal(modalTemplateRef, this.viewContainerRef); overlayRef.attach(modalPortal); } }
默认情况下,withFlexibleDimensions 和 withPush 都是 true,但我没有设置 minimum dimension。
效果
回来的时候位置跑偏了,我尝试加 minWidth 和 minHeight,效果会好一点
但它的位置还是怪怪的...
唉...懒惰去研究它的计算方式了,反正我通常是不开启 withFlexibleDimensions,以后再研究呗。
Overlay Animation
对 Angular Animation 不熟悉的朋友可以先看这篇 Angular 18+ 高级教程 – Animation 动画。
Default Animation on Attach
CDK Overlay 有 built-in 的 animation,我们先搭个环境看看它的效果。
App Template
<button (click)="showOverlay()">show overlay</button> <ng-template #template> <div class="card"> <h1>Title</h1> <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Fuga, sapiente.</p> </div> </ng-template>
App Styles
:host { display: block; padding: 32px; } button { border-radius: 12px; padding: 16px; background-color: lightblue; font-size: 20px; text-transform: uppercase; } .card { border: 1px solid black; border-radius: 28px; width: 360px; background-color: pink; padding: 24px 16px; display: flex; flex-direction: column; gap: 16px; h1 { font-size: 48px; } p { line-height: 1.5; } }
App 组件
import { Overlay } from '@angular/cdk/overlay'; import { TemplatePortal } from '@angular/cdk/portal'; import { ChangeDetectionStrategy, Component, DestroyRef, inject, TemplateRef, viewChild, ViewContainerRef, } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, templateUrl: './app.component.html', styleUrl: './app.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, imports: [], }) export class AppComponent { private readonly overlay = inject(Overlay); private readonly viewContainerRef = inject(ViewContainerRef); private readonly destroyRef = inject(DestroyRef); readonly templateRef = viewChild.required('template', { read: TemplateRef }); showOverlay() { const { overlay, templateRef, viewContainerRef, destroyRef } = this; const portal = new TemplatePortal(templateRef(), viewContainerRef); const positionStrategy = overlay.position().global().centerHorizontally().centerVertically(); const scrollStrategy = overlay.scrollStrategies.block(); const overlayRef = overlay.create({ positionStrategy, scrollStrategy, hasBackdrop: true, }); destroyRef.onDestroy(() => overlayRef.dispose()); overlayRef.attach(portal); overlayRef.updatePosition(); } }
效果
Backdrop 出现的时候会有 fade in 效果。
这个 fade in 效果是透过 CSS Transition 实现的
我们可以透过全局 CSS Style 对它做微调整
能改的只有 3 个,颜色,duration 和 easing。
而且 duration 只能设置小于 500ms。(why? 下面会讲解)
另外,虽然 Backdrop 有 fade in 效果,但 attachment (我们 attach 的 TemplatePortal) 是没有的,它就突然被 append 了出来😕。
Default Animation on Detach
有几个点都蛮奇葩的:
-
如果我们直接 OverlayRef.dispose(),那整个 Overlay Host 会直接被 remove 掉,Backdrop 的 transition 不会起作用,没有 fade out 效果。
-
如果我们使用 OverlayRef.detach(),那 Backdrop 会 fade out。
但比较奇葩的地方是 500ms 后 Backdrop element 会被 remove 掉,即使 fade out 还没有跑完。
所以假如我们 override Backdrop 的 transition-duration,那一定不能设置超过 500ms。
-
OverlayRef detach 之后还需要 dispose,而且这个 dispose 必须在 animation 结束后才触发 (不然 animation 会因为 dispose 而中断),
但 OverlayRef 没有提供任何方法可以监听 Backdrop 的 transitionend 事件。
我们只能监听 overlayRef.detachments(),但 detachments 事件会在 detach 时立刻触发,而不是等到 animation 结束。
-
attachment 在 detach 的时候也没有 animation,突然就被 remove 消失掉。
综上几个点,detach 的 default animation 太鸡肋了。真的能用吗?
Custom Animation
第一步是给 attachment 添加 animation。
@Component({ selector: 'app-root', standalone: true, templateUrl: './app.component.html', styleUrl: './app.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, imports: [], animations: [ trigger('fadeInOut', [ transition(':enter', [ style({ opacity: 0, transform: 'translateY(-32px)' }), animate('400ms ease', style({ opacity: '*', transform: '*' })), ]), transition(':leave', [animate('400ms ease', style({ opacity: 0, transform: 'translateY(-32px)' }))]), ]), ], })
在 card element 上添加 binding
<ng-template #template> <div @fadeInOut class="card"> <h1>Title</h1> <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Fuga, sapiente.</p> </div> </ng-template>
效果
detach 的部分看注释理解
<button (click)="showOverlay()">show overlay</button> <ng-template #template> <!-- 监听 fadeout animation done --> <div @fadeInOut (@fadeInOut.done)="handleCardAnimationDone($event)" class="card"> <h1>Title</h1> <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Fuga, sapiente.</p> </div> </ng-template>
export class AppComponent { private readonly overlay = inject(Overlay); private readonly viewContainerRef = inject(ViewContainerRef); private readonly destroyRef = inject(DestroyRef); readonly templateRef = viewChild.required('template', { read: TemplateRef }); private overlayRef: OverlayRef | null = null; private disposeOverlay() { this.overlayRef!.dispose(); this.overlayRef = null; } showOverlay() { const { overlay, templateRef, viewContainerRef, destroyRef } = this; // fadeout 的时候,Backdrop 是 pointer event none,用户可能会点击到 show overlay button // 这时最好阻止它,不然 duplicated open 不顺风水。 if (this.overlayRef) return; const portal = new TemplatePortal(templateRef(), viewContainerRef); const positionStrategy = overlay.position().global().centerHorizontally().centerVertically(); const scrollStrategy = overlay.scrollStrategies.block(); const overlayRef = (this.overlayRef = overlay.create({ positionStrategy, scrollStrategy, hasBackdrop: true, })); destroyRef.onDestroy(() => this.disposeOverlay()); const backdropClick$ = overlayRef.backdropClick(); const escapeKeydown$ = overlayRef.keydownEvents().pipe(filter(e => e.key === 'Escape')); // 监听 Backdrop click 和 keydown Escape,然后 detach,这时就会 fade out merge(backdropClick$, escapeKeydown$).subscribe(() => overlayRef.detach()); overlayRef.attach(portal); overlayRef.updatePosition(); } // 当 fadeout 结束后 dispose 遮罩层 handleCardAnimationDone(animationEvent: AnimationEvent) { console.log('animationEvent.toState', animationEvent.toState); if (animationEvent.toState === 'void') { this.disposeOverlay(); } } }
总结知识点:
-
在 attachment 加上 Angular Animation。
状态切换 :enter 和 :leave 的时候做 animation 效果。
-
不要直接 dispose 遮罩层,先 detach,然后监听 animation done 事件, 然后才 dispose 遮罩层。
最终效果
Backdrop fade out over 500ms 的问题
上面我们有提到,Backdrop 的 fade out duration 最多只能设置小于 500ms,因为 detachBackdrop 方法里有一个 hardcode setTimeout 500ms remove element,
所以不管 fade out 是否完成,element 在 500ms 后会直接被 remove 掉,fade out 效果就没了。
Angular Material Dialog 也没有解决这个问题,官网的例子
我们设置 animation duration 5 秒
效果
可以看到 Backdrop 很快就 fade out 了,只有 attachment 才用了 5 秒 fade out。
解决方案
没有 right way,唯一的方法就是不要执行 detachBackdrop 方法,我们自己手动 detach Backdrop。
// 1. 通过 overlayRef.backdropElement 可以直接操作 Backdrop element // remove class cdk-overlay-backdrop-showing 就会 fade out overlayRef.backdropElement!.classList.remove('cdk-overlay-backdrop-showing'); overlayRef.backdropElement!.style.pointerEvents = 'none'; // 2. 监听 transitionend overlayRef.backdropElement!.addEventListener('transitionend', () => { // 3. 此时如果调用 overlayRef.detachBackdrop() 方法,由于它不会再触发 transitionend 所以 500ms 之后 element 才会被 remove,这体验不好。 // 比较好的方式是直接调用私有的 _disposeBackdrop 方法 (虽然说用私有方法也不是 right way,但总比上一个好) (overlayRef as unknown as { _disposeBackdrop: () => void })._disposeBackdrop(); });
其实就是把原本的 detachBackdrop 方法内容般出来自己跑,只是去掉了 setTimeout 500ms。
总结
Overlay 还有一些零零碎碎的配置,比如 withViewportMargin,ConnectedPosition.offsetXY 等等,我都没有介绍到,因为实在太碎了。
建议大家自己玩一玩,也可以看看 Angular Material Dialog,Menu,Tooltip 等等的源码,这些组件都是基于 Overlay 实现的。
目录
上一篇 Angular Material 18+ 高级教程 – CDK Accessibility の ListKeyManager
下一篇 Angular Material 18+ 高级教程 – CDK Observers
想查看目录,请移步 Angular 18+ 高级教程 – 目录
喜欢请点推荐👍,若发现教程内容以新版脱节请评论通知我。happy coding 😊💻