diff --git a/Xamarin.Forms.Controls/CoreGallery.cs b/Xamarin.Forms.Controls/CoreGallery.cs
index dda1b99ed8d..84d2b7a470b 100644
--- a/Xamarin.Forms.Controls/CoreGallery.cs
+++ b/Xamarin.Forms.Controls/CoreGallery.cs
@@ -17,6 +17,7 @@
using Xamarin.Forms.Controls.GalleryPages.PlatformTestsGallery;
using Xamarin.Forms.Controls.GalleryPages.TwoPaneViewGalleries;
using Xamarin.Forms.Controls.GalleryPages.AppThemeGalleries;
+using Xamarin.Forms.Controls.GalleryPages.ExpanderGalleries;
namespace Xamarin.Forms.Controls
{
@@ -297,6 +298,7 @@ public override string ToString()
new GalleryPageFactory(() => new RadioButtonGroupGalleryPage(), "RadioButton group Gallery - Legacy"),
new GalleryPageFactory(() => new RadioButtonCoreGalleryPage(), "RadioButton Gallery"),
new GalleryPageFactory(() => new FontImageSourceGallery(), "Font ImageSource"),
+ new GalleryPageFactory(() => new ExpanderGalleries(), "Expander Gallery"),
new GalleryPageFactory(() => new IndicatorsSample(), "Indicator Gallery"),
new GalleryPageFactory(() => new CarouselViewGallery(), "CarouselView Gallery"),
new GalleryPageFactory(() => new CarouselViewCoreGalleryPage(), "CarouselView Core Gallery"),
diff --git a/Xamarin.Forms.Controls/GalleryPages/ExpanderGalleries/ExpanderGalleries.cs b/Xamarin.Forms.Controls/GalleryPages/ExpanderGalleries/ExpanderGalleries.cs
new file mode 100644
index 00000000000..b582c9fa7c1
--- /dev/null
+++ b/Xamarin.Forms.Controls/GalleryPages/ExpanderGalleries/ExpanderGalleries.cs
@@ -0,0 +1,45 @@
+namespace Xamarin.Forms.Controls.GalleryPages.ExpanderGalleries
+{
+ public class ExpanderGalleries : ContentPage
+ {
+ public ExpanderGalleries()
+ {
+ var descriptionLabel =
+ new Label { Text = "Expander Galleries", Margin = new Thickness(2, 2, 2, 2) };
+
+ Title = "Expander Galleries";
+
+ var button = new Button
+ {
+ Text = "Enable Expander",
+ AutomationId = "EnableExpander"
+ };
+ button.Clicked += ButtonClicked;
+
+ Content = new ScrollView
+ {
+ Content = new StackLayout
+ {
+ Children =
+ {
+ descriptionLabel,
+ button,
+ GalleryBuilder.NavButton("Expander Gallery", () =>
+ new ExpanderGallery(), Navigation)
+ }
+ }
+ };
+ }
+
+ void ButtonClicked(object sender, System.EventArgs e)
+ {
+ var button = sender as Button;
+
+ button.Text = "Expander Enabled!";
+ button.TextColor = Color.Black;
+ button.IsEnabled = false;
+
+ Device.SetFlags(new[] { ExperimentalFlags.ExpanderExperimental });
+ }
+ }
+}
\ No newline at end of file
diff --git a/Xamarin.Forms.Controls/GalleryPages/ExpanderGalleries/ExpanderGallery.xaml b/Xamarin.Forms.Controls/GalleryPages/ExpanderGalleries/ExpanderGallery.xaml
new file mode 100644
index 00000000000..e2db785cad9
--- /dev/null
+++ b/Xamarin.Forms.Controls/GalleryPages/ExpanderGalleries/ExpanderGallery.xaml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Xamarin.Forms.Controls/GalleryPages/ExpanderGalleries/ExpanderGallery.xaml.cs b/Xamarin.Forms.Controls/GalleryPages/ExpanderGalleries/ExpanderGallery.xaml.cs
new file mode 100644
index 00000000000..1c63aa7fee1
--- /dev/null
+++ b/Xamarin.Forms.Controls/GalleryPages/ExpanderGalleries/ExpanderGallery.xaml.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Windows.Input;
+using Xamarin.Forms;
+
+namespace Xamarin.Forms.Controls.GalleryPages.ExpanderGalleries
+{
+ public partial class ExpanderGallery : ContentPage
+ {
+ ICommand _command;
+
+ public ExpanderGallery()
+ {
+ InitializeComponent();
+ }
+
+ public ICommand Command => _command ?? (_command = new Command(p =>
+ {
+ var sender = (Item)p;
+ if(!sender.IsExpanded)
+ {
+ return;
+ }
+
+ foreach (var item in Items)
+ {
+ item.IsExpanded = sender == item;
+ }
+ }));
+
+ public Item[] Items { get; } = new Item[]
+ {
+ new Item
+ {
+ Name = "The First",
+ },
+ new Item
+ {
+ Name = "The Second",
+ IsExpanded = true
+ },
+ new Item
+ {
+ Name = "The Third",
+ },
+ new Item
+ {
+ Name = "The Fourth",
+ },
+ new Item
+ {
+ Name = "The Fifth"
+ },
+ };
+
+ public sealed class Item: INotifyPropertyChanged {
+ public event PropertyChangedEventHandler PropertyChanged;
+ bool _isExpanded;
+
+ public string Name { get; set; }
+ public bool IsExpanded
+ {
+ get => _isExpanded;
+ set
+ {
+ if(value == _isExpanded)
+ {
+ return;
+ }
+ _isExpanded = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsExpanded)));
+ }
+ }
+
+ }
+ }
+}
diff --git a/Xamarin.Forms.Core.UITests.Shared/Tests/ExpanderViewUITests.cs b/Xamarin.Forms.Core.UITests.Shared/Tests/ExpanderViewUITests.cs
new file mode 100644
index 00000000000..4efaf8e2b6f
--- /dev/null
+++ b/Xamarin.Forms.Core.UITests.Shared/Tests/ExpanderViewUITests.cs
@@ -0,0 +1,37 @@
+using NUnit.Framework;
+
+namespace Xamarin.Forms.Core.UITests
+{
+ [Category(UITestCategories.ExpanderView)]
+ internal class ExpanderViewUITests : BaseTestFixture
+ {
+ protected override void NavigateToGallery()
+ {
+ App.NavigateToGallery("* marked:'Expander Gallery'");
+ }
+
+ [TestCase]
+ public void ExpanderView()
+ {
+ App.WaitForElement("The Second", "");
+ App.Tap("Expander Level 2");
+ App.WaitForElement("Hi, I am Red", "View didn't expand the second level");
+ App.Tap("The Fourth");
+
+ App.WaitForNoElement("Hi, I am Red", "View didn't collapse like is should");
+
+ App.WaitForElement("Expander Level 2", "Fourth view didn't expand to show 'Expander level 2'");
+ App.Tap("Expander Level 2");
+ App.WaitForElement("Hi, I am Red", "Expander level 2 of Fourth view didn't expand like it should.");
+ App.Tap("Expander Level 2");
+
+ App.WaitForNoElement("Hi, I am Red", "View didn't collapse like is should");
+
+ App.Tap("The Fourth");
+
+ App.WaitForNoElement("Expander Level 2", "View didn't collapse like is should");
+
+ App.Back();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Xamarin.Forms.Core.UITests.Shared/UITestCategories.cs b/Xamarin.Forms.Core.UITests.Shared/UITestCategories.cs
index 5d8857dff1e..2d8e03d1084 100644
--- a/Xamarin.Forms.Core.UITests.Shared/UITestCategories.cs
+++ b/Xamarin.Forms.Core.UITests.Shared/UITestCategories.cs
@@ -18,6 +18,7 @@ internal static class UITestCategories
public const string DisplayAlert = "DisplayAlert";
public const string Editor = "Editor";
public const string Entry = "Entry";
+ public const string ExpanderView = "ExpanderView";
public const string Frame = "Frame";
public const string Image = "Image";
public const string ImageButton = "ImageButton";
diff --git a/Xamarin.Forms.Core.UITests.Shared/Xamarin.Forms.Core.UITests.projitems b/Xamarin.Forms.Core.UITests.Shared/Xamarin.Forms.Core.UITests.projitems
index 9e36e0238f3..a2dd4dbf1bd 100644
--- a/Xamarin.Forms.Core.UITests.Shared/Xamarin.Forms.Core.UITests.projitems
+++ b/Xamarin.Forms.Core.UITests.Shared/Xamarin.Forms.Core.UITests.projitems
@@ -29,6 +29,7 @@
+
diff --git a/Xamarin.Forms.Core/Expander.cs b/Xamarin.Forms.Core/Expander.cs
new file mode 100644
index 00000000000..54eb59d5eaa
--- /dev/null
+++ b/Xamarin.Forms.Core/Expander.cs
@@ -0,0 +1,376 @@
+using System;
+using System.Runtime.CompilerServices;
+using System.Windows.Input;
+using static System.Math;
+
+namespace Xamarin.Forms
+{
+ [ContentProperty(nameof(Content))]
+ public class Expander : TemplatedView
+ {
+ const string ExpandAnimationName = nameof(ExpandAnimationName);
+ const uint DefaultAnimationLength = 250;
+
+ public event EventHandler Tapped;
+
+ public static readonly BindableProperty SpacingProperty = BindableProperty.Create(nameof(Spacing), typeof(double), typeof(Expander), 0d, propertyChanged: (bindable, oldvalue, newvalue)
+ => ((Expander)bindable).ExpanderLayout.Spacing = (double)newvalue);
+
+ public static readonly BindableProperty HeaderProperty = BindableProperty.Create(nameof(Header), typeof(View), typeof(Expander), default(View), propertyChanged: (bindable, oldValue, newValue)
+ => ((Expander)bindable).SetHeader((View)oldValue));
+
+ public static readonly BindableProperty ContentProperty = BindableProperty.Create(nameof(Content), typeof(View), typeof(Expander), default(View), propertyChanged: (bindable, oldValue, newValue)
+ => ((Expander)bindable).SetContent((View)oldValue, (View)newValue));
+
+ public static readonly BindableProperty ContentTemplateProperty = BindableProperty.Create(nameof(ContentTemplate), typeof(DataTemplate), typeof(Expander), default(DataTemplate), propertyChanged: (bindable, oldValue, newValue)
+ => ((Expander)bindable).SetContent(true));
+
+ public static readonly BindableProperty IsExpandedProperty = BindableProperty.Create(nameof(IsExpanded), typeof(bool), typeof(Expander), default(bool), BindingMode.TwoWay, propertyChanged: (bindable, oldValue, newValue)
+ => ((Expander)bindable).SetContent(false));
+
+ public static readonly BindableProperty ExpandAnimationLengthProperty = BindableProperty.Create(nameof(ExpandAnimationLength), typeof(uint), typeof(Expander), DefaultAnimationLength);
+
+ public static readonly BindableProperty CollapseAnimationLengthProperty = BindableProperty.Create(nameof(CollapseAnimationLength), typeof(uint), typeof(Expander), DefaultAnimationLength);
+
+ public static readonly BindableProperty ExpandAnimationEasingProperty = BindableProperty.Create(nameof(ExpandAnimationEasing), typeof(Easing), typeof(Expander), default(Easing));
+
+ public static readonly BindableProperty CollapseAnimationEasingProperty = BindableProperty.Create(nameof(CollapseAnimationEasing), typeof(Easing), typeof(Expander), default(Easing));
+
+ public static readonly BindableProperty StateProperty = BindableProperty.Create(nameof(State), typeof(ExpanderState), typeof(Expander), default(ExpanderState), BindingMode.OneWayToSource);
+
+ public static readonly BindableProperty CommandParameterProperty = BindableProperty.Create(nameof(CommandParameter), typeof(object), typeof(Expander), default(object));
+
+ public static readonly BindableProperty CommandProperty = BindableProperty.Create(nameof(Command), typeof(ICommand), typeof(Expander), default(ICommand));
+
+ public static readonly BindableProperty ForceUpdateSizeCommandProperty = BindableProperty.Create(nameof(ForceUpdateSizeCommand), typeof(ICommand), typeof(Expander), default(ICommand), BindingMode.OneWayToSource);
+
+ DataTemplate _previousTemplate;
+ double _contentHeightRequest = -1;
+ double _lastVisibleHeight = -1;
+ double _previousWidth = -1;
+ double _startHeight;
+ double _endHeight;
+ bool _shouldIgnoreContentSetting;
+ bool _shouldIgnoreAnimation;
+ static bool isExperimentalFlagSet = false;
+
+ public Expander()
+ {
+ ExpanderLayout = new StackLayout { Spacing = Spacing };
+ ForceUpdateSizeCommand = new Command(ForceUpdateSize);
+ InternalChildren.Add(ExpanderLayout);
+ }
+
+ internal static void VerifyExperimental([CallerMemberName] string memberName = "", string constructorHint = null)
+ {
+ if (isExperimentalFlagSet)
+ return;
+
+ ExperimentalFlags.VerifyFlagEnabled(nameof(Markup), ExperimentalFlags.ExpanderExperimental, constructorHint, memberName);
+
+ isExperimentalFlagSet = true;
+ }
+
+ protected override SizeRequest OnMeasure(double widthConstraint, double heightConstraint)
+ {
+ VerifyExperimental();
+ return base.OnMeasure(widthConstraint, heightConstraint);
+ }
+
+ StackLayout ExpanderLayout { get; }
+
+ public double Spacing
+ {
+ get => (double)GetValue(SpacingProperty);
+ set => SetValue(SpacingProperty, value);
+ }
+
+ public View Header
+ {
+ get => (View)GetValue(HeaderProperty);
+ set => SetValue(HeaderProperty, value);
+ }
+
+ public View Content
+ {
+ get => (View)GetValue(ContentProperty);
+ set => SetValue(ContentProperty, value);
+ }
+
+ public DataTemplate ContentTemplate
+ {
+ get => (DataTemplate)GetValue(ContentTemplateProperty);
+ set => SetValue(ContentTemplateProperty, value);
+ }
+
+ public bool IsExpanded
+ {
+ get => (bool)GetValue(IsExpandedProperty);
+ set => SetValue(IsExpandedProperty, value);
+ }
+
+ public uint ExpandAnimationLength
+ {
+ get => (uint)GetValue(ExpandAnimationLengthProperty);
+ set => SetValue(ExpandAnimationLengthProperty, value);
+ }
+
+ public uint CollapseAnimationLength
+ {
+ get => (uint)GetValue(CollapseAnimationLengthProperty);
+ set => SetValue(CollapseAnimationLengthProperty, value);
+ }
+
+ public Easing ExpandAnimationEasing
+ {
+ get => (Easing)GetValue(ExpandAnimationEasingProperty);
+ set => SetValue(ExpandAnimationEasingProperty, value);
+ }
+
+ public Easing CollapseAnimationEasing
+ {
+ get => (Easing)GetValue(CollapseAnimationEasingProperty);
+ set => SetValue(CollapseAnimationEasingProperty, value);
+ }
+
+ public ExpanderState State
+ {
+ get => (ExpanderState)GetValue(StateProperty);
+ set => SetValue(StateProperty, value);
+ }
+
+ public object CommandParameter
+ {
+ get => GetValue(CommandParameterProperty);
+ set => SetValue(CommandParameterProperty, value);
+ }
+
+ public ICommand Command
+ {
+ get => (ICommand)GetValue(CommandProperty);
+ set => SetValue(CommandProperty, value);
+ }
+
+ public ICommand ForceUpdateSizeCommand
+ {
+ get => (ICommand)GetValue(ForceUpdateSizeCommandProperty);
+ set => SetValue(ForceUpdateSizeCommandProperty, value);
+ }
+
+ public void ForceUpdateSize()
+ {
+ _lastVisibleHeight = -1;
+ OnIsExpandedChanged();
+ }
+
+ protected override void OnBindingContextChanged()
+ {
+ base.OnBindingContextChanged();
+ _lastVisibleHeight = -1;
+ SetContent(true);
+ }
+
+ protected override void OnSizeAllocated(double width, double height)
+ {
+ base.OnSizeAllocated(width, height);
+ if (Abs(width - _previousWidth) >= double.Epsilon)
+ {
+ ForceUpdateSize();
+ }
+ _previousWidth = width;
+ }
+
+ void OnIsExpandedChanged()
+ {
+ if (Content == null || (!IsExpanded && !Content.IsVisible))
+ {
+ return;
+ }
+
+ Content.SizeChanged -= OnContentSizeChanged;
+
+ var isAnimationRunning = Content.AnimationIsRunning(ExpandAnimationName);
+ Content.AbortAnimation(ExpandAnimationName);
+
+
+ _startHeight = Content.IsVisible
+ ? Max(Content.Height - (Content is Layout l ? l.Padding.Top + l.Padding.Bottom : 0), 0)
+ : 0;
+
+ if (IsExpanded)
+ {
+ Content.IsVisible = true;
+ }
+
+ _endHeight = _contentHeightRequest >= 0
+ ? _contentHeightRequest
+ : _lastVisibleHeight;
+
+ var shouldInvokeAnimation = true;
+
+ if (IsExpanded)
+ {
+ if (_endHeight <= 0)
+ {
+ shouldInvokeAnimation = false;
+ Content.SizeChanged += OnContentSizeChanged;
+ Content.HeightRequest = -1;
+ }
+ }
+ else
+ {
+ _lastVisibleHeight = _startHeight = _contentHeightRequest >= 0
+ ? _contentHeightRequest
+ : !isAnimationRunning
+ ? Content.Height - (Content is Layout layout
+ ? layout.Padding.Top + layout.Padding.Bottom
+ : 0)
+ : _lastVisibleHeight;
+ _endHeight = 0;
+ }
+
+ _shouldIgnoreAnimation = Height < 0;
+
+ if (shouldInvokeAnimation)
+ {
+ InvokeAnimation();
+ }
+ }
+
+ void SetHeader(View oldHeader)
+ {
+ if (oldHeader != null)
+ {
+ ExpanderLayout.Children.Remove(oldHeader);
+ }
+ if (Header != null)
+ {
+ ExpanderLayout.Children.Insert(0, Header);
+ Header.GestureRecognizers.Add(new TapGestureRecognizer
+ {
+ CommandParameter = this,
+ Command = new Command(parameter =>
+ {
+ var parent = (parameter as View).Parent;
+ while (parent != null && !(parent is Page))
+ {
+ if (parent is Expander ancestorExpander)
+ {
+ ancestorExpander.Content.HeightRequest = -1;
+ }
+ parent = parent.Parent;
+ }
+ IsExpanded = !IsExpanded;
+ Command?.Execute(CommandParameter);
+ Tapped?.Invoke(this, EventArgs.Empty);
+ })
+ });
+ }
+ }
+
+ void SetContent(bool isForceUpdate)
+ {
+ if (IsExpanded && (Content == null || isForceUpdate))
+ {
+ _shouldIgnoreContentSetting = true;
+ Content = CreateContent() ?? Content;
+ _shouldIgnoreContentSetting = false;
+ }
+ OnIsExpandedChanged();
+ }
+
+ void SetContent(View oldContent, View newContent)
+ {
+ if (oldContent != null)
+ {
+ oldContent.SizeChanged -= OnContentSizeChanged;
+ ExpanderLayout.Children.Remove(oldContent);
+ }
+ if (newContent != null)
+ {
+ if (newContent is Layout layout)
+ {
+ layout.IsClippedToBounds = true;
+ }
+ _contentHeightRequest = newContent.HeightRequest;
+ newContent.HeightRequest = 0;
+ newContent.IsVisible = false;
+ ExpanderLayout.Children.Add(newContent);
+ }
+
+ if (!_shouldIgnoreContentSetting)
+ {
+ SetContent(true);
+ }
+ }
+
+ View CreateContent()
+ {
+ var template = ContentTemplate;
+ while (template is DataTemplateSelector selector)
+ {
+ template = selector.SelectTemplate(BindingContext, this);
+ }
+ if (template == _previousTemplate && Content != null)
+ {
+ return null;
+ }
+ _previousTemplate = template;
+ return (View)template?.CreateContent();
+ }
+
+ void OnContentSizeChanged(object sender, EventArgs e)
+ {
+ if (Content.Height <= 0)
+ {
+ return;
+ }
+ Content.SizeChanged -= OnContentSizeChanged;
+ Content.HeightRequest = 0;
+ _endHeight = Content.Height;
+ InvokeAnimation();
+ }
+
+ void InvokeAnimation()
+ {
+ State = IsExpanded ? ExpanderState.Expanding : ExpanderState.Collapsing;
+
+ if (_shouldIgnoreAnimation)
+ {
+ State = IsExpanded ? ExpanderState.Expanded : ExpanderState.Collapsed;
+ Content.HeightRequest = _endHeight;
+ Content.IsVisible = IsExpanded;
+ return;
+ }
+
+ var length = ExpandAnimationLength;
+ var easing = ExpandAnimationEasing;
+ if (!IsExpanded)
+ {
+ length = CollapseAnimationLength;
+ easing = CollapseAnimationEasing;
+ }
+
+ if (_lastVisibleHeight > 0)
+ {
+ length = Max((uint)(length * (Abs(_endHeight - _startHeight) / _lastVisibleHeight)), 1);
+ }
+
+ new Animation(v => Content.HeightRequest = v, _startHeight, _endHeight)
+ .Commit(Content, ExpandAnimationName, 16, length, easing, (value, isInterrupted) =>
+ {
+ if (isInterrupted)
+ {
+ return;
+ }
+ if (!IsExpanded)
+ {
+ Content.IsVisible = false;
+ State = ExpanderState.Collapsed;
+ return;
+ }
+ State = ExpanderState.Expanded;
+ });
+ }
+ }
+}
diff --git a/Xamarin.Forms.Core/ExpanderState.cs b/Xamarin.Forms.Core/ExpanderState.cs
new file mode 100644
index 00000000000..4f6515b1832
--- /dev/null
+++ b/Xamarin.Forms.Core/ExpanderState.cs
@@ -0,0 +1,10 @@
+namespace Xamarin.Forms
+{
+ public enum ExpanderState
+ {
+ Expanding,
+ Expanded,
+ Collapsing,
+ Collapsed
+ }
+}
diff --git a/Xamarin.Forms.Core/ExperimentalFlags.cs b/Xamarin.Forms.Core/ExperimentalFlags.cs
index 4898dc0b46b..a267190027f 100644
--- a/Xamarin.Forms.Core/ExperimentalFlags.cs
+++ b/Xamarin.Forms.Core/ExperimentalFlags.cs
@@ -17,6 +17,7 @@ internal static class ExperimentalFlags
internal const string MediaElementExperimental = "MediaElement_Experimental";
internal const string MarkupExperimental = "Markup_Experimental";
internal const string AppThemeExperimental = "AppTheme_Experimental";
+ internal const string ExpanderExperimental = "Expander_Experimental";
[EditorBrowsable(EditorBrowsableState.Never)]
public static void VerifyFlagEnabled(