Если вы используете веб-сайты поиска изображений, такие как Google Image Search или Flickr, вы заметите, что их изображения отображаются в виде сетки, которая выглядит как стена из кирпичей. Изображения неравномерны по высоте, но равны по ширине. Это называется эффектом кладки, потому что выглядит как стена из кирпича.

Чтобы реализовать эффект каменной кладки, мы должны установить ширину изображения, пропорциональную ширине экрана, и установить высоту изображения, пропорциональную соотношению сторон изображения.

Это мучительно, если это делается без каких-либо библиотек, поэтому люди создали пакеты для создания этого эффекта.

В этой статье мы создадим приложение для фотографий, которое позволит пользователям искать изображения и отображать изображения в сетке каменной кладки. Сетка изображений будет иметь бесконечную прокрутку, чтобы получить больше изображений. Мы будем использовать библиотеку vue-masonry для рендеринга сетки изображения и vue-infinite-scroll для эффекта бесконечной прокрутки.

Наше приложение будет отображать изображения из API Pixabay. Вы можете просмотреть документацию по API и зарегистрироваться для получения ключа на странице https://pixabay.com/api/docs/

Начиная

Получив ключ API Pixabay, мы можем приступить к написанию нашего приложения. Для начала создадим проект под названием photo-app. Запустить:

npx @vue/cli create photo-app

Это создаст файлы для нашего приложения и установит пакеты для встроенных библиотек. Мы выбираем «выбрать функции вручную» и выбираем Babel, Vue Router и CSS Preprocessor.

Далее мы устанавливаем собственные пакеты. Нам нужны упомянутые выше vue-masonry библиотека и vue-infinite-scroll. Кроме того, нам понадобится BootstrapVue для стилизации, Axios для выполнения HTTP-запросов и Vee-Validate для проверки формы.

Устанавливаем все пакеты, запустив:

npm i axios bootstrap-vue vee-validate vue-infinite-scroll vue-masonry

Создание приложения

Установив все пакеты, мы можем приступить к написанию нашего приложения. Создайте папку mixins в каталоге src и создайте файл requestsMixin.js.

Затем мы добавляем в файл следующее:

const axios = require("axios");
const APIURL = "https://pixabay.com/api";
export const requestsMixin = {
  methods: {
    getImages(page = 1) {
      return axios.get(`${APIURL}/?page=${page}&key=${process.env.VUE_APP_API_KEY}`);
    },
    searchImages(keyword, page = 1) {
      return axios.get(
        `${APIURL}/?page=${page}&key=${process.env.VUE_APP_API_KEY}&q=${keyword}`
      );
    }
  }
};

Мы вызываем здесь конечные точки для поиска изображений. process.env.VUE_APP_API_KEY извлекается из файла .env в корневой папке нашего проекта. Обратите внимание, что используемые нами переменные среды должны иметь ключи, начинающиеся с VUE_APP.

Затем в Home.vue замените существующий код на:

<template>
  <div class="page">
    <h1 class="text-center">Home</h1>
    <div
      v-infinite-scroll="getImagesByPage"
      infinite-scroll-disabled="busy"
      infinite-scroll-distance="10"
    >
      <div
        v-masonry="containerId"
        transition-duration="0.3s"
        item-selector=".item"
        gutter="5"
        fit-width="true"
        class="masonry-container"
      >
        <div>
          <img
            :src="item.previewURL"
            v-masonry-tile
            class="item"
            v-for="(item, index) in images"
            :key="index"
          />
        </div>
      </div>
    </div>
  </div>
</template>
<script>
import { requestsMixin } from "../mixins/requestsMixin";
export default {
  name: "home",
  mixins: [requestsMixin],
  data() {
    return {
      images: [],
      page: 1,
      containerId: null
    };
  },
  methods: {
    async getImagesByPage() {
      const response = await this.getImages(this.page);
      this.images = this.images.concat(response.data.hits);
      this.page++;
    }
  },
  beforeMount() {
    this.getImagesByPage();
  }
};
</script>

Здесь мы используем пакеты vue-infinite-scroll и vue-masonry. Обратите внимание, что мы указали transition-duration, чтобы настроить переход от отображения ничего к отображению изображений, а fit-width делает столбцы подходящими для контейнера. gutter определяет ширину промежутка между каждым столбцом в пикселях. Мы также устанавливаем имя класса CSS в контейнере v-masonry, чтобы позже изменить стили.

Внутри v-masonry div мы перебираем изображения в цикле, мы устанавливаем v-masonry-tile, чтобы указать, что это плитка, чтобы он изменил их размер до сетки каменной кладки.

В объекте script мы получаем изображения при загрузке страницы с помощью хука beforeMount. Поскольку мы добавляем бесконечную прокрутку, мы продолжаем добавлять изображения в массив по мере того, как пользователь прокручивает вниз. Мы вызываем getImagesByPage, когда пользователь прокручивает страницу вниз, как указано в опоре v-infinite-scroll. Мы устанавливаем infinite-scroll-disabled на busy, чтобы отключить прокрутку. infinite-scroll-distance указывает расстояние от низа страницы в процентах до начала прокрутки.

Затем создайте ImageSearchPage.vue в папке views и добавьте:

<template>
  <div class="page">
    <h1 class="text-center">Image Search</h1>
    <ValidationObserver ref="observer" v-slot="{ invalid }">
      <b-form @submit.prevent="onSubmit" novalidate>
        <b-form-group label="Keyword" label-for="keyword">
          <ValidationProvider name="keyword" rules="required" v-slot="{ errors }">
            <b-form-input
              :state="errors.length == 0"
              v-model="form.keyword"
              type="text"
              required
              placeholder="Keyword"
              name="keyword"
            ></b-form-input>
            <b-form-invalid-feedback :state="errors.length == 0">Keyword is required</b-form-invalid-feedback>
          </ValidationProvider>
        </b-form-group>
        <b-button type="submit" variant="primary">Search</b-button>
      </b-form>
    </ValidationObserver>
    <br />
    <div
      v-infinite-scroll="searchAllImages"
      infinite-scroll-disabled="busy"
      infinite-scroll-distance="10"
    >
      <div
        v-masonry="containerId"
        transition-duration="0.3s"
        item-selector=".item"
        gutter="5"
        fit-width="true"
        class="masonry-container"
      >
        <div>
          <img
            :src="item.previewURL"
            v-masonry-tile
            class="item"
            v-for="(item, index) in images"
            :key="index"
          />
        </div>
      </div>
    </div>
  </div>
</template>
<script>
import { requestsMixin } from "../mixins/requestsMixin";
export default {
  mixins: [requestsMixin],
  data() {
    return {
      form: {},
      page: 1,
      containerId: null,
      images: []
    };
  },
  methods: {
    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid) {
        return;
      }
      this.page = 1;
      await this.searchAllImages();
    },
    async searchAllImages() {
      if (!this.form.keyword) {
        return;
      }
      const response = await this.searchImages(this.form.keyword, this.page);
      if (this.page == 1) {
        this.images = response.data.hits;
      } else {
        this.images = this.images.concat(response.data.hits);
      }
      this.page++;
    }
  }
};
</script>

Бесконечная прокрутка и макет каменной кладки почти такие же, за исключением случаев, когда keyword изменяется, мы переназначаем массив this.images на новые элементы вместо того, чтобы продолжать добавлять их в существующий массив, чтобы пользователи видели новые результаты.

Форма завернута внутрь ValidationObserver, чтобы мы могли получить статус проверки всей формы внутри. В форме мы оборачиваем ввод с помощью ValidationProvider, чтобы можно было проверить поле формы и отобразить сообщение об ошибке проверки для ввода. Проверяем, заполнено ли keyword.

Как только пользователь нажимает кнопку «Поиск», запускается onSubmit обратный вызов, который выполняет await this.$refs.observer.validate(); для получения статуса проверки формы. Если это приведет к true, то для получения изображений будет запущено searchAllImages.

Затем мы заменяем существующий код в App.vue на:

<template>
  <div>
    <b-navbar toggleable="lg" type="dark" variant="info">
      <b-navbar-brand href="#">Photo App</b-navbar-brand>
      <b-navbar-toggle target="nav-collapse"></b-navbar-toggle>
      <b-collapse id="nav-collapse" is-nav>
        <b-navbar-nav>
          <b-nav-item to="/" :active="path == '/'">Home</b-nav-item>
          <b-nav-item to="/imagesearch" :active="path == '/imagesearch'">Image Search</b-nav-item>
        </b-navbar-nav>
      </b-collapse>
    </b-navbar>
    <router-view />
  </div>
</template>
<script>
export default {
  data() {
    return {
      path: this.$route && this.$route.path
    };
  },
  watch: {
    $route(route) {
      this.path = route.path;
    }
  }
};
</script>
<style lang="scss">
.page {
  padding: 20px;
}
.item {
  width: 30vw;
}
.masonry-container {
  margin: 0 auto;
}
</style>

Мы добавляем сюда BootstrapVue b-navbar, чтобы отобразить верхнюю панель со ссылками на наши страницы. В разделе script смотрим текущий маршрут, получая this.$route.path. Мы устанавливаем свойство active, сверяя путь с нашим наблюдаемым path, чтобы выделить ссылки.

В разделе style мы устанавливаем отступы наших страниц с помощью класса page, мы устанавливаем ширину фотографии с помощью класса item, как указано в item-selector нашего v-masonry div, и устанавливаем поле masonry-container на 0 auto, чтобы оно было сосредоточено на страница.

Затем в main.js замените существующий код на:

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
import BootstrapVue from "bootstrap-vue";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required } from "vee-validate/dist/rules";
import { VueMasonryPlugin } from "vue-masonry";
import infiniteScroll from "vue-infinite-scroll";
Vue.config.productionTip = false;
extend("required", required);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.use(VueMasonryPlugin);
Vue.use(infiniteScroll);
Vue.use(BootstrapVue);
new Vue({
  router,
  render: h => h(App)
}).$mount("#app");

Это добавляет все библиотеки, которые мы использовали в компонентах, и правила проверки Vee-Validate, которые мы использовали. Кроме того, мы импортируем сюда наши стили Bootstrap, чтобы видеть стили везде.

Затем в router.js замените существующий код на:

import Vue from "vue";
import Router from "vue-router";
import Home from "./views/Home.vue";
import ImageSearchPage from "./views/ImageSearchPage.vue";
Vue.use(Router);
export default new Router({
  mode: "history",
  base: process.env.BASE_URL,
  routes: [
    {
      path: "/",
      name: "home",
      component: Home
    },
    {
      path: "/imagesearch",
      name: "imagesearch",
      component: ImageSearchPage
    }
  ]
});

Это добавляет наши маршруты.

После того, как все будет сделано, запустим наше приложение, выполнив npm run start, и мы должны получить: