58
loading...
This website collects cookies to deliver better user experience
ion-modal
and ion-action-sheet
to layer components on top of the main view. In this post, I will show you another way you could stack your views using CSS Grid and some Angular Animations to create a custom bottom sheet.For those that prefer going through the source code, check out the repo here
src/app/
|- core
|- animations
|- fade.animation.ts
|- slide.animation.ts
|- services
|- layers.service.ts
|- shared
|- components
|- layers
|- food-details-bottomsheet
|- food-details-bottomsheet.ts | html
|- app.component.ts
|- app.component.html
app.component.html
and wrap everything inside ion-app
with a div
with display: grid
👇<!-- src/app/app.component.html -->
<ion-app>
<div class="grid grid-rows-1 grid-cols-1">
<div>
<ion-router-outlet></ion-router-outlet>
</div>
<!-- layers -->
<div class="z-40">
<!-- this will be where your layers go -->
</div>
</div>
</ion-app>
FoodDetailsBottomsheetComponent
. Let's also add some hardcoded values that we can use to render in our template.// src/app/shared/components/layers/food-details-bottomsheet/food-details-bottomsheet.component.ts
import { Component } from "@angular/core";
@Component({
selector: "app-food-details-bottomsheet",
templateUrl: "food-details-bottomsheet.component.html",
})
export class FoodDetailsBottomsheetComponent {
food = {
name: "Some Fancy Food",
image: "assets/images/food-avocado.png",
caption: "Some fancy food caption",
description: `
Lorem ipsum dolor sit amet, consectetur adipiscing elit,
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.`,
};
close(): void {}
}
div
that spans the full width and height of its parents that will contain the shade and bottom sheet layers. This container will be another CSS grid to be able to stack the shade and bottom sheet layers on top of each otherdiv
that spans the full width and height of the container to dim the backgrounddiv
that will contain the contents of the bottom sheet. This div
has the self-end
class (translates to align-self: flex-end;
in CSS) which aligns it to the bottom of the parent container.
<!-- src/app/shared/components/layers/food-details-bottomsheet/food-details-bottomsheet.component.html -->
<!-- container -->
<div class="h-screen grid grid-rows-1 grid-cols-1">
<!-- shade -->
<div
class="row-start-1 row-span-1 col-start-1 col-span-1 bg-black bg-opacity-50"
(click)="close()"
></div>
<!-- bottomsheet -->
<div
class="row-start-1 row-span-1 col-start-1 col-span-1 z-10 self-end p-5 bg-white rounded-t-xl"
>
<p class="text-center text-3xl text-brand-accent font-cursive">
{{ food?.name }}
</p>
<!-- add min height here to prevent view adjusting mid animation when the image loads -->
<div style="min-height: 190px">
<ion-img class="mt-5" [src]="food?.image"></ion-img>
</div>
<p class="text-center text-xs text-brand-gray-light">{{ food?.caption }}</p>
<p class="mt-5 font-sans text-sm text-brand-gray-medium">
{{ food?.description }}
</p>
</div>
</div>
app.component.html
and add the component's selector to the template.<!-- src/app/app.component.html -->
<ion-app>
<div class="grid grid-rows-1 grid-cols-1">
<div>
<ion-router-outlet></ion-router-outlet>
</div>
<!-- layers -->
<div class="z-40">
<!--✨ NEW: bottomsheet added here 👇 -->
<app-food-details-bottomsheet></app-food-details-bottomsheet>
</div>
</div>
</ion-app>
Subject
or BehaviorSubject
, or even regular variables and function calls. For simplicity, I will be using rxjs's BehaviorSubject
to control the opening and close of the bottom sheet.layers.service.ts
which will have an openFoodDetailsBottomsheet
and a closeFoodDetailsBottomsheet
function that we can call from anywhere within the app to open or close the bottom sheet. These functions will then update the layersSource$
BehaviorSubject
which can then be used by the bottom sheet to listen for new changes and react accordingly.// src/app/core/services/layers.service.ts
import { Injectable } from "@angular/core";
import { BehaviorSubject, Observable } from "rxjs";
@Injectable({
providedIn: "root",
})
export class LayersService {
private layersSource$ = new BehaviorSubject<Layers>(initialLayers);
layers$(): Observable<Layers> {
return this.layersSource$.asObservable();
}
openFoodDetailsBottomsheet(): void {
this.layersSource$.next({
...this.getLayersCurrentValue(),
foodDetailsBottomsheet: true,
});
}
closeFoodDetailsBottomsheet(): void {
this.layersSource$.next({
...this.getLayersCurrentValue(),
foodDetailsBottomsheet: false,
});
}
private getLayersCurrentValue(): Layers {
return this.layersSource$.getValue();
}
}
export interface Layers {
foodDetailsBottomsheet: boolean;
}
export const initialLayers: Layers = {
foodDetailsBottomsheet: false,
};
src/app/features/lunch/lunch.page.html
and add a click event to the div
container of app-food-card
👇<!-- src/app/features/lunc/lunch.page.html -->
...
<ion-content [fullscreen]="true">
<div class="grid grid-rows-1 grid-cols-1">
<div
class="row-start-1 row-span-1 col-start-1 col-span-1 bg-white p-1 mt-12"
@staggerFade
>
<ng-container *ngFor="let food of foodOptions">
<!--✨ NEW: click event added here 👇 -->
<div (click)="openFoodDetailsBottomsheet()">
<app-food-card [food]="food"></app-food-card>
</div>
</ng-container>
</div>
<div
class="fixed w-full row-start-1 row-span-1 col-start-1 col-span-1 z-40"
>
<app-options-drawer></app-options-drawer>
</div>
</div>
</ion-content>
src/app/features/lunch/lunch.page.ts
and inject the layers service we created in the previous section. We will then need to call layer service's openFoodDetailsBottomsheet
in our click event listener 👇// src/app/features/lunc/lunch.page.ts
import { Component } from '@angular/core';
import {
LayersService,
NavigationService,
staggerFadeAnimation,
} from '@app/core';
import { IFoodCard } from '@app/shared';
@Component({
selector: 'app-lunch',
templateUrl: 'lunch.page.html',
styleUrls: ['lunch.page.scss'],
animations: [staggerFadeAnimation],
})
export class LunchPage {
...
constructor(
private navigationService: NavigationService,
// ✨ NEW: layers service injected here 👇
private layersService: LayersService
) {}
back(): void {
this.navigationService.back();
}
// ✨ NEW: click event listener added here 👇
openFoodDetailsBottomsheet(): void {
this.layersService.openFoodDetailsBottomsheet();
}
}
<!-- src/app/shared/components/layers/food-details-bottomsheet/food-details-bottomsheet.component.html -->
<div class="h-screen grid grid-rows-1 grid-cols-1">
<!--✨ NEW: click event added here 👇 -->
<div
class="row-start-1 row-span-1 col-start-1 col-span-1 bg-black bg-opacity-50"
(click)="close()"
></div>
...
</div>
// src/app/shared/components/layers/food-details-bottomsheet/food-details-bottomsheet.component.ts
import { Component } from "@angular/core";
import { LayersService } from "@app/core";
@Component({
selector: "app-food-details-bottomsheet",
templateUrl: "food-details-bottomsheet.component.html",
})
export class FoodDetailsBottomsheetComponent {
// ✨ NEW: layers service injected here 👇
constructor(private layersService: LayersService) {}
// ✨ NEW: close bottomsheed 👇
close(): void {
this.layersService.closeFoodDetailsBottomsheet();
}
}
isOpen$
observable that maps the layers service's layers$
observable to listen to only changes in the foodDetailsBottomsheet
property.// src/app/shared/components/layers/food-details-bottomsheet/food-details-bottomsheet.component.ts
import { Component } from '@angular/core';
import { map, distinctUntilChanged } from 'rxjs/operators';
import { LayersService } from '@app/core';
@Component({
selector: 'app-food-details-bottomsheet',
templateUrl: 'food-details-bottomsheet.component.html',
})
export class FoodDetailsBottomsheetComponent {
// ✨ NEW: isOpen listener 👇
isOpen$ = this.layersService.layers$().pipe(
map((layers) => layers.foodDetailsBottomsheet),
distinctUntilChanged()
);
...
}
isOpen$
variable to our template using an async
pipe and conditionally display our component's outermost container using an *ngIf
<!-- src/app/shared/components/layers/food-details-bottomsheet/food-details-bottomsheet.component.html -->
<!-- ✨ NEW: *ngIf -->
<div *ngIf="isOpen$ | async" class="h-screen grid grid-rows-1 grid-cols-1">
<!-- shade -->
<div
class="row-start-1 row-span-1 col-start-1 col-span-1 bg-black bg-opacity-50"
(click)="close()"
></div>
...
</div>
:enter
and :leave
transitions.// src/app/core/animations/fade.animation.ts
import { trigger, transition, style, animate } from "@angular/animations";
export const fadeAnimation = trigger("fade", [
transition(":enter", [
style({ opacity: 0 }),
animate("300ms", style({ opacity: 1 })),
]),
transition(":leave", [animate("300ms", style({ opacity: 0 }))]),
]);
// src/app/core/animations/slide.animation.ts
import { trigger, transition, style, animate } from "@angular/animations";
export const slideUpAnimation = trigger("slideUp", [
transition(":enter", [
style({ transform: "translate(0,500px)" }),
animate(
"350ms cubic-bezier(0.17, 0.89, 0.24, 1.11)",
style({ transform: "translate(0,0)" })
),
]),
transition(":leave", [
animate("300ms ease-in-out", style({ transform: "translate(0,500px)" })),
]),
]);
animations
array in our Component
decorator.// src/app/shared/components/layers/food-details-bottomsheet/food-details-bottomsheet.component.ts
import { fadeAnimation, LayersService, slideUpAnimation } from '@app/core';
@Component({
selector: 'app-food-details-bottomsheet',
templateUrl: 'food-details-bottomsheet.component.html',
// ✨ NEW: animations array 👇
animations: [
fadeAnimation,
slideUpAnimation,
],
})
export class FoodDetailsBottomsheetComponent {
...
}
trigger
names prefixed with the @
symbol to our UI elements.<!-- src/app/shared/components/layers/food-details-bottomsheet/food-details-bottomsheet.component.html -->
<div *ngIf="isOpen$ | async" class="h-screen grid grid-rows-1 grid-cols-1">
<!-- shade -->
<!-- ✨ NEW: @fade 👇-->
<div
@fade
class="row-start-1 row-span-1 col-start-1 col-span-1 bg-black bg-opacity-50"
(click)="close()"
></div>
<!-- bottomsheet -->
<!-- ✨ NEW: @slideUp 👇-->
<div
@slideUp
class="row-start-1 row-span-1 col-start-1 col-span-1 z-10 self-end p-5 bg-white rounded-t-xl"
>
...
</div>
</div>
*ngIf
is applied to the parent of the elements with the animation directives. In other words, when the parent is added to the DOM, the children will be added as well, which triggers the enter animation. However, when the parent is removed from the DOM, it doesn't know about the children having an animation that needs to be executed before removing them from the DOM, hence causing those animations to get skipped.animateChild
. This causes the parent to wait until the children are done executing their animation before removing them from the DOM<!-- src/app/shared/components/layers/food-details-bottomsheet/food-details-bottomsheet.component.html -->
<!-- ✨ NEW: @container 👇-->
<div
*ngIf="isOpen$ | async"
@container
class="h-screen grid grid-rows-1 grid-cols-1"
>
...
</div>
// src/app/shared/components/layers/food-details-bottomsheet/food-details-bottomsheet.component.ts
import {
animate,
animateChild,
query,
style,
transition,
trigger,
} from '@angular/animations';
@Component({
selector: 'app-food-details-bottomsheet',
templateUrl: 'food-details-bottomsheet.component.html',
animations: [
fadeAnimation,
slideUpAnimation,
// ✨ NEW: container 👇
trigger('container', [
transition(':enter, :leave', [
query('@*', animateChild(), { optional: true }),
]),
]),
],
})