Custom Image Carousel that Manipulates Data w/ Angular Low-Level Directives, Lifecycle Methods, and Execution Workflow

Ria Pacheco - Sep 2 '22 - - Dev Community

With Angular, one might build a component that leans mostly on the template's logic (e.g. using the *ngSwitch directive), while another might create the same component leaning entirely on methods from the component itself, while another might have it dominantly rely on the structure of the data it's created for.

This post walks through the creation of a custom/reusable image carousel to help anyone new to the framework see a few of the tool's best low-level features in action: in-template directives, lifecycle hook methods, and dynamic component-to-template data-binding.

It's also just useful, if you like its simplicity/flexibility, for future copy/paste logic (and avoiding broken libraries).


Skip ahead


App Creation and Dependencies

First, we'll create a new app with the --skip-tests flag so that anything we generate through the @angular/cli won't include test files. To keep this high-level, we'll also install my SCSS package for access to shorthand classes and colors.



# Command Line 

# 1️⃣ Create the app
ng new ng-carousel --skip-tests

# 2️⃣ Install yutes
npm install @riapacheco/yutes


Enter fullscreen mode Exit fullscreen mode

SCSS Import

In your styles.scss file, add the following code:



// styles.scss

@import '~@riapacheco/yutes/main.scss';         // Reset default styles & access shorthand classes
@import '~@riapacheco/yutes/season/two.scss';   // Access colors

html {
  font-size: 10px;
  font-family: "Inter", sans-serif;
}
body {
  font-size: 1.6rem;
}


Enter fullscreen mode Exit fullscreen mode

CommonModule

Don't forget to add the CommonModule so that we have access to directives.



// Add this ⤵️
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';

@NgModule({
  declarations: [ AppComponent ],
  imports: [
    BrowserModule,
    // ⬇️ and this
    CommonModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }


Enter fullscreen mode Exit fullscreen mode

Material Icons

We'll use Google's Material Icons by adding the following snippet within the <head> element of the index.html file:



<!--index.html-->

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>NgCarousel</title>

  <!-- Add Material Icons here ⤵️ -->
  <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">

  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
  <app-root></app-root>
</body>
</html>


Enter fullscreen mode Exit fullscreen mode

Setting Up the Component

To make this component reusable in the most effective way possible, we first take into account the data schema it will accept and any properties that might drive or react to user controls. We consider the data schema since the directives found in templates often shape themselves around the structure that the data provides and we consider behavioral properties since they define the user's experience (and this one is pretty predictable).

The Image Carousel Component

Generate a new component by running the following command



# Command Line
ng g c components/image-carousel


Enter fullscreen mode Exit fullscreen mode

Replace the content found in the app.component.html file with the new component's selector. I've added a couple classes from @riapacheco/yutes to make things a bit easier on our eyes:



<!--app.component.html-->
<div class="mx-auto-200px pt-5">
  <app-image-carousel></app-image-carousel>
</div>


Enter fullscreen mode Exit fullscreen mode
  • The mx-auto-200px is a shorthand class from the yutes package that gives the div the width of the pixels specified (before the px) and centers it horizontally
  • The pt-5 is a shorthand class for padding-top: 5rem;

When you run $ ng serve in your terminal, you should see the following on http://localhost:4200:
Carousel App Works

Carousel Image Data Schema

In the component file, we'll create an exported interface that defines the properties of a single image and the types of data accepted for each of those properties.



// image-carousel.component.ts
import { Component, OnInit } from '@angular/core';

// Add this interface ⤵️
export interface ICarouselImage {
  url: string;
  caption?: string; // the `?` indicates optional data that won't return an error if missing
  alt?: string;
}

@Component({
  selector: 'app-image-carousel',
  templateUrl: './image-carousel.component.html',
  styleUrls: ['./image-carousel.component.scss']
})
export class ImageCarouselComponent implements OnInit {
  constructor() { }
  ngOnInit(): void {}
}


Enter fullscreen mode Exit fullscreen mode

Note: Within the interface, the ? indicates that the property its attached to is optional and consequently won't return an error if missing from the data

A Property for an Active Image

Since image carousels focus and render only one image at a time, we'll create a property on our component that will tell our template which image to display based on its index. Since an array's index starts at 0 (not 1), we'll assign 0 to the property so it knows to render the first image when the component initializes.



// image-carousel.component.ts
// .. other code
export class ImageCarouselComponent implements OnInit {
  // ⤵️ Add this property
  activeImageIndex = 0;

  constructor() { }
  ngOnInit(): void {}
}


Enter fullscreen mode Exit fullscreen mode

Width and Height Properties

Since the component will be placed in containers controlled by a parent (which impact dimensions unpredictably), we want to add properties that enable any parent to adjust the component's container and image dimensions if required. To do this, we'll add properties to the component file that can bind to the template like this:



// image-carousel.component.ts
// ... other code
export class ImageCarouselComponent implements OnInit {
  activeImageIndex = 0;
  // ⤵️ add this
  config = {
    height: 100,
    width: 100,
  };
  constructor() { }

  ngOnInit(): void {}
}


Enter fullscreen mode Exit fullscreen mode

Actual Image Data

We'll add some placeholder data that a parent component will later replace. In the component file, create an images property to the type of ICarouselImage as an array (by adding a [] suffix to the type):



// image-carousel.component.ts

// ...other code

export class ImageCarouselComponent implements OnInit {
  activeImageIndex = 0;
  config = {
    height: 100,
    width: 100,
  };

  // Add this ⤵️
  images: ICarouselImage[] = [
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy1.png?t=2022-09-01T22%3A04%3A27.297Z',
      caption: 'Standard digital clock',
      alt: ''
    },
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy2.png?t=2022-09-01T22%3A06%3A12.323Z',
      caption: 'Digital clock with date, weather, and steps',
      alt: ''
    },
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy3.png',
      caption: 'Pokemon themed watch face',
      alt: '',
    },
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy5.png',
      caption: 'Tetris themed watch face',
      alt: '',
    },
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy6.png',
      caption: 'Paint program themed watch face',
      alt: '',
    },
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy7.png',
      caption: 'Sports watch themed face',
      alt: '',
    },
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy8.png',
      caption: 'Binary watch face',
      alt: '',
    },
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy9.png',
      caption: 'Fancy watch face',
      alt: '',
    },
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy12.jpg',
      caption: 'Cat face watch face',
      alt: '',
    },
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy14.png',
      caption: 'PowerShell themed watch face',
      alt: '',
    }
  ];

  constructor() { }

  ngOnInit(): void {}
}


Enter fullscreen mode Exit fullscreen mode

Creating the Template

The Dynamic Template

First, add the following the component's scss file so that we have a simple outline showing us where the images should appear:



// image-carousel.component.scss
.carousel-container {
  padding: 5px;
  border: 1px solid black;
  border-radius: 3px;
}


Enter fullscreen mode Exit fullscreen mode

In the template, add the following (explained below snippet):



<!--1️⃣: div wrapper -->
<div class="carousel-container"
  [style.height.%]="config.height"
  [style.width.%]="config.width">

  <!-- ---------------------------- 2️⃣: Buttons ----------------------------- -->
  <a class="carousel-btn previous">
    <i class="material-icons">chevron_left</i>
  </a>
  <a class="carousel-btn next">
    <i class="material-icons">chevron_right</i>
  </a>

  <!-- ---------------------------- 3️⃣: Wrapper ----------------------------- -->
  <div *ngFor="let image of images; let i = index;" class="carousel-wrapper">
    <!--4️⃣: ng-container -->
    <ng-container *ngIf="i == activeImageIndex">
      <!--5️⃣: image-->
      <img
        [src]="image.url" 
        [alt]="image.alt"
        [style.height.%]="config.height"
        [style.width.%]="config.width">
    </ng-container>
  </div>
</div>



Enter fullscreen mode Exit fullscreen mode

What's Happening Here?

Element # Summary
(1) div wrapper - Containing div that applies styles we added earlier
- Uses Angular's [style] directive to access the div's width and height
- Assigns the component's config values to their associated properties as percentages
(2) buttons - Add 2 buttons (utilizing Material Icons) to act as user controls
(3) wrapper - The actual carousel wrapper that binds the images array data from the component; and
- Instantiates any actions contained with let i = index
(4) ng-container - Uses Angular's ng-container, which ensures the div is not present in the DOM unless the expression following *ngIf returns true
- Allows only the active image to render by only allowing an index position equal to the position specified by the component's activeImageIndex property
(5) img element - Binds the image url which was initially bounded by the *ngFor wrapper but narrowed down by the enclosing ng-container's expression
- Utilizes the same width and height properties as the component's wrapping div

Now if you serve the app locally, it should look similar to this:

watchface

Next and Previous Buttons

Though the buttons don't look too bad here, we'd like for the buttons to hover over the image so that the component is as tight (and predictable) as possible, for any parent component styles to influence.

In our component's scss file, we'll create some basic styling and some structural properties to control how the buttons are positioned in the container:



.carousel-container {
  padding: 5px;
  border: 1px solid black;
  border-radius: 3px;

  // enables and contains nested `absolute` elements
  position: relative;

  /* ------------------------- Style for BOTH buttons ------------------------- */
  a.carousel-btn {
    // Some quick styles
    color: white;
    background-color: black;
    border-radius: 2px;

    // enables control of position within the relative container
    position: absolute;

    // General width and height of buttons
    width: 3rem;
    height: 3rem;

    // Ensures the icon is centered within the button
    display: inline-flex;
    flex-flow: column nowrap;
    align-items: center;
    justify-content: center;

    // Ensures the buttons are centered vertically with math
    top: calc(50% - (3rem / 2)); // calc({half of container height} - ({half of button height})) 

    /* ------------------------- Styles for Each Button ------------------------- */
    &.previous { left: 0; }
    &.next { right: 0; }
  }
}


Enter fullscreen mode Exit fullscreen mode

If you view the served app, the buttons look pretty abnoxious.

face with 2 buttons

However, first we'll make sure the buttons do as we want them to [UX] before addressing design issues [UI].

Show Next Image

Since our reusable component accepts dynamic data, in that we can have as many images as we want in the images array, we need to identify the array's length (count of images) when the component initializes so that any user control logic we implement (for "next" or "previous" buttons) have min / max thresholds to work with when it's time to reset the carousel back to the first image.

To do this, we'll declare a new property in the component called lastIndexPosition to the type of number and assign value to it when the component initializes. Don't forget to assign the length of the images array minus 1, since array data starts with 0 instead of 1.



// image-carousel.component.ts
// ... other code

export class ImageCarouselComponent implements OnInit {

  activeImageIndex = 0;
  // ⤵️ Add this property
  lastIndexPosition!: number;
  config = {
    height: 100,
    width: 100,
  };
  images: ICarouselImage[] = [
    //... other code
  ];

  ngOnInit(): void {
    // ⤵️ And this
    this.lastIndexPosition = this.images.length - 1;
  }
}


Enter fullscreen mode Exit fullscreen mode

Now we can create an onNext() method that knows to update the activeImageIndex property with the next available index position, but will reset it to 0 if it's currently on the last image:



export class ImageCarouselComponent implements OnInit {
  activeImageIndex = 0;
  lastIndexPosition!: number;
  config = {...};
  images: ICarouselImage[] = [...];

  ngOnInit(): void {
    this.lastIndexPosition = this.images.length - 1;
  }

    // IF the current image is the last one in the array, reset to the first
    // ELSE add (and self-assign) 1 to the current position ⤵️
    onNext() {
    if (this.activeImageIndex >= this.lastIndexPosition) { this.activeImageIndex = 0; }
    else { this.activeImageIndex += 1; }
  }
}


Enter fullscreen mode Exit fullscreen mode

For our onPrevious() control, we'll first identify if the current image is the first one in the array. If it is, it will reset to the last image in the array. Below the onNext() method, add the following:



// IF the current image is the first in the array, show the last image
// ELSE subtract (and self-assign) 1 to the current position
onPrevious() {
  if (this.activeImageIndex == 0) { this.activeImageIndex = this.lastIndexPosition; }
  else { this.activeImageIndex -= 1; }
}


Enter fullscreen mode Exit fullscreen mode

Now if you add the methods to the template, you'll see that the buttons enable you to cycle through the images as you'd expect!

Next and previous buttons

Finishing Styling

Remove Highlighting on Buttons

Notice how the icons themselves sometimes highlight from the click. This is because the icons are rendered as text and typically we like highlighting text so that we can copy it or select it for print. However, in cases like these, we can remove this with the following code added to the buttons:



a.carousel-btn {
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  -khtml-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
}


Enter fullscreen mode Exit fullscreen mode

Appear Only on Hover

As we said earlier, though the buttons themselves are useful, they get in the way of the actual image we're trying to view. To fix this (I'm sure you already know this), just add an opacity property to the buttons with a value of 0 and when the overall wrapper experiences the :hover event, set the opacity to 1:



a.carousel-btn {
  opacity: 0;
}

// HOVER state
.carousel-container:hover {
  a.carousel-btn {
    opacity: 1;
    transition: 180ms ease-in-out; // threw this in for funsies
  }
}


Enter fullscreen mode Exit fullscreen mode

Make It Reusable

What Does "Reusable" Mean?

In this MVVM world, there are smart components and there are dumb components. Smart component (also known as Parent components) should have all the data, talk to services if they need that data, and should gain access to any dumb component controls. Though dumb (or child) components will use decorators and emitters to be able to communicate with their parent, when generally looking at a Smart component's TypeScript file, there shouldn't be any traces of that child component's logic (with some deliberate exceptions).

For this example, our ImageCarouselComponent is the child component and our AppComponent is the parent. Though, any other component, where the <app-image-carousel></app-image-carousel> selector is placed inside, would also be considered a parent component. We're using the AppComponent here to limit the work.

Prepare the Parent with Switchover Controls

Parent Data

Since the parent AppComponent will use the same data structure, we'll add the same data from the child component to the parent (named parentImages for differentiation). In the app.component.ts file, add the following:



import { Component } from '@angular/core';
// Import interface ⤵️
import { ICarouselImage } from './components/image-carousel/image-carousel.component';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  // Add the data ⤵️ the same as we did to the image-carousel but with a different name for the array
  parentImages: ICarouselImage[] = [
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy1.png?t=2022-09-01T22%3A04%3A27.297Z',
      caption: 'Standard digital clock',
      alt: ''
    },
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy2.png?t=2022-09-01T22%3A06%3A12.323Z',
      caption: 'Digital clock with date, weather, and steps',
      alt: ''
    },
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy3.png',
      caption: 'Pokemon themed watch face',
      alt: '',
    },
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy5.png',
      caption: 'Tetris themed watch face',
      alt: '',
    },
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy6.png',
      caption: 'Paint program themed watch face',
      alt: '',
    },
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy7.png',
      caption: 'Sports watch themed face',
      alt: '',
    },
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy8.png',
      caption: 'Binary watch face',
      alt: '',
    },
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy9.png',
      caption: 'Fancy watch face',
      alt: '',
    },
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy12.jpg',
      caption: 'Cat face watch face',
      alt: '',
    },
    {
      url: 'https://zwkbcfekyorurkjubugn.supabase.co/storage/v1/object/public/bucks/watchy_faces/watchy14.png',
      caption: 'PowerShell themed watch face',
      alt: '',
    }
  ];
}



Enter fullscreen mode Exit fullscreen mode

Make Child Data Open to all Parent Components

To signal to the app that parent components can access and provide data for the images property in the child component, we import the Input decorator from @angular/core and add it to the property as a prefix.



// image-carousel.component.ts
// Import decorator ⤵️
import { Component, Input, OnInit } from '@angular/core';

// other code

export class ImageCarouselComponent implements OnInit {
  // ⬇️ Prefix it here
  @Input() images: ICarouselImage[] = [ ... ];
}


Enter fullscreen mode Exit fullscreen mode

Complete the Connection

To completely switchover control of this property, we bind the data from one component to the other through the selector itself. In the app.component.html file, add the following to the child component's selector:



<!--app.component.html-->
<div class="mx-auto-200px pt-5">
  <app-image-carousel
    [images]="parentImages">
  </app-image-carousel>
</div>


Enter fullscreen mode Exit fullscreen mode
  • Any references added to an element using [] indicate a binding of data (as opposed to () which binds events)
  • This shows that the data from the child component (on the left side of the =) is now bound to the data in the parent component which has the same structure

Now you can remove the data inside the child component like this:



// image-carousel.component.ts
export class ImageCarouselComponent implements OnInit {
  @Input() images!: ICarouselImage[];
}


Enter fullscreen mode Exit fullscreen mode

Provide the Other Inputs

Now we can do the same for the other properties like config and activeImageIndex (if you wanted to start at a different position).



// image-carousel.component.ts
export class ImageCarouselComponent implements OnInit {
  @Input() images!: ICarouselImage[];  
  @Input() activeImageIndex = 0;
  @Input() lastIndexPosition!: number;
  @Input() config = {
    height: 100,
    width: 100,
  };
}


Enter fullscreen mode Exit fullscreen mode

Result

Hover Controls



Full code found here

. . . . . . . . . . . . . . . . .