Display Images like Google and Flickr with Vue.js

John Au-Yeung - Jan 26 '20 - - Dev Community

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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .