Sliding Colors Tab Bar Animation in NativeScript

In this tutorial, you'll learn how to create a bottom navigation bar with a sliding color animation. This tutorial will be focused more on…

Sliding Colors Tab Bar Animation in NativeScript poster

Take control of your career. Build JavaScript mobile apps.

ng atlanta

Catch Dave Coffin, Nathan Walker, and Alex Ziskind at ngAtlanta in February 2020 for an advanced NativeScript with Angular workshop called  Breathe life into mobile UX with solid architecture lessons. You can register now and take your NativeScript skills up a notch.  Register here.

In this tutorial, you'll learn how to create a bottom navigation bar with a sliding color animation. This tutorial will be focused more on creating the custom tab bar rather than how to use the Tabs component.



Result



Getting Started

Lets begin with a parent GridLayout with rows * and auto in our home.component.html to divide the screen into 2 parts - the main content area and the tab area.

Set up the main content using nativescript's Tabs component. In order to use a custom tab bar instead of the default one, we will need to omit the TabStrip tag. (more info on that on this blogpost by Martin Yankov).

We'll create 5 TabContentItem and their corresponding tab elements in our custom tab bar for this tutorial.


<!-- home.component.html -->
<GridLayout rows="*, auto">
    
  <!-- main content -->
  <GridLayout>
        <Tabs [selectedIndex]="selectedIndex" (selectedIndexChanged)="onSelectedIndexChanged($event)">
            <TabContentItem [backgroundColor]="tabList[0].backgroundColor">
                <StackLayout>
                    <Label text="Bottom Nav Content 1" class="h1 text-center p-t-20"></Label>
                </StackLayout>
            </TabContentItem>

            <TabContentItem [backgroundColor]="tabList[1].backgroundColor">
                <StackLayout>
                    <Label text="Bottom Nav Content 2" class="h1 text-center p-t-20"></Label>
                </StackLayout>
            </TabContentItem>

            <TabContentItem [backgroundColor]="tabList[2].backgroundColor">
                <StackLayout>
                    <Label text="Bottom Nav Content 3" class="h1 text-center p-t-20"></Label>
                </StackLayout>
            </TabContentItem>

            <TabContentItem [backgroundColor]="tabList[3].backgroundColor">
                <StackLayout>
                    <Label text="Bottom Nav Content 4" class="h1 text-center p-t-20"></Label>
                </StackLayout>
            </TabContentItem>

            <TabContentItem [backgroundColor]="tabList[4].backgroundColor">
                <StackLayout>
                    <Label text="Bottom Nav Content 5" class="h1 text-center p-t-20"></Label>
                </StackLayout>
            </TabContentItem>
        </Tabs>
    </GridLayout>
    
    <!-- bottom tabs -->  
  
</GridLayout>



Configuration

Lets start by setting up an interface for our tabs configuration. Let's call it BottombarSlidingColorConfig for the tab bar and Tab for each individual tab item.


// bottombar-sliding-color.ts
export interface BottombarSlidingColorConfig {
    backgroundColor: string,
    height: number
}

export interface Tab {
    image: string,
    text?: string,
    backgroundColor: string
}

export interface Tabs extends Array<Tab> { }

To achieve the sliding effect, we will start with the template. The component is comprised of the following:

  • base layer (containing the background color of each tab item),
  • mask layer (the inactive tab background colors that will slide to expose the active tab)
  • tab contents layer (contains image and text for each tab)

In terms of z-index, the tab image and text layer will be stacked on top of the mask layer which is then stacked above the base layer.


To make the tabs configurable from the parent component, we will use the tabs and config properties that was passed in to generate and style the tabs instead of hardcoding those in our template.



Container

Lets start off with a GridLayout to house all our layers, and set its height and backgroundColor using values from our config. We will also set the columns property to gridColumns, which we will go over in the next section.


<GridLayout *ngIf="tabs && gridColumns" [columns]="gridColumns"
    [height]="config?.height || 60"
    [backgroundColor]="config?.backgroundColor || '#0D0D12'"
    verticalAlignment="bottom">
    
</GridLayout>

Define gridColumns

Declare gridColumns with type string in our component file, and we will set its value in our OnChanges hook. The purpose of gridColumns is to divide the tab component into the number of tab items we need with equal widths. To do that, we will create an array with '*' and join them with a comma.

ngOnChanges(changes: SimpleChanges): void {
	if (changes.tabs && changes.tabs.currentValue) {
		let wildcard = [];
		changes.tabs.currentValue.forEach(item => {
			wildcard.push('*');
		});
		this.gridColumns = wildcard.join(',');
	}
}



Base Layer

For this layer, we will loop through the tabs array and create a GridLayout setting its backgroundColor to what we defined in the parent component. The actual tab contents (image and text) will be handled in the top most layer of our custom tab component.


<!-- Tab backgrounds -->
<GridLayout *ngFor="let tab of tabs; let i = index" [col]="i"
	[backgroundColor]="tab.backgroundColor">
</GridLayout>



Mask Layer

The mask layer will be wrapped in an AbsoluteLayout, which will contain a GridLayout for the The mask itself is comprised of 2 parts, a left mask and a right mask. The reason for this is so we can animate the left and right mask independent of each other to create the chasing animation effect.


<!-- moving mask -->
<AbsoluteLayout col="0" [colSpan]="tabs.length">
	<GridLayout columns="auto, auto, auto">
		<GridLayout #leftMask col="0" [height]="config?.height || 60"
			[backgroundColor]="config?.backgroundColor"></GridLayout>
		<GridLayout #centerMask col="1" [height]="config?.height || 60">
		</GridLayout>
		<GridLayout #rightMask col="2" [height]="config?.height || 60"
			[backgroundColor]="config?.backgroundColor"></GridLayout>
	</GridLayout>
</AbsoluteLayout>



Tab Contents Layer

This layer will contain the tab's image and text and also be handling the tap events (since this is the topmost layer of our tab component. Similar to our base layer that contains the background colors for each tab, we will loop through each tab and set the image and text of each one, and also attach a tap event.


<!-- Tab image and text -->
<GridLayout *ngFor="let tab of tabs; let i = index" [col]="i"
	(tap)="selectTab(i)">
	<Image #tabImage [src]="tab.image" height="25" width="25"></Image>
	<Label #tabLabel [text]="tab.text || ''" fontSize="15" fontWeight="bold" color="#fff" marginBottom="10" 			verticalAlignment="bottom" horizontalAlignment="center"></Label>
</GridLayout>



Wrapping up the template

Combining all the individual layers we created, we should end up with a template like this


<!-- bottombar-sliding-color.component.html -->
<GridLayout *ngIf="tabs && gridColumns" [columns]="gridColumns"
    [height]="config?.height || 60"
    [backgroundColor]="config?.backgroundColor || '#0D0D12'"
    verticalAlignment="bottom">

    <!-- Tab backgrounds -->
    <GridLayout *ngFor="let tab of tabs; let i = index" [col]="i"
        [backgroundColor]="tab.backgroundColor">
    </GridLayout>

    <!-- moving mask -->
    <AbsoluteLayout col="0" [colSpan]="tabs.length">
        <GridLayout columns="auto, auto, auto">
            <GridLayout #leftMask col="0" [height]="config?.height || 60"
            [backgroundColor]="config?.backgroundColor"></GridLayout>
	    <GridLayout #centerMask col="1" [height]="config?.height || 60">
	    </GridLayout>
	    <GridLayout #rightMask col="2" [height]="config?.height || 60"
	      [backgroundColor]="config?.backgroundColor"></GridLayout>
        </GridLayout>
    </AbsoluteLayout>

    <!-- Tab image and text -->
    <GridLayout *ngFor="let tab of tabs; let i = index" [col]="i"
        (tap)="selectTab(i)">
        <Image #tabImage [src]="tab.image" height="25" width="25"></Image>
        <Label #tabLabel [text]="tab.text || ''" fontSize="15" fontWeight="bold" color="#fff" marginBottom="10" verticalAlignment="bottom" horizontalAlignment="center"></Label>
    </GridLayout>

</GridLayout>


Animating the UI elements

Moving on to more exciting part - creating the animations for our tab bar.


Getting references to the UI elements

To start off, we will need to get references to our UI elements to be able to animate them. We will be using @ViewChild and @ViewChildren to get referenes to our UI elements. We will first need to import the following properties from @angular/core


// bottombar-sliding-color.component.ts
import { ViewChild, ElementRef, ViewChildren, QueryList } from "@angular/core";

Followed by the following to get reference the our tab bar components.


// bottombar-sliding-color.component.ts

// to animate the highlight
@ViewChild('centerMask', { static: false }) centerMask: ElementRef;
@ViewChild('leftMask', { static: false }) leftMask: ElementRef;
@ViewChild('rightMask', { static: false }) rightMask: ElementRef;

// to animate image
@ViewChildren('tabImage') tabImage: QueryList<ElementRef>
@ViewChildren('tabLabel') tabLabel: QueryList<ElementRef>



Initializing the Tabs

We will intialize the tabs on the ngAfterViewInit hook (to make sure that we have access to all the elements on our template), setting up the tab elements' widths and x translation and the active/inactive tab stylings.


// bottombar-sliding-color.component.ts

ngAfterViewInit(): void {
    this.initializeTabs();
}

initializeTabs(): void {
	// set up widths
	this.centerMask.nativeElement.width = screen.mainScreen.widthDIPs / this.tabs.length;
	this.leftMask.nativeElement.width = screen.mainScreen.widthDIPs;
	this.rightMask.nativeElement.width = screen.mainScreen.widthDIPs;

	// set up x translations
	this.leftMask.nativeElement.translateX = this.getMaskTranslateX(this.selectedIndex - 1);
	this.rightMask.nativeElement.translateX = this.getMaskTranslateX(this.selectedIndex);

	// set the selectedTab
	this._selectedTab = this.selectedIndex;

	// set the active/inactive image styling
	this.tabImage.toArray().forEach((item, i) => {
	if (i === this.selectedIndex) {
		this.animateCurrentImage(item, false);
	} else {
		this.animatePreviousImage(item, false);
	}
	});

	// set the active/inactive text styliing
	this.tabLabel.toArray().forEach((item, i) => {
		if (i === this.selectedIndex) {
			this.animateCurrentText(item, false);
		} else {
			this.animatePreviousText(item, false);
		}
	});
}



Handling the tap events

Previously, we set up a tap event on our tab contents layer for each individual tab which calls selectTab passing the index of the tapped tab. This function will be handling the animations of the tab and also emitting the selectedIndexChange event. We added a shouldEmit variable to this function to give more flexibility if we don't want to emit an event (this might be useful if we want to select the tab programmatically and don't want to emit the event).


I split the animation into 3 parts here, one for animating the tab mask (animateTab), one for the inactive to active tab (animateCurrentTab), and one for the active to inactive tab (animatePreviousTab). We will go into more detail on each one in the next sections.


selectTab(index: number, shouldEmit: boolean = true): void {
	const previousTab = this._selectedTab;
	// only animate if user taps on a different tab
	if (index !== this._selectedTab) {
		this._selectedTab = index;
		this.animateTab(index, previousTab);
		this.animateToActiveTab(index);
		this.animateToInactiveTab(previousTab);
	}
	// always emit the new index, unless explicitly stated not to
	if (shouldEmit) {
		this.selectedIndexChange.emit(this._selectedTab);
	}
}



AnimateTab

This will be used to animate the mask to create the chase effect. As mentioned earlier, to do this, we will be animating the left and right independently.


Lets start with creating a helper function to calculates the translateX for each index


getMaskTranslateX(index: number): number {
	return (index * screen.mainScreen.widthDIPs / this.tabs.length) - screen.mainScreen.widthDIPs;
}

We will be using Animations and AnimationDefinition to run our animations, lets import them in our component file


import { Animation, AnimationDefinition } from "tns-core-modules/ui/animation";

The animation will be divided into two parts, we will call it the stretch and the chase.

The Stretch

First, we want to move the left or right mask first depending on the direction. If we are going from left to right, we want the right mask to animate first, and vice versa. To do this, we will set a condition on the target in our animation definition to select one of the mask depending on if the current index is greater than the previous index. This will create a stretch effect, revealing the base layers between the previous tab and the current tab.


startStretchAnimation(index: number, previousIndex: number): Promise<void> {
	const definitions = new Array<AnimationDefinition>();
	const def: AnimationDefinition = {
		target: index > previousIndex ? this.rightMask.nativeElement : this.leftMask.nativeElement,
		translate: { x: this.getMaskTranslateX(index), y: 0 },
		duration: 150
	}
	definitions.push(def);
	let animationSet = new Animation(definitions);
	return animationSet.play()
}

The Chase

The stretch will be followed by the chase animation, moving the other mask to follow the animated mask. To do this we will set the target to the opposite of the previous target.


startChaseAnimation(index: number, previousIndex: number): Promise<void> {
	const definitions = new Array<AnimationDefinition>();
	const def: AnimationDefinition = {
		target: index > previousIndex ? this.leftMask.nativeElement : this.rightMask.nativeElement,
		translate: { x: this.getMaskTranslateX(index), y: 0 },
		duration: 200
	}
	definitions.push(def);
	const animationSet = new Animation(definitions);
	return animationSet.play();
}

Chaining the animations

Since both animations returns a promise, we can just chain them together using then


animateTab(index: number, previousIndex: number): Promise<void> {
	return this.startStretchAnimation(index, previousIndex).then(() => {
		return this.startChaseAnimation(index, previousIndex);
	});
}



Active/Inactive Tab Animation

In addition to the moving mask, we also have a active and inactive state for each tab with some transition animations. Both of these looks similar, but for the purposes of this tutorial, I will split it into 2 sections - AnimateToActiveTab and AnimateToInactiveTab.


Lets start with creating some helper functions to get the image and text elements at a particular index, which we will use to get the elements to be animated in both active and inactive animations.


getImage(index: number) {
	if (index < this.tabs.length) {
		return this.tabImage.toArray()[index];
	} else {
		return this.tabImage.toArray()[0];
	}
}

getText(index: number) {
	if (index < this.tabs.length) {
		return this.tabLabel.toArray()[index];
	} else {
		return this.tabLabel.toArray()[0];
	}
}

AnimateToActiveTab

To create the inactive to active tab animation we will create 2 separate functions

  • animateCurrentImage - to animate the image (image gets enlarged)
  • animateCurrentText - to animate the text (text slides up)

Note that I added an optional animate parameter to both the functions, this is just to make these more easily configurable, if we want to show the active state without the animation (for example, on page load when the tab displays the default selected tab)


// enlarge current tab image
animateCurrentImage(arg: any, animate: boolean = true): Promise<any> {
	if (animate) {
		return arg.nativeElement.animate({
			scale: { x: 1.3, y: 1.3 },
			translate: { x: 0, y: - 10 },
			curve: AnimationCurve.cubicBezier(1, .02, .45, .93),
			duration: 200
		});
	} else {
		arg.nativeElement.scaleX = 1.3;
		arg.nativeElement.scaleY = 1.3;
		arg.nativeElement.translateX = 0;
		arg.nativeElement.translateY = - 10;
		return Promise.resolve();
	}
}

// animate in current tab text
animateCurrentText(arg: any, animate: boolean = true): Promise<any> {
	if (animate) {
		return arg.nativeElement.animate({
			translate: { x: 0, y: 0 },
			curve: AnimationCurve.cubicBezier(1, .02, .45, .93),
			duration: 200
		});
	} else {
		arg.nativeElement.translateX = 0;
		arg.nativeElement.translateY = 0;
		return Promise.resolve();
	}
}

Now that we have those setup, we will just have to chain them together using promises.


animateToActiveTab(index: number): Promise<void> {
	return this.animateCurrentImage(this.getImage(index)).then(() => {
		return this.animateCurrentText(this.getText(index))
	});
}

AnimateToInactiveTab

I won't go into much detail with this one since this is very similar to how we did the inactive to active tab, except the animations are reversed. So heres the code:

// shrink previous tab image back to original size
animatePreviousImage(arg: any, animate: boolean = true): Promise<any> {
	if (animate) {
		return arg.nativeElement.animate({
			scale: { x: 1, y: 1 },
			translate: { x: 0, y: 0 },
			curve: AnimationCurve.cubicBezier(1, .02, .45, .93),
			duration: 200
		});
	} else {
		arg.nativeElement.scaleX = 1;
		arg.nativeElement.scaleY = 1;
		arg.nativeElement.translateX = 0;
		arg.nativeElement.translateY = 0;
		return Promise.resolve();
	}
}

// animate out previous tab text
animatePreviousText(arg: any, animate: boolean = true): Promise<any> {
	if (animate) {
		return arg.nativeElement.animate({
			translate: { x: 0, y: 80 },
			curve: AnimationCurve.cubicBezier(1, .02, .45, .93),
			duration: 200
		});
	} else {
		arg.nativeElement.translateX = 0;
		arg.nativeElement.translateY = 80;
		return Promise.resolve();
	}
}

animateToInactiveTab(index: number): Promise<void> {
	return this.animatePreviousImage(this.getImage(index)).then(() => {
		return this.animatePreviousText(this.getText(index))
	});
}

Something to note here is the animateToInactiveTab looks identical to the animateToActiveTab in the previous section, passing the index to our helper functions. The index being passed into the animateToInactiveTab is the index of the previous active tab whereas the index being passed into the animateToActiveTab is the index of the active tab.



Using our Custom Tab Component

To give us more control and flexibility with the tab contents and properties, we set up our custom tab component to accept a few configurations as inputs from the parent. Let start by defining those and passing it to our tab component.


Import Tabs interface to define the properties of the each tab and pass it to the bottombar component as tabs and BottombarSlidingColorConfig interface to define the styling of the bottombar and pass it in as config.

// home.component.ts

import { Tabs, BottombarSlidingColorConfig } from "./bottombar-sliding-color/bottombar-sliding-color-config";

// set up each tab item
tabs: Tabs = [
  { image: 'path/to/image', text: 'Home', backgroundColor: "#7B6BE7" },
  { image: 'path/to/image', text: 'Notifications', backgroundColor: "#5A73DD" },
  { image: 'path/to/image', text: 'Profile', backgroundColor: "#6CDFDF" },
  ...
];

// set up tab configuration
bottombarConfig: BottombarSlidingColorConfig = {
  height: 80,
  backgroundColor: '#10101F'
};

<!-- home.component.html -->

<GridLayout rows="*, auto">
    
    <!-- main content -->
    ...
    
    <!-- bottom tabs -->  
    <app-bottombar-sliding-color [tabs]=" tabs" [selectedIndex]="0"
        [config]="bottombarConfig"
        (selectedIndexChange)="onTabIndexChange($event)">
    </app-bottombar-sliding-color>
  
</GridLayout>

To update our Tabs component (which we created at the beggining of the post) with the new tab index, we just have to update the variable which was binded to the Tab's selectedIndex property whenever our custom tab bar emits the new selected index.


onTabIndexChange(index: number): void {
	// this is the property binded to the Tabs component
	this.selectedIndex = index;
}



Wrapping up

Thats it, we just created a custom tab bar that can be used with nativescript's Tab components. Checkout the full source code here and a playground version here



William is a Nativescript Ambassador based in Chicago, IL. He is a front end enthusiast with a love for all things web.

Did you enjoy this? Share it!

Take control of your career. Build JavaScript mobile apps.