Tab Reselection in Xamarin.Forms, Part 2

In Part 1, we created a Forms project with iOS and Android targets that adds a tab reselect/double-tap event to Xamarin.Forms TabbedPage. There are two things left: handling UWP, and dealing with NavigationPage.

Tab Reselect for UWP

The underlying control for TabbedPage on UWP is the Pivot, and as far as I can tell, there is no event that fires on all tab selection events unless it involves a change of selected tab. So we have a somewhat harder problem than with either iOS or Android.

We can be notified on all Tapped events on the TabbedPage. But that fires for the entire page, not just the tab labels. When that event is raised due to tab selection, the event args tell us that control receiving the tap is the TextBlock for the tab label. However a TextBlock could correspond to a tab label, a Forms Label element, or possibly something else, and the TextBlock objects used as tab labels are not marked in any obvious, supported way (there is no IsTabLabel property, for instance). And there is no way to ask the Pivot for the list of tab label TextBlock elements, at least without excessive digging and reflection. I’d prefer to avoid that.

Let’s look at how Xamarin.Forms sets up the FormsPivot to be used as a TabbedPageRenderer using this ResourceDictionary: https://github.com/xamarin/Xamarin.Forms/blob/8c5fd096945301a2db0d85baf77ce46812a8d89f/Xamarin.Forms.Platform.UAP/TabbedPageStyle.xaml. The page titles go in TextBlocks named TabbedPageHeaderTextBlock. These are the tab labels we’re looking for.

If we look in the source code for the TabbedPageRenderer (https://github.com/xamarin/Xamarin.Forms/blob/master/Xamarin.Forms.Platform.UAP/TabbedPageRenderer.cs), we see it looking for the same string to use in setting the style. Bingo! We can use that name too to tell if we’re looking at a tab label. It’s not pretty, as it relies on a non-public implementation detail, but it will serve the purpose.

The basic approach ends up being similar to iOS, where we keep track of the previously-selected tab, then fire the event whenever that same tab is tapped:

public class MainTabPageRenderer : TabbedPageRenderer
{
    private Xamarin.Forms.Page _prevPage;

    protected override void OnElementChanged(VisualElementChangedEventArgs e)
    {
        base.OnElementChanged(e);

        Control.Tapped += Control_Tapped;
        _prevPage = Control.SelectedItem as Xamarin.Forms.Page;
    }

    private void Control_Tapped(object sender, Windows.UI.Xaml.Input.TappedRoutedEventArgs e)
    {
        var src = e.OriginalSource as TextBlock;
        if (src != null
             && src.Name == "TabbedPageHeaderTextBlock"
             && Element is TabReselectDemo.MainPage)
        {
            var newPage = src.DataContext as Xamarin.Forms.Page;
            if (newPage == _prevPage)
            {
                var page = Element as TabReselectDemo.MainPage;
                page.NotifyTabReselected();
            }
            _prevPage = newPage;
        }
    }
}

I’d feel better if the string “TabbedPageHeaderTextBlock” were a const that could be accessed from the base class, but unfortunately it is private and not even accessible via reflection. This is also going to get called quite often, which is unfortunately from a perf perspective, but that hasn’t been a problem in my uses of it.

NavigationPage

If you aren’t using NavigationPage children for your TabbedPage, that’s all you need to know. If you are using NavigationPage, which is likely, there’s one last detail to cover.

A common thing that people want to do on tab reselection is to pop to the root of the navigation stack for that tab. This is straightforward to implement in the common Forms code:

    private async void MainPage_OnTabReselected(Page curPage)
    {
        if (curPage is NavigationPage)
        {
            var navPage = curPage as NavigationPage;
            await navPage.PopToRootAsync();
            curPage = navPage.CurrentPage;
        }
   ...

And if that’s what you want to do, then great, done!

However if you don’t want to pop to the root, you’ve got a bit of work. The pop to root happens automatically on iOS (the PopToRootAsync call is redundant there). This is a built-in behavior on iOS, but it is possible to override in a ShouldSelectViewController delegate on the iOS renderer:

protected override void OnElementChanged(VisualElementChangedEventArgs e)
{
    base.OnElementChanged(e);

    ShouldSelectViewController = HandleUITabBarSelection;
}

bool HandleUITabBarSelection(UITabBarController tabBarController, UIViewController viewController)
{
    return viewController != tabBarController.SelectedViewController;
}

This just suppresses passing the selection notification along in the case of double-taps/reselects. With that, your iOS app will now behave exactly the same way as Android and UWP with respect to tab double-tap/reselect.

Since the current implementation of the TabbedRenderer does not set ShouldSelectViewController, this approach does not affect the Xamarin-provided behavior of TabbedPage. However like some of the previous work, we are depending on implementation details that could change in the future. So to warn in case things change in future Forms releases, I’d add this before setting the delegate:

System.Diagnostics.Debug.Assert(ShouldSelectViewController == null,
                                "Fix double-tap implementation");

Wrapping Up

I generally try to stick to well-supported extensibility points when working with any framework. I couldn’t completely do that here, so here’s my self-scoring on bad I feel (on a scale of 1-10) about how I implemented this extensibility:

  • iOS: 1 (I don’t feel bad at all. I stuck to pretty standard extensibility points.)
  • iOS (NavigationPage behavior): 2 (Slight risk of future changes needed, but the assert will tell us about that.)
  • Android: 3 (overriding a private, non-virtual method seems fishy, but major changes in version upgrades could cause compile failures)
  • UWP: 4 (copying a string literal from a base class is not great, and changes in version upgrades would cause runtime behavior changes without build breaks)

In the way that I think about things, I prefer using documented interfaces, as those are most likely to be stable when doing upgrades. Failing that, I’d rather have something fail to build after upgrade. That tells me something is wrong right away, and so is fast to discover and deal with.

However sometimes you have to take dependencies on things that won’t break at compile time if the platform changes. In those cases, it’s a tough call. I generally prefer it when things just don’t work rather than crash, but it can depend on the situation.

Anyway, the code is on GitHub: https://github.com/david-js/XFTabReselect. The master branch has the code without NavigationPage support, and the NavigationPage branch (shockingly) adds that in.

Author