Wednesday, May 24, 2017

Xamarin Forms Device Orientation

I stole this idea shamelessly from Charles Petzold's new book on Xamarin Forms.

I want to create a screen that re-arranges itself when the user changes the orientation of the device. In this example, the text box and slider will be above and below in portrait mode but they will be side-by-side in landscape mode.


We can achieve this by defining a 2x2 grid. The entry element goes into the top left cell and stays there. The slider goes into the bottom left cell in portrait mode or the top right cell when in landscape mode. Any row or column that is empty gets collapsed. We use MVVM to bind the row heights, column widths, and the row and column of the slider element.

It seems to me that the cleanest viewmodel for this requirement contains an IsPortrait property and also a Text property to bind the Entry and Slider elements. I used converters to expand and collapse rows and columns based on the value of the IsPortrait property.

I'm also using a new feature introduced in C# 5.0 called "CallerMemberName" which allows a parameter to default to the name of the calling member. I will be using it in a generic SetProperty method in my ViewModel which will greatly reduce the amount of coding required for the properties.

Let's start with a new Xamarin.Forms project called SampleOrientation. I'm using Visual Studio 2017. Remember to select the PCL option after clicking [OK].


Let's start by creating our ViewModel. Create a new folder in the portable project called ViewModels and add a new class called BaseViewModel. I plan on using this base class for all my view models. It contains all the code to implement and simplify INotifyPropertyChanged. If you don't know what that is, now would be a good time to do some research. We will call SetProperty from our property setters. Note it returns true if the value changed so we can do other things if we need to.

using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace SampleOrientation.ViewModels
{
    class BaseViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;
        protected bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
        {
            if (Object.Equals(storage, value)) return false;
            storage = value;
            OnPropertyChanged(propertyName);
            return true;
        }

        private void OnPropertyChanged(string propertyName)
        {
            PropertyChangedEventHandler handler = PropertyChanged;
            if (handler != null)
                PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

Now we add a new class file called SampleOrientationViewModel to the ViewModels folder. It derives from BaseViewModel and adds the properties needed by our application. We can now add our two properties to the view model class. Note how compact the setters are now. We also add an OnSizeChanged event handler which gets called whenever the size of the page is changed (such as when the orientation changes). It will either set or clear the IsPortrait property.

using System;
using Xamarin.Forms;

namespace SampleOrientation.ViewModels
{
    class SampleOrientationViewModel : BaseViewModel

    {
        private bool _IsPortrait = true;
        public bool IsPortrait
        {
            get { return _IsPortrait; }
            set { SetProperty(ref _IsPortrait, value); }
        }

        private String _Text = "0";
        public String Text
        {
            get { return _Text; }
            set { SetProperty(ref _Text, value); }
        }

        public void OnSizeChanged(object sender, EventArgs e)
        {
            IsPortrait = (((ContentPage)sender).Height > ((ContentPage)sender).Width);
        }
    }
}

Let's jump over to MainPage.xaml and add the reference to our new view model. We need to add a new namespace declaration to the ContentPage tag and also start a resource dictionary. That allows us to set the page's binding context. Once we have done this, all binding paths will be resolved in our view model.

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:SampleOrientation"
             xmlns:vm="clr-namespace:SampleOrientation.ViewModels"
             BindingContext="{StaticResource vm}"
             x:Class="SampleOrientation.MainPage">
    <ContentPage.Resources>
        <ResourceDictionary>
            <vm:SampleOrientationViewModel x:Key="vm"/>
        </ResourceDictionary>
    </ContentPage.Resources>

Before we go any further, let's write our converters. We need a converter to control the height of row 1, one to control the width of column 1, others to control the row and column that the slider will be in, and one more to convert between the Entry.Text and the Slider.Value. That's five in total.

Add a new class file to the portable project called Converters. Converters can be shared between pages of a project but view models tend not to be. Add the following converter classes to Converters.cs. It looks like a lot of code, but you can see it's very repetitive. The converters are in the SampleOrientation namespace so they can be references by the local xmlns that Visual Studio already declared for us.

using System;
using System.Globalization;
using Xamarin.Forms;

namespace SampleOrientation
{
    class GetSliderRow : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            // In portrait mode the slider goes into row 1 (below the Entry)
            return (bool)value ? 1 : 0;
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }
    class GetSliderColumn : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            // In landscape mode the slider goes into column 1 (to the right of the Entry)
            return (bool)value ? 0 : 1;
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }

    class StringToDouble : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            // Parse a text into a double if possible
            double d;
            if (double.TryParse((string)value, out d))
                return d;
            else
                return 0;
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            return value.ToString();
        }
    }

    class GetRow1Height : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            // In portrait mode grid row 1 must be visible
            if ((bool)value)
                return new GridLength(1, GridUnitType.Star);
            else
                return new GridLength(0, GridUnitType.Absolute);
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }

    class GetCol1Width : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            // In portrait mode grid col 1 must be hidden
            if ((bool)value)
                return new GridLength(0, GridUnitType.Absolute);
            else
                return new GridLength(1, GridUnitType.Star);
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }
}

I get a reference to the view model and register its OnSizeChanged event handler for the page's SizeChanged event. I would prefer to do this in XAML but I can't figure out how to declare an event handler that is not in MainPage.xaml.cs. Yet.

I also added a try/catch around InitializeComponent because in Visual Studio 2017 all XAML errors are swallowed before they get to me and rethrowing them solves the problem. Here is the full MainPage.xaml.cs.

using SampleOrientation.ViewModels;
using Xamarin.Forms;

namespace SampleOrientation
{
    public partial class MainPage : ContentPage
    {
        SampleOrientationViewModel vm;
        public MainPage()
        {
            try
            {
                InitializeComponent();
                vm = (SampleOrientationViewModel)this.BindingContext;
                this.SizeChanged += vm.OnSizeChanged;
            }
            catch { throw; }
        }
    }
}

Now it is time to finish the XAML. Here is MainPage.xaml in its entirety.

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:SampleOrientation"
             xmlns:vm="clr-namespace:SampleOrientation.ViewModels"
             BindingContext="{StaticResource vm}"
             x:Class="SampleOrientation.MainPage">
    <ContentPage.Resources>
        <ResourceDictionary>
            <local:GetSliderRow x:Key="GetSliderRow"/>
            <local:GetSliderColumn x:Key="GetSliderColumn"/>
            <local:GetRow1Height x:Key="GetRow1Height"/>
            <local:GetCol1Width x:Key="GetCol1Width"/>
            <local:StringToDouble x:Key="StringToDouble"/>
            <vm:SampleOrientationViewModel x:Key="vm"/>
        </ResourceDictionary>
    </ContentPage.Resources>
    <Grid RowSpacing="0" ColumnSpacing="0">
        <Grid.RowDefinitions>
            <RowDefinition Height="*"></RowDefinition>
            <RowDefinition Height="{Binding IsPortrait, Converter={StaticResource GetRow1Height}}"></RowDefinition>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"></ColumnDefinition>
            <ColumnDefinition Width="{Binding IsPortrait, Converter={StaticResource GetCol1Width}}"></ColumnDefinition>
        </Grid.ColumnDefinitions>
        <Frame Grid.Row="0" Grid.Column="0" OutlineColor="DarkBlue">
                <Entry Keyboard="Numeric" Text="{Binding Text}" VerticalOptions="Center" HorizontalOptions="FillAndExpand" BackgroundColor="PaleGoldenrod"></Entry>
        </Frame>
        <Frame Grid.Row="{Binding IsPortrait, Converter={StaticResource GetSliderRow}}" 
               Grid.Column="{Binding IsPortrait, Converter={StaticResource GetSliderColumn}}">
            <Slider Value="{Binding Text, Converter={StaticResource StringToDouble}}" VerticalOptions="Center" HorizontalOptions="FillAndExpand"></Slider>
        </Frame>
    </Grid>
</ContentPage>

Note that none of the elements have names. You don't need them. Everything can be done through binding. Other than the location of the SizeChanged event handler, everything is pure MVVM.

No comments:

Post a Comment