背景
Overlay
OverlayRef的attach()支持ComponentPortal和TemplatePortal等,为了统一管理overlay的内容,我们需要创建一个OverlayToolTipComponent用来展示具体的tooltip
@Component({ selector: 'overlay-tooltip-inner', template: ` <div class="overlay-tooltip-inner"> @if (text) { <div>{{ text }}</div> } @else { <ng-container *ngTemplateOutlet="contentTemplate"></ng-container> } </div> `, styles: [` .overlay-tooltip-inner { padding: 5px; background-color:rgb(207, 229, 248); border-radius: 4px; box-shadow: 0px 2px 6px rgba(0, 0, 0, 0.2); } `], standalone: false }) export class OverlayToolTipComponent { @Input() set overlayTooltip(tooltip: string | TemplateRef<any>) { if (_.isString(tooltip)) { this.text = tooltip; } else { this.contentTemplate = tooltip; } } text: string; contentTemplate: TemplateRef<any>; constructor() { // } }
OverlayToolTipDirective
接下来创建OverlayToolTipDirective,它接受的tooltip参数类型是string | TemplateRef<any>
@Directive({ selector: '[overlayTooltip]', standalone: false }) export class OverlayToolTipDirective implements OnChanges, OnDestroy { private _overlayRef: OverlayRef = undefined; private _tooltip: string | TemplateRef<any> = ''; @Input() set overlayTooltip(tooltip: string | TemplateRef<any>) { this._tooltip = tooltip ?? ''; } private flexibleConnectedPositionStrategy: FlexibleConnectedPositionStrategy; constructor(private _overlay: Overlay, private _overlayPositionBuilder: OverlayPositionBuilder, private _elementRef: ElementRef) { // } ngOnChanges(changes: SimpleChanges): void { if (_.size(this._tooltip) > 0) { this.updateFlexibleConnectedPositionStrategy(); this.bindingTriggers(); } } private updateFlexibleConnectedPositionStrategy() { this.flexibleConnectedPositionStrategy = this._overlayPositionBuilder .flexibleConnectedTo(this._elementRef) .withPositions([this.createPosition('center', 'top', 'center', 'bottom')]); } private generateOverlayRef() { if (!this.flexibleConnectedPositionStrategy) { this.updateFlexibleConnectedPositionStrategy(); } this._overlayRef = this._overlay.create({ positionStrategy: this.flexibleConnectedPositionStrategy }); } private createPosition(originX: HorizontalConnectionPos, originY: VerticalConnectionPos, overlayX: HorizontalConnectionPos, overlayY: VerticalConnectionPos ): ConnectionPositionPair { return { originX, originY, overlayX, overlayY }; } private bindingTriggers() { this._elementRef.nativeElement.addEventListener('mouseover', this.show()); this._elementRef.nativeElement.addEventListener('mouseout', this.hide()); } private show() { if (!this._overlayRef) { this.generateOverlayRef(); } if (this._overlayRef && !this._overlayRef.hasAttached()) { const tooltipRef: ComponentRef<OverlayToolTipComponent> = this._overlayRef.attach(new ComponentPortal(OverlayToolTipComponent)); tooltipRef.instance.overlayTooltip = this._tooltip; } } private hide() { if (!_.isEmpty(this._overlayRef) && this._overlayRef.hasAttached()) { this._overlayRef.detach(); } } private cleanUpOverlayRef() { if (this._overlayRef?.dispose) { this._overlayRef.dispose(); this._overlayRef = undefined; } } ngOnDestroy() { this.cleanUpOverlayRef(); this.removeExistingListeners(); } removeExistingListeners() { this._elementRef.nativeElement.removeEventListener('mouseover', this.show()); this._elementRef.nativeElement.removeEventListener('mouseout', this.hide()); } }
效果如下:
位置自适应
由上图可以看出,当位置不够容纳tooltip时,目标元素会被遮挡。所以我们需要添加placement和autoPosition允许用户指定tooltip的位置和tooltip是否可以自适应位置
通过OverlayPositionBuilder的withPositions()设置position数组。
class ConnectionPositionPairExt extends ConnectionPositionPair { sort: number; } export class OverlayToolTipDirective implements OnChanges, OnDestroy { ... @Input() placement: 'top' | 'bottom' | 'left' | 'right' = 'top'; @Input() autoPosition = true; // updateFlexibleConnectedPositionStrategy() 更改如下: private updateFlexibleConnectedPositionStrategy() { this.flexibleConnectedPositionStrategy = this._overlayPositionBuilder .flexibleConnectedTo(this._elementRef) .withPositions(this.getAvailablePositions()); } private getAvailablePositions(): ConnectionPositionPairExt[] { // 生成四个方向的默认位置配置 const positions = [ this.createPosition('center', 'top', 'center', 'bottom', 1), // top this.createPosition('start', 'center', 'end', 'center', 2), // left this.createPosition('center', 'bottom', 'center', 'top', 3), // bottom this.createPosition('end', 'center', 'start', 'center', 4), // right ]; // 根据当前 placement 设置优先级 const priorityMap: { [key in string]: number } = { ['bottom']: 2, ['left']: 1, ['right']: 3, }; positions[priorityMap[this.placement] || 0].sort = 0; // 返回排序后的位置配置 return this.autoPosition ? positions.sort((a, b) => a.sort - b.sort) : [positions[priorityMap[this.placement] || 0]]; } ... }
效果如下,string或者template
总结
这样我们就在不引入其他库的前提下完成了一个内容丰富位置灵活的tooltip组件啦。
要注意,在tooltip被触发时再创建OverlayRef以避免不必要的性能开销。当tooltip隐藏和Directive销毁时,删除事件监听并调用OverlayRef的detach()和dispose()。
另外,Overlay的ConnectedPosition还可以指定tooltip和目标元素之间的距离,也可以增加panelClass以便深度定制tooltip的内容。