Build an Accordion view in Xamarin.Forms

Build an accordion view in Xamarin.Forms

Few weeks ago I posted about absolute and relative layouts. Layouts are called Layouts because they contain children which are placed in a particular way. Xamarin.Forms has a lot of layouts and views to structure pages like grid, table view or list view.

Today I would like to show you how we can use some of these basic views to build an Accordion view.

Here’s a preview of the Accordion view:

accordion

Full source code available on GitHub - https://github.com/Kimserey/AccordionView/blob/master/Accordion/AccordionView.cs

This post will be composed of four steps:

  1. Create a BindableProperty
  2. Define the accordion expandable section
  3. Define the accordion view
  4. Usage sample

1. Create a BindableProperty

As we saw in one of my previous post, Xamarin.Forms works around data bindings. View properties are bound to viewmodel properties.

Default views like Label, Button, ListView or TableView come with the necessary bindable properties like BackgroundColor, TextColor, ItemsSource, ItemTemplate, etc… But today we are going to create our own view so we will need to provide our own BindablePropertys

In order to create a BindableProperty, we can use the static method from the BindableProperty class BindableProperty.Create(...).

public static readonly BindableProperty TitleProperty =
    BindableProperty.Create(
        propertyName: "Title",
        returnType: typeof(string),
        declaringType: typeof(AccordionSectionView),
        propertyChanged: AccordionSectionView.ChangeTitle);

Create(...) takes as argument:

  • the propertyName which needs to match the property which will be set in the class, here Title
  • the returnType which specifies the type returned by the property, here Title returns a type string
  • the declaringType which specifies the type that declares the property, the class itself, here called AccordionSectionView
  • the propertyChanged which specifies a delegate to be executed when the property is changed

Here is an example of a potential propertyChanged delegate:

static void ChangeTitle(BindableObject bindable, object oldValue, object newValue)
{
    //Do something on changed
}

After that we have defined the BindableProperty, we need to define the underlying property - the property containing the concrete value.

public string Title
{
    get { return (string)GetValue(TitleProperty); }
    set { SetValue(TitleProperty, value); }
}

In order to interact with the BindableProperty, Xamarin.Forms provide GetValue and SetValue which gets and sets the bindable property.

2. Define the accordion expandable section

Now that we know how to make our own binding properties, let’s start with the Accordion view.

accordion

Our accordion view will be built of two parts:

  • an expandle section with a header and a list
  • an overall list which will contain the expandable sections

We will start here by the expandable section.

2.1 The Accordion expandable section

The accordion expandable section is the item portion which contains a header and a list and can be expanded or retracted.

expand

For example, in the image above, we can see November section being retracted and when expanded, display a list of dates with prices.

Where do we start?

First thing to do is to figure out which are the bindable properties. In order to figure this, I ask myself, which property of the model will be needed to construct the view? and the answer to that usually are the bindable properties. Here the section needs a title and a list of items which will be used to construct the content.

The whole section can be defined as a StackLayout as it is basically stacking views in a vertical fashion. For the content, I will also use a StackLayout so that everything will be visible. This will cause performance issue if you list is potentially infinite, so you might want to use something else if it doesn’t suit your needs.

With that in mind, we can write the following:

public class AccordionSectionView: StackLayout
{
    private Label _headerTitle = new Label { TextColor = Color.White, VerticalTextAlignment = TextAlignment.Center, HeightRequest = 50 };
    private StackLayout _content = new StackLayout { HeightRequest = 0 };
    private DataTemplate _template;

    public static readonly BindableProperty ItemsSourceProperty =
        BindableProperty.Create(
            propertyName: "ItemsSource",
            returnType: typeof(IList),
            declaringType: typeof(AccordionSectionView),
            defaultValue: default(IList),
            propertyChanged: AccordionSectionView.PopulateList);

    public IList ItemsSource
    {
        get { return (IList)GetValue(ItemsSourceProperty); }
        set { SetValue(ItemsSourceProperty, value); }
    }

    public static readonly BindableProperty TitleProperty =
        BindableProperty.Create(
            propertyName: "Title",
            returnType: typeof(string),
            declaringType: typeof(AccordionSectionView),
            propertyChanged: AccordionSectionView.ChangeTitle);

    public string Title
    {
        get { return (string)GetValue(TitleProperty); }
        set { SetValue(TitleProperty, value); }
    }

    public AccordionSectionView(DataTemplate itemTemplate, ScrollView parent)
    {
        _template = itemTemplate;
        this.Spacing = 0;
        this.Children.Add(_headerTitle);
        this.Children.Add(_content);
    }

    void ChangeTitle()
    {
        _headerTitle.Text = this.Title;
    }

    void PopulateList()
    {
        _content.Children.Clear();

        foreach (object item in this.ItemsSource)
        {
            var template = (View)_template.CreateContent();
            template.BindingContext = item;
            _content.Children.Add(template);
        }
    }

    static void ChangeTitle(BindableObject bindable, object oldValue, object newValue)
    {
        if (oldValue == newValue) return;
        ((AccordionSectionView)bindable).ChangeTitle();
    }

    static void PopulateList(BindableObject bindable, object oldValue, object newValue)
    {
        if (oldValue == newValue) return;
        ((AccordionSectionView)bindable).PopulateList();
    }
}

We created two BindableProperty and defined the headerTitle and the content. Also we are taking a DataTemplate as constructor parameter to create template for the items in content.

var template = (View)_template.CreateContent();
template.BindingContext = item;
_content.Children.Add(template);

Now if you run this you will see the headers but nothing happens when you click on the headers.

2.2 Handle gesture

In order to handle tap gesture on the header, we need to add a GestureRecognizer to the header. This is done using GestureRecognizers.Add(new TapGestureRecognizer(...)).

We need to add the following code in the constructor of the view:

_headerTitle.GestureRecognizers.Add(
    new TapGestureRecognizer
    {
        Command = new Command(async () =>
        {
            if (_isExpanded)
            {
                _content.HeightRequest = 0;
                _content.IsVisible = false;
                _isExpanded = false;
            }
            else
            {
                _content.HeightRequest = _content.Children.Count * 50;
                _content.IsVisible = true;
                _isExpanded = true;

                // Scroll top by the current Y position of the section
                if (parent.Parent is VisualElement)
                {
                    await parent.ScrollToAsync(0, this.Y, true);
                }
            }
        })
    }
);

I have added state indicator _isExpanded. When the section is already expanded, it means that we want to hide the section therefore we set the height to zero and we make the content invisible. When the section is retracted, it means that we want to show the section therefore we set back the height and make the content visible and also scroll the element to the top by scrolling using the current Y position.

Scrolling using Y will bring the section as close as possible to the top.

2.3 Full Accordion expandable section

Here is the full accordion section discribed above.

public class AccordionSectionView: StackLayout
{
    private Label _headerTitle = new Label { VerticalTextAlignment = TextAlignment.Center, HeightRequest = 50 };
    private StackLayout _content = new StackLayout { HeightRequest = 0 };
    private DataTemplate _template;

    public static readonly BindableProperty ItemsSourceProperty =
        BindableProperty.Create(
            propertyName: "ItemsSource",
            returnType: typeof(IList),
            declaringType: typeof(AccordionSectionView),
            defaultValue: default(IList),
            propertyChanged: AccordionSectionView.PopulateList);

    public IList ItemsSource
    {
        get { return (IList)GetValue(ItemsSourceProperty); }
        set { SetValue(ItemsSourceProperty, value); }
    }

    public static readonly BindableProperty TitleProperty =
        BindableProperty.Create(
            propertyName: "Title",
            returnType: typeof(string),
            declaringType: typeof(AccordionSectionView),
            propertyChanged: AccordionSectionView.ChangeTitle);

    public string Title
    {
        get { return (string)GetValue(TitleProperty); }
        set { SetValue(TitleProperty, value); }
    }

    public AccordionSectionView(DataTemplate itemTemplate, ScrollView parent)
    {
        _template = itemTemplate;
        this.Spacing = 0;
        this.Children.Add(_headerTitle);
        this.Children.Add(_content);

        _headerTitle.GestureRecognizers.Add(
            new TapGestureRecognizer
            {
                Command = new Command(async () =>
                {
                    if (_isExpanded)
                    {
                        _content.HeightRequest = 0;
                        _content.IsVisible = false;
                        _isExpanded = false;
                    }
                    else
                    {
                        _content.HeightRequest = _content.Children.Count * 50;
                        _content.IsVisible = true;
                        _isExpanded = true;

                        // Scroll top by the current Y position of the section
                        if (parent.Parent is VisualElement)
                        {
                            await parent.ScrollToAsync(0, this.Y, true);
                        }
                    }
                })
            }
        );
    }

    void ChangeTitle()
    {
        _headerTitle.Text = this.Title;
    }

    void PopulateList()
    {
        _content.Children.Clear();

        foreach (object item in this.ItemsSource)
        {
            var template = (View)_template.CreateContent();
            template.BindingContext = item;
            _content.Children.Add(template);
        }
    }

    static void ChangeTitle(BindableObject bindable, object oldValue, object newValue)
    {
        if (oldValue == newValue) return;
        ((AccordionSectionView)bindable).ChangeTitle();
    }

    static void PopulateList(BindableObject bindable, object oldValue, object newValue)
    {
        if (oldValue == newValue) return;
        ((AccordionSectionView)bindable).PopulateList();
    }
}

3. Define the accordion view

Now that we have the Accordion section, all we need is to implement the Accordion view itself. Since we might have sections with total height exceeding the size of the screen, we will use a ScrollView.

For the Accordion view, we ask the same question, wwhich property of the model will be needed to construct the view? Here we will only need a list which will contain all the sections.

public class AccordionView : ScrollView
{
    private StackLayout _layout = new StackLayout { Spacing = 1 };

    public DataTemplate Template { get; set; }
    public DataTemplate SubTemplate { get; set; }

    public static readonly BindableProperty ItemsSourceProperty =
        BindableProperty.Create(
            propertyName: "ItemsSource",
            returnType: typeof(IList),
            declaringType: typeof(AccordionSectionView),
            defaultValue: default(IList),
            propertyChanged: AccordionView.PopulateList);

    public IList ItemsSource
    {
        get { return (IList)GetValue(ItemsSourceProperty); }
        set { SetValue(ItemsSourceProperty, value); }
    }

    public AccordionView(DataTemplate itemTemplate)
    {
        this.SubTemplate = itemTemplate;
        this.Template = new DataTemplate(() => (object)(new AccordionSectionView(itemTemplate, this)));
        this.Content = _layout;
    }

    void PopulateList()
    {
        _layout.Children.Clear();

        foreach (object item in this.ItemsSource)
        {
            var template = (View)this.Template.CreateContent();
            template.BindingContext = item;
            _layout.Children.Add(template);
        }
    }

    static void PopulateList(BindableObject bindable, object oldValue, object newValue)
    {
        if (oldValue == newValue) return;
        ((AccordionView)bindable).PopulateList();
    }
}

Internally we define a Template which creates an AccordionSectionView.

this.Template = new DataTemplate(() => (object)(new AccordionSectionView(itemTemplate, this)));

Since the binding context will be a list of sections, during population of the list, we set the binding context of the template to the section given.

var template = (View)this.Template.CreateContent();
template.BindingContext = item;
_layout.Children.Add(template);

And that’s it now we have the whole Accordion view.

3.1 Default template for item

Just to make things easier, we can define a default template for the item display.

public class DefaultTemplate : AbsoluteLayout
{
    public DefaultTemplate()
    {
        this.Padding = 5;
        this.HeightRequest = 50;
        var title = new Label { HorizontalTextAlignment = TextAlignment.Start, HorizontalOptions = LayoutOptions.StartAndExpand };
        var price = new Label { HorizontalTextAlignment = TextAlignment.End, HorizontalOptions = LayoutOptions.End };
        this.Children.Add(title, new Rectangle(0, 0.5, 0.5, 1), AbsoluteLayoutFlags.All);
        this.Children.Add(price, new Rectangle(1, 0.5, 0.5, 1), AbsoluteLayoutFlags.All);
        title.SetBinding(Label.TextProperty, "Date", stringFormat: "{0:dd MMM yyyy}");
        price.SetBinding(Label.TextProperty, "Amount", stringFormat: "{0:C2}");
    }
}

4. Usage sample

Let’s see how we can use it in a sample. First we define a model:

public class ShoppingCart
{
    public DateTime Date { get; set; }
    public double Amount { get; set; }
}

public class Section
{ 
    public string Title { get; set; }
    public IEnumerable<ShoppingCart> List { get; set; }
}

public class ViewModel
{
    public IEnumerable<Section> List { get; set; }
}

Then all we need to do is use the AccordionView and pass the DefaultTemplate and set the binding context.

public class AccordionViewPage : ContentPage
{
    public AccordionViewPage()
    {
        this.Title = "Accordion";

        var template = new DataTemplate(typeof(DefaultTemplate));

        var view = new AccordionView(template);
        view.SetBinding(AccordionView.ItemsSourceProperty, "List");
        view.Template.SetBinding(AccordionSectionView.TitleProperty, "Title");
        view.Template.SetBinding(AccordionSectionView.ItemsSourceProperty, "List");

        view.BindingContext =
            new ViewModel
            { 
                List = new List<Section> {
                    new Section
                    {
                        Title = "December",
                        List = new List<ShoppingCart> {
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 }
                        }
                    },
                    new Section
                    {
                        Title = "November",
                        List = new List<ShoppingCart> {
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 }
                        }
                    },
                    new Section
                    {
                        Title = "October",
                        List = new List<ShoppingCart> {
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 }
                        }
                    },
                    new Section
                    {
                        Title = "September",
                        List = new List<ShoppingCart> {
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 }
                        }
                    },
                    new Section
                    {
                        Title = "August",
                        List = new List<ShoppingCart> {
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 }
                        }
                    },
                    new Section
                    {
                        Title = "July",
                        List = new List<ShoppingCart> {
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 },
                            new ShoppingCart { Date = DateTime.UtcNow, Amount = 10.05 }
                        }
                    },
                }
            };
        this.Content = view;
    }
}

public class App : Application
{
    public App()
    {
        MainPage = new NavigationPage(new AccordionViewPage());
    }
}

For simplicity, I removed the style of the header and the icons but you will find it in the GitHub source code.

Full source code available on GitHub - https://github.com/Kimserey/AccordionView/blob/master/Accordion/AccordionView.cs

Conclusion

Today we saw how we could use the basic views from Xamarin.Forms and build a reusable Accordion view. In the process we learnt how to make our own bindable properties and how we could set delegates to be triggered when properties change. We also discovered one of the animation, scrolling, available in Xamarin.Forms. I hope you enjoyed reading this post as much as I enjoyed writing it. If you have any question, leave it here or hit me on Twitter @Kimserey_Lam. See you next time!

Other post you will like!

Comments

  1. Thank you for this fantastic control,its great.Hmm I want to ask a question,can we use this with a viewmodel? I want to bind this ,like this but its not working.Nothing shows.

    ReplyDelete
    Replies
    1. Yes there's no reason why it wouldn't work with a viewmodel. In fact I am using a viewmodel in this example.

      Delete
  2. Does this example work well with an XAML implementation?

    ReplyDelete
  3. Hi, Is there is way to expand only one item at a time?

    ReplyDelete
  4. Hi, Thank you for great control. Is there is way to expand only one item and collapse remaining?

    ReplyDelete

Post a Comment

Popular posts from this blog

Absolute layout and relative layout Xamarin forms

How to fix Xamarin Android app instantly closing with error after deployment