Blog-Archiv

Freitag, 14. August 2015

JS Visibility Detection

It is not easy for a JavaScript to find out if a certain HTML element is currently visible for the user, meaning it is in the viewport of the browser window. See the discussion on stackoverflow for the great variety of answers to this question.

After reading and trying out some of these answers I decided to create my own solution of this problem. In this Blog I will present that implementation, done in pure JavaScript (no jQuery; but mind that I do not care for browsers that go their own way; Webkit browsers will work with my code).

Why would we need such functionality? Go to one of the new HTML-5 sites and watch the background picture changing when you scroll down. This is done by tracking the visibility of HTML elements. As soon as a certain element gets into view, the background image is exchanged.

Element Rectangle

In modern browsers we have an HTML element function called getBoundingClientRect(). This returns a rectangle with top, left, bottom, right (and even width and height), which is relative to the browser's viewport. The viewport is that part of the HTML page which is currently visible according to the user's scroll-position.

var element = document.getElementById("some-id");
var viewPortRelativeLocation = element.getBoundingClientRect();

To find out whether a rectangle is visible or not we need to translate this rectangle to page-absolute coordinates. Then we could check if this absolute rectangle overlaps the current browser viewport.

Browser Viewport Rectangle

Here is a function that delivers the browser's viewport in absolute coordinates (absolute means relative to the browser's client area, excluding scroll bars).

    var browserViewPort = function() {
      var rectangle = {
        left: window.pageXOffset || document.documentElement.scrollLeft,
        top: window.pageYOffset || document.documentElement.scrollTop,
        width: Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
        height: Math.max(document.documentElement.clientHeight, window.innerHeight || 0)
      };
      rectangle.right = rectangle.left + rectangle.width;
      rectangle.bottom = rectangle.top + rectangle.height;
      return rectangle;
    };

This implementation is a concession to currently still sticky browser differences. I believe that in future document.documentElement.scrollLeft and document.documentElement.clientWidth will be the survivors. Always use a DRY implementation for such, then you can adapt it at any time to new browser standards.

Now to translate the element rectangle to absolute coordinates, we just need to add the browser rectangle to it.

    var getAbsoluteRectangle = function(browserRect, elementRect) {
      return {
        left: elementRect.left + browserRect.left,
        top: elementRect.top + browserRect.top,
        right: elementRect.right + browserRect.left,
        bottom: elementRect.bottom + browserRect.top
      };
    };

Intersecting Rectangles

Seems that we are almost done, but complexity is awaiting behind the corner. I considered writing a separate Blog about intersecting segments, with discussion of all variants I found about it. But to be short I simply will provide an implementation for it.

Intersecting means that the rectangles somehow overlap.

    var intersect = function(r1, r2) {
      return r1.left < r2.right &&
             r1.right > r2.left &&
             r1.top < r2.bottom &&
             r1.bottom > r2.top;
    };

This surely can not be easily understood, but it works. I will deduce this later.

Detecting Visibility

Another complexity is awaiting. An element could be nested within a scrolling container, like

<div style="height: 20em; overflow: auto;">
  ......
</div>

So we need to first check if the element is visible within its parent, and further going from parent to parent until, excluding, the document's body. Only then we can check if the element's rectangle intersects the browser's viewport.

    /** @return true when given element is at least partially visible, else false. */
    var isVisible = function(element) {
      var browser = browserViewPort();
      var rect = getAbsoluteRectangle(browser, element.getBoundingClientRect());
      
      /* could be in a scroll container, so check all parents */
      var parent = element.parentNode;
      while (parent && parent != document.body) {
        var parentRectangle = getAbsoluteRectangle(browser, parent.getBoundingClientRect());
        if ( ! intersect(parentRectangle, rect) || ! intersect(parentRectangle, browser))
          return false;
          
        parent = parent.parentNode;
      }
      
      return intersect(browser, rect);
    };

First the browser's viewport is fetched, needed for translating rectangles to absolute coordinates. Then the element's absolute rectangle is calculated. Now a loop across all parents is started, whenever the element's rectangle does not intersect the parent, or the parent does not intersect the browser's viewport, false is returned. When this all was true, also the element's rectangle is checked to intersect the browser's viewport.

Receiving Scroll Events

Now to perform the trick with changing background images we need to receive scroll events from the browser. Then we can use the function isVisible(element) to find out if a certain element is (at least partially) visible.

Receiving the main scrollbar movements is easy, we just need to add "scroll" listeners to the global JS window object. But for the nested scroll panes we need to know each of them to add a scroll listeners. You can use following code to install them.

    var initializeTracking = function(eventCallback, scrollPanes) {
      window.addEventListener("scroll", eventCallback);
      window.addEventListener("resize", eventCallback);
      
      if (scrollPanes)
        for (var i = 0; i < scrollPanes.length; i++)
          scrollPanes[i].addEventListener("scroll", eventCallback);
    };

This accepts at least one, optionally two parameters. The first is your callback function, called any time some scrollbar is moved. The second can be an array of nested scroll panes to watch.

Test Suite

This is a solution that complies with following test scenarios ...

  1. element can be both smaller or bigger than the browser's viewport
  2. element is ...
    • ... initially visible, but can be scrolled out of view
    • ... initially invisible, but can be scrolled into view
  3. element is nested in a nested scrollable container, that itself is nested in either another container or the page

... and use cases ...

  1. user scrolls the page with the main browser scrollbar
  2. user scrolls the nested scrollable container, and then scrolls the page with the main browser scrollbar
  3. user resizes the browser window in a way that makes elements invisible
  4. user resizes a resizable element (CSS resize: both;) in a way that makes nested elements invisible

Have a look at my test page and you will understand what I mean. Try to carry out all described use cases with that page. On bottom of that page you find the current JS source code, and you can try out if this also works with the browser of your choice.




Keine Kommentare: