Drill down menu 简单来说就是数据的下钻,点击一个 Summary 的信息后弹出 Detail 详情,或者弹出一个菜单让用户选择不同的操作。
Demo 环境
windows10
angular-material: 13.0.0
{ "name": "my-drilldown-menu", "version": "0.0.0", "scripts": { "ng": "ng", "start": "ng serve", "build": "ng build", "watch": "ng build --watch --configuration development", "test": "ng test" }, "private": true, "dependencies": { "@angular/animations": "^14.2.0", "@angular/cdk": "^13.0.0", "@angular/common": "^14.2.0", "@angular/compiler": "^14.2.0", "@angular/core": "^14.2.0", "@angular/forms": "^14.2.0", "@angular/material": "^13.0.0", "@angular/platform-browser": "^14.2.0", "@angular/platform-browser-dynamic": "^14.2.0", "@angular/router": "^14.2.0", "lodash": "^4.17.21", "rxjs": "~7.5.0", "tslib": "^2.3.0", "zone.js": "~0.11.4" }, "devDependencies": { "@angular-devkit/build-angular": "^14.2.9", "@angular/cli": "~14.2.9", "@angular/compiler-cli": "^14.2.0", "@types/jasmine": "~4.0.0", "@types/lodash": "^4.14.191", "jasmine-core": "~4.3.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.1.0", "karma-coverage": "~2.2.0", "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.0.0", "typescript": "~4.7.2" } }
需求描述
这个功能需要用户在点击 table 第一列后显示二级列表数据,同时弹出 drill down menu。二级列表数据是通过点击的 Summary 数据向后台请求所返回的,初始化的列表中不存在二级列表的数据。在等待数据加载的过程中显示一个 Loading 的状态。drill down menu 也需要锚定点击的 Summary 数据,同时 menu 的生命周期受点击事件和 table 滚动事件的影响。点击空白处或者监听到 table 滚动则 menu 关闭。
流程图
总结所遇到的问题
解决方案
一:监听 scroll 事件,同时监听 document click 事件,创建方法 clickOutside 实现点击空白处关闭 menu
private clickOutside = (event: Event) => { const menuEl = this.drilldownMenu?.nativeElement; const menuBtnEls = this.el.nativeElement.querySelectorAll('.dropdown-trigger-btn'); let clickedMenuInside, clickedMenuBtnInside; if (menuEl || menuBtnEls?.length > 0) { clickedMenuInside = menuEl?.contains(event.target); clickedMenuBtnInside = _.some(menuBtnEls, item => item.contains(event.target)); if (!clickedMenuInside && !clickedMenuBtnInside) { this.closeDrilldownMenu(); } } }; handleScrollTable() { this.closeDrilldownMenu(); } private closeDrilldownMenu = () => { this.clickedTriggerIndex = undefined; this.triggers?.forEach(trigger => trigger?.closeMenu()); };
二:向数据(Summary list)中添加 index 属性,在点击时将 index 传入到 callback 中借以锚定到当前点击的 trigger。
handleClickSecondIcon(event: Event, element: PeriodicElement) { const isOpenDetailElement = this.expandedElement?.position === element?.position; const newClickedTriggerIndex = element.position - 1; if (this.lastPosition !== element.position) { const oldTrigger = this.triggers?.get(this.lastPosition - 1); oldTrigger?.closeMenu(); this.lastPosition = element.position; } this.expandedElement = isOpenDetailElement ? undefined : element; this.clickedTriggerIndex = this.clickedTriggerIndex !== newClickedTriggerIndex ? newClickedTriggerIndex : undefined; if (!isOpenDetailElement) { this.getDetailData(element); } else { this.dataSource = _.cloneDeep(ELEMENT_DATA); } event.stopPropagation(); }
注:Code 中的 position 是 mock data 自带的属性,此处使用它代替 index,它是从1开始取值的正整数
三:因为需要等待二级数据的加载,menu 的状态和相关的属性(index)需要被保存下来。等待 table 渲染完毕再进行判断(whether scroll or click outside),再手动开启 menu。
方法一示例
// Solution 1: listened callback handleChangeTableContent() { this.openDrilldownMenu(); } private openDrilldownMenu = () => { let trigger; // The value of this.clickedTriggerIndex may be 0 if (_.isNumber(this.clickedTriggerIndex)) { // After table is rendered, open the menu setTimeout(() => { trigger = this.triggers?.get(this.clickedTriggerIndex as number); trigger?.openMenu(); }); } };
ngOnInit() { document.addEventListener('click', this.clickOutside); // Solution 2: Subscription this.subscribeSvc.registerSubscribe('resetMenuStatusAfterTableRendered', this.openDrilldownMenu); } getDetailData(element: PeriodicElement) { // Mock data request const newDataSource = _.cloneDeep(ELEMENT_DATA); this.detailLoading = true; newDataSource.splice(element.position, 0, ...ELEMENT_DETAIL_DATA); // Mock Promise.then setTimeout(() => { this.detailLoading = false; this.dataSource = newDataSource; // Solution 2: Subscription this.subscribeSvc.runSubscribe('resetMenuStatusAfterTableRendered'); }, 3000); }
单纯从这个 demo 的功能上看完全不需要模拟发布订阅模式,因为我们能在控制 View 层的 Contorller 中监听到数据返回的时刻。
反过来说,当发送请求和获取数据的步骤不在当前 Controller 中,或者在将数据绑定到 table 上时会经过 pipe 的处理时,发布订阅模式就会起到用处。
此时仅监听 tableChanged 并不完全准确,尤其是为了优化 table 渲染速度而进行逐步加载时。
这个优化的目的是不将所有数据一口气渲染出来,而是设定计时器,每次只加载一部分数据。这有效的防止了页面的卡顿,但是此时 tableChanged 被调用的时机并不一定是所有数据被加载完成的时刻。
在这种情况下,于 Controller 中订阅消息。在加载计时器完成全部数据加载后发布消息,就可以监听到所有数据被加载完成的时刻。