Steve Kim
Steve Kim

Reputation: 5591

Angular check when an element is in view

In angular, how do I detect if a certain element is in view?

For example, I have the following:

<div class="test">Test</div>

Is there a way to detect when this div is in view?

Thanks.

Upvotes: 18

Views: 38544

Answers (6)

Flobesst
Flobesst

Reputation: 1340

Depending on what you want to achieve, you can use Angular's Deferrable Views.

With the new control flow introduced in Angular 17, you can create a loop to iterate over a list and display a placeholder until the element is visible in the viewport.

@for (element of elements; track element.id) {
  @defer (on viewport) {
    <my-component />
  } @placeholder {
    <div class="skeleton-loader"></div>
  }
}

Note that the loop isn't mandatory here, it's just an example of implementation.

Upvotes: 1

Ivan Ivanyuk
Ivan Ivanyuk

Reputation: 342

https://stackoverflow.com/a/68484378/9566462 has a bug, here is a corrected version

Use like

<div appIsOnScreen #isOnScreen="isOnScreen">123</div>
{{ isOnScreen.isOnScreen$ | async }}

Directive

import { Directive, ElementRef } from '@angular/core';
import { defer, Observable, shareReplay } from 'rxjs';

@Directive({
  exportAs: 'isOnScreen',
  selector: '[appIsOnScreen]',
  standalone: true,
})
export class IsOnScreenDirective {
  public readonly isOnScreen$: Observable<boolean>;

  constructor(private readonly _elementRef: ElementRef) {
    this.isOnScreen$ = defer(() => {
      return this.getIsOnScreen$(this._elementRef.nativeElement);
    }).pipe(
      shareReplay({
        bufferSize: 1,
        refCount: true,
      }),
    );
  }

  private getIsOnScreen$(element: Element): Observable<boolean> {
    return new Observable<boolean>((subscriber) => {
      const intersectionObserver = new IntersectionObserver(
        (entries) => {
          // Sometimes entries receive multiple entries
          // Last one is correct
          subscriber.next(entries[entries.length - 1].isIntersecting);
        },
        {
          threshold: 1,
        },
      );

      intersectionObserver.observe(element);

      return () => {
        intersectionObserver.disconnect();
      };
    });
  }
}

Upvotes: 1

Blagovest Georgiev
Blagovest Georgiev

Reputation: 111

I had the same task in one of the latest projects I've worked on and ended up using a npm package, which provides a working directive out of the box. So if someone doesn't feel like writing and testing directives, like me, check out this npm package ng-in-view. It works like a charm in Angular 14.
I hope that this post will help someone to save some time writing directives.

Upvotes: 3

Ria Pacheco
Ria Pacheco

Reputation: 161

I ended up creating a slightly vanilla directive (though it uses the @HostListener decorator and stuff). If the height of the directive-bound element is fully within the viewport, it emits a boolean value ($event) of true, otherwise it returns false. It does this through JS getBoundingClientRect() method; where it can then take the rect element's position and do some quick/simple math with the viewport's height against the top/bottom position of the element.

inside-viewport.directive.ts:

import { Directive, ElementRef, EventEmitter, HostListener, Output } from '@angular/core';

@Directive({
  selector: '[insideViewport]'
})
export class InsideViewportDirective {
  @Output() insideViewport = new EventEmitter();
  constructor(
    private elementRef: ElementRef;
  ) { }

  @HostListener('body:scroll', ['$event'])
  public onScrollBy(): any {
    const windowHeight = window.innerHeight;
    const boundedRect = this.elementRef.nativeElement.getBoundingClientRect();

    if (boundedRect.top >= 0 && boundedRect.bottom <= windowHeight) {
      this.insideViewport.emit(true);
    } else {
      this.insideViewport.emit(false);
    }
  }
}

Then, in the template app.component.html, I added an additional second argument (string), to follow the first $event arg (which emits the boolean value) so that the component could identify which element was scrolled into view, given that you can apply this directive to multiple elements:

<div (insideViewport)="onElementView($event, 'blockOne')">
  <span *ngIf="showsText"> I'm in view! </span>
</div>

And in the component (aka the "do stuff" part) app.component.ts, now we can receive the boolean value to drive the behavior of the conditional component property; while also qualifying the element as the first block (aka blockOne) in a view that might have multiple "blocks":

// ... other code
export class AppComponent implements OnInit {

  showsText!: boolean;

  //... other code

  onElementView(value: any, targetString: string): void {
    if (value === true && targetString === 'blockOne') {
      this.showsText = true;
    } else { this.showsText = false; }
  }
}

Hope this helps as I've been trying to figure the most vanilla-y way to do this (with core angular stuff) for a while lol -- but I am not nearly versed enough on best-case APIs sadface

Upvotes: 1

noamyg
noamyg

Reputation: 3094

Based off this answer, adapted to Angular:

Template:

<div #testDiv class="test">Test</div>

Component:

  @ViewChild('testDiv', {static: false}) private testDiv: ElementRef<HTMLDivElement>;
  isTestDivScrolledIntoView: boolean;

  @HostListener('window:scroll', ['$event'])
  isScrolledIntoView(){
    if (this.testDiv){
      const rect = this.testDiv.nativeElement.getBoundingClientRect();
      const topShown = rect.top >= 0;
      const bottomShown = rect.bottom <= window.innerHeight;
      this.isTestDivScrolledIntoView = topShown && bottomShown;
    }
  }

Example with scroll event binding

Another nice feature is to determine how much of that <div> is to be considered as "within view". Here's a reference to such implementation.

Upvotes: 18

GuCier
GuCier

Reputation: 7405

Here is a directive that you can use. It uses the shiny IntersectionObserver API

The directive

import {AfterViewInit, Directive, TemplateRef, ViewContainerRef} from '@angular/core'

@Directive({
  selector: '[isVisible]',
})

/**
 * IS VISIBLE DIRECTIVE
 * --------------------
 * Mounts a component whenever it is visible to the user
 * Usage: <div *isVisible>I'm on screen!</div>
 */
export class IsVisible implements AfterViewInit {

  constructor(private vcRef: ViewContainerRef, private tplRef: TemplateRef<any>) {
  }

  ngAfterViewInit() {
    const observedElement = this.vcRef.element.nativeElement.parentElement

    const observer = new IntersectionObserver(([entry]) => {
      this.renderContents(entry.isIntersecting)
    })
    observer.observe(observedElement)
  }

  renderContents(isIntersecting: boolean) {

    this.vcRef.clear()

    if (isIntersecting) {
      this.vcRef.createEmbeddedView(this.tplRef)
    }
  }
}

Usage

<div *isVisible>I'm on screen!</div>

Upvotes: 9

Related Questions