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(