Кладем плитку из картинок. Masonry grid gallery на Angular Material своими руками

Ух, давненько ничего не писал, но тут созрел повод. На моем вялотекущем проекте встала задача добавить пользователям возможность размещать и просматривать фотографии. Было решено оформить массив загруженных фоток в стиле Pinterest.

После легкого курса гуглотерапии было усвоено следующее:

  • данная компоновка сетки из картинок носит условное название "Masonry" (да-да, масоны - сообщество вольных плиточников);
  • есть библиотека, которая позволяет вертикально укладывать фотки будто плитки - Masonry;
  • у нее нет официальной поддержки Angular;
  • ее адаптации сторонних разработчиков под Angular либо очень сырые, либо заброшены (сори, без примеров и ссылок, гуглите и оценивайте сами, применяйте на свой страх и риск);
  • реализация плиточной кладки на чистом CSS на момент начала 2018 года не возможна;
  • готовые решения в статьях показывают не совсем верную реализацию (высота ряда равна высоте наибольшей плитки, плитки располагаются по колонкам и т.п.);
  • есть интересная статья, как можно реализовать требуемый эффект на CSS Grid с помощью малой толики javascript.

Вывод: это нужно пилить самостоятельно и писать по этому поводу статью ;)

Для наглядности демо того, что должно получиться в итоге.

Ну что ж, приступим?

Постановка задачи

Думаю, что при просмотре демки, общий принцип работы данного компонента и способ построения плиток в сетке становится сразу понятен. Но все же обобщу требования:

  • имеется массив из картинок;
  • данные изображения требуется расположить на странице таким образом, чтобы они занимали все её свободное пространство;
  • ширина всех картинок одинакова и вычисляется исходя из ширины страницы и заданного количества колонок;
  • картинки по порядку располагаются в ряд;
  • если картинку в текущем ряду поместить невозможно, то она переносится ниже;
  • перенесенная картинка должна быть помещена в колонку с наименьшей высотой;
  • последующие картинки располагаются также в колонки с наименьшей высотой (с наибольшим свободным пространством под ними).

Дополнительные требования (адаптивность/responsive design):

  • количество колонок определяется разработчиком для различных диапазонов ширины окна браузера.

Дополнительные требования (декоративные):

  • визуальный эффект перемещения картинок при изменении размера экрана;
  • визуальный эффект при загрузке/добавлении изображения;
  • визуальный эффект приближения при наведении курсора;
  • визуальный эффект при клике.

Инструменты

В моем приложении, а следовательно и в примере данной статьи, используются:

  • Angular ~5.2.10;
  • Material ~5.2.4;
  • Flex-layout ~5.0.0.

Принцип

Для построения сетки изображений будет использоваться компонент Material Grid List. Он позволяет задавать следующие параметры:

  • cols - количество колонок сетки;
  • rowHeight - высота каждого ряда;
  • gutterSize - ширина шва между плитками сетки.

Для каждой конкретной плитки задаются:

  • colspan - количество колонок, которое будет занято плиткой;
  • rowspan - количество рядов, занимаемых плиткой.

Итак, основная идея заключается в том, что мы устанавливаем очень малую высоту ряда (rowHeight="1px"), картинки растягиваем на всю ширину плитки (style="width: 100%;"). Высота каждой картинки будет вычислена пропорционально ее базовым размерам. Нам остается только извлечь ее и передать в качестве значения количества рядов ([rowspan]="imageHeight").

Реализация

Наше приложение будет состоять из трех компонентов:

  • app.component - главный компонент, в котором будем генерировать массив картинок и передавать его в галерею;
  • grid-gallery.component - собственно компонент самой галереи;
  • grid-gallery-item.component - элемент галереи, плитка, в которой будет располагаться изображение.

Начнем с реализации основного функционала, а далее доработаем его в части адаптивности и декоративности.

Интерфейс изображения

//app/image.model.ts

export interface Image {
  src: string,
  alt?: string
}

Главный компонент приложения

//app/app.component.ts

import {Component, Input} from '@angular/core';
import {Image} from "./image.model";

@Component({
  selector: 'app-component',
  templateUrl: 'app.component.html',
  styleUrls: ['app.component.scss'],
})
export class AppComponent {

  numOfImages = 10; // Требуемое количество сгенерированных изображений
  images: Image []; // Массив изображений

  constructor() {
    this.images = this.generateImagesList();
  }

  private generateImagesList(): Image[] {
    const images: Image[] = [];
    for (let i = 0; i < this.numOfImages; i++){
      const image = this.generateRandomImage();
      image.alt = `#${i}`;
      images.push(image);
    }
    return images;
  }

  private generateRandomImage(): Image {
    const width = 600;
    const height = (Math.random() * (1000 - 400) + 400).toFixed();
    return {src: `https://picsum.photos/${width}/${height}/?random`};
  }

  addImage() {
    const image = this.generateRandomImage();
    image.alt = `#${this.images.length}`;
    this.images.push(image);
  }
}

Генерируем массив из 10 изображений. Для этого используем сервис picsum.photos. Высота изображения устанавливается случайным образом из диапазона 400 - 1000 px, ширина - 600 px. Ну и метод для добавления изображения.

<!-- app/app.component.html -->

<!-- Header -->
<div class="toolbar" fxFlex='100%' fxLayout="row" fxLayoutAlign="end center">
  <button mat-raised-button color="primary" (click)="addImage()">ADD IMAGE</button>
</div>

<!-- Gallery -->
<app-grid-gallery [images]="images" [cols]="4" [rowHeight]="1"></app-grid-gallery>

В шаблоне будет хэдер с кнопкой для добавления картинки и компонент галереи app-grid-gallery. В него мы передаем наш массив с фотками, указываем требуемое количество колонок (4) и указываем высоту рядов (как писал выше - 1 px).

Ну и приступаем непосредственно к самой галерее.

Компонент галереи

// app/grid-gallery/grid-gallery.component.ts

import {Component, Input} from '@angular/core';
import {Image} from "../image.model";

@Component({
  selector: 'app-grid-gallery',
  templateUrl: './grid-gallery.component.html'
})
export class GridGalleryComponent{

  @Input() images: Image[];
  @Input() cols: number = 4; // Количество колонок
  @Input() rowHeight: number = 1; // Высота рядов, px
  @Input() gutterSize: number = 1; // Ширина шва, px
}

Здесь практически пусто. Определяем входные параметры компонента и указываем их дефолтные значения. В дальнейшем здесь также будем следить за шириной окна браузера и изменять количество колонок. Но для начала этого достаточно.

В шаблоне поинтереснее:

<!-- app/grid-gallery/grid-gallery.component.html -->

<mat-grid-list [cols]="cols"
               [rowHeight]="rowHeight"
               [gutterSize]="gutterSize+'px'">

  <mat-grid-tile *ngFor="let imageItem of images"
                 [rowspan]="item.rows">

    <app-grid-gallery-item  #item 
                            [image]="imageItem" 
                            [rowHeight]="rowHeight" 
                            [gutterSize]="gutterSize">
    </app-grid-gallery-item>

  </mat-grid-tile>

</mat-grid-list>

mat-grid-list и mat-grid-tile - компоненты Material Grid List. В цикле пробегаем по массиву изображений, генерируя для каждого mat-grid-tile. В каждую плитку вставляем компонент app-grid-gallery-item. Его рассмотрим чуть ниже.

Тут важный момент: для каждого элемента мы создаем шаблонную переменную #item и привязываем свойство компонента с количеством строк к параметру rowspan плитки:

[rowspan]="item.rows"

Компонент элемента галереи

Основное назначение данного компонента - следить за высотой картинки и вычислять количество рядов, которое должна занимать плитка, исходя из заданных rowHeight и gutterSize.

// app/grid-gallery/grid-gallery-item/grid-gallery-item.component.ts

import {Component, ElementRef, Input, ViewChild} from '@angular/core';
import {Image} from "../../image.model";

@Component({
  selector: 'app-grid-gallery-item',
  templateUrl: './grid-gallery-item.component.html',
  styleUrls: ['./grid-gallery-item.component.scss']
})
export class GridGalleryItemComponent {

  @Input() image: Image;
  @Input() rowHeight: number = 1;
  @Input() gutterSize: number = 1;

  @ViewChild('img') img: ElementRef;

  public rows: number = 0; // Число рядов, используемое для rowspan mat-grid-tile

  calculateRows() {
    this.rows = Math.floor(this.img.nativeElement.offsetHeight / (this.rowHeight + this.gutterSize));
  }
}

Здесь нужно обратить внимание на метод calculateRows. Он отвечает за обновление высоты нашей плитки. Округляем в меньшую сторону для того, чтобы изображение немного обрезалось, а не оставалось пустое пространство между картинками.

<!-- app/grid-gallery/grid-gallery-item/grid-gallery-item.component.html -->

<img  #img 
      [src]="image?.src" 
      [alt]="image?.alt"/>

В шаблоне все просто. Используем переменную шаблона #img для доступа к ней из класса через @ViewChild. Передаем src и alt.

Ну и немножко стилей:

// app/grid-gallery/grid-gallery-item/grid-gallery-item.component.scss

:host {
  height: 100%; 

  img {
    width: 100%;
  }
}

Устанавливаем высоту компонента в 100% и растягиваем картинку на всю ширину плитки.

Теперь нужно все это оживить. Для этого необходимо определить моменты, когда вызывать метод calculateRows. Во-первых, нам нужно просчитать количество строк сразу после отрисовки компонента. На ум приходит использовать хуки жизненного цикла Angular: OnInit, AfterViewInit или AfterViewChecked. Однако эти методы будут вызываться до того, как успеет подгрузиться изображение, а следовательно количество рядов не будет вычислено. Для решения этой задачи привяжем вызов метода calculateRows к событию (load) нашего изображения:

<!-- app/grid-gallery/grid-gallery-item/grid-gallery-item.component.html -->

<img  #img 
      [src]="image?.src" 
      [alt]="image?.alt"
      (load)="calculateRows()"/>

Отлично! Теперь после загрузки приложения наши картинки будут выстраиваться плитками в нужном нам порядке. Но... При изменении ширины окна браузера картинки будут изменять свой размер, а количество рядов, занимаемых плиткой, пересчитано не будет. Поэтому, во-вторых, нужно добавить декоратор @HostListener к методу calculateRows для обработки события изменения размеров окна:

// app/grid-gallery/grid-gallery-item/grid-gallery-item.component.ts

...
  @HostListener('window:resize')
  calculateRows() {
  ...
  }

Посмотреть работу компонента и код на этой стадии можно здесь.

Далее приступаем к добавлению отзывчивого дизайна.

Добавляем адаптивность

В разделе постановки задач я привел дополнительное требование, касающееся адаптивности (англ. Adaptive Web Design). Требуется обеспечить возможность указывать для компонента галереи количество колонок в зависимости от ширины окна браузера. То есть мы хотим указать, что при открытии приложения на экранах мобильных устройств с шириной до 600 px, все наши изображения должны быть выстроены в 1 колонку, для экранов с шириной 600-960 px - в две колонки, и т.д. Здесь нам и понадобится модуль Angular Flex-Layout.

// app/grid-gallery/grid-gallery.component.ts

import {Component, Input, OnInit, OnDestroy} from '@angular/core';
import {MediaChange, ObservableMedia} from '@angular/flex-layout';
import {Subscription} from "rxjs/Subscription";
import {Image} from "../image.model";

@Component({
  selector: 'app-grid-gallery',
  templateUrl: './grid-gallery.component.html'
})
export class GridGalleryComponent implements  OnInit, OnDestroy {

  @Input() images: Image[];
  @Input() cols: number = 4;
  @Input('cols.xs') cols_xs: number = 1;
  @Input('cols.sm') cols_sm: number = 2;
  @Input('cols.md') cols_md: number = 3;
  @Input('cols.lg') cols_lg: number = 4;
  @Input('cols.xl') cols_xl: number = 6;
  @Input() rowHeight: number = 1;
  @Input() gutterSize: number = 1;

  mediaWatcher: Subscription;

  constructor(private media: ObservableMedia) {
  }

  ngOnInit(){
    this.mediaWatcher = this.media.subscribe((change: MediaChange) => {
      this.cols = this[`cols_${change.mqAlias}`];
     });
  }

  ngOnDestroy(): void {
    this.mediaWatcher.unsubscribe();
  }
}

Добавляем @Input свойства cols_xs, cols_sm, cols_md, cols_lg и cols_xl для компонента галереи и указываем их значения по умолчанию. Почитать про использование медиа псевдонимов (aliases) можно здесь.

Для отслеживания события перехода от одного медиа диапазона к другому воспользуемся сервисом ObservableMedia. Подпишемся на MediaChange и из него будем вытягивать актуальный alias, а затем обновлять параметр cols для mat-grid-list.

Указывать количество колонок в шаблоне можно следующим образом:

<!-- app/app.component.html -->

<!-- Gallery -->
<app-grid-gallery [images]="images" 
                  cols.xs="1"
                  cols.sm="2"
                  cols.md="3"
                  cols.lg="4"
                  cols.xl="6">
</app-grid-gallery>

Код приложения на текущем этапе.

Наводим лоск

Добавим немного декоративной мишуры.

Во-первых, обернем наши изображения в кнопки mat-button. Это добавит эффект волны при клике по ним.

<!-- app/grid-gallery/grid-gallery-item/grid-gallery-item.component.html -->

<button mat-button>
  <img  #img 
        [src]="image?.src" 
        [alt]="image?.alt"
        (load)="calculateRows()"/>
</button>

В стилях сбросим padding для кнопок и зададим эффект медленного приближения картинки при наведении мыши:

// app/grid-gallery/grid-gallery-item/grid-gallery-item.component.scss

:host {
  height: 100%; 

  button {
    padding: 0;
  }

  img {
    width: 100%;
    transition: transform 90s;
    transform: scale(1) translateZ(0); 
  }

  &:hover {
    img {
      transition: transform 30s;
      transform: scale(1.33) translateZ(0); 
    }
  }
}

Ну и напоследок добавим анимацию появления и перемещения плиток:

// app/grid-gallery/grid-gallery.component.scss

mat-grid-tile {
  transition: top 0.3s, left 0.3s, height 0.3s;
}

Финальная версия компонента.

Надеюсь, что кому-нибудь это будет полезно. Буду рад вопросам, замечаниям и здоровой критике в комментариях. Спасибо.

Павел Прудников

Постигающий дзен фулстэк вэб буддизма

Минск, Беларусь

Подписаться на Блог MEAN stack разработчика

Получайте свежие записи прямо на ваш почтовый ящик.

Или подпишитесь на RSS с Feedly!

Комментарии

comments powered by Disqus