Content-based Modal Transitions in Ionic

May 15th 2020 Ionic 4+

Although transition animations for modal pages can be customized individually for each instance of a modal page, they still can't easily affect the content of the page that's opening them. The animation factory function only gets access to the modal page that's being opened but not to the originating page.

Despite this limitation, it's still possible to create transitions like the following one in Ionic:

Modal transition animation manipulating the content of the originating page

However, this transition isn't implemented as a single modal animation. I don't think that would even be possible. Instead, it consists of two parts:

  • Most of the animation is happening on the originating page before the modal is even created.
  • Only a minor part at the end of the animation is implemented as a modal transition animation.

The selected image is moved to the top of the page using the concept named FLIP animation. I've first heard about it in a Josh Morony's video. Paul Lewis provides a much more detailed explanation of the concept in his blog post. In this post, I'll focus on how I applied the principle to my animation. If you have any difficulties following my explanation, I suggest you check the two resources I linked earlier in the paragraph.

The images in my animation are Ionic Cards. Clicking one of them opens the modal page and triggers the corresponding transition animation:

<ion-card *ngFor="let image of images; index as i" (click)="openModal(i)">
  <img #imageElement [src]="image">
</ion-card>

To define the animation, I start by determining the absolute starting position of the image to animate (First in FLIP):

const imageElement = this.imageElements.toArray()[index].nativeElement;
const firstRect = imageElement.getBoundingClientRect();

I use the ViewChildren decorator to get a reference to the img elements on the page:

@ViewChildren('imageElement', { read: ElementRef })
private imageElements: QueryList<ElementRef<HTMLElement>>;

Then, I determine the ending position of the image to animate (Last in FLIP):

const clone = imageElement.cloneNode(true) as HTMLElement;
this.pageRef.nativeElement.appendChild(clone);
clone.classList.add('last-pos');
const lastRect = clone.getBoundingClientRect();

Of course, I must apply the final position to the element before I can read it. I do that by assigning the following CSS class to it:

img.last-pos {
  position: fixed;
  left: 0;
  top: 0;
  z-index: 10;
}

You can also notice that I'm creating a (deep) clone of the element. This is to prevent the parent card from collapsing because it would have no more content after I removed the image from it. The pageRef field is a reference to the page element which is injected into the constructor:

constructor(
  private modalCtrl: ModalController,
  private pageRef: ElementRef<HTMLElement>
) {}

To give an illusion that the original image is moving and not its clone, I hide the original card:

const cardElement = this.cardElements.toArray()[index].nativeElement;
cardElement.classList.add('hidden');

Again, I get the reference to the cards with the ViewChildren decorator:

@ViewChildren(IonCard, {read: ElementRef})
private cardElements: QueryList<ElementRef<HTMLElement>>;

And the CSS class simply sets the opacity:

ion-card.hidden {
  opacity: 0;
}

From the starting and the ending position of the image I can calculate the transform that needs to be applied to the ending position to move the image back to its starting position (Invert in FLIP):

const invert = {
  translateX: firstRect.left - lastRect.left,
  translateY: firstRect.top - lastRect.top,
  scaleX: firstRect.width / lastRect.width,
  scaleY: firstRect.height / lastRect.height
};

Using these data, I can now create an Ionic animation for the image and play it (Play in FLIP):

const imageAnimation = createAnimation()
  .addElement(clone)
  .duration(300)
  .easing('ease-in-out')
  .beforeStyles({
    'transform-origin': '0 0'
  })
  .fromTo(
    'transform',
    `translate(${invert.translateX}px, ${invert.translateY}px) scale(${invert.scaleX}, ${invert.scaleY})`,
    'translate(0, 0) scale(1, 1)'
  );

await imageAnimation.play();

Once this animation plays to the end, it's time to open the modal and play its custom transition animation that continues from what was animated so far:

await this.playAnimation(index);

const modal = await this.modalCtrl.create({
  component: ModalPage,
  componentProps: { 
    image: this.images[index]
  },
  enterAnimation: modalEnterAnimation
});
await modal.present();

The default modal transition must be simplified to better match the ending state of the originating page. The image at the top is already at the correct position which means that only the rest of the page needs to fade in, without any transformations. It's best to start with the default animation and modify it as necessary. I've described the process in detail for regular page transition animations in a previous blog post. Here's the final result:

import { createAnimation, Animation } from '@ionic/core';

export function modalEnterAnimation(rootElement: HTMLElement): Animation {
  return createAnimation()
    .addElement(rootElement.querySelector('.modal-wrapper'))
    .easing('ease-in-out')
    .duration(300)
    .beforeStyles({transform: 'none'})
    .fromTo('opacity', 0, 1);
}

If you close the modal, you'll notice that the underlying page remained in its state from the end of the animation. You don't want that. In an ideal situation, you could create a reverse animation for closing the modal. But for this example, I decided to only reset the state of the page.

I returned the required actions as a lambda function from the playAnimation method:

return () => {
  clone.remove();
  cardElement.classList.remove('hidden');
};

I called it after the modal transition ended, i.e. after the promise returned by its present method resolved:

const resetAnimation = await this.playAnimation(index);

const modal = await this.modalCtrl.create({
  component: ModalPage,
  componentProps: { 
    image: this.images[index]
  },
  enterAnimation: modalEnterAnimation
});
await modal.present();

resetAnimation();

In the video at the beginning of the post, the other cards on the page also move off the screen so that the fade-in of the text on the modal page looks nicer. To achieve that, I created another animation for the ion-content element:

constructor(
  private modalCtrl: ModalController,
  private pageRef: ElementRef<HTMLElement>
) {}

const contentAnimation = createAnimation()
  .addElement(this.contentElement.nativeElement)
  .fromTo(
    'transform',
    'translateX(0)',
    `translateX(-${this.contentElement.nativeElement.offsetWidth}px)`
  );

I add both animations into a common parent one which I then play instead of each one separately. I also moved the common duration and easing configuration into the parent:

const parentAnimation = createAnimation()
  .duration(300)
  .easing('ease-in-out')
  .addAnimation([imageAnimation, contentAnimation]);

await parentAnimation.play();

To reset the changes made by the animation, I added a call to its stop method to the existing lambda for resetting the state of the page:

return () => {
  clone.remove();
  cardElement.classList.remove('hidden');
  parentAnimation.stop();
};

With all the changes applied, this is the final state of the playAnimation method:

private async playAnimation(index: number) {

  const imageElement = this.imageElements.toArray()[index].nativeElement;
  const firstRect = imageElement.getBoundingClientRect();

  const clone = imageElement.cloneNode(true) as HTMLElement;
  this.pageRef.nativeElement.appendChild(clone);
  clone.classList.add('last-pos');
  const lastRect = clone.getBoundingClientRect();

  const invert = {
    translateX: firstRect.left - lastRect.left,
    translateY: firstRect.top - lastRect.top,
    scaleX: firstRect.width / lastRect.width,
    scaleY: firstRect.height / lastRect.height
  };

  const imageAnimation = createAnimation()
  .addElement(clone)
  .beforeStyles({
    'transform-origin': '0 0'
  })
  .fromTo(
    'transform',
    `translate(${invert.translateX}px, ${invert.translateY}px) scale(${invert.scaleX}, ${invert.scaleY})`,
    'translate(0, 0) scale(1, 1)'
  );

  const cardElement = this.cardElements.toArray()[index].nativeElement;
  cardElement.classList.add('hidden');

  const contentAnimation = createAnimation()
  .addElement(this.contentElement.nativeElement)
  .fromTo(
    'transform',
    'translateX(0)',
    `translateX(-${this.contentElement.nativeElement.offsetWidth}px)`
  );

  const parentAnimation = createAnimation()
  .duration(300)
  .easing('ease-in-out')
  .addAnimation([imageAnimation, contentAnimation]);

  await parentAnimation.play();

  return () => {
    clone.remove();
    cardElement.classList.remove('hidden');
    parentAnimation.stop();
  };
}

The concepts it demonstrates should be a good starting point for whatever transition animations you need to create.

The full source code for the sample project is available in a Bitbucket repository.

This blog post is a part of a series of posts about animations in Ionic.

Get notified when a new blog post is published (usually every Friday):

Copyright
Creative Commons License