Ух, давненько ничего не писал, но тут созрел повод. На моем вялотекущем проекте встала задача добавить пользователям возможность размещать и просматривать фотографии. Было решено оформить массив загруженных фоток в стиле 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;
}
Надеюсь, что кому-нибудь это будет полезно. Буду рад вопросам, замечаниям и здоровой критике в комментариях. Спасибо.