Search code examples
angularsignalsaccordionangular-animationsangular-signals

Difference between changing input of Angular component inside and outside a component (+animations)


I made an accordion component (angular18) with lazy loading of it's body content and smooth animation of opening and closing. So it works pretty well when i toggle it from inside (by clicking on header), but when i toggle it from outside - it doesn't open smoothly (but closes smoothly).

Here's the stackblitz: https://stackblitz.com/edit/stackblitz-starters-rjsmkv

What's the point of this behavior? i'm quite new to Angular animations..

tried to replace model with input - same thing. Css animations with maxheight are not good enough because of different content sizes.

upd. added squares to ensure than animations works there - they do.


Solution

  • I think the problem is due to mixing of signal and observable updates. Where the signal is updated first followed by the observable being updated later.

    The scenario is there is no content inside the accordion and the signal is updated, then the animation has already started. At a later point of time the observable delayedIsOpen is updated by that time the animation is midway or even over. This causes the jerk.

    I have noticed that using both the sources as observables, solves the issue.

    So we can create another observable isOpenDelayed = toObservable(this.isOpen); just to fix the timing issue, then use this to trigger the animation.

    <div
      class="main-content"
      [@content]="
        (isOpenDelayed | async)
          ? { value: 'visible', params: { transitionParams } }
          : { value: 'hidden', params: { transitionParams } }
      "
    >
      @if (delayedIsOpen | async) {
      <ng-container *ngTemplateOutlet="accordionBodyRef" />
      }
    </div>
    

    Full Code:

    HTML:

    <div
      class="test"
      [@sqare]="
        isOpen()
          ? { value: 'red', params: { transitionParams } }
          : { value: 'blue', params: { transitionParams } }
      "
    >
      isOpen
    </div>
    <p></p>
    <div
      class="test"
      [@sqare]="
        (delayedIsOpen | async)
          ? { value: 'red', params: { transitionParams } }
          : { value: 'blue', params: { transitionParams } }
      "
    >
      delayedIsOpen
    </div>
    
    <p></p>
    
    <div
      class="accordion"
      [ngStyle]="{ '--transiton-time': transitionTime + 'ms' }"
    >
      <div class="accordion-head">
        <div class="top-side top-side_left" (click)="toggle()">
          <ng-content select="[topLeft]" />
        </div>
      </div>
      <div
        class="main-content"
        [@content]="
          (isOpenDelayed | async)
            ? { value: 'visible', params: { transitionParams } }
            : { value: 'hidden', params: { transitionParams } }
        "
      >
        @if (delayedIsOpen | async) {
        <ng-container *ngTemplateOutlet="accordionBodyRef" />
        }
      </div>
    </div>
    

    TS:

    import {
      animate,
      state,
      style,
      transition,
      trigger,
    } from '@angular/animations';
    import {
      ChangeDetectionStrategy,
      Component,
      input,
      model,
      ContentChild,
      TemplateRef,
    } from '@angular/core';
    import { toObservable } from '@angular/core/rxjs-interop';
    import { delay, of, switchMap } from 'rxjs';
    
    @Component({
      selector: 'accordion',
      templateUrl: './accordion.component.html',
      styleUrl: './accordion.component.scss',
      changeDetection: ChangeDetectionStrategy.OnPush,
      animations: [
        trigger('content', [
          state(
            'hidden',
            style({
              height: '0',
              visibility: 'hidden',
            })
          ),
          state(
            'visible',
            style({
              height: '*',
              visibility: 'visible',
            })
          ),
          transition('visible <=> hidden', [animate('{{transitionParams}}')]),
          transition('void => *', animate(0)),
        ]),
        trigger('sqare', [
          state(
            'red',
            style({
              background: 'red',
            })
          ),
          state(
            'blue',
            style({
              background: 'blue',
            })
          ),
          transition('red <=> blue', [animate('{{transitionParams}}')]),
          transition('void => *', animate(0)),
        ]),
      ],
    })
    export class AccordionComponent {
      isOpen = model<boolean>(false);
    
      transitionTime = 500;
      transitionParams = `${this.transitionTime}ms linear`;
    
      delayedIsOpen = toObservable(this.isOpen).pipe(
        switchMap((open) =>
          open ? of(open) : of(open).pipe(delay(this.transitionTime))
        )
      );
    
      isOpenDelayed = toObservable(this.isOpen);
    
      @ContentChild('accordionBody', { read: TemplateRef })
      accordionBodyRef: TemplateRef<unknown> | null = null;
    
      toggle(): void {
        this.isOpen.update((value) => !value);
      }
    }
    

    Stackblitz Demo