This is a guest post by Bradley Gore. If you'd like to work with us on a guest post, tweet us at @NativeScript.
If you're building mobile apps, it doesn't take long until you have a need for a tabbed interface. Tabbed interfaces are widely used across the mobile landscape, and apps built with NativeScript are no exception. Fortunately, there's a TabView component. Let's get to know it, shall we?
*Video tutorial here
To get started with TabView
, we simply need to declare it in our XML file and then give it some items to populate the view with:
<Page loaded="onPageLoaded">
<TabView>
<TabView.items>
<TabViewItem title="Left Tab">
<TabViewItem.view>
<Label text="Hi there, I'm Left Tab's Content!" />
</TabViewItem.view>
</TabViewItem>
<TabViewItem title="Right Tab">
<TabViewItem.view>
<Label text="Howdy, I'm Right Tab's Content!" />
</TabViewItem.view>
</TabViewItem>
</TabView.items>
</TabView>
</Page>
As you can see, we have a component and it contains a set of items, TabView.items
. Each item is a TabViewItem
and it has a title
and a view
. The title is what appears in the tab, and the TabViewItem.view
is what appears in the screen when that tab is selected. There are a slew of other properties ( selectedIndex
,selectedBackgroundColor
, tabsBackgroundColor
, etc...) you can give, but this is the crux of what's needed to make the TabView
.
And it's that simple.. or, so it seems.
Even though this is the standard example given —actually, it's only a slightly modified copy from NativeScript's Cookbook entry— the truth of the TabView
is that it's one of those things that's easy to learn and hard to master. Things can start to get a bit tricky when you have larger views, more complex view models for each tab, etc... so we're going to have to get to know our TabView
a bit better in order to bend it to our will use it successfully :)
Let's walk through these 4 key ingredients to effectively using the TabView
:
TabView
component, doesn't mean they all have to live together... Get in the habit of doing this, and you'll be glad you did. I'll show you how :)TabView
has some unique characteristics (at least on Android, which is where I focus for now) when it comes to when TabViewItem
views are loaded and unloaded, and knowing this can make all the difference.Tip: The techniques I show for managing view models can actually apply to more than just TabView
- they can apply to any views you component-ize :)
This is the first key to effectively working with the TabView
, and the benefits will be felt immediately. Let's roll with the example we started with - we have two TabViewItems
(Left Tab and Right Tab) that reside in the TabView
, and we want to separate those out. Let's start with the file structure, for that will help other things make sense as we go. Here's how I'd structure it:
After getting the files structured, I'll use XML Namespaces to bring in our views (see these NativeScript docs or this previous post of mine for a primer on how that works). So, my XML for myTabView.xml would look like this:
<Page>
<TabView>
<TabView.items>
<TabViewItem title="Left Tab" xmlns:LeftTab="myTabView/leftTab">
<TabViewItem.view>
<LeftTab:leftTab />
</TabViewItem.view>
</TabViewItem>
<TabViewItem title="Right Tab" xmlns:RightTab="myTabView/rightTab">
<TabViewItem.view>
<RightTab:rightTab />
</TabViewItem.view>
</TabViewItem>
</TabView.items>
</TabView>
</Page>
It may not look like a big change now, but imagine each of those two tabs had dozens of lines of XML each and then tack on a couple more TabViewItem
in the same condition and the difference becomes clear.
Now that our views are separated out, they each have their own XML and JS files - this means that setting a bindingContext
for the entire tab's view is exactly how you would for any page, and can wire up loaded
, unloaded
, etc... events for our view. But, how will our view know if it's the selected view? Fortunately, the TabView
has an event for this and we can listen to it in our individual views. Here's what our leftTab.xml and leftTab.js files would look like:
*Note - I'll use TypeScript in the example just so you can see the types of each object, but the pure JavaScript would work the same way.
<!-- leftTab.xml -->
<stack-layout loaded="onViewLoaded" unloaded="onViewUnloaded">
<!-- entirety of the tab's content -->
</stack-layout>
//leftTab.ts (TypeScript)
import {TabView, SelectedIndexChangedEventData} from 'ui/tab-view';
import {View} from 'ui/core/view';
import {EventData} from "data/observable";
const THIS_TAB_IDX: number = 0; //index at which this tab resides
var thisView: View;
var isThisTabSelected: boolean = false;
function onTabChanged(evt: SelectedIndexChangedEventData) {
isThisTabSelected = evt.newIndex === THIS_TAB_IDX;
}
export function onViewLoaded(args: EventData) {
//args.object is the reference to the <stack-layout> view in leftTab.xml
thisView: View = <View>args.object,
let tabView: TabView = thisView.parent;
isThisTabSelected = tabView.selectedIndex === THIS_TAB_IDX;
tabView.on(TabView.selectedIndexChangedEvent, onTabChanged);
}
export function onViewUnloaded(args: EventData) {
//TabView's items' views get loaded/unloaded as user navigates, so clean up handlers, etc...
let tabView: TabView = thisView.parent;
tabView.off(TabView.selectedIndexChangedEvent, onTabChanged);
}
Since any View
instance has a handle to the parent, and since we know in this instance that our parent is a TabView
, we can simply tap into its selectedIndexChangeEvent
. Also, notice that we're unsubscribing our handler in the onViewUnloaded
event - this is super important. Otherwise, if you have enough tabs to warrant loading/unloading views as user navigates, then your event handlers will live on after your View
has unloaded and result in running the same handler function multiple times per tab change event.
If you have a more dynamic situation, like where you may hide/show tabs based on the application state and can't depend on a statically defined THIS_TAB_IDX
, then you can just inspect the selected TabViewItem
based on the new selected index. For instance:
//update import to include TabViewItem
import {TabView, TabViewItem, SelectedIndexChangedEventData} from 'ui/tab-view';
function onTabChanged(evt: SelectedIndexChangedEventData) {
let tabView: TabView = <TabView>thisView.parent, selectedTabViewItem: TabViewItem = tabView.items[evt.newIndex];
isThisTabSelected = selectedTabViewItem.view === thisView;
}
TabViewItem
instance has a reference to its View
on the .view
property, so we can just check equality on our reference to the view, and what the selected item says its view is. There is also the .title
property that could be used as well.
One interesting thing about the TabView
is how it handles (un)loading of each TabViewItem
's view. When the TabView
is first loaded, it loads up all of the items' views right then. However, as you navigate through the tabs, it starts unloading tabs that are more than one tab away from the presently selected tab, as well as loading any tabs that are only one tab away that have been unloaded. For instance, if you have 4 tabs here's a run-down of what happens:
TabView Load: All 4 TabViewItem
views are loaded
Select Third Tab: First tab gets unloaded
Select Fourth Tab: Second tab gets unloaded
Select Third Tab: Second tab gets loaded
Select Second Tab: First tab gets loaded + Fourth tab gets unloaded
This truly is one of those things that are easier to show than to tell, so I proved this out in detail in the video accompanying this post. Being armed with this knowledge can make such a huge difference in how you interact with the TabView
.
This part was hard for me at first. I struggled with this, posted questions to Nathanael Anderson in the NativeScript slack, and tried a multitude of techniques before finally being comfortable with this. At the end of the day, it's actually fairly simple if you understand these two key concepts:
bindingContext
to any view you want to.View
instance has an _onBindingContextChanged
(inherited from ui/core/bindable) that you can monkey-patch if needed.
Let's explore the use case where we have a Page
with a TabView
, and when the Page
is being navigated to it sets the binding context, and one of the TabViewItem
components will use something from that binding context for its view's binding context. Let's say the data is not asynchronously supplied, and we can use the first technique of just using a different bindingContext
for a specific View
than that of the entire Page
.
XML and JS for the Page
<!--myTabView.xml-->
<Page navigatingTo="onPageNavigatingTo">
<TabView>
<TabView.items>
<TabViewItem title="Widgets List" xmlns:WidgetsListTab="myTabView/widgetsList">
<TabViewItem.view>
<WidgetsListTab:widgetsList />
</TabViewItem.view>
</TabViewItem>
<!-- more tab view items... -->
</TabView.items>
</TabView>
</Page>
export function onPageNavigatingTo(arg) {
//set the binding context for the page
arg.object.bindingContext = {
widgets: [
{id: 1, name="Turtles", price: 10, qty: 276, sold: 120},
//all teh other widgetz goez here
]
};
}
XML and JS for the Widgets List Tab Item
<!--myTabView/widgetsList/widgetsList.xml-->
<stack-layout loaded="onTabViewLoaded">
<grid-layout rows="auto, auto, auto" columns="2*, *" id="widgetsSummary">
<label text="Total Widgets" />
<label col="1" text="{{ widgetsCount }}" />
<label row="1" col="0" text="Avg Widget Price" />
<label row="1" col="1" text="{{ avgWidgetPrice }}" />
<label row="2" col="0" text="Best Selling Widget" />
<label row="2" col="1" text="{{ bestSellingWidget.name }}" />
</grid-layout>
</stack-layout>
export function onTabViewLoaded(arg) {
let thisView = arg.object,
allWidgets = thisView.page.bindingContext.widgets,
widgetsSummaryView = thisView.getViewById('widgetsSummary'),
avgWidgetPrice,
bestSellingWidget;
avgWidgetPrice = allWidgets.reduce((sum, w) => sum + w.price, 0)) / allWidgets.length;
bestSellingWidget = allWidgets
.sort((a, b) => a.sold < b.sold ? 1 : a.sold > b.sold ? -1 : 0 )[0]
// set the bindingContext for the <grid-layout>
widgetsSummaryView.bindingContext = {
widgetsCount: allWidgets.length,
avgWidgetPrice: avgWidgetPrice,
bestSellingWidget = bestSellingWidget
};
}
And that works quite nicely if your data isn't being loaded async (i.e. from a web service) or the View
needing a separate bindingContext
derived from the Page
is not the initially selected tab. But, what do you do if your data is async and the initially selected tab needs to derive some data from the bindingContext
of the Page
? Here's where we can monkey-patch the _onBindingContextChanged
handler. Let's take the same example as above, so we can just show the updated JavaScript files:
Page
import * as myWidgetSvc from './dal/widgetsvc';
export function onPageNavigatingTo(arg) {
myWidgetSvc
.getAllTheWidgetz()
.then(widgets => arg.object.bindingContext = {widgets: widgets});
}
We don't know when this data will actually come in - and if you're segragating your tab items' views they won't know when they can safely try to derive their data from the Page
data, so here's what we can do:
JS for the Widgets List Tab Item
var thisView;
export function onTabViewLoaded(arg) {
thisView = arg.object;
if (thisView.page.bindingContext && thisView.page.bindingContext.widgets) {
populateViewData();
} else {
let origBindingContextChanged = thisView._onBindingContextChanged;
thisView._onBindingContextChanged = (old, newContext) => {
origBindingContextChanged(old, newContext);
thisView._onBindingContextChanged = origBindingContextChanged;
populateViewData();
};
}
}
function populateViewData() {
//set the bindingContext of widgetSummary after deriving necessary data
}
This works because the dataContext you set for the Page applies to any items contained within it. This is actually true for any View. So our View gets its callback called because the promise from the Page navigatingTo event was resolved and its dataContext was updated, but we basically intercepted it and then change the context of our child item - the widgetSummary.
Now, after all that, hopefully you're more intimately familiar with the TabView
than when you started! I hope this was helpful! If it was, please drop a comment letting me know, or connect with me on twitter! Also, feel free to check out my other posts on NativeScript!
-Bradley