Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

MatSort breaks MatTable detail row animations

I've been beating my head against this problem for quite a while before I got here. Essentially, I have an Angular Material table that uses animations to create a detail row. When the table sorts, it rearranges the data. Some of the detail rows have a transition to void during that process. Afterwards, the detail rows stop playing the animation, even though the animation events are firing. I suspect that MatSort is breaking the animations somehow, but I'm not sure how.

Angular Material table:

<mat-table matSort
        [dataSource]="tableData"
        multiTemplateDataRows>

        <!-- More Column -->
        <ng-container matColumnDef="more">
            <mat-header-cell *matHeaderCellDef 
                translate>
                More
            </mat-header-cell>
            <mat-cell *matCellDef="let scheduleCourse">
                <p class="fa fa-angle-right" *ngIf="!tableData.checkExpanded(scheduleCourse)"></p>
                <p class="fa fa-angle-down" *ngIf="tableData.checkExpanded(scheduleCourse)"></p>
            </mat-cell>
        </ng-container>

        <!-- Meets Column -->
        <ng-container matColumnDef="meets">
            <mat-header-cell *matHeaderCellDef
                mat-sort-header="Meets" 
                translate>
                Meets
                <filter [data]="tableData" columnName="Meets" dataType="string"></filter>
            </mat-header-cell>
            <mat-cell *matCellDef="let scheduleCourse">
                {{scheduleCourse.Meets}}
            </mat-cell>
        </ng-container>

        <!-- Term Column -->
        <ng-container matColumnDef="term">
            <mat-header-cell *matHeaderCellDef 
                mat-sort-header="Term"
                translate>
                Term
                <filter [data]="tableData" columnName="Term" dataType="string"></filter>
            </mat-header-cell>
            <mat-cell *matCellDef="let scheduleCourse">
                {{scheduleCourse.Term}}
            </mat-cell>
        </ng-container>

        <!-- Course Name Column -->
        <ng-container matColumnDef="course">
            <mat-header-cell *matHeaderCellDef 
                mat-sort-header="Course"
                translate>
                Course Name
                <filter [data]="tableData" columnName="Course" dataType="string"></filter>
            </mat-header-cell>
            <mat-cell *matCellDef="let scheduleCourse">
                {{scheduleCourse.Course}}
            </mat-cell>
        </ng-container>

        <!-- Teacher Column -->
        <ng-container matColumnDef="teacher">
            <mat-header-cell *matHeaderCellDef 
                mat-sort-header="Teacher"
                translate>
                Teacher
                <filter [data]="tableData" columnName="Teacher" dataType="string"></filter>
            </mat-header-cell>
            <mat-cell *matCellDef="let scheduleCourse">
                {{scheduleCourse.Teacher}}
            </mat-cell>
        </ng-container>

        <!-- Room Column -->
        <ng-container matColumnDef="room">
            <mat-header-cell *matHeaderCellDef 
                mat-sort-header="Room"
                translate>
                Room
                <filter [data]="tableData" columnName="Room" dataType="string"></filter>
            </mat-header-cell>
            <mat-cell *matCellDef="let scheduleCourse">
                {{scheduleCourse.Room}}
            </mat-cell>
        </ng-container>

        <!-- Entry Date Column -->
        <ng-container matColumnDef="entry date">
            <mat-header-cell *matHeaderCellDef 
                mat-sort-header="EntryDate"
                translate>
                Entry Date
                <filter [data]="tableData" columnName="EntryDate" dataType="date"></filter>
            </mat-header-cell>
            <mat-cell *matCellDef="let scheduleCourse">
                {{scheduleCourse.EntryDate.toString() != junkDate.toString() ? scheduleCourse.EntryDate.toLocaleDateString() : ''}}
            </mat-cell>
        </ng-container>

        <!-- Dropped Date Column -->
        <ng-container matColumnDef="dropped date">
            <mat-header-cell *matHeaderCellDef 
                mat-sort-header="DroppedDate"
                translate>
                Dropped Date
                <filter [data]="tableData" columnName="DroppedDate" dataType="date"></filter>
            </mat-header-cell>
            <mat-cell *matCellDef="let scheduleCourse">
                {{scheduleCourse.DroppedDate.toString() != junkDate.toString() ? scheduleCourse.DroppedDate.toLocaleDateString() : ''}}
            </mat-cell>
        </ng-container>

        <!-- Team Column -->
        <ng-container matColumnDef="team">
            <mat-header-cell *matHeaderCellDef 
                mat-sort-header="TeamCode"
                translate>
                Team
                <filter [data]="tableData" columnName="TeamCode" dataType="string"></filter>
            </mat-header-cell>
            <mat-cell *matCellDef="let scheduleCourse">
                {{scheduleCourse.TeamCode}}
            </mat-cell>
        </ng-container>

        <!-- Expand Row 1 -->
        <ng-container matColumnDef="expandedRow">
            <td mat-cell
                *matCellDef="let scheduleCourse"
                [attr.colspan]="columns.length"
                style="width: 100%">

                <!-- Links and Actions -->
                <div class="detailRow">
                    <div class="detailItem">
                        <label style="color: #595959" translate>Course-Section</label>
                        &nbsp;
                        {{scheduleCourse.SubjectCode}}-{{scheduleCourse.Section}}
                    </div>
                    <a class="detailItem"
                        (click)="assignmentClick(scheduleCourse)"
                        translate>
                        Assignments
                    </a>
                    <a class="detailItem"
                        (click)="attendanceClick(scheduleCourse)"
                        translate>
                        Attendance
                    </a>
                    <a class="detailItem"
                        (click)="emailTeacherClick(scheduleCourse)"
                        translate>
                        Email Teacher
                    </a>
                    <a class="detailItem"   
                        (click)="gradesClick(scheduleCourse)"
                        translate>
                        Grades
                    </a>

                    <!-- Menu Button -->
                    <button class="detailItem"
                        *ngIf="showProfiles"
                        style="cursor: pointer; border: none; background-color: inherit;" 
                        [matMenuTriggerFor]="actionMenu"
                        [matMenuTriggerData]="{'scheduleCourse': scheduleCourse}">
                        <img src="./assets/images/actions.png"
                            alt="actions">
                    </button>
                </div>

                <!-- School Indicator -->
                <div *ngIf="showSchool(scheduleCourse)" 
                    class="detailRow">
                    <div class="detailItem">
                        <label style="color: #595959" translate>
                            School
                        </label>
                        &nbsp;
                        {{scheduleCourse.SchoolName}}
                    </div>
                </div>
            </td>
        </ng-container>

        <!-- Row definitions -->
        <mat-header-row *matHeaderRowDef="columns"></mat-header-row>
        <mat-row *matRowDef="let row; columns: columns;"
            matRipple 
            tabindex="0" 
            style="cursor: pointer"
            [ngStyle]="{'background-color': selectedRow == row ? 'whitesmoke' : ''}"
            [ngClass]="{'detailRowOpened': tableData.checkExpanded(row)}"
            (click)="tableData.toggleExpanded(row); selectedRow = row;"></mat-row>
        <mat-row *matRowDef="let row; columns: ['expandedRow']" 
            matRipple
            (click)="selectedRow = row;"
            [ngClass]="{'selectedRow': selectedRow == row}"
            (@detailExpand.done)="animation($event)"
            [@detailExpand]="tableData.checkExpanded(row) ? 'expanded' : 'collapsed'"
            style="overflow: hidden"></mat-row>
    </mat-table>

The detailExpand animation:

export const detailExpand = [
    trigger('detailExpand', [
        state('collapsed', style({
            paddingTop: '0px',
            height: '0px',
            minHeight: '0',
            paddingBottom: '0px'
        })),
        state('expanded', style({
            paddingTop: '*',
            height: 'auto',
            paddingBottom: '25px'
        })),
        transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)'))
      ])
];

My component, in case you need it:

@Component({
    selector: 'student-schedule',
    templateUrl: './student-schedule.component.html',
    styleUrls: [
        './student-schedule.component.css'
    ],
    animations: [
        detailExpand
    ]
})
export class StudentScheduleComponent implements OnInit, DoCheck, OnDestroy {

    // Properties
    private _viewOption = 1;
    private _includeDropped = false;
    schedule: ScheduleCourse[] = [];
    subscriptions: Subscription[] = [];
    tableData = new TylerMatTableDataSource();
    junkDate = System.junkDate;
    V10: boolean;
    columns = ['more', 'meets', 'term', 'course', 'teacher', 'room', 'entry date', 'dropped date', 'team'];
    selectedRow: ScheduleCourse;
    expandEmitter = new EventEmitter<boolean>();
    tableHeight: number;
    minTableWidth: number;
    @ViewChild('tableContainer', {read: ElementRef}) tableContainer: ElementRef;
    showProfiles: boolean;
    studentEnrollment: Enrollment;
    _sort: MatSort;

    // Class Functions
    constructor(
        private studentScheduleService: StudentScheduleService,
        private loginService: LoginService,
        private router: Router,
        private dialog: MatDialog,
        private studentService: StudentService,
        private sendEmailService: SendEmailService
    ) { }

    get viewOption(): number {
        return this._viewOption;
    }

    set viewOption(value: number) {
        this._viewOption = value;
        this.getSchedule();
    }

    get includeDropped(): boolean {
        return this._includeDropped;
    }

    set includeDropped(value: boolean) {
        this._includeDropped = value;
        this.checkColumns();
    }

    @ViewChild(MatSort) set sort(value: MatSort) {
        this._sort = value;
        this.tableData.sort = this._sort;
    }

    get sort(): MatSort {
        return this._sort;
    }

    // Event Functions
    ngOnInit() {
        // POST: initializes the data
        this.V10 = this.loginService.LoginSettings.V10;
        this.showProfiles = this.loginService.LoginSettings.ParentPortalCourseScheduleProfiles;
        this.checkColumns();
        this.subscriptions.push(
            this.expandEmitter.subscribe(expand => {
                this.tableData.expandAll(expand);
            }),
            this.studentService.selectedStudentStream$.subscribe(() => {
                this.studentEnrollment = this.studentService.studentEnrollment;
                this.getSchedule();
            })
        );
    }

    ngDoCheck() {
        // POST: determines the height and width of the table container
        if (this.tableContainer) {
            this.tableHeight = System.getTableHeight(this.tableContainer);
        }
    }

    ngOnDestroy() {
        // POST: unsubscribes to all observables
        this.subscriptions.forEach(subscription => {
            subscription.unsubscribe();
        });
    }

    assignmentClick(scheduleCourse: ScheduleCourse) {
        // PRE: the user clicks on an assignment link under a course
        // POST: routes the user to that assignment page
        // TODO: Ensure it links to the proper class
        this.router.navigateByUrl('/student360/assignments');
    }

    attendanceClick(scheduleCourse: ScheduleCourse) {
        // PRE: the user clicks on an attendance link under a course
        // POST: routes the user to that attendance page
        this.router.navigateByUrl('/student360/attendance');
    }

    emailTeacherClick(scheduleCourse: ScheduleCourse) {
        // PRE: the user clicks on an attendance link under a course
        // POST: routes the user to the email page
        // TODO: Ensure it links to the proper teacher
        this.sendEmailService.teacherName = scheduleCourse.TeacherName;
        this.sendEmailService.teacherEmailAddress = scheduleCourse.TeacherEmail;
        this.router.navigateByUrl('/student360/sendEmail');
    }

    gradesClick(scheduleCourse: ScheduleCourse) {
        // PRE: the user clicks on a grade link under a course
        // POST: routes the user to the grade page
        this.router.navigateByUrl('/student360/reportcardgrades');
    }

    courseDescriptionClick(scheduleCourse: ScheduleCourse) {
        // PRE: the user clicks on a course description link under a course
        // POST: shows a modal for the course's description
        this.dialog.open(CourseDescriptionDialogComponent, {
            data: {
                course: scheduleCourse.Course,
                section: scheduleCourse.Section,
                teacherName: scheduleCourse.TeacherName,
                schoolName: scheduleCourse.SchoolName,
                curriculum: scheduleCourse.Curriculum,
                description: scheduleCourse.Description
            }
        });
    }

    classInformationClick(scheduleCourse: ScheduleCourse) {
        // PRE: the user clicks on a class information link under a course
        // POST: shows a modal for that class' profile
        this.dialog.open(ProfileViewerDialogComponent, {
            data: {
                courseSSEC_ID: scheduleCourse.Id,
                courseName: scheduleCourse.Course,
                courseSection: scheduleCourse.Section,
                teacherName: scheduleCourse.TeacherName,
                school: scheduleCourse.SchoolName
            }
        });
    }

    teacherProfileClick(scheduleCourse: ScheduleCourse) {
        // PRE: the user clicks on a teacher profile link under a couse
        // POST: shows a modal for that teacher's profile
        this.dialog.open(ProfileViewerDialogComponent, {
            data: {
                teacherId: scheduleCourse.TeacherId,
                teacherName: scheduleCourse.TeacherName,
                school: scheduleCourse.SchoolName
            }
        });
    }

    animation(event) {
        console.log(event);
    }

    // Methods
    showSchool(scheduleCourse: ScheduleCourse): boolean {
        return this.studentEnrollment.SchoolName &&
            scheduleCourse.SchoolName &&
            this.studentEnrollment.SchoolName.trim().toUpperCase() != scheduleCourse.SchoolName.trim().toUpperCase();
    }

    getSchedule() {
        // POST: obtains the schedule from the server
        this.subscriptions.push(
            this.studentScheduleService.getStudentSchedule(this.viewOption).subscribe(schedule => {
                this.schedule = schedule;
                for (let i = 0; i < this.schedule.length; i++) {
                    this.schedule[i] = System.convert<ScheduleCourse>(this.schedule[i], new ScheduleCourse());
                }
                this.tableData = new TylerMatTableDataSource(this.schedule);
                if (this.sort) {
                    this.tableData.sort = this.sort;
                }
            })
        );
    }

    checkColumns() {
        // POST: checks the columns for ones that shouldn't be there

        // Team is a V9 only column
        if (this.V10 && this.columns.includes('team')) {
            this.columns.splice(this.columns.indexOf('team'), 1);
        } else if (!this.V10 && !this.columns.includes('team')) {
            this.columns.push('team');  // Team is always on the end
        }

        // Entry date and dropped date are only there if include dropped
        if (this.includeDropped) {
            if (!this.columns.includes('entry date')) {
                this.columns.splice(5, 0, 'entry date');
            }
            if (!this.columns.includes('dropped date')) {
                this.columns.splice(6, 0, 'dropped date');
            }
            this.minTableWidth = 1000;
        } else {
            if (this.columns.includes('dropped date')) {
                this.columns.splice(this.columns.indexOf('dropped date'), 1);
            }
            if (this.columns.includes('entry date')) {
                this.columns.splice(this.columns.indexOf('entry date'), 1);
            }
            this.minTableWidth = 750;
        }
    }
}

This is the animation event to void that I'm talking about. After this one, the animation stops working. Also, I've tested to see if I can create a void transition animation, but that animation doesn't play either.

Now, I know that the tableData works properly because the table displays fine. Further, the animations work perfectly before that event is fired from sorting. In fact, the sorting works and the "detailRow.done" event keeps firing even when the animation isn't playing. So, I know it must be something to do with MatSort and Animation interaction: I just don't know what.

Here's what I've tried:

  • Removing [ngStyle] and [ngClass]
  • Removing the width and height styling on the table and its container
  • Removing the ngDoCheck lifecycle hook
  • Changing mat-sort-header to use the matColumnDef and making the matColumnDef match the sort property name
  • Using a setTimeout to set the sort to the tableData
  • "Bouncing" the table in and out of the DOM after the sort changes
  • Forcing a renderRows on the table after sort changes

UPDATE 1

I tried reproducing the problem in a stackblitz, but I couldn't do so successfully. It appears that MatSort and Angular Animations play well with each other and that something else is going on here. That gives me some direction.

UPDATE 2

So, I've found the problem, although it's odd that it is a problem. I've extended the MatTableDataSource with a few helper functions, which is where I get the "tableData.checkExpanded" and "tableData.toggleExpanded" functions. When I use an array of booleans from the component to check for expansion, the component works fine. When I used those functions, I end up with this problem. This is the code for that class. I may update the stackblitz to see if I can reproduce it using this.

export class TylerMatTableDataSource extends MatTableDataSource<any>{
    filterNumber:number = 0;
    filterTestValue:string = '';
    filters:FilterModel[] = [];

    expandedElements:number[] = [];

    constructor(initialData?: any[]){
        super(initialData);
        this.filterPredicate = this.genericFilter;
    }    

    toggleExpanded(row: any) {
        if (row != undefined) {

            if(row.detailRow == undefined || row.detailRow == false){
                row.detailRow = true;
            }
            else{
                row.detailRow = false;
            }
        }
    }

    checkExpanded(row:any):boolean{
        if(row.detailRow == undefined){
            row.detailRow = false;
        }

        return row.detailRow;
    }

    expandAll(expand: boolean) {
        this.data.forEach(element => {
            element.detailRow = expand;
        });
    }
}

UPDATE 3

I've updated the stackblitz to demonstrate the problem. Note that this only happens when I use two *ngIf's on the p tags in the 'More' column. If I use interpolation, the error does not occur.

https://stackblitz.com/edit/angular-te2cen

like image 930
Julia Norman Avatar asked Sep 11 '18 14:09

Julia Norman


3 Answers

When using Flex layout (no 'table', 'tr', 'td' elements), I cannot get animation triggers to work reliably with sorting. Some rows are just randomly dead after sorting the table. I'm using Angular 10.

After four hours of debugging and testing, I moved to [ngClass] and css animations, which works flawlessly.

  > mat-row.detail-row {
    overflow: hidden;
    border-style: none;
    min-height: auto;
    &.detail-row-collapsed {
      max-height: 0;
      transition: max-height .4s ease-out;
    }
    &.detail-row-expanded {
      max-height: 1000px;
      transition: max-height .4s ease-in;
    }
  }
like image 70
Stefan Norberg Avatar answered Nov 05 '22 12:11

Stefan Norberg


Note: This works for Angular < 10, see below for Angular >= 10 solution

I have had the same problem and fixed by adding an additional void state by changing the animation from

trigger('detailExpand', [
  state('collapsed', style({ height: '0px', minHeight: '0', display: 'none' })),
  state('expanded', style({ height: '*' })),
  transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')),
])

to

trigger('detailExpand', [
  state('collapsed, void', style({ height: '0px', minHeight: '0', display: 'none' })),
  state('expanded', style({ height: '*' })),
  transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')),
  transition('expanded <=> void', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)'))
])

Only changes are first state('collapsed' to state('collapsed, void' and the last transition(...) line.

Now both sorting and expanding rows work as expected.

Credit to pabloFuente for solution here.

like image 25
Daniel Avatar answered Nov 05 '22 12:11

Daniel


Angular 10 solution

export const detailExpand = trigger('detailExpand',
    [
        state('collapsed, void', style({ height: '0px'})),
        state('expanded', style({ height: '*' })),
        transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')),
        transition('expanded <=> void', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)'))
    ]);
like image 6
Jonathan Moy Avatar answered Nov 05 '22 13:11

Jonathan Moy