Guest post: Getting Cozy With NativeScript's TabView


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

Getting Started

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,selectedBackgroundColortabsBackgroundColor, 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:

  • Separate files for each tab's view - Trust me on this one. Nobody wants to work in an eleventy-hundred-line XML file containing the markup for 5 separate tabs. Just because they are all under a single 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 :)
  • Communicating tab change events - If we're going to separate our views into their own files (both XML and JS), then we'll want each view instance to be able to know when it's the active view.
  • Managing Loading/Unloading of Views - 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.
  • Managing View Models - Now that our tab's views are separated out, having separate view models for each tab is easy. But, what if we need some model that's common across two or more tabs? Well, I'm glad you asked ;-)

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 :)

Separate Files for TabViewItem Views

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:

  • /app
    • /myTabView
      • myTabView.js
      • myTabView.xml
      • /leftTab
        • leftTab.js
        • leftTab.xml
      • /rightTab
        • rightTab.js
        • rightTab.xml

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.

Communicate Selected Tab Change

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 loadedunloaded, 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;
}

Each 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.

 

Loading and Unloading of TabViewItem Views

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.

Managing View Models

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:

  1. You can attach a separate bindingContext to any view you want to.
  2. Every 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

Author

Dan Wilson

Comments


Comments are disabled in preview mode.
NativeScript is licensed under the Apache 2.0 license
© 2020 All Rights Reserved.