Создание приложения с галереей изображений с бесконечной прокруткой и API Pixabay
Добавить бесконечную прокрутку в приложение Vue.js очень просто. Уже есть библиотеки для создания красивой галереи изображений. С Vue.js создание приложения для галереи изображений - приятное занятие.
Мы будем использовать Vue Material, чтобы приложение выглядело привлекательно, и Pixabay API.
В нашем приложении мы предоставим страницы для поиска изображений и видео, а на главной странице будет бесконечная галерея изображений с прокруткой, чтобы пользователи могли просматривать самые популярные изображения из API Pixabay.
Для начала мы устанавливаем Vue CLI, запустив npm i @vue/cli
. Затем мы создаем проект, запустив vue create pixabay-app
.
При запросе параметров мы выбираем настраиваемые параметры и включаем Babel, Vuex, Vue Router и препроцессор CSS. Все они нам нужны, поскольку мы создаем единую страницу с общим состоянием между компонентами, а препроцессор CSS сокращает повторение CSS.
Затем нам нужно установить библиотеки, необходимые для работы нашего приложения. Запустите npm i axios querystring vue-material vee-validate vue-infinite-scroll
, чтобы установить нужные нам пакеты.
axios
- наш HTTP-клиент, querystring
- это пакет для сообщения объектов в строке запроса, vue-material
предоставляет нашему приложению элементы материального дизайна, чтобы оно выглядело привлекательно.
vee-validate
- это библиотека проверки формы. vue-infinite-scroll
- это библиотека с бесконечной прокруткой. С помощью этой библиотеки легко добавить бесконечную прокрутку.
Теперь мы можем приступить к написанию кода. Начнем с написания общего кода, который будут использовать наши страницы.
В папке components
мы создаем файл с именем Results.vue
и добавляем следующее:
<template> <div class="center"> <div class="results"> <md-card v-for="r in searchResults" :key="r.id"> <md-card-media v-if="type == 'image'"> <img :src="r.previewURL" class="image" /> </md-card-media> <md-card-media v-if="type == 'video'"> <video class="image"> <source :src="r.videos.tiny.url" type="video/mp4" /> </video> </md-card-media> </md-card> </div> </div> </template> <script> export default { name: "results", props: { type: String }, computed: { searchResults() { return this.$store.state.searchResults; } }, data() { return {}; } }; </script> <!-- Add "scoped" attribute to limit CSS to this component only --> <style scoped lang="scss"> .md-card { width: 30vw; margin: 4px; display: inline-block; vertical-align: top; } </style>
В этом файле мы получаем данные из хранилища Vuex и затем отображаем их пользователю. this.$store
предоставляется Vuex и предоставляет состояние с помощью свойства state
.
Результаты поиска могут быть изображениями или видео, поэтому мы разрешаем type
опору, чтобы мы могли различать типы результатов. Состояние находится в свойстве computed
, поэтому мы можем получить его из состояния.
Затем мы добавляем миксин для выполнения запросов. Для этого мы добавляем папку mixins
и файл с именем photosMixin.js
, а также добавляем следующее:
const axios = require('axios'); const querystring = require('querystring'); const apiUrl = 'https://pixabay.com/api'; const apikey = 'Pixabay api key'; export const photosMixin = { methods: { getPhotos(page = 1) { const params = { page, key: apikey, per_page: 21 } const queryString = querystring.stringify(params); return axios.get(`${apiUrl}/?${queryString}`); }, searchPhoto(data) { let params = Object.assign({}, data); params['key'] = apikey; params['per_page'] = 21; Object.keys(params).forEach(key => { if (!params[key]) { delete params[key]; } }) const queryString = querystring.stringify(params); return axios.get(`${apiUrl}/?${queryString}`); }, searchVideo(data) { let params = Object.assign({}, data); params['key'] = apikey; params['per_page'] = 21; Object.keys(params).forEach(key => { if (!params[key]) { delete params[key]; } }) const queryString = querystring.stringify(params); return axios.get(`${apiUrl}/videos/?${queryString}`); } } }
Здесь мы получаем изображения и видео из API Pixabay. Мы отправляем данные поиска, переданные в строку запроса, с помощью пакета querystring
.
Завершив вспомогательный код, мы можем приступить к созданию нескольких страниц. Сначала мы работаем над домашней страницей, на которой будет отображаться сетка изображений, и пользователь может прокрутить вниз, чтобы увидеть больше.
Мы создаем файл с именем Home.vue
в папке views
, если он еще не существует, и помещаем следующее:
<template> <div class="home"> <div class="center"> <h1>Home</h1> <div v-infinite-scroll="loadMore" infinite-scroll-disabled="busy" infinite-scroll-distance="10" > <md-card v-for="p in photos" :key="p.id"> <md-card-media> <img :src="p.previewURL" class="image" /> </md-card-media> </md-card> </div> </div> </div> </template> <script> // @ is an alias to /src import Results from "@/components/Results.vue"; import { photosMixin } from "@/mixins/photosMixin"; export default { name: "home", components: { Results }, data() { return { photos: [], page: 1 }; }, mixins: [photosMixin], beforeMount() { this.getAllPhotos(); }, methods: { async getAllPhotos() { const response = await this.getPhotos(); this.photos = response.data.hits; }, async loadMore() { this.page++; const response = await this.getPhotos(this.page); this.photos = this.photos.concat(response.data.hits); } } }; </script> <style lang="scss" scoped> .md-card { width: 30vw; margin: 4px; display: inline-block; vertical-align: top; } .home { margin: 0 auto; } </style>
Свойство v-infinite-scroll
- это обработчик событий, предоставляемый vue-infinite-scroll
, чтобы мы могли что-то делать, когда пользователь прокручивает страницу вниз.
В этом случае мы вызываем функцию loadMore
, чтобы загрузить данные со следующей страницы и добавить их в массив this.photos
. Мы отображаем изображения в виде карточек.
infinite-scroll-distance=”10"
означает, что мы запускаем обработчик, определенный в v-infinite-scroll
, когда пользователь прокручивает 90 процентов текущей страницы.
Далее мы создаем страницу поиска изображений. В папке views
мы создаем файл с именем ImageSearch.vue
и добавляем следующее:
<template> <div class="imagesearch"> <div class="center"> <h1>Image Search</h1> </div> <form @submit="search" novalidate> <md-field :class="{ 'md-invalid': errors.has('q') }"> <label for="q">Keyword</label> <md-input type="text" name="q" v-model="searchData.q" v-validate="'required'"></md-input> <span class="md-error" v-if="errors.has('q')">{{errors.first('q')}}</span> </md-field> <md-field :class="{ 'md-invalid': errors.has('minWidth') }"> <label for="minWidth">Min Width</label> <md-input type="text" name="minWidth" v-model="searchData.min_width" v-validate="'numeric|min_value:0'" ></md-input> <span class="md-error" v-if="errors.has('minWidth')">{{errors.first('minWidth')}}</span> </md-field> <md-field :class="{ 'md-invalid': errors.has('minHeight') }"> <label for="minHeight">Min Height</label> <md-input type="text" name="minHeight" v-model="searchData.min_height" v-validate="'numeric|min_value:0'" ></md-input> <span class="md-error" v-if="errors.has('minHeight')">{{errors.first('minHeight')}}</span> </md-field> <md-field> <label for="movie">Colors</label> <md-select v-model="searchData.colors" name="colors"> <md-option :value="c" v-for="c in colorChoices" :key="c">{{c}}</md-option> </md-select> </md-field> <md-button class="md-raised" type="submit">Search</md-button> </form> <Results type='image' /> </div> </template> <script> // @ is an alias to /src import Results from "@/components/Results.vue"; import { photosMixin } from "@/mixins/photosMixin"; export default { name: "home", components: { Results }, data() { return { photos: [], searchData: {}, colorChoices: [ "grayscale", "transparent", "red", "orange", "yellow", "green", "turquoise", "blue", "lilac", "pink", "white", "gray", "black", "brown" ] }; }, mixins: [photosMixin], beforeMount() { this.$store.commit("setSearchResults", []); }, computed: { isFormDirty() { return Object.keys(this.fields).some(key => this.fields[key].dirty); } }, methods: { async search(evt) { evt.preventDefault(); if (!this.isFormDirty || this.errors.items.length > 0) { return; } const response = await this.searchPhoto(this.searchData); this.photos = response.data.hits; this.$store.commit("setSearchResults", response.data.hits); } } }; </script>
На этой странице представлена форма, в которой пользователь может ввести параметры поиска, такие как ключевое слово, размеры изображения и цвета. Мы проверяем правильность данных для каждого поля с помощью свойства v-validate
, предоставляемого пакетом vee-validate
.
Итак, для minWidth
и minHeight
мы убеждаемся, что это неотрицательные числа. Если это не так, мы выводим сообщение об ошибке. Мы также не разрешим отправку, если данные формы недействительны с проверкой this.errors.items.length > 0
, которая также предоставляется vee-validate
.
Если все верно, мы вызываем функцию this.searchPhotos
, предоставленную нашим photoMixin
, и устанавливаем результат при возврате в магазин с помощью функции this.$store.commit
, предоставленной магазином.
Мы передаем image
в свойство type
результатов, чтобы отображались результаты изображения.
Точно так же для страницы поиска видео мы создаем новый файл с именем VideoSearch.vue
в папке views
. Ставим следующее:
<template> <div class="videosearch"> <div class="center"> <h1>Video Search</h1> </div> <form @submit="search" novalidate> <md-field :class="{ 'md-invalid': errors.has('q') }"> <label for="q">Keyword</label> <md-input type="text" name="q" v-model="searchData.q" v-validate="'required'"></md-input> <span class="md-error" v-if="errors.has('q')">{{errors.first('q')}}</span> </md-field> <md-field :class="{ 'md-invalid': errors.has('minWidth') }"> <label for="minWidth">Min Width</label> <md-input type="text" name="minWidth" v-model="searchData.min_width" v-validate="'numeric|min_value:0'" ></md-input> <span class="md-error" v-if="errors.has('minWidth')">{{errors.first('minWidth')}}</span> </md-field> <md-field :class="{ 'md-invalid': errors.has('minHeight') }"> <label for="minHeight">Min Height</label> <md-input type="text" name="minHeight" v-model="searchData.min_height" v-validate="'numeric|min_value:0'" ></md-input> <span class="md-error" v-if="errors.has('minHeight')">{{errors.first('minHeight')}}</span> </md-field> <md-field> <label for="categories">Categories</label> <md-select v-model="searchData.category" name="categories"> <md-option :value="c" v-for="c in categories" :key="c">{{c}}</md-option> </md-select> </md-field> <md-field> <label for="type">Type</label> <md-select v-model="searchData.video_type" name="type"> <md-option :value="v" v-for="v in videoTypes" :key="v">{{v}}</md-option> </md-select> </md-field> <md-button class="md-raised" type="submit">Search</md-button> </form> <Results type="video" /> </div> </template> <script> import Results from "@/components/Results.vue"; import { photosMixin } from "@/mixins/photosMixin"; export default { name: "home", components: { Results }, data() { return { photos: [], searchData: {}, videoTypes: ["all", "film", "animation"], categories: ` fashion, nature, backgrounds, science, education, people, feelings, religion, health, places, animals, industry, food, computer, sports, transportation, travel, buildings, business, music ` .replace(/ /g, "") .split(",") }; }, mixins: [photosMixin], beforeMount() { this.$store.commit("setSearchResults", []); }, computed: { isFormDirty() { return Object.keys(this.fields).some(key => this.fields[key].dirty); } }, methods: { async search(evt) { evt.preventDefault(); if (!this.isFormDirty || this.errors.items.length > 0) { return; } const response = await this.searchVideo(this.searchData); this.photos = response.data.hits; this.$store.commit("setSearchResults", response.data.hits); } } }; </script>
Мы позволяем пользователям искать по ключевым словам, типу, размерам и категории.
Логика проверки формы и поиска аналогична странице поиска изображений, за исключением того, что мы передаем video
в свойство type
результатов, чтобы отображались результаты с изображениями.
В App.vue
мы добавляем верхнюю панель и левое меню для навигации. Мы заменяем существующий код следующим:
<template> <div id="app"> <md-toolbar> <md-button class="md-icon-button" @click="showNavigation = true"> <md-icon>menu</md-icon> </md-button> <h3 class="md-title">Pixabay App</h3> </md-toolbar> <md-drawer :md-active.sync="showNavigation" md-swipeable> <md-toolbar class="md-transparent" md-elevation="0"> <span class="md-title">Pixabay App</span> </md-toolbar> <md-list> <md-list-item> <router-link to="/"> <span class="md-list-item-text">Home</span> </router-link> </md-list-item> <md-list-item> <router-link to="/imagesearch"> <span class="md-list-item-text">Image Search</span> </router-link> </md-list-item> <md-list-item> <router-link to="/videosearch"> <span class="md-list-item-text">Video Search</span> </router-link> </md-list-item> </md-list> </md-drawer> <router-view /> </div> </template> <script> export default { name: "app", data: () => { return { showNavigation: false }; } }; </script> <style> .center { text-align: center; } form { width: 95vw; margin: 0 auto; } .md-toolbar.md-theme-default { background: #009688 !important; height: 60px; } .md-title, .md-toolbar.md-theme-default .md-icon { color: #fff !important; } </style>
md-list
содержит ссылки на наши страницы, и у нас есть флаг showNavigation
, чтобы сохранить состояние меню и позволить нам переключать меню.
В main.js
мы включаем библиотеки, которые использовали в этом приложении, чтобы оно запускалось при запуске приложения:
import Vue from 'vue' import App from './App.vue' import router from './router' import store from './store' import VueMaterial from 'vue-material'; import VeeValidate from 'vee-validate'; import 'vue-material/dist/vue-material.min.css' import 'vue-material/dist/theme/default.css' import infiniteScroll from 'vue-infinite-scroll' Vue.use(infiniteScroll) Vue.use(VueMaterial); Vue.use(VeeValidate); Vue.config.productionTip = false new Vue({ router, store, render: h => h(App) }).$mount('#app')
В router.js
мы добавляем наши маршруты, чтобы пользователи могли видеть приложение, когда они вводят URL-адрес или щелкают ссылки в меню:
import Vue from 'vue' import Router from 'vue-router' import Home from './views/Home.vue'; import Search from './views/Search.vue'; Vue.use(Router) export default new Router({ mode: 'history', base: process.env.BASE_URL, routes: [ { path: '/', name: 'home', component: Home }, { path: '/search', name: 'search', component: Search } ] })
В store.js
мы помещаем:
import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) export default new Vuex.Store({ state: { searchResults: [] }, mutations: { setSearchResults(state, payload) { state.searchResults = payload; } }, actions: { } })
Чтобы результаты можно было установить со страниц и отобразить в компоненте Results
. Функция this.$store.commit
устанавливает данные, а свойство this.$store.state
извлекает состояние.
Когда мы запускаем npm run serve
, мы получаем: