Subscribe to my email list now at http://jauyeung.net/subscribe/
Follow me on Twitter at https://twitter.com/AuMayeung
Many more articles at https://medium.com/@hohanga
Even more articles at http://thewebdev.info/
If you use image search websites like Google Image Search or Flickr, you will notice that their images display in a grid that looks like a wall of bricks. The images are uneven in height, but equal in width. This is called the masonry effect because it looks like a wall of bricks.
To implement the masonry effect, we have to set the width of the image proportional to the screen width and set the image height to be proportional to the aspect ratio of the image.
This is a pain to do if it’s done without any libraries, so people have made packages to create this effect.
In this article, we will build a photo app that allows users to search for images and display images in a masonry grid. The image grid will have infinite scroll to get more images. We will use the vue-masonry
library to render the image grid, and vue-infinite-scroll
for the infinite scrolling effect.
Our app will display images from the Pixabay API. You can view the API documentation and register for a key at https://pixabay.com/api/docs/
Getting Started
Once we have the Pixabay API key, we can get started writing our app. To begin, we create a project called photo-app
. Run:
npx @vue/cli create photo-app
This will create the files for our app and install the packages for the built-in libraries. We choose ‘manually select features’ and choose Babel, Vue Router, and CSS Preprocessor.
Next, we install our own packages. We need the vue-masonry
library and vue-infinite-scroll
we mentioned above. In addition, we need BootstrapVue for styling, Axios for making HTTP requests, and Vee-Validate for form validation.
We install all the packages by running:
npm i axios bootstrap-vue vee-validate vue-infinite-scroll vue-masonry
Building the App
With all the packages installed, we can start writing our app. Create a mixins
folder in the src
directory and create a requestsMixin.js
file.
Then we add the following to the file:
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}`
);
}
}
};
We call the endpoints to search for images here. process.env.VUE_APP_API_KEY
is retrieved from the .env
file in the root folder of our project. Note that the environment variables we use need to have keys that begin with VUE_APP
.
Next, in Home.vue
, replace the existing code with:
<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>
We use the vue-infinite-scroll
and vue-masonry
packages here. Note that we specified the transition-duration
to tweak the transition from showing nothing to showing the images, and fit-width
makes the columns fit the container. gutter
specifies the width of the space between each column in pixels. We also set a CSS class name in the v-masonry
container to change the styles later.
Inside the v-masonry
div
, we loop through the images, we set the v-masonry-tile
to indicate that it is tile so that it will resize them to a masonry grid.
In the script
object, we get the images when the page loads with the beforeMount
hook. Since we are adding infinite scrolling, we keep adding images to the array as the user scrolls down. We call getImagesByPage
as the user scrolls down as indicated by the v-infinite-scroll
prop. We set infinite-scroll-disabled
to busy
to set disable scrolling. infinite-scroll-distance
indicates the distance from the bottom of the page in percent for scrolling to be triggered.
Next create ImageSearchPage.vue
in the views
folder and add:
<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>
The infinite scrolling and masonry layout are almost the same, except when the keyword
changes, we reassign the this.images
array to the new items instead of keep adding them to the existing array so that users see the new results.
The form is wrapped inside the ValidationObserver
so that we can get the validation status of the whole form inside. In the form, we wrap the input with ValidationProvider
so that the form field can be validated and validation error message displayed for the input. We check if keyword
is filled in.
Once the user clicks Search, the onSubmit
callback is run, which executes await this.$refs.observer.validate();
to get the form validation status. If that results to true
, then searchAllImages
will be run to get the images.
Next we replace the existing code in App.vue
with:
<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>
We add the BootstrapVue b-navbar
here to display a top bar with links to our pages. In the script
section, we watch the current route by getting this.$route.path
. We set the active
prop by check the path against our watched path
to highlight the links.
In the style
section, we set the padding of our pages with the page
class, we set the photo width with the item
class as indicated in the item-selector
of our v-masonry
div, and we set the masonry-container
’s margin to 0 auto
so that it will be centered on the page.
Next in main.js
, replace the existing code with:
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");
This adds all the libraries we used in the components and the Vee-Validate validation rules that we used. Also, we import our Bootstrap styles here so that we see the styles everywhere.
Next in router.js
, replace the existing code with:
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
}
]
});
This adds our routes.