Creating an Animated Accordion Control with Xamarin Forms.

My mountain weather app now has a new page, which displays the weather forecast for specific mountain locations. I am again using the Met Office data point service to retrieve a 5 day mountain specific forecast. This provides me with 3 hourly forecast periods each containing 14 forecast elements like Temperature, Wind Speed etc. I am using a similar layout to my area forecast using my TabbedView with a tab for each of the 5 days. I am reusing my ItemsView to display the 3 hourly periods as a horizontal scrolling list. The result looks like this.

Screen Shot 2015-04-29 at 20.01.45

Notice however that I am only displaying some of the elements so it doesn’t look too crowded. What I really want to be able to do is to tap on one of the periods and have it expand a detail section to display all the elements for that period. I also want this to fill the whole width of the screen together with the panel I just tapped. I want the period panel to scroll to the side and for the detail section to slide out. This is very similar to how the BBC weather app works. As you can see below:

IMG_1191  IMG_1192  IMG_1193

Another requirement here is for the tapped panel to either slide to the left or to the right depending on where its original position is. If it happens to be close to the right of the scroll area then I want it to slide to the right and for the detail to slide out to the left. If it were to always slide to the left then it would look a little odd because it would have to scroll most to the screen width. So it is more natural for it to slide to the closest edge.

That’s quite a requirement to tackle. Sounds like I need an Accordion control, which doesn’t come with Xamarin Forms, unsurprisingly. I couldn’t find one out there so I wrote my own.

It seemed like a natural choice to reuse my ItemsView as the base class, which already provides properties for the ItemsSource and ItemTemplate. Let’s have another quick look at what ItemsView looks like. You can find more about this in my previous post here.

using System;
using System.Linq;
using Xamarin.Forms;
using System.Collections;
using System.Collections.Generic;
using System.Windows.Input;
using Silkweb.Mobile.Core.Interfaces;

namespace Silkweb.Mobile.Core.Views
{
    public class ItemsView : Grid
    {
        protected ScrollView ScrollView;
        protected readonly ICommand SelectedCommand;
        protected readonly StackLayout ItemsStackLayout;

        public ItemsView()
        {
            ScrollView = new ScrollView
            {
                Orientation = ScrollOrientation.Horizontal
            };

            ItemsStackLayout = new StackLayout
            {
                Orientation = StackOrientation.Horizontal,
                Padding = new Thickness(0),
                Spacing = 0,
                HorizontalOptions = LayoutOptions.FillAndExpand
            };

            ScrollView.Content = ItemsStackLayout;
            Children.Add(ScrollView);

            SelectedCommand = new Command<object>(item =>
            {
                var selectable = item as ISelectable;
                if (selectable == null) return;
                
                SetSelected(selectable);
                SelectedItem = selectable.IsSelected ? selectable : null;
            });

            PropertyChanged += (sender, e) =>
            {
                if (e.PropertyName == "Orientation")
                {
                    ItemsStackLayout.Orientation = ScrollView.Orientation == ScrollOrientation.Horizontal ? StackOrientation.Horizontal : StackOrientation.Vertical;
                }

            };
        }

        protected virtual void SetSelected(ISelectable selectable)
        {
            selectable.IsSelected = true;
        }

        public bool ScrollToStartOnSelected { get; set; }

        public event EventHandler SelectedItemChanged;

        public static readonly BindableProperty ItemsSourceProperty =
            BindableProperty.Create<ItemsView, IEnumerable>(p => p.ItemsSource, default(IEnumerable<object>), BindingMode.TwoWay, null, ItemsSourceChanged);

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

        public static readonly BindableProperty SelectedItemProperty =
            BindableProperty.Create<ItemsView, object>(p => p.SelectedItem, default(object), BindingMode.TwoWay, null, OnSelectedItemChanged);

        public object SelectedItem
        {
            get { return (object)GetValue(SelectedItemProperty); }
            set { SetValue(SelectedItemProperty, value); }
        }

        public static readonly BindableProperty ItemTemplateProperty =
            BindableProperty.Create<ItemsView, DataTemplate>(p => p.ItemTemplate, default(DataTemplate));

        public DataTemplate ItemTemplate
        {
            get { return (DataTemplate)GetValue(ItemTemplateProperty); }
            set { SetValue(ItemTemplateProperty, value); }
        }

        private static void ItemsSourceChanged(BindableObject bindable, IEnumerable oldValue, IEnumerable newValue)
        {
            var itemsLayout = (ItemsView)bindable;
            itemsLayout.SetItems();
        }

        protected virtual void SetItems()
        {
            ItemsStackLayout.Children.Clear();

            if (ItemsSource == null)
                return;

            foreach (var item in ItemsSource)
                ItemsStackLayout.Children.Add(GetItemView(item));

            SelectedItem = ItemsSource.OfType<ISelectable>().FirstOrDefault(x => x.IsSelected);
        }

        protected virtual View GetItemView(object item)
        {
            var content = ItemTemplate.CreateContent();
            var view = content as View;
            if (view == null) return null;

            view.BindingContext = item;

            var gesture = new TapGestureRecognizer
            {
                Command = SelectedCommand,
                CommandParameter = item
            };

            AddGesture(view, gesture);

            return view;
        }

        protected void AddGesture(View view, TapGestureRecognizer gesture)
        {
            view.GestureRecognizers.Add(gesture);

            var layout = view as Layout<View>;

            if (layout == null)
                return;

            foreach (var child in layout.Children)
                AddGesture(child, gesture);
        }

        private static void OnSelectedItemChanged(BindableObject bindable, object oldValue, object newValue)
        {
            var itemsView = (ItemsView)bindable;
            if (newValue == oldValue)
                return;

            var selectable = newValue as ISelectable;
            itemsView.SetSelectedItem(selectable ?? oldValue as ISelectable);
        }

        protected virtual void SetSelectedItem(ISelectable selectedItem)
        {
            var items = ItemsSource;

            foreach (var item in items.OfType<ISelectable>())
                item.IsSelected = selectedItem != null && item == selectedItem && selectedItem.IsSelected;

            var handler = SelectedItemChanged;
            if (handler != null)
                handler(this, EventArgs.Empty);
        }

    }
}

I have amended ItemView slightly so that it derives from Grid rather than ScrollView directly as before. You will see why I have done this in a moment.

My Accordion control will extend this and add a new BindableProperty for the ItemDetailTemplate, which will provide the template for the detail. I also handle the changed event for this so that I can create the content of the template when it is set. Here’s the code so far:

 
    public class Accordion : ItemsView
    {
       private View _detailView;

        public static readonly BindableProperty ItemDetailTemplateProperty =
            BindableProperty.Create<Accordion, DataTemplate>(p => p.ItemDetailTemplate, default(DataTemplate)
            , BindingMode.TwoWay, null, ItemDetailTemplateChanged);

        public DataTemplate ItemDetailTemplate
        {
            get { return (DataTemplate)GetValue(ItemDetailTemplateProperty); }
            set { SetValue(ItemDetailTemplateProperty, value); }
        }

        private static void ItemDetailTemplateChanged(BindableObject bindable, DataTemplate oldvalue, DataTemplate newvalue)
        {
            var Accordion = bindable as Accordion;
            if (Accordion == null) return;
            Accordion.CreateDetailView();
        }

        private void CreateDetailView()
        {
            var itemDetail = ItemDetailTemplate.CreateContent() as View;

            _detailView = new ScrollView
            {
                Content = itemDetail
            };
        }
    }

Notice I am wrapping the item detail in a ScrollView. This allows me to naturally scroll the item detail into view within its own scroll view. This took me ages to figure out, but as you will see shortly it works very effectively.

Next I need to handle the expanding and collapsing of the item when it is tapped. For this I need to override the SetSelectedItem method, which I have made virtual in the ItemsView. This is called when an item is tapped and its IsSelected property gets set. Note that all the items must implement ISelectable. For this to work correctly I also need to toggle the IsSelected property of the item. I do this by overriding the SetSelected method of ItemsView and toggling the value like this:

        protected override void SetSelected(ISelectable selectable)
        {
            selectable.IsSelected = !selectable.IsSelected;
        }

The code in the SetSelectItem override needs to check whether the item is selected or not and animate the collapse or expanding of the detail and position the tapped item. I found this a real challenge and spent days figuring this out. There are a number of animation extension methods available in Xamarin Forms including Animate. This allows you to supply an animation action that will get invoked as the animation runs and is passed the percentage from 0 to 1 of the animation sequence. What I needed to figure out was how to expand the detail content and scroll the item to its nearest edge both at the same time.

Firstly I also need to know the current scroll position so that I can scroll the item relative to the current scroll position. I discovered that there isn’t a property on ScrollView that tells me this. However, if I handle the Scrolled event the ScrolledEventArgs does have the scroll x and y positions, so I capture this and save it as a private member.

        private ScrolledEventArgs _scrolledEventArgs;

        public Accordion()
        {
            ScrollView.Scrolled += AccordionViewScrolled;
        }

        private void AccordionViewScrolled(object sender, ScrolledEventArgs e)
        {
            _scrolledEventArgs = e;
        } 

Now that I have this information let’s take a look at the code for the SetSelectItem method:

        protected override void SetSelectedItem(ISelectable selectedItem)
        {
            base.SetSelectedItem(selectedItem);

            var element = ItemsStackLayout.Children.FirstOrDefault(x => x.BindingContext == selectedItem);
            if (element == null) return;

            var index = ItemsStackLayout.Children.IndexOf(element);
            var scrollPosition = _scrolledEventArgs != null ? _scrolledEventArgs.ScrollX : 0;

            if (selectedItem.IsSelected)
            {
                var scrollDistance = element.X - scrollPosition; // the distance to scroll
                _lastScrollPosition = scrollPosition;

                if (Device.OS != TargetPlatform.WinPhone)
                {
                    if (ItemsStackLayout.Children.Contains(_detailView))
                        ItemsStackLayout.Children.Remove(_detailView);
                }
                else
                    CreateDetailView();

                _detailView.BindingContext = selectedItem;

                if (scrollDistance < Width / 2)
                    ItemsStackLayout.Children.Insert(index + 1, _detailView);
                else
                    ItemsStackLayout.Children.Insert(index, _detailView);

                var width = Width - element.Width; // width to expand to

                _detailView.Animate("expand",
                    x =>
                    {
                        var change = width * x;
                        _detailView.WidthRequest = change;

                        var position = scrollPosition + (scrollDistance * x);
                        ScrollView.ScrollToAsync(position, 0, false);

                    }, 0, 400, Easing.Linear, (d, b) =>
                    {
                        _overlay.IsVisible = true;
                    });
            }
            else
            {
                var width = _detailView.WidthRequest; // width to collapse
                var scrollDistance = scrollPosition - _lastScrollPosition; // the distance to scroll

                _detailView.Animate("collapse",
                    x =>
                    {
                        var change = width * x;
                        _detailView.WidthRequest = width - change;

                        var position = scrollPosition - (scrollDistance * x);
                        ScrollView.ScrollToAsync(position, 0, false);

                    }, 0, 400, Easing.Linear, (d, b) =>
                    {
                        ItemsStackLayout.Children.Remove(_detailView);
                        _detailView.Parent = null;
                        _overlay.IsVisible = false;
                    });
            }
        }

As you can see there is a lot going on here, so let’s break it down.

Firstly I get the actual visual element bound to the selected item and work out what its index is.

var element = ItemsStackLayout.Children.FirstOrDefault(x => x.BindingContext == selectedItem);
if (element == null) return;

var index = ItemsStackLayout.Children.IndexOf(element);

And I get the current scroll position if there is one (we may not have scrolled at all).

var scrollPosition = _scrolledEventArgs != null ? _scrolledEventArgs.ScrollX : 0;

Then I check if the item is selected. Let’s take a look at the selected code which expands the detail view.

Firstly I calculate the distance I need to scroll from the item position to the current scroll position. I also need to save the current scroll position so that I can scroll back to it later when I collapse the item.

var scrollDistance = element.X - scrollPosition; // the distance to scroll
_lastScrollPosition = scrollPosition;

Next I need to remove the item detail view if it already exists in the item StackLayout. This ensures that I am only reusing the item detail view once. I also discovered a strange issue with Windows Phone reusing the details view which caused it to throw an exception so I have a device specific case for this that recreates the detail view for Windows Phone.

if (Device.OS != TargetPlatform.WinPhone)
{
    if (ItemsStackLayout.Children.Contains(_detailView))
        ItemsStackLayout.Children.Remove(_detailView);
}
else
    CreateDetailView();

I then set the BindingContext of the detail view to the selectedItem and insert the detail view into the item StackLayout. This needs to be inserted after the index of the current element if we have selected an item before the centre point or before it if the selected item is at or beyond the centre point.

 
_detailView.BindingContext = selectedItem;

if (scrollDistance < Width / 2)
    ItemsStackLayout.Children.Insert(index + 1, _detailView);
else
    ItemsStackLayout.Children.Insert(index, _detailView);

Before I animate I need to calculate the width that I need to expand the detail to. This is simply the total width less the width of the selected element.

 
var width = Width - element.Width; // width to expand to

Now for the fun part. The Animate method needs to set the width of the detail view to the above width multiplied by the animation percentage passed to the Animate method. I also need to scroll the selected item by the scroll distance I calculated above by multiplying the scroll distance by the animation percentage and adding it to the current scroll position. I then call the ScrollToAsync method on the ScrollView to scroll it to this position with the animate flag set to false because I am doing the animation.

    _detailView.Animate("expand",
        percent =>
        {
            var change = width * percent;
            _detailView.WidthRequest = change;

            var position = scrollPosition + (scrollDistance * percent);
            ScrollView.ScrollToAsync(position, 0, false);

        }, 0, 400, Easing.Linear, (d, b) =>
        {
            _overlay.IsVisible = true;
        }); 

I set the animation time to 400ms and specify Linear Easing. Notice there is also another action that is the completed action. I will cover this shortly.

Now let’s take a look at the collapse code.

    var width = _detailView.WidthRequest; // width to collapse
    var scrollDistance = scrollPosition - _lastScrollPosition; // the distance to scroll

    _detailView.Animate("collapse",
        percent =>
        {
            var change = width * percent;
            _detailView.WidthRequest = width - change;

            var position = scrollPosition - (scrollDistance * percent);
            ScrollView.ScrollToAsync(position, 0, false);

        }, 0, 400, Easing.Linear, (d, b) =>
        {
            ItemsStackLayout.Children.Remove(_detailView);
            _detailView.Parent = null;
            _overlay.IsVisible = false;
        });

This time the width to collapse is just the width of the detail view. The distance to scroll is the current scroll position less the last scroll position, which we saved when the item was expanded. This is the distance I need to scroll the item back to.

The Animate method calculates the amount to reduce the width by and subtracts this from the width and sets this to the detail view WidthRequest. I then work out the amount I need to scroll using the animation percentage and call the ScrollToAsync method as before. Finally notice that the animation completed action then removes the detail view from the items StackLayout and sets its parent to null to ensure it isn’t related anymore.

Finally, notice the code which sets _overlay.IsVisible to false. When the Item is expanded I don’t want to be able to scroll the items. I want the ScrollView to be fixed until tapping it again collapses the item. I deliberated over this and tried all sorts of crazy things before I discovered a very simple solution. All I needed to do was to position a transparent control over the top of the scroll view, which stops it from interacting with any gestures. This overlay however does need to handle tap gestures so that it can collapse the item. I therefore create a ContentView as a private member with a tap genture, which I add to the Accordion with its visibility initially set to false. This is the reason why the ItemsView is now a Grid so that I can add this overlay. I guess this also allows any kind of adorner to be added. Here’s the code in the constructor.

        private readonly ContentView _overlay;

        public Accordion()
        {
            ...

            _overlay = new ContentView
            {
                IsVisible = false
            };

            AddGesture(_overlay, new TapGestureRecognizer(view =>
            {
                _overlay.IsVisible = false;
                SelectedCommand.Execute(SelectedItem);
            }));

            Children.Add(_overlay);
        }

The tap gesture sets the visibility of the overlay to false and then calls the SelectCommand to toggle the IsSelected of the item. I then set the visibility of the overlay accordingly in the animation completed call-backs of the Animate methods.

Phew! That took quite some explaining. I hope you managed to follow along. Let’s take a look at the Accordion code in full.

using System.Linq;
using Silkweb.Mobile.Core.Interfaces;
using Xamarin.Forms;

namespace Silkweb.Mobile.Core.Views
{
    public class Accordion : ItemsView
    {
        private View _detailView;
        private ScrolledEventArgs _scrolledEventArgs;
        private double _lastScrollPosition;
        private readonly ContentView _overlay;

        public Accordion()
        {
            ScrollView.Scrolled += AccordionViewScrolled;

            _overlay = new ContentView
            {
                IsVisible = false
            };

            AddGesture(_overlay, new TapGestureRecognizer(view =>
            {
                _overlay.IsVisible = false;
                SelectedCommand.Execute(SelectedItem);
            }));

            Children.Add(_overlay);
        }

        private void AccordionViewScrolled(object sender, ScrolledEventArgs e)
        {
            _scrolledEventArgs = e;
        }

        protected override void SetSelected(ISelectable selectable)
        {
            selectable.IsSelected = !selectable.IsSelected;
        }

        public static readonly BindableProperty ItemDetailTemplateProperty =
            BindableProperty.Create<Accordion, DataTemplate>(p => p.ItemDetailTemplate, default(DataTemplate)
            , BindingMode.TwoWay, null, ItemDetailTemplateChanged);

        public DataTemplate ItemDetailTemplate
        {
            get { return (DataTemplate)GetValue(ItemDetailTemplateProperty); }
            set { SetValue(ItemDetailTemplateProperty, value); }
        }

        private static void ItemDetailTemplateChanged(BindableObject bindable, DataTemplate oldvalue, DataTemplate newvalue)
        {
            var accordionView = bindable as Accordion;
            if (accordionView == null) return;
            accordionView.CreateDetailView();
        }

        private void CreateDetailView()
        {
            var itemDetail = ItemDetailTemplate.CreateContent() as View;

            _detailView = new ScrollView
            {
                Content = itemDetail
            };
        }

        protected override void SetSelectedItem(ISelectable selectedItem)
        {
            base.SetSelectedItem(selectedItem);

            var element = ItemsStackLayout.Children.FirstOrDefault(x => x.BindingContext == selectedItem);
            if (element == null) return;

            var index = ItemsStackLayout.Children.IndexOf(element);
            var scrollPosition = _scrolledEventArgs != null ? _scrolledEventArgs.ScrollX : 0;

            if (selectedItem.IsSelected)
            {
                var scrollDistance = element.X - scrollPosition; // the distance to scroll
                _lastScrollPosition = scrollPosition;

                if (Device.OS != TargetPlatform.WinPhone)
                {
                    if (ItemsStackLayout.Children.Contains(_detailView))
                        ItemsStackLayout.Children.Remove(_detailView);
                }
                else
                    CreateDetailView();

                _detailView.BindingContext = selectedItem;

                if (scrollDistance < Width / 2)
                    ItemsStackLayout.Children.Insert(index + 1, _detailView);
                else
                    ItemsStackLayout.Children.Insert(index, _detailView);

                var width = Width - element.Width; // width to expand to

                _overlay.IsVisible = true;

                _detailView.Animate("expand",
                    percent =>
                    {
                        var change = width * percent;
                        _detailView.WidthRequest = change;

                        var position = scrollPosition + (scrollDistance * percent);
                        ScrollView.ScrollToAsync(position, 0, false);

                    }, 0, 400, Easing.Linear, (d, b) =>
                    {
                        _overlay.IsVisible = true;
                    });
            }
            else
            {
                var width = _detailView.WidthRequest; // width to collapse
                var scrollDistance = scrollPosition - _lastScrollPosition; // the distance to scroll

                _detailView.Animate("collapse",
                    percentage =>
                    {
                        var change = width * percentage;
                        _detailView.WidthRequest = width - change;

                        var position = scrollPosition - (scrollDistance * percentage);
                        ScrollView.ScrollToAsync(position, 0, false);

                    }, 0, 400, Easing.Linear, (d, b) =>
                    {
                        ItemsStackLayout.Children.Remove(_detailView);
                        _detailView.Parent = null;
                        _overlay.IsVisible = false;
                    });
            }
        }
    }
}

There’s actually not a huge amount of code really for what I am doing. Now let’s use this in my app. Here’s the Xaml for my mountain site forecast view.

<ContentView ...>

  <ContentView.Resources>
    <ResourceDictionary>
      
      <DataTemplate x:Key="itemItemplate">
        <views:SiteForecastPeriodView WidthRequest="75" IsVisible="{Binding IsVisible}" />
      </DataTemplate>

      <DataTemplate x:Key="detailItemplate">
        <views:SiteForecastPeriodDetailView />
      </DataTemplate>

    </ResourceDictionary>
  </ContentView.Resources>

  <AbsoluteLayout>
    <StackLayout Spacing="0"
                 AbsoluteLayout.LayoutBounds="0.0, 1, 1, 0.95"
                 AbsoluteLayout.LayoutFlags="All">
      <StackLayout Padding="5,0,5,0">
        <Label Text="{Binding Title}" Style="{StaticResource largeHeaderStyle}" />
        <Label Text="{Binding Altitude, StringFormat='{0} meters'}" Style="{StaticResource textStyle}" />
        <Label Text="{Binding Day}" Style="{StaticResource mediumHeaderStyle}" FontAttributes="None" />
      </StackLayout>

      <coreViews:Accordion Padding="0,10,0,0" ItemsSource="{Binding Periods}"
                           ItemTemplate="{StaticResource itemItemplate}"
                           ItemDetailTemplate="{StaticResource detailItemplate}"
                           VerticalOptions="FillAndExpand"/>
    </StackLayout>
  </AbsoluteLayout>
</ContentView> 

As you can see there’s not a lot to it. I define two data templates for both the item and the item detail templates. These simply display the views I have created for each template so that it keeps this view nice and clean. I then declare an Accordion within my view and set the templates accordingly. Notice to that the ItemsSource is bound to a property called Periods on my view model. I’m not going to show you the view model as I don’t think it’s that relevant here.

Now let’s spin this up and see how it looks on all 3 platforms with these videos.



Wow! That looks really great I’m sure you’ll agree. And again this was all done without the use of any Custom Renders and is pure Xamarin Forms.

You may have noticed the use of AbsoluteLayout and the curious use of the LayoutBounds. This will be the subject of my next post.

30 thoughts on “Creating an Animated Accordion Control with Xamarin Forms.

      1. Hi ,
        Jonathan Yates

        Can you give me the code of accordian only? I am not be able run it succesfully.

        And which equipment is required to confirm it .

        I am stuck on that point i want to use the accordian.

        Like

      1. No I haven’t yet but would be very interested to hear anyone who has. However, what I have done is remove the ScrollViews and Animation for Android as it doesn’t work that well. In the case of Android I just add/remove the detail and hide the other items except for the selected. I noticed the Android version of a similar accordion on the BBC Weather App does exactly the same.

        Like

    1. Ok, I solved the xaml problem. I replaced “SiteForecastPeriodView” and “SiteForecastPeriodDetailView” with my own viewcell which just contains a label just for testing purpose.
      Am I doing it right? I’m not able to see anything. Except the title of the area.

      Note: I replaced images with 1×1 px image 😀

      Like

  1. Hi Jonathan!

    I was researching your blog post about Xamarin and the MOUNTAIN WEATHER app (nice post, btw, congrats!) and I’m specifically interested on the ACCORDION control.

    In your blog post you say you can download the code from here (http://1drv.ms/1ELEuQL), but that link doesn’t work anymore.

    Jonathan Yates – OCTOBER 12, 2015 AT 11:15 AM
    Just create the accordion in code and set the DataTemplates accordingly by creating them in code also.

    Jonathan Yates – MAY 1, 2015 AT 6:54 AM
    I did, but I’m afraid this is no longer available. However you can download the code from my final post of my 10 part series here (http://1drv.ms/1ELEuQL).

    As I say, that link to OneDrive doesn’t work anymore..
    I saw the code in the blog posts, but there’s code missing in order to make it work, like the DataTemplates and views SiteForecastPeriodView and SiteForecastPeriodDetailView.

    I’m just interested on a similar accordion control.

    Could you provide that code for the ACCORDION and related DataTemplates/Views that you explain in your blog post so I can make it work?

    Thanks a lot,
    Cesar.

    Like

Leave a comment