This article was originally published on Rails Designer
The time you build and design web-apps for the desktop, without touch events, has been long gone. Users expect features to work on their touch devices (phones, tablets) just as well.
In this article I want to explore two features where touch events can be used:
- (image) carousels;
- tinder-like, left- and right card swipes.
Something like this (view the gif in all its glory on the original article):
(don’t hate for disliking the first cat!)
Because there is a lot of overlap with these two features I am also going to explore inheritance, meaning one Stimulus controller inherits functionality from another class (just like Ruby’s UsersController < ApplicationController
).
As most of the time with such features, let’s start with the HTML as it helps to guide what functionality is needed (this HTML is using Tailwind CSS, but that is optional):
<section data-controller="carousel" data-carousel-index-value="0" class="relative w-full max-w-lg mx-auto">
<div class="overflow-hidden">
<ul data-carousel-target="container" class="flex transition-transform duration-300">
<li data-carousel-target="slide" class="w-full shrink-0">
<img src="https://unsplash.com/photos/NRQV-hBF10M/download?ixid=M3wxMjA3fDB8MXxhbGx8fHx8fHx8fHwxNzQyNzc0MDI4fA&force=true&w=640" alt="body of water surrounded by trees" class="object-cover w-full h-64">
</li>
<li data-carousel-target="slide" class="w-full shrink-0">
<img src="https://unsplash.com/photos/1Z2niiBPg5A/download?ixid=M3wxMjA3fDB8MXxhbGx8fHx8fHx8fHwxNzQyNzg4MDM2fA&force=true&w=640" alt="foggy mountain summit" class="object-cover w-full h-64">
</li>
<li data-carousel-target="slide" class="w-full shrink-0">
<img src="https://unsplash.com/photos/78A265wPiO4/download?ixid=M3wxMjA3fDB8MXxhbGx8fHx8fHx8fHwxNzQyNzg1NzQyfA&force=true&w=640" alt="landscape photography of mountain hit by sun rays" class="object-cover w-full h-64">
</li>
</ul>
</div>
<nav class="flex justify-between mt-4">
<button data-action="carousel#previous" class="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300">
Previous
</button>
<button data-action="carousel#next" class="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300">
Next
</button>
</nav>
</section>
Easy enough, right? The HTML is already telling pretty much how the carousel
controller will look like:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["container", "slide"]
static values = {
index: { type: Number, default: 0 }
}
next() {
this.indexValue = (this.indexValue + 1) % this.totalSlides
}
previous() {
this.indexValue = (this.indexValue - 1 + this.totalSlides) % this.totalSlides
}
// private
indexValueChanged() {
const offset = this.indexValue * -100
this.containerTarget.style.transform = `translateX(${offset}%)`
}
get totalSlides() {
return this.slideTargets.length
}
}
The next
and previous
methods handle circular navigation in the carousel where next
increments the indexValue
and previous
decrements it, both using the modulo operator (%) with the value from totalSlides
to wrap around to the beginning/end, so the indexValue
always stays within valid bounds (0 to totalSlides – 1).
Feel like JavaScript concepts like modulo is still going over your head? Check out JavaScript For Rails Developers. 👈
Cool, right? A simple carousel that works on desktops! Now let’s add support for touch devices. All that is needed in the HTML is the data-carousel-threshold-value
and the data-action
:
<section
data-controller="carousel"
data-carousel-index-value="0"
data-carousel-threshold-value="50"
data-action="touchstart->carousel#touchStart touchend->carousel#touchEnd mousedown->carousel#touchStart mouseup->carousel#touchEnd"
class="relative w-full max-w-lg mx-auto"
>
<!-- … -->
</section>
Now extend the controller with touchStart
and touchEnd
methods:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["container", "slide"]
static values = {
index: { type: Number, default: 0 },
threshold: { type: Number, default: 50 }
}
// …
touchStart(event) {
this.startX = this.clientX(event)
}
touchEnd(event) {
this.endX = this.clientX(event)
this.swipe()
}
// …
}
Okay, it needs two methods: clientX
and swipe
:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
// …
// private
clientX(event) {
return event.changedTouches ? event.changedTouches[0].clientX : event.clientX
}
swipe() {
if (this.isValidSwipe()) this.navigateBasedOnSwipe
this.clearTouch()
}
// …
}
Alright, getting deeper into the rabbit hole, now those methods need a isValidSwipe
, navigateBasedOnSwipe
and clearTouch
(let’s also add the last swipeDistance
getter-method that is needed):
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
// …
isValidSwipe() {
return Math.abs(this.swipeDistance) >= this.thresholdValue
}
navigateBasedOnSwipe() {
this.swipeDistance > 0 ? this.previous() : this.next()
}
clearTouch() {
this.startX = null
this.endX = null
}
get swipeDistance() {
return this.endX - this.startX
}
// …
}
If you try to swipe left/right on the carousel, on a real touch device or enabling touch simulation in your developer console, you can navigate the images as well. Pretty cool (and the images too, right?)!
But let’s not stop here! I also like to explore a Tinder-like feature where you swipe carts (cats + cards) left and right (should I build a carts.app?). As usual, first the HTML:
<section class="relative w-64 mx-auto h-80">
<ul class="relative w-full h-full">
<li
data-controller="swipe-card"
data-swipe-card-threshold-value="100"
data-swipe-card-like-url-value="/api/like/1"
data-swipe-card-dislike-url-value="/api/dislike/1"
data-action="touchstart->swipe-card#touchStart touchend->swipe-card#touchEnd"
class="absolute inset-0 z-30 bg-white shadow-md rounded-md"
>
<img src="https://unsplash.com/photos/gKXKBY-C-Dk/download?ixid=M3wxMjA3fDB8MXxhbGx8fHx8fHx8fHwxNzQyODA5NTM4fA&force=true&w=640" alt="black and white cat lying on brown bamboo chair inside room" class="object-cover w-full h-full">
</li>
<li
data-controller="swipe-card"
data-swipe-card-threshold-value="100"
data-swipe-card-like-url-value="/api/like/2"
data-swipe-card-dislike-url-value="/api/dislike/2"
data-action="touchstart->swipe-card#touchStart touchend->swipe-card#touchEnd"
class="absolute inset-0 z-20 bg-white shadow-md rounded-md"
>
<img src="https://unsplash.com/photos/9UUoGaaHtNE/download?ixid=M3wxMjA3fDB8MXxhbGx8fHx8fHx8fHwxNzQyODAzODg3fA&force=true&w=640" alt="orange Persian cat sleeping" class="object-cover w-full h-full">
</li>
<li
data-controller="swipe-card"
data-swipe-card-threshold-value="100"
data-swipe-card-like-url-value="/api/like/3"
data-swipe-card-dislike-url-value="/api/dislike/3"
data-action="touchstart->swipe-card#touchStart touchend->swipe-card#touchEnd"
class="absolute inset-0 z-10 bg-white shadow-md rounded-md"
>
<img src="https://unsplash.com/photos/LEpfefQf4rU/download?ixid=M3wxMjA3fDB8MXxhbGx8fHx8fHx8fHwxNzQyODA3NDQwfA&force=true&w=640" alt="orange tabby cat on brown parquet floor" class="object-cover w-full h-full">
</li>
<li class="absolute inset-0 z-10 items-center justify-center hidden text-gray-500 bg-white shadow-md rounded-md only:flex">
No more carts… 😿
</li>
</ul>
</section>
You notice the same touchStart
and touchEnd
event listeners, but a different controller name, that is because I want to use inheritance that holds the reusable logic between the carousel and swipe-card controllers. So before moving on, create a swipe_controller first and explore which logic is shared between them:
// app/javascript/controllers/swipe_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
index: { type: Number, default: 0 },
threshold: { type: Number, default: 50 }
}
touchStart(event) {
this.startX = this.clientX(event)
}
touchEnd(event) {
this.endX = this.clientX(event)
this.swipe()
}
swipe() {
if (this.isValidSwipe()) this.navigateBasedOnSwipe
this.clearTouch()
}
// private
clientX(event) {
return event.changedTouches ? event.changedTouches[0].clientX : event.clientX
}
isValidSwipe() {
return Math.abs(this.swipeDistance) >= this.thresholdValue
}
clearTouch() {
this.startX = null
this.endX = null
}
get navigateBasedOnSwipe() {
this.swipeDistance > 0 ? this.previous() : this.next()
}
get swipeDistance() {
return this.endX - this.startX
}
}
Then update the carousel_controller with this:
import SwipeController from "./swipe_controller"
export default class extends SwipeController {
static targets = ["container", "slide"]
next() {
this.indexValue = (this.indexValue + 1) % this.totalSlides
}
previous() {
this.indexValue = (this.indexValue - 1 + this.totalSlides) % this.totalSlides
}
// private
indexValueChanged() {
const offset = this.indexValue * -100
this.containerTarget.style.transform = `translateX(${offset}%)`
}
get totalSlides() {
return this.slideTargets.length
}
}
It has way less lines of code now and it holds only the logic specific for the carousel feature.
Notice how this controller extends the SwipeController
, instead of the Controller class from Stimulus. This works exactly the same as inheritance with Ruby. Read this article if you want to learn more about inheritance with Stimulus Controller.
So with this knowledge, and the HTML already done, the swipe-card controller will be pretty straightforward, but still fairly involved:
import SwipeController from "./swipe_controller"
export default class extends SwipeController {
static values = {
threshold: { type: Number, default: 100 },
likeUrl: String,
dislikeUrl: String
}
swipe() {
if (this.isValidSwipe()) this.#actOnSwipe()
this.clearTouch()
}
// private
#actOnSwipe() {
this.swipeDistance > 0 ? this.#like() : this.#dislike()
}
#like() {
console.log(`Like card with URL: ${this.likeUrlValue}`)
this.#animateOffScreen("right")
}
#dislike() {
console.log(`Dislike card with URL: ${this.dislikeUrlValue}`)
this.#animateOffScreen("left")
}
#animateOffScreen(direction) {
const xOffset = direction === "right" ? window.innerWidth : -window.innerWidth
const rotation = direction === "right" ? 30 : -30
const animationDuration = 500 // in milliseconds
this.element.style.transition = `transform ${animationDuration}ms ease-out`
this.element.style.transform = `translateX(${xOffset}px) rotate(${rotation}deg)`
setTimeout(() => {
this.element.remove()
}, animationDuration)
}
}
First off: there are a few extra values set. Then the swipe
method is overwritten (which is called in the touchEnd
method in swipe_controller).
In the swipe
method the check for a valid swipe is ran. If valid, it fires #actOnSwipe
. Here it check swipe distance: if a positive integer, run like
and otherwise dislike
. In both methods would you use the Fetch API to make a request to your back-end (see this article how you can make requests in Stimulus controllers). Here I have stubbed it with a simple console.log
.
Then finally, again in both methods, the #animateOffScreen
is ran that takes care of the transitioning of the element off the screen and then removing it from the DOM.
Let’s add one more nicety to the swipe-card controller, when the touchmove event is fired, lets add a little CSS to the cards for some fun. Add touchmove->swipe-card#touchMove
to the element’s data-action. The add the following method to the swipe-card controller:
import SwipeController from "./swipe_controller"
export default class extends SwipeController {
// …
touchMove(event) {
if (!this.startX) return
const deltaX = this.clientX(event) - this.startX
this.element.style.transform = `translateX(${deltaX}px) rotate(${deltaX * 0.1}deg)`
}
// …
}
This will move and slightly rotate the element based on the horizontal distance (deltaX
) from where the touch/drag started. Smooth!
And that is how I approach Stimulus controllers with similar functionality. Got suggestions or ideas to make this better? Let me know. 🤙