DEV Community

Cover image for Responsive Component Rendering from Screen Size
Geoffrey Rodgers
Geoffrey Rodgers

Posted on

Responsive Component Rendering from Screen Size

The Problem

I'm pretty familiar with the general ideas of responsive design, but in building my web app, I wanted more control than just choosing different styles to be applied and deciding whether certain components should be positioned horizontally or vertically, depending on the user's current screen size.

I wanted to be able to render completely different UI structures.

Here's a taste of what this article is about:

The same 'page' component on a small screen...

alt text

...and on a larger screen...

alt text

I'm sure some folks know how to make that sort of drastic change using just CSS, but it's much easier for me to design page layouts as I think about how they will appear on different screen sizes, and that will often include completely different UI components.

So...how to do that?

Enter StencilJS and Web Components

alt text

Stencil, for me, has been the opening of a door. I still sometimes walk into the door frame, but the door is open.

alt text

Ok, weird metaphor... Bottom line: I feel like I can tackle more challenges using web components now that Stencil is on the scene.

If you're not familiar with Stencil, you should be. CHECK IT OUT

For this challenge, I decided to use window.matchMedia() provided by the JavaScript Web API to receive events when the screen size has changed and encapsulate that into a web component I can drop into my projects. With my new component, I could listen for a custom sizeChanged event based on my predetermined screen size breakpoints and define custom rendering logic around those events.

The My Solution

Alright, I'm going to cut to the chase with the rest of this article and just show the code.

My viewport-size-publisher web component:

import { Component, Event, EventEmitter, Method, Prop } from "@stencil/core";

@Component({
  tag: 'viewport-size-publisher'
})
export class ViewportSizePublisher {

  @Event() sizeChanged: EventEmitter;

  @Prop() sizes: Object[] = [];

  private sizesList;

  componentWillLoad() {

    if (!this.sizes || this.sizes.length < 1) {

      // Default sizes, if none are provided as a Prop
      this.sizesList = [
        { name: 'xs', minWidth: '0', maxWidth: '319' },
        { name: 'sm', minWidth: '320', maxWidth: '511' },
        { name: 'md', minWidth: '512', maxWidth: '991' },
        { name: 'lg', minWidth: '992', maxWidth: '1199' },
        { name: 'xl', minWidth: '1200', maxWidth: '9999' }
      ];
    }
    else {

      this.sizesList = [...this.sizes];
    }
  }

  componentDidLoad() {

    // Add listeners for all sizes provided
    for (let i = 0; i < this.sizesList.length; i++) {

      window.matchMedia(`(min-width: ${this.sizesList[i].minWidth}px) 
                          and (max-width: ${this.sizesList[i].maxWidth}px)`)
        .addEventListener("change", this.handleMatchMediaChange.bind(this));
    }
  }

  componentDidUnload() {

    // Remove listeners for all sizes provided
    for (let i = 0; i < this.sizesList.length; i++) {

      window.matchMedia(`(min-width: ${this.sizesList[i].minWidth}px) 
                          and (max-width: ${this.sizesList[i].maxWidth}px)`)
        .removeEventListener("change", this.handleMatchMediaChange.bind(this));
    }
  }

  @Method()
  async getCurrentSize() {

    // Iterate over all given sizes and see which one matches
    for (let i = 0; i < this.sizesList.length; i++) {

      if (window.matchMedia(`(min-width: ${this.sizesList[i].minWidth}px) 
          and (max-width: ${this.sizesList[i].maxWidth}px)`).matches) {

        return this.sizesList[i].name;
      }
    }
  }

  handleMatchMediaChange(q) {

    if (q.matches) {

      // Find the name of the matching size and emit an event
      for (let i = 0; i < this.sizesList.length; i++) {

        if (q.media.indexOf(`min-width: ${this.sizesList[i].minWidth}px`) > -1) {

          this.sizeChanged.emit({ size: this.sizesList[i].name });
        }
      }
    }
  }

  render() {
    return [];
  }
}

In this component, I am taking in size definitions and custom names for the different screen sizes or breakpoints. When the component loads, I add event listeners for all of the media queries generated from those screen sizes. When the component unloads, I remove those event listeners.

There's also a @Method() definition that allows other components to get the current size of the screen when they are first loading.

As the screen changes size, I emit a custom event called sizeChanged.

Usage of my web component in my app-root.tsx component:

<viewport-size-publisher sizes={[
          { name: ViewportSize.ExtraSmall, minWidth: '0', maxWidth: '319' },
          { name: ViewportSize.Small, minWidth: '320', maxWidth: '511' },
          { name: ViewportSize.Medium, minWidth: '512', maxWidth: '991' },
          { name: ViewportSize.Large, minWidth: '992', maxWidth: '1199' },
          { name: ViewportSize.ExtraLarge, minWidth: '1200', maxWidth: '9999' }
        ]} />

Here, you'll notice I'm using an enum to define and standardize the different screen size names, and I'm passing that into my new component.

Implementation of Responsive Logic in Page Component

import { Component, Prop, State, Listen } from "@stencil/core";
import { ViewportSize} from "../../../interfaces/interfaces";

@Component({
  tag: 'group-home-page'
})
export class GroupHomePage {
  ...
  @State() viewportSize: ViewportSize;

  async componentWillLoad() {
    ...
    // Get current viewport size, set this.viewportSize accordingly.
    let viewportSizePubElem = document.querySelector('viewport-size-publisher');
    this.viewportSize = await viewportSizePubElem.getCurrentSize();
  }

  @Listen('document:sizeChanged')
  handleViewportSizeChange(event: any) {
    ...
    this.viewportSize = event.detail.size;
  }

  renderForSmallerSizes() {
    return [
      ...
      //<ion-list> components without surrounding <ion-card>
      ...
    ];
  }

  renderForLargerSizes() {
    return [
      ...
      //<ion-list> components with surrounding <ion-card>
      ...
    ];
  }

  renderBasedOnSize() {

    switch (this.viewportSize) {
      case ViewportSize.ExtraSmall:
      case ViewportSize.Small:
      case ViewportSize.Medium: {
        return this.renderForSmallerSizes();
      }
      case ViewportSize.Large:
      case ViewportSize.ExtraLarge: {
        return this.renderForLargerSizes();
      }
    }
  }

  render() {
    return [
      <ion-header>
        <ion-toolbar color='secondary'>
          <ion-buttons slot='start'>
            <ion-menu-button></ion-menu-button>
          </ion-buttons>
          <ion-title>{ this.group ? this.group.name : '...' }</ion-title>
        </ion-toolbar>
      </ion-header>,
      <ion-content>

        {this.renderBasedOnSize()}

      </ion-content>
    ];
  }
}

Let's break down what's going on in this file a bit more.

@State() viewportSize: ViewportSize;

Any time this state variable is modified, it will cause the component to re-render.

When the page component is loading, I set the state variable with the current size, which I get by executing the getCurrentSize method:

this.viewportSize = await viewportSizeElem.getCurrentSize();

Thereafter, I decorated a function with a listener to handle any changes made to the screen size and update my state variable:

@Listen('document:sizeChanged')

As the component is rendering or re-rendering, a series of functions return the UI component structure I want for the different screen sizes.

Although I'm combining several different screen sizes into just two main structures, I could easily created different render... functions for Small versus ExtraSmall.

Conclusion

What are your thoughts on this approach? How have you done something similar in your project?

Top comments (0)