Welcome to the Widget of the Week series, where I take gifs or videos of awesome UI/UX components, and bring them to life with code.
Today we are going to create a swipe gallery that works with both touch or mouse controls.
The inspiration comes from this submission created by RONGYU and looks like this:
Who is this for?
This tutorial is aimed at front-end developers that want to level up their skills. It is recommended that you have some prior knowledge of HTML, CSS, JS.
I'll be using Vue.js to make the widget, if you're not familiar to this framework these awesome posts can help you get up to speed:
Preparations
For today's widget, we will be using Vue.js, and for some animations, we'll use TweenMax. Also, I'll be using the newly released TailwindCSS v1.0.1.
If you want to follow along you can fork this codepen template that already has the dependencies.
Creating the mobile viewport
First what I want to do is constrain the area of our widget container to match the size of a mobile device. For that I'll first write some CSS rules:
.mobile-container {
width: 320px;
height: 568px;
}
This will be the only CSS class we will need for the whole widget... that's right the rest of styling will be done using TailwindCSS.
Now to see it working we need to add some mark up to our widget, let's start by making our app container:
<div id="app" class="flex items-center justify-center bg-black w-screen h-screen"></div>
Those are a bunch of TailwindCSS classes, most of them are self-explanatory if you are used to writing CSS rules. From left to right they match to the following CSS rules:
display: flex;
align-items: center;
justify-content: center;
background-color: black;
width: 100vw;
height: 100vw;
As you can see we wrote less code and also we are able to make any changes without having to jump between the CSS file and the HTML one.
For the rest of the tutorial I won't be "translating" each TailwindCSS class, but I'll surely highlight the ones that matter the most. For the rest you can visit TailwindCSS documentation.
Now let's make use of the .mobile-container
class that we created:
<!-- inside the app div -->
<div class="mobile-container relative overflow-hidden bg-white"></div>
We are making the container relative
to be able to move the gallery images relatively to it, also overflow-hidden
should help us to hide any content that gets outside of the container's box.
Now we should have something like this:
Images
To start making our gallery we need a couple of images to work with, you can use the following array of images I hosted for this widget:
// js
const images = [
'https://res.cloudinary.com/ederchrono/image/upload/v1556684546/wotw-013/nature-1.jpg',
'https://res.cloudinary.com/ederchrono/image/upload/v1556684546/wotw-013/nature-2.jpg',
'https://res.cloudinary.com/ederchrono/image/upload/v1556684526/wotw-013/nature-3.jpg',
'https://res.cloudinary.com/ederchrono/image/upload/v1556684544/wotw-013/nature-4.jpg',
'https://res.cloudinary.com/ederchrono/image/upload/v1556684520/wotw-013/nature-5.jpg',
'https://res.cloudinary.com/ederchrono/image/upload/v1556684527/wotw-013/nature-6.jpg'
]
Set up Vue.js
As usual in this series, we need to setup Vue.js to bind our js
data to the HTML template and make our widget interactive:
new Vue({
el: '#app',
data: {
currentImageIndex: 0
}
})
Not much is happening right now, I'm only declaring a property in my data object that holds the index of the current item in our gallery.
How will it work?
Before going forward I need to show you a couple of diagrams that should help explain the real behavior of the gallery.
Our gallery should loop over all images in the array, but instead of creating all of the images we will only need to have 3 images at the same time: Previous
, Current
and Next
Whenever we swipe through the images, we can only see at most those three images so we will be doing a couple of "magic tricks" for them to look like if the gallery was infinite.
When swiping there are two outcomes, you drag the current image to the right and show the previous one or
drag the next image on top of the current one:
After releasing the dragged image to either side we need to decide if the image stays in the center, or leaves the viewport:
For that we will take the image position if more than half of it is inside the viewport it stays, if not it leaves. Also to keep working with the same three images we should swap them accordingly.
Setup the images
We now know that three images are going to be rendered, we have the currentImageIndex
already but we need the previous and the next one too. Also, it would be awesome if the image URL could be stored in a variable, or even better, computed properties:
// after our data inside the Vue instance
computed: {
currentImage () {
return images[this.currentImageIndex]
},
previousImageIndex () {
return (this.currentImageIndex - 1 + images.length) % images.length
},
previousImage () {
return images[this.previousImageIndex]
},
nextImageIndex () {
return (this.currentImageIndex+1) % images.length
},
nextImage () {
return images[this.nextImageIndex]
},
}
Instead of using methods, computed properties help us
to both simplify our component and improve its performance, computed properties are cached based on their reactive dependencies.
We have everything to start rendering the images, so let's get back to the HTML part:
<!-- inside .mobile-container -->
<!-- image below -->
<img class="absolute h-full z-0" :src="previousImage" />
<!-- interactive image -->
<img class="absolute h-full z-10" :src="currentImage" />
<!-- image above -->
<img class="absolute h-full z-20" :src="nextImage" />
All three images need to be stacked above the previous ones, that's why we are using z-0
, z-10
, z-20
and absolute
position. The h-full
class ensures that the images fill vertically the space of the container.
We have a small problem, the nextImage
as shown in the first diagram should be outside (on the right side) of the viewport. We will eventually also animate it so I will bind the style attribute to a computed property called nextImageStyle
.
<!-- image above -->
<img class="absolute h-full z-20" :style="nextImageStyle" :src="nextImage" />
Then we need to create that computed property, but it needs a constant referencing the device width:
// before the Vue instance
const DEVICE_WIDTH = 320
For this widget it is a hardcoded constant, but in a real-world scenario we should be able to get the device width and set that constant accordingly.
// inside computed: {
nextImagePosition () {
return DEVICE_WIDTH
},
nextImageStyle () {
return {
'left': `${this.nextImagePosition}px`
}
}
We created a couple of computed properties for the styling, this seems like overkill but they will be useful when animating the images. After this, you should be seeing the first image, the waterfall, instead of some green leaves.
Getting user inputs
The user will interact with our gallery by touching or clicking the current image, then it will start moving the cursor or finger and after that, they should release the image.
Those are three events we need to listen:
- start
- move
- end
The start event will always be triggered by the current image, but the other two events can happen either inside the image or outside the gallery container. The next step is to listen to those events and to be able to make it work both in mobile and desktop devices, we need to listen not only touch events but mouse events too:
<!-- add these 4 events to the app div -->
<div
id="app"
@mousemove="drag"
@touchmove="drag"
@mouseup="stopDrag"
@touchend="stopDrag"
class="flex items-center justify-center bg-black w-screen h-screen"
></div>
<!-- these 2 events go in the current image -->
<img
class="absolute h-full z-10"
@mousedown.prevent="startDrag"
@touchstart="startDrag"
:src="currentImage"
/>
Notice the
prevent
modifier, this helps to prevent the regular drag and drop behavior that browsers add to images.
We have three different methods that need to be declared inside our Vue instance, but first, let's create a helper function to extract the position of either the mouse or the finger touching the screen:
const getCursorX = (event) => {
if (event.touches && event.touches.length) {
// touch
return event.touches[0].pageX
}
if (event.pageX && event.pageY) {
// mouse
return event.pageX
}
return 0
}
We should be able to use this function to update accordingly the cursor movement, but we will also need to keep track of the initial click position and if the user is currently dragging the image:
// inside the vue instance data
dragging: false,
cursorStartX: 0,
cursorCurrentX: 0
// after data
methods: {
startDrag(e) {
this.dragging = true
this.cursorStartX = getCursorX(e)
this.cursorCurrentX = this.cursorStartX
},
drag(e) {
if(!this.dragging) {
// avoid updating if not dragging
return
}
this.cursorCurrentX = getCursorX(e)
},
stopDrag(e) {
this.dragging = false
}
},
To see if all of this is working correctly, you can add this widget to see how the properties change:
<!-- inside the app div -->
<pre
class="fixed bottom-0 left-0 p-3 text-white z-50 bg-gray-800 opacity-75 pointer-events-none"
>
dragging: {{ dragging }}
imagesIndexes: [{{previousImageIndex}}] [{{currentImageIndex}}] [{{nextImageIndex}}]
cursorStartX: {{ cursorStartX }}
cursorCurrentX: {{ cursorCurrentX }}</pre
>
Moving the images
Here comes the interesting part, for the next steps we will first declare some constants that we will be using:
// after DEVICE_WIDTH constant
const HALF_WIDTH = DEVICE_WIDTH / 2
const DRAGGING_SPEED = 1.2
const MAX_BLUR = 8
After we finish you can play with these values to see how things change.
Like I mentioned above there are two cases when dragging an image, either it is being dragged to the left or to the right. let's create a couple of computed props for that:
// inside computed
diffX () {
return this.cursorStartX - this.cursorCurrentX
},
swipingLeft () {
return this.diffX >= 0
},
Basically we are getting the difference between where the user started to drag and where the cursor is currently. If that difference is greater than
0
it means that the user is dragging the image to the left.
Before deep diving into moving the images, I'll create another helper function that should give us a hand when it comes to keeping the images inside the container
const clampPosition = (position) => {
// constrain image to be between 0 and device width
return Math.max(Math.min(position, DEVICE_WIDTH), 0)
}
And now we can replace the nextImagePosition
computed prop with this new one:
// inside computed, replacing the old one
nextImagePosition () {
const swipingRight = !this.swipingLeft
if(!this.dragging || swipingRight) {
return DEVICE_WIDTH
}
const position = DEVICE_WIDTH - (this.diffX * DRAGGING_SPEED)
return clampPosition(position)
},
Try it out!
The nextImage
should come out when you press and drag to the left.
Whenever the user is not dragging or if the user is swiping right, we want the next image to be in the same spot outside the viewport. In the other case, the image should get closer to the center of the container depending on the dragging speed.
In the same way, we can do something similar for the currentImage
when the user swipes right, first bind the style attribute:
<!-- interactive image -->
<img
class="absolute h-full z-10"
@mousedown.prevent="startDrag"
@touchstart="startDrag"
:style="currentImageStyle"
:src="currentImage"
/>
Then create the computed methods for that:
// inside computed
currentImagePosition () {
if(!this.dragging || this.swipingLeft) {
return 0
}
const position = this.diffX * -DRAGGING_SPEED
return clampPosition(position)
},
currentImageStyle () {
return {
'left': `${this.currentImagePosition}px`
}
},
The blur effect
In the reference, when the current image is being covered by the next one it gradually blurs to create an effect of being sent to the bottom. Let's create the last helper function:
const calculateBlur = (position) => {
return MAX_BLUR * (1 - position / DEVICE_WIDTH)
}
This function should give us a value between 0 and the
MAX_BLUR
depending on the position of an image. When the image is closer to being outside the viewport there is less blur, when it is closer to being centered there's a bigger blur value.
Also our previous image will need a style attribute:
<!-- image below -->
<img class="absolute h-full z-0" :style="prevImageStyle" :src="previousImage" />
The previousImage
blur depends on the currentImage
position, and the currentImage
blur depends on the nextImage
position:
// inside computed
currentImageStyle () {
const blur = calculateBlur(this.nextImagePosition)
return {
'left': `${this.currentImagePosition}px`,
'filter': `blur(${blur}px)`
}
},
prevImageStyle () {
const blur = calculateBlur(this.currentImagePosition)
return {
'filter': `blur(${blur}px)`
}
},
Animate image after dropping it
So far, so good. Images are moving and blurring accordingly, but after we release them they just go back. We need a way to make them go where we want after we swiped.
Like we said before, depending if half of the image is showing or hiding we will animate it in or out.
Let's add some data props that we will need for this:
// inside data
animating: false,
currentImageAnimatedX: 0,
nextImageAnimatedX: DEVICE_WIDTH
The animating
property will let us know whenever we are moving an image and prevent any other action. currentImageAnimatedX
and nextImageAnimatedX
will hold the position when animating the corresponding images.
In order for those two properties to work correctly we need to update both images positioning computed props:
// inside computed
nextImagePosition () {
// add these 3 lines...
if(this.animating) {
return this.nextImageAnimatedX
}
const swipingRight = !this.swipingLeft
if(!this.dragging || swipingRight) {
return DEVICE_WIDTH
}
const position = DEVICE_WIDTH - (this.diffX * DRAGGING_SPEED)
return clampPosition(position)
},
currentImagePosition () {
// ... and these 3 too
if(this.animating) {
return this.currentImageAnimatedX
}
if(!this.dragging || this.swipingLeft) {
return 0
}
const position = this.diffX * -DRAGGING_SPEED
return clampPosition(position)
},
Then we need to change the stopDrag
method to trigger the animation:
// inside methods
stopDrag(e) {
let animationProps = this.createReleaseAnimation()
this.dragging = false
this.animating = true
TweenLite.to(this, 0.2, {
...animationProps,
onComplete: () => {this.animating = false}
})
},
We are using
TweenLite
to tween the Vue instance data, this will reactively update the images styles computed properties.
You may have noticed that we need to define the createReleaseAnimation
, this is the method that will hold the logic to know where should images go after being released. This is some kind of decision tree, so I'll explain it with comments inline:
// inside methods
createReleaseAnimation() {
if(this.swipingLeft) {
if(this.nextImagePosition > HALF_WIDTH) {
// next image should be animated back to be offscreen
this.nextImageAnimatedX = this.nextImagePosition
return {nextImageAnimatedX: DEVICE_WIDTH}
}
// current image "copies" the nextImage position
this.currentImageAnimatedX = this.nextImagePosition
// the nextImage is sent offscreen
this.nextImageAnimatedX = DEVICE_WIDTH
// Change the image index to become the next image in the array
// images src attribute will update accordingly
this.currentImageIndex = this.nextImageIndex
return {currentImageAnimatedX: 0}
}
// swipe right
if(this.currentImagePosition < HALF_WIDTH) {
// current image should be animated back to center position
this.currentImageAnimatedX = this.currentImagePosition
return {currentImageAnimatedX: 0}
}
// the nextImage "copies" the currentImage position
this.nextImageAnimatedX = this.currentImagePosition
// the currentImage gets centered to become the prevImage
this.currentImageAnimatedX = 0
// Change the image index to become the previous image in the array
this.currentImageIndex = this.previousImageIndex
return {nextImageAnimatedX: DEVICE_WIDTH}
}
What we are doing is defining each of the four cases:
- Swiped left but the image should get back offscreen
- Swiped left and the image should get to the center of the container
- Swiped right and the image should get back to the center
- Swiped right and the previous image should become the new current image
On each of those cases, we define an object that will be used by TweenLite to change the corresponding animatedX
property to the target destination.
And now the final result!
I left a <pre>
tag showing all of the properties as they update but feel free to remove it if you just want to see the gallery without it.
And that’s it for this Widget of the Week.
If you're hungry for more you can check other WotW:
Top comments (1)
Hi there Eder!
Beautiful article!
Just a question: When i use my mouse to drag the image either left or right, if the mouse gets outside the mobile-container the dragging stops and the image stays to the position it was before mouse left the mobile-container. If i go back to the mobile-container with my mouse the image is like sticking on the mouse cursor and follows it. If i left click inside the mobile-container then the image will behave like it should, meaning it will switch to the image that had larger % into view.
Any idea why i get this?