Understanding Tabs with Xamarin.Forms

Recently, I decided to address a long standing issue with my Xamarin.Forms version of Score Predict: overflow.  As you may know, Xamarin.Forms allows  you to use the TabbedPage control to define a page that shows tabs for children, using the appropriate control on each platform.  On Windows Phone this is the Panorama control, Android shows the horizontal tabs across the top, and iOS goes to the typical TabBarController with the tabs running along the bottom.

Because I will expect the control to show 6 tabs I will be invoking the overflow scenario on both Android and iOS (Windows Phone doesnt have a concept of this).  With Android, I had no problems, and using my default approach things came out perfectly, with the tab bar supporting horizontal scrolling to see additional items.  Where I ran into problems was with iOS and its More Navigation Controller.  This controller is an auto-generated view that renders as a TableViewController within a NavigationController.  Unfortunately, with my default approach I ended up with this:

DoubleTab

Pretty ugly.  The problem here is that I had made the choice to use a NavigationPage as the wrapper for all pages.  I was doing this so I could more finely control the navigation, taking pages out as needed.  This will not work with Tab pages.  The solution is to embed the Navigation page wrappers into each child.  This can be done declaratively.

   <pages:ScorePredictNavigationPage Title="This Week">
      <x:Arguments>
        <pages:ThisWeekPage>
          <pages:ThisWeekPage.ToolbarItems>
            <ToolbarItem Name="Logout" Command="{Binding LogoutCommand}">
              <ToolbarItem.Order>
                <OnPlatform x:TypeArguments="ToolbarItemOrder"
                       WinPhone="Secondary"
                       Android="Secondary"
                       iOS="Primary" />
              </ToolbarItem.Order>
            </ToolbarItem>
          </pages:ThisWeekPage.ToolbarItems>
        </pages:ThisWeekPage>
      </x:Arguments>
    </pages:ScorePredictNavigationPage>

ScorePredictNavigationPage is just an empty class that I created which inherits from NavigationPage and specifies the various colors to keep the style consistent.  By doing this for each of your pages you will be able to use Navigation and Toolbar items for that tab; and yes the Toolbars will change as the user changes tabs depending on the items available.

This is where I encountered my first snafu.  It is the Navigation Bar, on Android, which also provides the action bar.  Therefore, if your page is not within a NavigationPage you do not get the Action bar, and thus the options in it.  However, on iOS, the overflow will inherently provide a Navigation page, further if you wrap these options in Navigation pages the sizing and handling by the More is quirky.  The solution that I found was I needed to ensure that I provided pages which had the Navigation chrome on Android, but did not have it on Windows Phone and iOS.

My first attempt, was store each version of the page in the local Resource Dictionary and reference it as a StaticResource.  This did not as <OnPlatform> does not seem to work as a child of TabbedPage; it became clear I was going to have to do this in code.  So, I used the Device.OS enumeration to determine which platform I was on and add the appropriate page to end of Children.  While this worked on Windows Phone and Android, I found that it caused some quirky behavior and overwrote my visual customizations for the More Navigation Controller (future blog post).  I then dialed down my approach and specified the NavigationPageless About and History pages in Children.  In code, I removed these pages and added the NavigationPage wrapped versions when the OS is Android.

        protected override void OnAppearing()
        {
            if (Device.OS == TargetPlatform.Android)
            {
                Children.Remove(Children.Last());       // remove about
                Children.Remove(Children.Last());       // remove history

                Children.Add((Page)Resources["ChromedHistoryPage"]);        // add history
                Children.Add((Page)Resources["ChromedAboutPage"]);  // add about
            }
        }

As you can see, we remove the last two pages from Children and then reference the pages by their Resource Key, I show the About page definition below:

<TabbedPage.Resources>
    <ResourceDictionary>
      <pages:ScorePredictNavigationPage Title="About" x:Key="ChromedAboutPage">
        <x:Arguments>
          <pages:AboutPage Title="About">
            <pages:AboutPage.ToolbarItems>
              <ToolbarItem Name="Logout" Command="{Binding LogoutCommand}">
                <ToolbarItem.Order>
                  <OnPlatform x:TypeArguments="ToolbarItemOrder"
                    WinPhone="Secondary"
                    Android="Secondary"
                    iOS="Primary" />
                </ToolbarItem.Order>
              </ToolbarItem>
            </pages:AboutPage.ToolbarItems>
          </pages:AboutPage>
        </x:Arguments>
      </pages:ScorePredictNavigationPage>
    </ResourceDictionary>
  </TabbedPage.Resources>

You really have to love the flexibility that Xaml gives you, and the fact I can use it for iOS makes me beyond happy.  Now you might be wondering if the user will see a flash before the two final tabs are added.  Not so far in my experiments, however, with the way Android renders tabs, its unlikely the user could get to the end before they loaded; maybe on a wider screen.  Either way, I do not recommend this approach for large amounts of tabs.  Hopefully the Forms team will make this approach irrelevant in the future.

With our chromeless pages iOS works great and Windows Phone remains ambivalent as ever, Android removes these pages and uses its own.

The Login Process

I mentioned previously that I was forced to alter my logon process because I could not contain the TabbedPage in the same Navigation Page as the Logon flow pages, something I did previously which allowed me to alter the navigation stack; this was a subject of past blog posts.  The rule in Forms is you cannot have two Navigation Pages in the same navigation context.  Since there was no question the Logon process was going to need a NavigationPage and I needed Navigation Pages within each tab to provide the toolbar it was clear I needed to show a modal.

Originally, I did not show a modal because I am still at a loss on how to properly use the hardware back button on Android and Windows Phone with Forms.  Figuring out how to get the app to exit from the Logon modal would be essential; its an integral part of the UI experience for Windows Phone and   According to this forum thread (http://forums.xamarin.com/discussion/29934/xamarin-forms-1-3-0-released/p1) Jason Smith (PM for the Forms team) implies that if you return “false” from OnBackButtonPressed it will exit the app.  I was not able to confirm this.

In lieu of getting this supposed functionality to work, I decided that I would simply have the app just straight terminate.  This is not allowed on iOS, however, since it will only ever be triggered by a hardware back button, it is irrelevant for Apple users.  To accomplish on Android and Windows Phone requires diving into the platform itself, so we use the “bridge pattern”.  I created interface called IKillApplication with a single method KillApp.  I then had MainPage (Windows Phone) and MainActivity (Android) implement this interface and pass a reference to themselves to the constructor for Score Predict.

// Android
public class MainActivity : FormsApplicationActivity, IKillApplication
{
   protected override void OnCreate(Bundle bundle)
   {
      base.OnCreate(bundle);
      Forms.Init(this, bundle);

      LoadApplication(new ScorePredictApplication(new DroidNavigator(), this,
         new ServiceInjectionModule(), new DroidInjectionModule()));
   }

   public void KillApp()
   {
      Process.KillProcess(Android.OS.Process.MyPid());
   }
}

// Windows Phone
public partial class MainPage : IKillApplication
{
   public MainPage()
   {
      InitializeComponent();
      Forms.Init();

      var app = new ScorePredictApplication(new PhoneNavigator(), this,
         new ServiceInjectionModule(), new PhoneInjectionModule());
      LoadApplication(app);
   }

   public void KillApp()
   {
      App.Current.Terminate();
   }
}

In my final version, I will likely isolate this to its own class and pass an instance of that class rather than using the Page or Activity, this this suffices for explanation.  I wire this up so that each ViewModel can override the BackButtonPressed method.  They can then call the KillApp to terminate the application.  By supporting this, I am able to terminate the application from the Login modal and thus prevent the user from returning to the application without authentication.

Unfortunately this introduced a new problem.  Before, since Main page would never be visible until the user logged in, I could safely assume my custom lifecycle events would not fire when the user was not authorized; this is now no longer the case.  In fact, for both Android and iOS the This Week page (the first page in the tab set) will “appear” despite being covered immediately by the modal; on Windows Phone the first two pages in the tab set “appear” this so the Panorama can show a bit of the second page (this is the common effect in Windows Phone to indicate where more content exists so the user goes there).

Since the “appear” event would only get fired once, I needed a way to communicate to these view models that they should attempt to load their data.  Solution: a quick and dirty message bus, based on Caliburn.Micro.  Here is a sample of its usage:

        public ThisWeekPageViewModel(IThisWeekService thisWeekService, IBus messageBus,
            IReadUserSecurityService readUserSecurityService)
        {
            ThisWeekService = thisWeekService;
            MessageBus = messageBus;
            ReadUserSecurityService = readUserSecurityService;
        }

        protected override async void Refresh()
        {
            await LoadWeekDataAsync();
        }

        public override async void OnShow()
        {
            MessageBus.ListenFor<LoginCompleteMessage>(LoadWeekData);
            if (ReadUserSecurityService.ReadUser() != null)
            {
                await LoadWeekDataAsync();
            }
        }

        private async void LoadWeekData()
        {
            await LoadWeekDataAsync();
        }

And now, the publish:

        private async void Login()
        {
            try
            {
                MessageBus.Publish<LoginCompleteMessage>();
                await Navigation.PopModalAsync(true);
            }
            catch (Exception ex)
            {
                DialogService.Alert("Login did not succeed. Please try again");
            }
        }

I kept it dirt simple.  The basic mechanism is the Bus stores a Dictionary with a List of Actions keyed by a Type.  When that type is published each Action is invoked.  This enables me to send these events from anywhere in the application; although I do not intend to do that very often, and kept it simple for this simple case.

Its important to remember that only the first tab in Android and iOS will need to be reloaded following a login, and the first two in Windows Phone.

I definitely learned some very important lessons from this experimentation and I think Score Predict is better for it.  I think the team has some ends to shore up to make this more pleasant to program against.  I find myself really diving into custom renderers for quite a few things, especially on iOS where it seems things are just not as far along as the other two platforms.

If you are curious to see the full source, you can always check out the full Score Predict source here (https://github.com/xximjasonxx/ScorePredictForms) – @jfarrell if you have any questions.

Cheers

6 thoughts on “Understanding Tabs with Xamarin.Forms

  1. Hi Jason, thanks for taking the time to write these great detailed articles! I’ve enjoyed reading them as I’m deciding if I want to use Xamarin.Forms or stick with Xam.iOS and Xam.Android until the forms stuff has matured some more.

    By the way, you should return true without calling the base class implementation in your override of the OnBackButtonPressed method if you want back button presses to be ignored. You may have already worked that out since you originally wrote about it, but FYI just in case.

    Your problems with the login workflow had just about scared me away from forms, but I tried this code and it seems to work with the latest forms library (1.4.1.6349). I first assign a LoginPage instance to the App’s MainPage property in the App’s constructor. Then I assign a HomePage instance to it once the user has logged in. Next, I save whatever info is needed to restore the app state using the App’s persisted Properties dictionary in the OnSleep method, and restore the app state in the OnStart and OnResume methods. I only tested this on iOS and Android (not on WinPhone), but it seemed to work as hoped on both of those platforms.

    This version doesn’t animate the transition from the login page to the home page, but it shouldn’t be too hard to do something like fading the login page to a solid color, then fading the home page from the same color.

    Hopefully this will work for you as well, and the WP formatting engine will be kind to my code. If not, I can email it to you.

    using System;
    using Xamarin.Forms;

    namespace XamFormsTest
    {
    class LoginPage : ContentPage
    {
    public LoginPage()
    {
    Button loginButton = new Button
    {
    Text = “Log in”
    };
    loginButton.Clicked += OnLoginButtonClicked;

    Title = “Login Page”;
    Content = new StackLayout
    {
    VerticalOptions = LayoutOptions.Center,
    Children =
    {
    loginButton
    }
    };
    }

    protected void OnLoginButtonClicked(Object sender, EventArgs e)
    {
    App.IsLoggedIn = true;

    (Application.Current as App).MainPage = new HomePage();
    }
    }

    class HomePage : ContentPage
    {
    public HomePage()
    {
    Title = “Home Page”;
    Content = new StackLayout
    {
    VerticalOptions = LayoutOptions.Center,
    Children =
    {
    new Label
    {
    Text = “Here’s the home page!”
    }
    }
    };
    }
    }

    public class App : Application
    {
    public static bool IsLoggedIn;

    public App()
    {
    // The root page of your application
    MainPage = new LoginPage();
    }

    protected override void OnStart()
    {
    // Handle when your app starts
    if (IsLoggedIn && MainPage is LoginPage)
    {
    // Restore the previous app state saved in OnSleep
    RestoreAppState();
    }
    }

    protected override void OnSleep()
    {
    // Do something to save the state of the app so it can be restored
    if (IsLoggedIn)
    {
    SaveAppState();
    }
    }

    protected override void OnResume()
    {
    // Handle when your app resumes
    if (IsLoggedIn && MainPage is LoginPage)
    {
    // Restore the previous app state saved in OnSleep
    RestoreAppState();
    }
    }

    void SaveAppState()
    {
    // Do something to save the state of the app so it can be restored
    var didSomething = true;
    }

    void RestoreAppState()
    {
    // Just initing a new home page for this test
    MainPage = new HomePage();
    }
    }
    }

    Like

    • Thanks for this, I will look into it. I have been talking a lot with Jason Smith who is in charge of the Forms team at Xamarin. Lots of new stuff coming and he admitted that the back button feature to exit the app got lost in a Git commit and didnt make it until recently.

      Cheers,

      Jason

      Like

  2. Hi there, great article. I was wondering if you have ever tried pushing pages onto the navigation stack once you are viewing one of the “overflow” tabs on iOS. It appears to not work correctly. Just wondering if you had noticed this and/or found a workaround.

    Like

  3. Hi there, I really appreciate this article. I was wondering if you have experienced any issues with the Navigation stack in one of the “overflow” tabs on iOS. It seems as though attempting to push a page to the stack in that scenario does not work. Just wondering if you had noticed and/or found a workaround.

    Like

    • William,

      So with regard to the overflow tabs on iOS, I admitted I have not tested them thoroughly. Actually, I have been so busy lately its been hard for me to continue my dive. I will definitely look into it as my application relies heavily on a tabbed interface.

      Cheers,

      Jason

      Like

Leave a comment