High Performance Animated Accordion List in Ionic
A couple of years ago I published some tutorials on building an accordion style list in Ionic. The method covered in that tutorial works well enough, but in this tutorial I will be taking another look at building an accordion list, this time with a non-comprising approach to creating a high performance accordion component.
You can see this in action here.
The main issue with building an animated accordion component is that you would naturally animate the height
of the content that is being opened in the accordion list. This is what gives it that "accordion" feel. One item expands pushing the other down, and then when it is closed it collapses all of the items below it back again. However, the problem with animating height
is that it is bad for performance. Animating height
will trigger browser "layouts" as items are pushed around the screen and need to have their positions recalculated. This is an expensive process for the browser, especially if it needs to do it a lot (e.g. as it does when animating the height
of an item in an accordion list).
In some scenarios, animating height
might still keep your application peformant enough to be acceptable, but if we want to animate whilst maintaining a high degree of performance we should focus on animating only the transform
property for this kind of behaviour. That's easier said than done, though. The good thing about a transform
is that it only impacts the element being transformed, meaning that the positions of other elements on the screen won't be impacted and so the browser doesn't need to perform expensive layout recalculations. The bad thing for our scenario is that we want the other items in the list to be impacted - when one item is opened, all the other items need to move down the screen.
To solve this catch-22 situation, we use a trick that I also made use of in Advanced Animations & Interactions with Ionic to create a high performance delete animation.
Outline
Source codeThe Trick
Before we get into the code for this I want to highlight how the concept works in general, otherwise things might get a bit confusing. Here's the general process for how opening an accordion item will work:
- An accordion item is clicked
- The content for the item is displayed immediately (no animation)
- Every item below the item being opened is transformed up so that it hides the content that was just displayed (at this point, there will be no noticeable change on screen, because the items were just transformed back into the position that they were at initially)
- The elements that were just transformed up have the transforms animated away. This will cause them to slide down to reveal the content that was just displayed.
By translating the position of all of the items below the one being opened, we can give the appearance of the height
of the content being animated, but really everything else below it is just being moved out of the way with a transform
. The one remaining issue with this is that since we rely on the elements below the one being opened to initially block the content from being visible, we run into a problem when either:
- The last element in the accordion list is being opened (it won't have anything to block the content, so the content will jsut appear immediately and won't be animated)
- The content for an item earlier in the accordion list is long enough that it extends past the bottom of the list anyway, in which case we will see the content leaking out of the bottom of the list.
To handle this, we create an invisible "blocker" element that sits at the bottom of the list, and will change its height dynamically to make sure it is large enough to block any content from being visible (e.g. if the item being opened has content that is 250px
high, the blocker will dynamically be set to a height of 250px
). If you're thinking - hey! you said we weren't going to use height - the important difference here is that we are not animating the height, it will just instantly be set to whatever value we need.
To make this blocker "invisible" we have to set it to be the same colour as the background, which creates one weird limitation for this component that it can only be used on pages with a solid background colour (e.g. not a gradient or image).
Even with this overview description, I still think the concept is a bit confusing. To help, I've created a diagram of what this process actually looks like. I have given the "blocker" element an obvious colour, and reduced the opacity of the items so that we can better see what is going on behind the scenes:
In this example, the blocker isn't actually necessary because we are opening the second item in the accordion list and the content is not long enough to extend past the bottom of the list. However, if this item was one of the last two items the blocker would come into play to hide the content.
Once you understand this process, closing the item again is quite a bit simpler. We just first animate all of those items back with a transform so that they are covering the content again, and once they are covering the content we remove the content (basically the same process, just in reverse).
Before We Get Started
We will be building the components or this tutorial inside of an Ionic/StencilJS application. If you are using a framework like Angular or React with Ionic, most of the concepts should reasonably easily port over (especially since a lot of the logic is built on the Ionic Animations API). If there is enough interest, I may create additional versions of this tutorial for other frameworks.
This is an intermediate/advanced tutorial, and I will be skipping over explaining some of the more basic stuff. If you are interested in an in-depth explanation of how the Ionic Animations API works and how to use it to create your own custom animations and interactions, you might be interested in checking out: Advanced Animations & Interactions with Ionic.
The end result of this tutorial will comprise of two custom components that will allow us to easily create an accordion list like this:
<my-accordion-group>
<my-accordion-item></my-accordion-item>
<my-accordion-item></my-accordion-item>
<my-accordion-item></my-accordion-item>
</my-accordion-group>
The Accordion Item Component
We will create the <my-accordion-item>
component first since it is a bit simpler, but both of the components will be required for this to work. Most of the logic and animations for opening an item happen by moving other items around, so most of that happens in the <my-accordion-group>
component which has access to all the other items, rather than the individual <my-accordion-item>
components.
Let's first take a look at the basic structure of the component, and then we will implement the toggleOpen
method in detail which contains the most important logic for this component.
import { Component, Listen, State, Element, Host, h, Event, EventEmitter } from '@stencil/core';
@Component({
tag: 'my-accordion-item',
styleUrl: 'my-accordion-item.css',
shadow: true,
})
export class MyAccordionItem {
@Element() hostElement: HTMLElement;
@Event() toggle: EventEmitter;
@State() isOpen: boolean = false;
public content: HTMLDivElement;
private isTransitioning: boolean = false;
componentDidLoad() {
this.content = this.hostElement.shadowRoot.querySelector('.content');
}
@Listen('click')
toggleOpen() {
}
render() {
return (
<Host>
<div class="header">
<ion-icon name={this.isOpen ? 'chevron-down' : 'chevron-forward'}></ion-icon>
<slot name="header"></slot>
</div>
<div class="content">
<slot name="content"></slot>
</div>
</Host>
);
}
}
Our template mostly consists of a header
area and a content
area, and each of these have named slots so that we can insert content into those areas when we are using the component. This component will also emit a toggle
event which will pass important information back up to the group component, and we are keeping track of a couple of things here like if the item is currently open and if it is currently transitioning between open/closed states.
Now let's take a look at the toggleOpen
code:
@Listen('click')
toggleOpen() {
if (this.isTransitioning) {
return;
}
this.isOpen = !this.isOpen;
this.isTransitioning = true;
this.toggle.emit({
element: this.hostElement,
content: this.content,
shouldOpen: this.isOpen,
startTransition: () => {
this.isTransitioning = true;
},
endTransition: () => {
this.isTransitioning = false;
},
setClosed: () => {
this.isOpen = false;
},
});
}
This method will be triggered any time a click
event is detected on the component. However, we want to make sure we only trigger the toggle if it is in a stable open/closed state so we first check the isTransitioning
value. The most interesting part here is that we trigger an event (that the group component will listen for) and we pass some information and methods back up to that component. This will tell the group component whether this item is being opened or closed, but it also provides additional information that the component will need. The three methods we supply in this event will allow the group component to easily communicate back to this component to appropriately set the isTransitioning
and isOpen
values. The element
reference will be used to find this individual item in the larger accordion list, and the content
element is used so that the group component will be able to determine the correct height
for the content that is being displayed.
There is also some CSS we need to add. Most of this is just to get the styling for the individual item components right, but there are a couple of important things here:
:host {
display: block;
height: 100%;
background-color: #fff;
overflow: auto;
border: 3px solid #fff;
will-change: transform;
}
ion-icon {
font-size: 20px;
float: right;
position: relative;
top: 20px;
}
.header {
background-color: #f5f5f5;
padding: 0px 20px 5px 20px;
border: 1px solid #ececec;
}
.content {
display: none;
overflow: auto;
padding: 0 20px;
}
The items in the accordion list will frequently be transformed as various items are opened/closed so we set the will-change: transform
property to reduce some unnecessary paints (if you are not familiar with will-change
I would advise not using it in other situations until you have learned more about it). It is also important for us to initially set the content of all of our items to display: none
since the content will only be displayed when the item is being opened. Using display: none
is important as opposed to say opacity: 0
because we don't want it taking up space in the DOM when it is not visible.
The Accordion Group Component
Now let's take a look at the implementation of the <my-accordion-group>
component. We will take a similar approach here, we will first set up the basic outline and then implement the more complex methods in detail.
import { Component, Listen, Element, h } from '@stencil/core';
import { createAnimation, Animation } from '@ionic/core';
@Component({
tag: 'my-accordion-group',
styleUrl: 'my-accordion-group.css',
shadow: true,
})
export class MyAccordionGroup {
@Element() hostElement: HTMLElement;
public elementsToShift: Array<any>;
public blocker: HTMLElement;
public currentlyOpen: CustomEvent = null;
public shiftDownAnimation: Animation;
public blockerDownAnimation: Animation;
componentDidLoad() {
this.blocker = this.hostElement.shadowRoot.querySelector('.blocker');
}
@Listen('toggle')
async handleToggle(ev) {
ev.detail.shouldOpen ? await this.animateOpen(ev) : await this.animateClose(ev);
ev.detail.endTransition();
}
async closeOpenItem() {
if (this.currentlyOpen !== null) {
const itemToClose = this.currentlyOpen.detail;
itemToClose.startTransition();
await this.animateClose(this.currentlyOpen);
itemToClose.endTransition();
itemToClose.setClosed();
return true;
}
}
async animateOpen(ev) {
}
async animateClose(ev) {
}
render() {
return [<slot></slot>, <div class="blocker"></div>];
}
}
Our template here consists entirely of a <slot>
where all of the <my-accordion-item>
components will be injected, and then we have our "blocker" element after that so that it displays at the end of the list. Our handleToggle
method will be triggered whenever the toggle
event from one of our <my-accordion-item>
components is detected. This method will handle calling the correct open/close method, and once the animation has finished playing to open or close the component, it will call the endTransition
method provided by the individual item so that it can set its isTransitioning
value correctly.
We also have an additional closeOpenItem
method here. With the way the component is set up, only one item can be open at a time. If there is already an item open when animateOpen
is called, it will first close that open item with closeOpenItem
. Usually the <my-accordion-item>
handles setting its own isOpen
and isTransitioning
values when it is first clicked, but since this close is triggered from outside of the item the group component will need to call the provided startTransition
and setClosed
methods to set those values manually.
Now let's take a look at the animateOpen
method:
async animateOpen(ev) {
// Close any open item first
await this.closeOpenItem();
this.currentlyOpen = ev;
// Create an array of all accordion items
const items = Array.from(this.hostElement.children);
// Find the item being opened, and create a new array with only the elements beneath the element being opened
let splitOnIndex = 0;
items.forEach((item, index) => {
if (item === ev.detail.element) {
splitOnIndex = index;
}
});
this.elementsToShift = [...items].splice(splitOnIndex + 1, items.length - (splitOnIndex + 1));
// Set item content to be visible
ev.detail.content.style.display = 'block';
// Calculate the amount other items need to be shifted
const amountToShift = ev.detail.content.clientHeight;
const openAnimationTime = 300;
// Initially set all items below the one being opened to cover the new content
// but then animate back to their normal position to reveal the content
this.shiftDownAnimation = createAnimation()
.addElement(this.elementsToShift)
.delay(20)
.beforeStyles({
['transform']: `translateY(-${amountToShift}px)`,
['position']: 'relative',
['z-index']: '1',
})
.afterClearStyles(['position', 'z-index'])
.to('transform', 'translateY(0)')
.duration(openAnimationTime)
.easing('cubic-bezier(0.32,0.72,0,1)');
// This blocker element is placed after the last item in the accordion list
// It will change its height to the height of the content being displayed so that
// the content doesn't leak out the bottom of the list
this.blockerDownAnimation = createAnimation()
.addElement(this.blocker)
.delay(20)
.beforeStyles({
['transform']: `translateY(-${amountToShift}px)`,
['height']: `${amountToShift}px`,
})
.to('transform', 'translateY(0)')
.duration(openAnimationTime)
.easing('cubic-bezier(0.32,0.72,0,1)');
return await Promise.all([this.shiftDownAnimation.play(), this.blockerDownAnimation.play()]);
}
We have already discussed the general concept of what is happening in detail, and I have added comments to the code above to highlight the code that is triggering various parts of that process. An important concept being used here is the fact that elements that are positioned with the position
CSS property will be stacked above those that are not positioned. Our "blocker" is last in the DOM, meaning naturally it would be above everything else. However, we only want the blocker to be above the item being opened, and under the items beneath the item being opened. To deal with this tricky scenario, we set a position
and z-index
on all of the items being moved down to force them to be above the blocker element. You might wonder why we can't just use z-index
alone, that is because by using a transform
the normal rules for element stacking in the DOM are changed, but z-index
plus position
does the job.
Let's take a look at the animateClose
method now:
async animateClose(ev) {
this.currentlyOpen = null;
const amountToShift = ev.detail.content.clientHeight;
const closeAnimationTime = 300;
// Now we first animate up the elements beneath the content that was opened to cover it
// and then we set the content back to display: none and remove the transform completely
// With the content gone, there will be no noticeable position change when removing the transform
const shiftUpAnimation: Animation = createAnimation()
.addElement(this.elementsToShift)
.afterStyles({
['transform']: 'translateY(0)',
})
.to('transform', `translateY(-${amountToShift}px)`)
.afterAddWrite(() => {
this.shiftDownAnimation.destroy();
this.blockerDownAnimation.destroy();
})
.duration(closeAnimationTime)
.easing('cubic-bezier(0.32,0.72,0,1)');
const blockerUpAnimation: Animation = createAnimation()
.addElement(this.blocker)
.afterStyles({
['transform']: 'translateY(0)',
})
.to('transform', `translateY(-${amountToShift}px)`)
.duration(closeAnimationTime)
.easing('cubic-bezier(0.32,0.72,0,1)');
await Promise.all([shiftUpAnimation.play(), blockerUpAnimation.play()]);
// Hide the content again
ev.detail.content.style.display = 'none';
// Destroy the animations to reset the CSS values that they applied. This will remove the transforms instantly.
shiftUpAnimation.destroy();
blockerUpAnimation.destroy();
return true;
}
Again, I have added comments to the code above to describe what is happening at each step. Perhaps one less obvious thing that is happening here is that to reset everything back to its initial value we call the destroy
method on the animations. When the display
is set back to none
again, the transforms are no longer required because the content isn't taking up space in the DOM, so we can remove the transforms we applied to everything and they will remain in the same position (in fact, if we left the transforms on the items would be incorrectly placed above where they should be).
Finally, we just need a bit of CSS for this component:
:host {
display: block;
}
.blocker {
background-color: #fff;
height: 50px;
will-change: transform;
}
It's important that you set the background-color
of the blocker to whatever the background colour of your page is... otherwise you will have a very noticeable weird box at the bottom of your accordion list. We also use will-change
on the blocker since it is constantly moving around.
Using the Component
Now that we have our components defined, we can quite easily build an accordion list whenever we want. Here is the example I used:
<my-accordion-group>
<my-accordion-item>
<h3 slot="header">Overview</h3>
<div slot="content">
<p>
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an
unknown printer took a galley of type and scrambled it to make a type specimen book.
</p>
</div>
</my-accordion-item>
<my-accordion-item>
<h3 slot="header">Characters</h3>
<ul style={{ paddingLeft: `10px` }} slot="content">
<li>Mace Tyrell</li>
<li>Tyrion Lannister</li>
<li>Sansa Stark</li>
<li>Catelyn Stark</li>
<li>Roose Bolton</li>
<li>Jon Snow</li>
<li>Hot Pie</li>
</ul>
</my-accordion-item>
<my-accordion-item>
<h3 slot="header">Plot</h3>
<p slot="content">Hello there.</p>
<p slot="content">Hello there.</p>
<p slot="content">Hello there.</p>
<p slot="content">Hello there.</p>
<p slot="content">Hello there.</p>
<p slot="content">Hello there.</p>
</my-accordion-item>
<my-accordion-item>
<h3 slot="header">Production</h3>
<div slot="content">
<p>
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an
unknown printer took a galley of type and scrambled it to make a type specimen book.
</p>
</div>
</my-accordion-item>
<my-accordion-item>
<h3 slot="header">Awards</h3>
<div slot="content">
<p>
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an
unknown printer took a galley of type and scrambled it to make a type specimen book.
</p>
</div>
</my-accordion-item>
</my-accordion-group>
I created this example because it uses different ways to utilise the slots and has different types/lengths of content.
Summary
This might seem like a lot of work just to avoid animating the height
property, but now that we have the component built we can easily use it without having to think about it. There still might be room for improvement in this component because it is just something I put together in a few hours, but in my testing, it was able to easily maintain ~60fps even when testing with 6x CPU Slowdown performance throttling. The key to performance here is that it is doing all of the hard work upfront in the first few milliseconds after it is triggered (which won't be noticeable to the user) and then the animation can play smoothly throughout since it doesn't need to do complex work during the animation (as it would if we were animating height
).