Angular —— 一个弹出菜单与列表扩展/滚动冲突的解决方案

Drill down menu 简单来说就是数据的下钻,点击一个 Summary 的信息后弹出 Detail 详情,或者弹出一个菜单让用户选择不同的操作。

虽然是一个简单的功能,但随着数据的增多,以及操作复杂性的增大,问题就随之而来。

Demo 环境

windows10

使用 Angular-cli 搭建的标准项目

angular: 14.2.0

angular-material: 13.0.0

node: 16.13.1

npm: 8.1.2

package.json

{
  "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 关闭。

流程图

总结所遇到的问题

  1. 在禁用 Backdrop 的情况下,监听事件控制 Menu 的状态
  2. List 中存在多个触发 Menu 开启的 trigger,在手动控制 Menu 状态时需要锚定到特定 trigger
  3. 点击 Summary 字段重新加载数据并重新渲染 table 会将应用于 table 上的组件状态重置

问题一示例

 问题三示例

解决方案

一:监听 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。

有两种方式获取渲染完毕的时刻:

  1. 通过 table 组件的 API,监听 table rendered(推荐)
  2. 返回请求数据并更新了 table data 后

推荐使用第一种方法,但是有些特殊情况下第二种方法更可行。

方法一示例

// 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 中订阅消息。在加载计时器完成全部数据加载后发布消息,就可以监听到所有数据被加载完成的时刻。

以上就是我基于工作中遇到的问题所做的经验分享

Thanks

猜你喜欢

转载自blog.csdn.net/KenkoTech/article/details/129417123