Chris Moore
Chris Moore

Reputation: 525

Angular Animation For Dynamically Changing Height

I've successfully gotten a panel to animate expanding and closing when entering and leaving the DOM. The problem is I now have a busy indicator inside the panel prior to showing details, and the animation only occurs for opening the busy indicator, and snaps when the detail content is shown.

How can I get the Angular animation to animate on any height change?

I have an example here: https://stackblitz.com/edit/angular-animation-for-dynamically-changing-height?embed=1&file=src/app/app.component.ts

trigger('expandCollapseDetails', [
    state('void', style({
        'height': '0px',
        overflow: 'hidden'
    })),
    //element being added into DOM.
    transition(':enter', [
        animate('500ms ease-in-out', style({
            'height': '*',
            overflow: 'hidden'
        }))
    ]),
    //element being removed from DOM.
    transition(':leave', [
        animate('500ms ease-in-out', style({
            'height': '0px',
            overflow: 'hidden'
        }))
    ])
])

Upvotes: 29

Views: 52166

Answers (6)

R1D3R175
R1D3R175

Reputation: 170

Any of these solutions didn't work for me, so I created a custom directive that updates the height of the component using Angular Signals.

import {Directive, effect, ElementRef, input} from '@angular/core';

@Directive({
  selector: '[appAutoHeightTransition]',
  standalone: true,
})
export class AutoHeightTransitionDirective {
  appAutoHeightTransition = input<unknown>();
  previousHeight?: number;

  constructor(private element: ElementRef) {
    effect(() => {
      this.appAutoHeightTransition();
      const element = this.element.nativeElement as HTMLElement;

      if (this.previousHeight === undefined) {
        this.previousHeight = element.clientHeight;
        return;
      }

      const previousHeight = this.previousHeight;
      const currentHeight = element.clientHeight;
      this.previousHeight = element.clientHeight;

      element.style.height = `${previousHeight}px`;
      requestAnimationFrame(() => {
        element.style.height = `${currentHeight}px`;
      });
    });
  }
}

To use it in a component...

  1. Set transition CSS property on the container
transition: height 0.3s ease-in-out
  1. Find the variables which modifies the height of the component; in my case it was whether the table had data or not
consentsDataSource =
    new MatTableDataSource<ConsentWithProcedureAndLastUpdatedByPerson>();
  1. Use the directive in the template
<article
  [appAutoHeightTransition]="this.consentsDataSource.data.length > 0"
  id="consents-box"
  [class.has-results]="this.consentsDataSource.data.length > 0"
>
  <!-- something --> 
</article>

EDIT

I came up with a more dynamic solution, leaving the other just in case. This is the new directive.

import {Directive, effect, ElementRef, input} from '@angular/core';

@Directive({
  selector: '[appAutoHeightTransition]',
  standalone: true,
})
export class AutoHeightTransitionDirective {
  appAutoHeightTransition = input<unknown>();

  constructor(private element: ElementRef) {
    effect(() => {
      this.appAutoHeightTransition();
      const element = this.element.nativeElement as HTMLElement;
      const transition = element.style.transition;
      const opacity = element.style.opacity;
      const currentHeight = element.clientHeight;

      element.style.height = `auto`;
      element.style.transition = '';
      element.style.opacity = '0';
      requestAnimationFrame(() => {
        const newHeight = element.clientHeight;
        element.style.height = `${currentHeight}px`;
        element.style.transition = transition;
        element.style.opacity = opacity;

        requestAnimationFrame(() => {
          const padding = getComputedStyle(element).padding;
          element.style.height = `calc(${newHeight}px - ${padding})`;
        });
      });
    });
  }
}

Upvotes: 0

Vahid
Vahid

Reputation: 7551

I made a directive based on @MartinCremer answer. I think using a directive makes more sense since by doing that, you also should add the animation to your parent component (and it's close the standard way of adding animations).

So inside my animations.ts file. I've added the animation:

    export const smoothHeight = trigger('grow', [
      transition('void <=> *', []),
      transition('* <=> *', [style({ height: '{{startHeight}}px', opacity: 0 }), animate('.5s ease')], {
        params: { startHeight: 0 }
      })
    ]);

then you should add this animation to your parent component (the component that you want to use the animation inside it):

    import { smoothHeight } from '@app/animations';
    @Component({
      selector: 'app-parent',
      templateUrl: './parent.component.html',
      styleUrls: ['./parent.component.scss'],
      animations: [smoothHeight]
    })

And here is the directive which is really close to the component of @MartinCremer:

    import { Directive, OnChanges, Input, HostBinding, ElementRef } from '@angular/core';

    @Directive({
      selector: '[smoothHeight]',
      host: { '[style.display]': '"block"', '[style.overflow]': '"hidden"' }
    })
    export class SmoothHeightAnimDirective implements OnChanges {
      @Input()
      smoothHeight;
      pulse: boolean;
      startHeight: number;

      constructor(private element: ElementRef) {}

      @HostBinding('@grow')
      get grow() {
        return { value: this.pulse, params: { startHeight: this.startHeight } };
      }

      setStartHeight() {
        this.startHeight = this.element.nativeElement.clientHeight;
      }

      ngOnChanges(changes) {
        this.setStartHeight();
        this.pulse = !this.pulse;
      }
    }

Finally inside parent.component.html use the directive:

    <div [smoothHeight]="yourAnimationIndicator">
      // any html content goes here
    </div>

Just replace yourAnimationIndicator with the variable that the animation should trigger on change of its value.

Here is a stackblitz demo

Upvotes: 20

march
march

Reputation: 101

I had some jump issues with @Martin Cremer's solution. With complex content that needs a little time to display, the target height set when the animation is triggered wasn't the actual final height of the new content.

So I used 2 containers, the inner one #content, used to listen to the height changes, and the outer one #container, of which I adjust the height to match the inner one (with a smooth transition).

I copy the the height changes from the inner to the outer container as long as the animation is on going, after being triggered, hence the msSpeed variable property.

On initialization, I don't do it, hence the initDone property.

The HTML:

<div #container
     class="container"
     [style.transition]="animationOn?('height ease '+msSpeed+'ms'): 'none'">
    <div #content
         class="content">
        <ng-content></ng-content>
    </div>
</div>

THE TS:

import { animate, style, transition, trigger } from '@angular/animations';
import { Component, ElementRef, HostBinding, Input, OnChanges, OnInit, Renderer2, ViewChild } from '@angular/core';

@Component({
  selector: 'app-smooth-height',
  templateUrl: './smooth-height.component.html',
  styleUrls: ['./smooth-height.component.scss'],
  animations: [
    trigger('grow', [
      transition('void <=> *', []),
      transition('* <=> *', [
        style({ opacity: 0 }),
        animate('{{speed}} ease')])
    ])
  ]
})
export class SmoothHeightComponent implements OnInit, OnChanges {

  @Input() trigger: any;
  @Input() msSpeed: number = 500;

  @ViewChild('container') container: ElementRef;
  @ViewChild('content') content: ElementRef;

  initDone = false;
  animationOn = false;

  constructor(private element: ElementRef, private renderer: Renderer2) { }

  ngOnInit(): void { }

  @HostBinding('@grow') get grow(): any {
    return { value: this.trigger, params: { speed: this.msSpeed + 'ms' } }; 
  }

  ngAfterViewInit(): void {

    const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
      const contentHeight = entries[0].contentRect.height;
      this.renderer.setStyle(this.container.nativeElement, "height", (contentHeight) + 'px');
    })
    observer.observe(this.content.nativeElement)

    this.initDone = true;
  }

  ngOnChanges(): void {
    if (this.initDone) {
      this.animationOn = true;
      setTimeout(() => { this.animationOn = false; }, this.msSpeed)
    }
  }
}

THE CSS:

:host {
  display: block;
}

.container {
  display: grid;
  width: 100%;
  overflow-x: visible;
  margin: 0 -20px;
  padding: 0 20px;
}

.content {
  height: fit-content;
}

Upvotes: 4

Martin Cremer
Martin Cremer

Reputation: 5572

I've written a component that smoothly animates the height of projected content if that content changes. It's used like this:

<smooth-height [trigger]="content">
  {{content}}
</smooth-height>

Here's a stackblitz: https://stackblitz.com/edit/angular4-kugxw7

This is the component:

import {ElementRef, HostBinding, Component, Input, OnChanges} from '@angular/core';
import {animate, style, transition, trigger} from "@angular/animations";

@Component({
  selector: 'smooth-height',
  template: `
    <ng-content></ng-content>
  `,
  styles: [`
    :host {
      display: block;
      overflow: hidden;
    }
  `],
  animations: [
    trigger('grow', [
      transition('void <=> *', []),
      transition('* <=> *', [
        style({height: '{{startHeight}}px', opacity: 0}),
        animate('.5s ease'),
      ], {params: {startHeight: 0}})
    ])
  ]
})
export class SmoothHeightComponent implements OnChanges {
  @Input()
  trigger: any;

  startHeight: number;

  @HostBinding('@grow') grow: any;

  constructor(private element: ElementRef) {}  
              
  ngOnChanges(){
    this.startHeight = this.element.nativeElement.clientHeight;

    this.grow = {
      value: this.trigger,
      params: {startHeight: this.startHeight}
    };
  }
}

Upvotes: 59

Lucas Sim&#245;es
Lucas Sim&#245;es

Reputation: 657

Transitions and unfixed sizes (fit-content, max-content, etc..) do not communicate well.

Here's a sample of hack for this case:

animations: [
    trigger('openCloseAnimation', [
      state('open', style({ maxHeight: '100px', overflow: 'auto' })),
      state('closed', style({ maxHeight: '60px' })),
      transition('* => closed', animate('0.2s')),
      transition('* => open', animate('0.5s')),
    ]),
  ],

With MaxHeight, your div/container dont will exceed more than 'max-content', but will behave with 'fit-content'.

Upvotes: 2

Kevin Upton
Kevin Upton

Reputation: 3644

You can achieve something similar with a bit of css and js:

Solution:

component.ts

import { Component, OnChanges, ViewChild, Input } from '@angular/core';

@Component({
  selector: 'app-exandable',
  templateUrl: './exandable.component.html',
  styleUrls: ['./exandable.component.css']
})
export class ExandableComponent implements OnChanges {

  @Input()
  src;

  @ViewChild('expandable')
  expandable;

  ngOnChanges() {
    this.updateHeight();
  }

  updateHeight(delay = 0) {
    const el = this.expandable.nativeElement;

    setTimeout(() => {

      const prevHeight = el.style.height;
      el.style.height = 'auto';
      const newHeight = el.scrollHeight + 'px';
      el.style.height = prevHeight;

      setTimeout(() => {
        el.style.height = newHeight;
      }, 50);
    }, delay);
  }
}

css

.expandable {
  transition: height 0.2s ease-in-out;
  overflow: auto;
}

Code:

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

Upvotes: 4

Related Questions