6

I'm implementing a lazy image loader in my Angular (5) app, and am curious how I can avoid having to call setTimeout() in my ngAfterViewInit(), if possible.

The relevant portions of the code are:

# component
ngOnInit(): void {
  this.workService.getCategories().then(workCategories => {
    this.workCategories = workCategories;
  });
}

ngAfterViewInit(): void {
  setTimeout(() => {
    const images = Array.from(document.querySelectorAll('.lazy-image'));
  }, 100);
}

# component template
<div *ngFor="let workCategory of workCategories">
  <h3>{{ workCategory.fields.name }}</h3>
  <div *ngFor="let workSample of workCategory.fields.workSamples">
    <img width="294" height="294" class="lazy-image" src="..." data-src="..." />
  </div>
</div>

If I remove setTimeout() the images array is always empty. AfterViewInit should run after all of the child components have been created. I've also tried AfterContentInit, which behaves the same and AfterContentChecked, which crashed Chrome.

Is it possible to avoid setTimeout in this case?

ConnorsFan
  • 70,558
  • 13
  • 122
  • 146
Brandon Taylor
  • 33,823
  • 15
  • 104
  • 144
  • Is there any specific reason you are trying to grab direct access to the DOM nodes? Usually you want to do the least amount of direct access to dom as possible when using angular. For instance are you trying to setup the onload events for use in the lazy load? – Patrick Evans Mar 11 '18 at 23:49
  • I'm using an implementation of the IntersectionObserver to replace the original src with a data attribute containing the actual image url as they come into view. – Brandon Taylor Mar 11 '18 at 23:54
  • Can we see what you do in `ngOnInit` and the part of the markup with the `lazy-image` element? – ConnorsFan Mar 12 '18 at 00:37
  • I've added the OnInit and a portion of the template markup. In a nutshell, I retrieve the work categories, then iterate over the samples for each category. – Brandon Taylor Mar 12 '18 at 00:46
  • Just tried the changeDetectorRef strategy, and the array of images is still empty. I'm ok sticking with setTimeout, I was just curious if there was a better approach. – Brandon Taylor Mar 12 '18 at 01:01

2 Answers2

5

This stackblitz shows one method to get notified when the elements have been created with the ngFor directive. In the template, you assign a template reference variable #lazyImage to the img element:

<div *ngFor="let workCategory of workCategories">
  ...
  <div *ngFor="let workSample of workCategory.fields.workSamples">
    <img #lazyImage width="294" height="294" class="lazy-image" src="..." data-src="..." />
  </div>
</div>

In the code, @ViewChildren("lazyImage") is used to declare a QueryList<ElementRef> associated to these images. By subscribing to the changes event of the Querylist in ngAfterViewInit, you get notified when the elements are available. The HTML elements can then be retrieved from the QueryList:

import { Component, ViewChildren, AfterViewInit, QueryList } from '@angular/core';

@Component({
  ...
})
export class AppComponent {

  @ViewChildren("lazyImage") lazyImages: QueryList<ElementRef>;

  ngAfterViewInit() {
    this.lazyImages.changes.subscribe(() => {
      let images = this.lazyImages.toArray().map(x => x.nativeElement);
    });
  }
}

In cases where only the last created item is to be processed, the QueryList.last can be used:

    this.lazyImages.changes.subscribe(() => {
      this.doSomethingOnLastImage(this.lazyImages.last);
    });
ConnorsFan
  • 70,558
  • 13
  • 122
  • 146
  • Thank you. I'll give this a try in the morning. – Brandon Taylor Mar 12 '18 at 01:54
  • This works perfectly. I need to take another look at the docs for ViewChildren. Thank you very much for the help! – Brandon Taylor Mar 12 '18 at 10:19
  • Thanks for this! In this scenario, is this 'waiting' for the last item to be rendered? – DA. Jul 26 '18 at 17:11
  • @DA - The `QueryList.changes` event is triggered every time the content of the `ngFor` loop changes, and the event handler is executed at that moment. If several elements are added at the same time, the event is triggered only once. A `console.log` statement in [the stackblitz](https://stackblitz.com/edit/angular-input-issue-84qqqa) shows that. – ConnorsFan Jul 26 '18 at 17:15
  • Aha! That makes sense. Thanks! – DA. Jul 26 '18 at 17:41
  • and how can someone uses the value of `images`, in this example, outside the `subscribe` function? – x7R5fQ Jul 16 '19 at 14:22
  • @AZSH - This example does not show that; I assumed that the OP wanted to do something with the local variable `images`, as implied in his original code. However, you could declare a class property `public images: Array;` and set it in the callback: `this.images = this.lazyImages...`. That would make it available outside of the callback. – ConnorsFan Jul 16 '19 at 14:27
  • @ConnorsFan this does not work, unfortunately. the value of 'images' variable is `undefined` – x7R5fQ Jul 16 '19 at 14:46
  • @AZSH - We may continue that discussion in [your post](https://stackoverflow.com/q/57029057/1009922). – ConnorsFan Jul 16 '19 at 14:50
0

You can use the requesAnimationFrame API.

Your problem is that even though Angular told the browser to render the images, they are not rendered yet, it takes some times for it to do so and update the DOM, that's why your array is empty.

The requestAnimationFrame API asks for the browser to tell you (via a callback method) when it's done with it's current tasks, when rendering is complete.

ngAfterViewInit(): void {
  window.requestAnimationFrame(() => {
    //place your code here
  })
}
Ploppy
  • 14,810
  • 6
  • 41
  • 58