Creating a Gmail Style Swipe to Archive with the Ionic Animations API
In my recent tutorials, I have been explaining how to build more complex UI/UX patterns into Ionic applications with the Ionic Animations API and the improved gesture support in Ionic 5. I often keep a look out for impressive gestures or animations whether they are in traditional "native" mobile applications, or just a designers concept, and see if I can implement something the same or similar in Ionic.
My goal is to help dispel the perception that interesting interactions and animations are for the realm of native mobile applications that use native UI controls, as opposed to Ionic applications which use a web view to power the UI (which I think is actually one of Ionic's greatest advantages). With a bit of knowledge about how to go about designing complex gestures and animations in a web environment, we can often create results that are indistinguishable from a native user interface.
That is why I decided to tackle building the Swipe to Archive feature that the Gmail mobile application uses in Ionic. The basic idea is that you can swipe any of the emails in your inbox, and if you swipe far enough the email will be archived. As you swipe, and icon is revealed underneath that implies the result of the gesture. If you do not swipe far enough, then the email will slide back into it's normal resting place. It looks like this:
Building this in Ionic is similar in concept to the Tinder Swipe Cards I created recently, but there are some interesting differences. For the Tinder cards, we rely entirely on Ionic's Gesture system, but in this tutorial we will be making use of both the Gestures system and the Ionic Animations API.
Some interesting challenges that we will solve in this tutorial include:
- Creating an entirely self contained component to perform the functionality
- Making an element follow a gesture on the screen
- Detecting an archive/don't-archive threshold from the gesture
- Chaining animations such that one animation plays only after another has finished
- Creating a complex delete animation which gives an element time to "animate away" before physically being deleted
- Using CSS grid to place elements on top of one another
We will be building this as a StencilJS component inside of an Ionic/StencilJS application, but this tutorial could also be used to build the same functionality into Angular, React, or Vue with a few tweaks. I will do my best to highlight the StencilJS specific concepts as we go.
The end result will look like this:
Outline
Source codeBefore We Get Started
If you are following along with StencilJS, I will assume that you already have a basic understanding of how to use StencilJS. If you are following along with a framework like Angular, React, or Vue then you will need to adapt parts of this tutorial as we go.
If you would like a thorough introduction to building Ionic applications with StencilJS, you might be interested in checking out my book.
If you do not already have a basic understanding of the Ionic Animations API or Gestures I would recommend familiarising yourself with the following resources first:
1. The Basic Component Structure and Layout
You will need to first create a new component for whatever you are using to build your application, but if you are using StencilJS as I am in this tutorial you can run the following command to help create it:
npm run generate
I named my component app-swipe-delete
but you can use whatever you like. Let's get the basic structure of the component implemented first, and then we will add on the gesture and animation functionality.
Modify the component to reflect the following:
import { Component, Host, Element, Event, EventEmitter, h } from '@stencil/core';
import { Gesture, GestureConfig, createGesture, createAnimation } from "@ionic/core";
@Component({
tag: 'app-swipe-delete',
styleUrl: 'swipe-delete.css'
})
export class SwipeDelete {
@Element() hostElement: HTMLElement;
@Event() deleted: EventEmitter;
private gesture: Gesture;
async componentDidLoad(){
const innerItem = this.hostElement.querySelector('ion-item');
const style = innerItem.style;
const windowWidth = window.innerWidth;
}
disconnectedCallback(){
if(this.gesture){
this.gesture.destroy();
this.gesture = undefined;
}
}
render() {
return (
<Host style={{display: `grid`, backgroundColor: `#2ecc71`}}>
<div style={{gridColumn: `1`, gridRow: `1`, display: `grid`, alignItems: `center`}}>
<ion-icon name="archive" style={{marginLeft: `20px`, color: `#fff`}}></ion-icon>
</div>
<ion-item style={{gridColumn: `1`, gridRow: `1`}}>
<slot></slot>
</ion-item>
</Host>
);
}
}
We have set up all of the imports that we need for this component, which includes what is required for creating gestures and using the Ionic Animations API. If you are using Angular, keep in mind that you can import the GestureController
from @ionic/angular
and use that instead of createAnimation
.
We use @Element()
to grab a reference to the node that represents the element we are creating in the DOM - if you are not using StencilJS you would need to grab a reference to the DOM element in some other way. We also use @Event()
to set up a deleted
event which is another StencilJS specific concept, but is also achievable in other ways frameworks like Angular and React. Basically, we want to at some point indicate that the element has been deleted, and we will listen for that event outside of the component to determine when to remove its data from the application (e.g. when to remove the corresponding item from the array that contains everything in our list).
In the componentDidLoad
lifecycle hook which runs automatically when the component has loaded, we set up some more references to values we will need to define the gesture and animations. We will be animating the items to the right side of the screen, but an important thing to keep in mind is that we don't want to move the entire component. The component will be comprised of two blocks sitting on top of each other - the block behind will remain in place (and will display a little "archive" icon) and the block on top will slide to the right. To achieve this, we grab a reference to the <ion-item>
that will live inside of this component so that we can manipulate it later. We also grab a reference to the width of the screen so that we know how far to the right we need to animate the <ion-item>
when it is being deleted.
The disconnectedCallback
just runs some clean up code for when the component is destroyed. Then we have the template itself. Inside of our component we have an <ion-item>
and a generic <div>
. We want the <div>
to display the background colour and the icon, and we want the <ion-item>
to sit on top of that to display the regular content. To get these two elements sitting on top of one another we need to start getting a little tricky. We could achieve this with some absolute positioning, but that will make some other things like positioning the archive icon the way we want awkward. Instead, we are using a little CSS Grid trick to position the elements on top of each other. We use display: grid
to make use of CSS Grid, and then we tell both elements to occupy the same grid-column
and grid-row
. Then in our <div>
we can just use align-items: center
to get our <ion-icon>
to be vertically aligned.
The only other thing we are doing here is using a <slot>
inside of the <ion-item>
. The basic idea of a slot is that it will project content supplied to the component inside of the component itself (if you are familiar with Angular's <ng-content>
it is pretty much the same thing). What this means is that if we use the component like this:
<app-swipe-delete>Hello there</app-swipe-delete>
That content would be projected inside of our component like this:
<div style={{gridColumn: `1`, gridRow: `1`, display: `grid`, alignItems: `center`}}>
<ion-icon name="archive" style={{marginLeft: `20px`, color: `#fff`}}></ion-icon>
</div>
<ion-item style={{gridColumn: `1`, gridRow: `1`}}>
Hello there
</ion-item>
If you would like to read more about how slots work, you can take a look at the following article:
NOTE: We are using an <ion-item>
here to make use of the default Ionic styling, but you could just as easily build this with another generic <div>
instead of <ion-item>
if you didn't want this component to be dependent on Ionic.
2. Setting up the Gesture
Now let's work on the gesture. What we want to do is be able to swipe the list item elements and have the element follow our finger. If the element is swiped/dragged far enough, then it will perform a delete animation.
Modify the load lifecycle hook to reflect the following:
async componentDidLoad(){
const innerItem = this.hostElement.querySelector('ion-item');
const style = innerItem.style;
const windowWidth = window.innerWidth;
const options: GestureConfig = {
el: this.hostElement,
gestureName: 'swipe-delete',
onStart: () => {
style.transition = "";
},
onMove: (ev) => {
if(ev.deltaX > 0){
style.transform = `translate3d(${ev.deltaX}px, 0, 0)`;
}
},
onEnd: (ev) =>{
style.transition = "0.2s ease-out";
if(ev.deltaX > 150){
style.transform = `translate3d(${windowWidth}px, 0, 0)`;
} else {
style.transform = ''
}
}
}
this.gesture = await createGesture(options);
this.gesture.enable();
}
This gesture is almost identical to the one we created in the Tinder card tutorial, so if you would like some more elaboration I would recommend giving that tutorial a read. The basic idea is that we translate the element in the X
direction for whatever the deltaX
value of the gesture is. The deltaX
value represents how far in the horizontal direction the users finger/mouse/whatever has moved from the origin point of the gesture. If we use transform: translate
to move our element the same amount as the deltaX
then it will follow the users input on the screen. We also use the onEnd
method once the gesture has been completed to determine if the gesture was swiped far enough to trigger a delete/archive (i.e. was the final deltaX
value above 150
?). If it was swiped far enough, then we automatically animate the item all of the way off of the screen by setting the translate value on the X-axis to the windowWidth
value.
An important difference with this gesture, as opposed to the Tinder cards, is that we are listening for the gesture on the host element of the component but we are actually animating one of the inner items of the component - the <ion-item>
that is inside of the component. As I mentioned, we don't want to move the entire component over as we need part of it to stay in place to display the background colour and the archive icon.
3. Implementing the Leave Animation
We have already created our gesture and have the item animating off screen if the item was swiped far enough, but there are still a couple more things that we need to do. Once the item has animated off screen, we don't want the green block sitting behind that item and displaying the archive icon to just sit there indefinitely, we will want that to animate away as well. On top of that, once is has finished animating away we want to trigger the delete
event so that we know we can now delete that element for real (rather that just hiding it away through animations).
It is important that we wait until the animation has finished before triggering that delete event, otherwise our animation will get cut off. The Ionic Animations API provides an easy was to do this, as we can use the onFinish
method to trigger some code once an animation has completed.
Modify the load lifecycle hook to reflect the following:
async componentDidLoad(){
const innerItem = this.hostElement.querySelector('ion-item');
const style = innerItem.style;
const windowWidth = window.innerWidth;
const hostDeleteAnimation = createAnimation()
.addElement(this.hostElement)
.duration(200)
.easing('ease-out')
.fromTo('height', '48px', '0');
const options: GestureConfig = {
el: this.hostElement,
gestureName: 'swipe-delete',
onStart: () => {
style.transition = "";
},
onMove: (ev) => {
if(ev.deltaX > 0){
style.transform = `translate3d(${ev.deltaX}px, 0, 0)`;
}
},
onEnd: (ev) =>{
style.transition = "0.2s ease-out";
if(ev.deltaX > 150){
style.transform = `translate3d(${windowWidth}px, 0, 0)`;
hostDeleteAnimation.play()
hostDeleteAnimation.onFinish(() => {
this.deleted.emit(true);
})
} else {
style.transform = ''
}
}
}
this.gesture = await createGesture(options);
this.gesture.enable();
}
Now we have defined a hostDelete
animation that will animate the height
of the entire component from 48px
to 0
, which means it will shrink away just as the Gmail delete animation does. It is important to note that animating a property like height
can be expensive for performance (basically you should only animate transform
and opacity
wherever possible to achieve your animations). Depending on the context, you might want to profile the performance of this animation to make sure it suits your needs. A higher performance version of this animation could be achieved by animating the opacity
to hide the host element rather than height
but it wouldn't be quite as nice. Another limitation of this animation is that it requires a fixed/known height to animate from.
NOTE: The non-height animation version of this is much more complex, but if you're interested, it is one of the examples we cover in my book Advanced Animations & Interactions with Ionic.
Then all we do is trigger this animation with the play()
method inside of our onEnd
method if appropriate. The cool part here is that we then also add an onFinish
callback for that animation, so that when it is finished it will trigger this.delete.emit(true)
.
4. Using the Component
Now that we have our component created, let's take a look at how to use it and how to listen for that deleted
event so that we can remove the items data from the list at the appropriate time. Again, this example will be for StencilJS but the same basic principles will apply elsewhere.
import { Component, State, h } from '@stencil/core';
@Component({
tag: 'app-home',
styleUrl: 'app-home.css',
})
export class AppHome {
@State() items = [];
componentWillLoad() {
this.items = [
{ uid: 1, subject: 'hello', message: 'hello' },
{ uid: 2, subject: 'hello', message: 'hello' },
{ uid: 3, subject: 'hello', message: 'hello' },
{ uid: 4, subject: 'hello', message: 'hello' },
{ uid: 5, subject: 'hello', message: 'hello' },
{ uid: 6, subject: 'hello', message: 'hello' },
{ uid: 7, subject: 'hello', message: 'hello' },
{ uid: 8, subject: 'hello', message: 'hello' },
{ uid: 9, subject: 'hello', message: 'hello' },
{ uid: 10, subject: 'hello', message: 'hello' },
];
}
render() {
return [
<ion-header>
<ion-toolbar color="primary">
<ion-title>Home</ion-title>
</ion-toolbar>
</ion-header>,
<ion-content>
<ion-list>
{this.items.map((item) => (
<app-swipe-delete
key={item.uid}
onDeleted={() => this.handleDelete(item.uid)}
>
{item.subject}
</app-swipe-delete>
))}
</ion-list>
</ion-content>,
];
}
handleDelete(uid) {
this.items = this.items.filter((item) => {
return item.uid !== uid;
});
this.items = [...this.items];
}
}
All we need to do now to make use of this component is to loop over an array of data and use our <app-swipe-delete>
component for each element. We can supply it with whatever content we want to display inside of the item, and we can also listen for the deleted
event to handle removing the item from the list when necessary. To do that in StencilJS we use onDeleted
, but this might look slightly different depending on what you are using.
The end result should look something like this:
Summary
What we have created is a reasonably advanced interaction/animation, but with the use of Ionic's gesture system and the Ionic Animations API it is actually reasonably smooth to create with relatively little code. There is a lot you could do and create with reasonably minor variations on the general concepts we have made use of in this tutorial.