Rotating a list in Xamarin.Forms using CollectionView

Rotating a list in Xamarin.Forms using CollectionView

ยท

6 min read

One of the advantages of using a CollectionView rather than a ListView is the ability to change the layout and orientation of the items in the collection. In this article I look at how we can use this feature to improve the look of our Xamarin.Forms apps in Portrait and Landscape orientation. The code for this article is available on GitHub: irongut/RotateCollectionDemo

Page Layout

I started this as an experiment for one of my existing Xamarin.Forms apps so I created a slightly simplified copy of its page layout with different icons and text. With Halloween ๐ŸŽƒ approaching I searched FlatIcon for suitably spooky images ๐Ÿ‘ป and copied some ghoulish text ๐ŸงŸโ€โ™‚๏ธ from Zombie Ipsum. I also implemented a dark theme, using my favourite colour palette Nord.

Portrait Screenshot

In the original app the list of items is a ListView which only looks good in Portrait mode, I want to change it to a CollectionView and respond to the user rotating the device so the page always looks great.

Data

I'm only really interested in the UI so I'm using a simple DataItem model with string properties for the text and the embedded resource URIs of the SVG images.

public class DataItem
{
    private string name;
    public string Name { get => name; set => SetProperty(ref name, value); }

    private string firstLine;
    public string FirstLine { get => firstLine; set => SetProperty(ref firstLine, value); }

    private string secondLine;
    public string SecondLine { get => secondLine; set => SetProperty(ref secondLine, value); }

    private string firstImage;
    public string FirstImage { get => firstImage; set => SetProperty(ref firstImage, value); }

    private string secondImage;
    public string SecondImage { get => secondImage; set => SetProperty(ref secondImage, value); }

    protected bool SetProperty<T>(ref T field, T newValue)
    {
        if (!Equals(field, newValue))
        {
            field = newValue;
            return true;
        }

        return false;
    }
}

The constructor of MainViewModel generates a random list of DataItem, adding them to Data - an ObservableCollection which is bound to CollectionView.ItemsSource.

public ObservableCollection<DataItem> Data { get; }

public MainViewModel()
{
    Data = new ObservableCollection<DataItem>();
    GenerateData();
}

private void GenerateData()
{
    Data.Clear();
    for (int i = 0; i < random.Next(6, 10); i++)
    {
        DataItem item = new DataItem();
        if (random.Next(2) < 1)
        {
            item.Name = "Zombie Boy";
            item.FirstLine = "Pestilentia est plague haec";
            item.SecondLine = "Summus brains sit";
            item.FirstImage = "resource://RotateCollectionDemo.Resources.zombie.boy.svg";
        }
        else
        {
            item.Name = "Zombie Girl";
            item.FirstLine = "Decaying ambulabat mortuos";
            item.SecondLine = "Apathetic malus voodoo";
            item.FirstImage = "resource://RotateCollectionDemo.Resources.zombie.girl.svg";
        }
        item.SecondImage = random.Next(2) < 1
            ? "resource://RotateCollectionDemo.Resources.zombie.hand.svg"
            : "resource://RotateCollectionDemo.Resources.pumpkin.svg";
        Data.Add(item);
    }
}

Detecting Device Orientation

In order to change the layout of a page based on device orientation we need to know the initial orientation and the new orientation after the user rotates their device.

Xamarin.Essentials DeviceDisplay.MainDisplayInfo contains a Rotation property and OnMainDisplayInfoChanged event that is triggered whenever any screen metric changes. This seems like the obvious choice but unfortunately the event is unreliable on Android, often returning the previous orientation; see xamarin/Essentials#1355.

A more reliable alternative is to override the Page.OnSizeAllocated method, check if the width or height have changed and call a ViewModel method to update the UI. Using OnSizeAllocated handles both initial orientation and when the user rotates their device. This app only has one page so I've implemented OnSizeAllocated in the code behind, in a normal project I would create a base page which overrides OnSizeAllocated and a base ViewModel with an abstract SetLayout() method.

public partial class MainPage : ContentPage
{
    private double width;
    private double height;

    private readonly MainViewModel vm = new MainViewModel();

    public MainPage()
    {
        InitializeComponent();
        BindingContext = vm;
    }

    protected override void OnSizeAllocated(double width, double height)
    {
        base.OnSizeAllocated(width, height);
        if (this.width != width || this.height != height)
        {
            this.width = width;
            this.height = height;
            vm.SetLayout();
        }
    }
}

In the ViewModel I use Xamarin.Essentials DeviceDisplay.MainDisplayInfo to determine if the device is in Portrait or Landscape orientation by comparing the Width and Height of the screen.

public void SetLayout()
{
    DisplayInfo displayInfo = DeviceDisplay.MainDisplayInfo;
    if (displayInfo.Width > displayInfo.Height)
    {
        // landscape
    }
    else
    {
        // portrait
    }
}

That updates the UI when the user rotates their device but what about when they resume the app from the background? To ensure the page updates on resume I've overridden the Application.OnResume method in App.xaml.cs and used the Xamarin.Forms MessagingCenter to send an AppOnResume message. The MessagingCenter class implements a publish-subscribe pattern with weak references, allowing loosely coupled message-based communication between components.

protected override void OnResume()
{
    MessagingCenter.Send(this, "AppOnResume");
}

My ViewModel subscribes to the AppOnResumemessage and calls SetLayout() when it receives the message.

public void OnAppearing()
{
    MessagingCenter.Subscribe<App>(this, "AppOnResume", (_) => SetLayout());
}

public virtual void OnDisappearing()
{
    MessagingCenter.Unsubscribe<App>(this, "AppOnResume");
}

Rotating the CollectionView

Once we can detect device orientation changes we can update the UI. In this example I want my CollectionView to be a vertical list for Portrait orientation and a horizontal list for Landscape orientation. Creating a data binding on CollectionView.ItemsLayout was a bit tricky but after some trial and error I realised I needed to create a LinearItemsLayout object for each orientation when the page is created and switch between them when the user rotates their device.

private readonly ItemsLayout portLayout = new LinearItemsLayout(ItemsLayoutOrientation.Vertical) { ItemSpacing = 15 };
private readonly ItemsLayout landLayout = new LinearItemsLayout(ItemsLayoutOrientation.Horizontal) { ItemSpacing = 15 };

private ItemsLayout collectionLayout;
public ItemsLayout CollectionLayout { get => collectionLayout; set => SetProperty(ref collectionLayout, value); }

Just switching the layout isn't enough for a great user experience, my vertical list items would look wrong forced into a horizontal list so I want to change the item template as well. The easiest way to do this is to include a data template for each orientation with a data binding on CollectionView.ItemTemplate to switch between them.

private DataTemplate collectionTemplate;
public DataTemplate CollectionTemplate { get => collectionTemplate; set => SetProperty(ref collectionTemplate, value); }

public void SetLayout()
{
    DisplayInfo displayInfo = DeviceDisplay.MainDisplayInfo;
    if (displayInfo.Width > displayInfo.Height)
    {
        // landscape
        CollectionTemplate = App.Current.Resources.TryGetValue("landTemplate", out object value)
            ? (DataTemplate)value
            : throw new Exception("landTemplate not found!");
        CollectionLayout = landLayout;
    }
    else
    {
        // portrait
        CollectionTemplate = App.Current.Resources.TryGetValue("portTemplate", out object value)
            ? (DataTemplate)value
            : throw new Exception("portTemplate not found!");
        CollectionLayout = portLayout;
    }
}

The data templates can be found in Styles\DefaultStyles.xaml and are added to the application resource dictionary in App.xaml.cs.

Landscape Screenshot

Final Thoughts

Using CollectionView we can easily update the layout of list based pages when the user rotates their device and improve the user experience of our apps. I haven't tested this with .Net MAUI yet but it should work with minimal changes.

For larger screen devices like tablets we could use grid layouts instead of list layouts for an even better user experience.

Combined  Screenshot

Did you find this article valuable?

Support Dave Murray by becoming a sponsor. Any amount is appreciated!

ย