Display Images like Google and Flickr with Vue.js
Tags: vue, webdev, javascript, tutorial
Published: 2020-01-26 02:33:57

**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/](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:

```bash
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:

```bash
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:

```javascript
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:

```javascript
<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:

```javascript
<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:

```javascript
<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:

```javascript
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:

```javascript
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.