diff --git a/src/Controls/src/Core/BindableLayout/BindableLayout.cs b/src/Controls/src/Core/BindableLayout/BindableLayout.cs index e99a7a2e40a8..3ee825380163 100644 --- a/src/Controls/src/Core/BindableLayout/BindableLayout.cs +++ b/src/Controls/src/Core/BindableLayout/BindableLayout.cs @@ -1,7 +1,6 @@ #nullable disable using System; using System.Collections; -using System.Collections.Generic; using System.Collections.Specialized; using Microsoft.Maui.Controls.Internals; @@ -148,6 +147,18 @@ internal static void Add(this IBindableLayout layout, object item) _ = layout.Children.Add(item); } } + + internal static void Replace(this IBindableLayout layout, object item, int index) + { + if (layout is Maui.ILayout mauiLayout && item is IView view) + { + mauiLayout[index] = view; + } + else + { + layout.Children[index] = item; + } + } internal static void Insert(this IBindableLayout layout, object item, int index) { @@ -200,6 +211,31 @@ internal static void Clear(this IBindableLayout layout) class BindableLayoutController { + static readonly BindableProperty BindableLayoutTemplateProperty = BindableProperty.CreateAttached("BindableLayoutTemplate", typeof(DataTemplate), typeof(BindableLayoutController), default(DataTemplate)); + + /// + /// Gets the template reference used to generate a view in the . + /// + static DataTemplate GetBindableLayoutTemplate(BindableObject b) + { + return (DataTemplate)b.GetValue(BindableLayoutTemplateProperty); + } + + /// + /// Sets the template reference used to generate a view in the . + /// + static void SetBindableLayoutTemplate(BindableObject b, DataTemplate value) + { + b.SetValue(BindableLayoutTemplateProperty, value); + } + + static readonly DataTemplate DefaultItemTemplate = new DataTemplate(() => + { + var label = new Label { HorizontalTextAlignment = TextAlignment.Center }; + label.SetBinding(Label.TextProperty, "."); + return label; + }); + readonly WeakReference _layoutWeakReference; readonly WeakNotifyCollectionChangedProxy _collectionChangedProxy = new(); readonly NotifyCollectionChangedEventHandler _collectionChangedEventHandler; @@ -290,7 +326,7 @@ void SetEmptyView(object emptyView) if (!_isBatchUpdate) { - CreateChildren(); + UpdateEmptyView(); } } @@ -302,78 +338,134 @@ void SetEmptyViewTemplate(DataTemplate emptyViewTemplate) if (!_isBatchUpdate) { - CreateChildren(); + UpdateEmptyView(); } } - void CreateChildren() + void UpdateEmptyView() { if (!_layoutWeakReference.TryGetTarget(out IBindableLayout layout)) { return; } - ClearChildren(layout); + TryAddEmptyView(layout, out _); + } - UpdateEmptyView(layout); + void CreateChildren() + { + if (!_layoutWeakReference.TryGetTarget(out IBindableLayout layout)) + { + return; + } - if (_itemsSource == null) + if (TryAddEmptyView(layout, out IEnumerator enumerator)) + { return; + } + + var layoutChildren = layout.Children; + var childrenCount = layoutChildren.Count; - foreach (object item in _itemsSource) + // if we have the empty view, remove it before generating children + if (childrenCount == 1 && layoutChildren[0] == _currentEmptyView) { - layout.Add(CreateItemView(item, layout)); + layout.RemoveAt(0); + childrenCount = 0; } - } - void ClearChildren(IBindableLayout layout) - { - var index = layout.Children.Count; - while (--index >= 0) + // Add or replace items + var index = 0; + do { - var child = (View)layout.Children[index]!; - layout.RemoveAt(index); + var item = enumerator.Current; + if (index < childrenCount) + { + ReplaceChild(item, layout, layoutChildren, index); + } + else + { + layout.Add(CreateItemView(item, SelectTemplate(item, layout))); + } - // Empty view inherits the BindingContext automatically, - // we don't want to mess up with automatic inheritance. - if (child == _currentEmptyView) continue; - - // Given that we've set BindingContext manually on children we have to clear it on removal. + ++index; + } while (enumerator.MoveNext()); + + // Remove exceeding items + while (index <= --childrenCount) + { + var child = (BindableObject) layoutChildren[childrenCount]!; + layout.RemoveAt(childrenCount); + // It's our responsibility to clear the BindingContext for the children + // Given that we've set them manually in CreateItemView child.BindingContext = null; } } - void UpdateEmptyView(IBindableLayout layout) + bool TryAddEmptyView(IBindableLayout layout, out IEnumerator enumerator) { - if (_currentEmptyView == null) - return; + enumerator = _itemsSource?.GetEnumerator(); - if (!_itemsSource?.GetEnumerator().MoveNext() ?? true) + if (enumerator == null || !enumerator.MoveNext()) { - layout.Add(_currentEmptyView); - return; + var layoutChildren = layout.Children; + + // We may have a single child that is either the old empty view or a generated item + if (layoutChildren.Count == 1) + { + var maybeEmptyView = (View)layoutChildren[0]!; + + // If the current empty view is already in place we have nothing to do + if (maybeEmptyView == _currentEmptyView) + { + return true; + } + + // We may have a single child that is either the old empty view or a generated item + // So remove it to make room for the new empty view + layout.RemoveAt(0); + + // If this is a generated item, we need to clear the BindingContext + if (maybeEmptyView.IsSet(BindableLayoutTemplateProperty)) + { + maybeEmptyView.ClearValue(BindableObject.BindingContextProperty); + } + } + else if (layoutChildren.Count > 1) + { + // If we have more than one child it means we have generated items only + // So clear them all to make room for the new empty view + ClearChildren(layout); + } + + // If an empty view is set, add it + if (_currentEmptyView != null) + { + layout.Add(_currentEmptyView); + } + + return true; } - layout.Remove(_currentEmptyView); + return false; } - View CreateItemView(object item, IBindableLayout layout) + void ClearChildren(IBindableLayout layout) { - return CreateItemView(item, _itemTemplate ?? _itemTemplateSelector?.SelectTemplate(item, layout as BindableObject)); + var index = layout.Children.Count; + while (--index >= 0) + { + var child = (View)layout.Children[index]!; + layout.RemoveAt(index); + + // It's our responsibility to clear the manually-set BindingContext for the generated children + child.ClearValue(BindableObject.BindingContextProperty); + } } - View CreateItemView(object item, DataTemplate dataTemplate) + DataTemplate SelectTemplate(object item, IBindableLayout layout) { - if (dataTemplate != null) - { - var view = (View)dataTemplate.CreateContent(); - view.BindingContext = item; - return view; - } - else - { - return new Label { Text = item?.ToString(), HorizontalTextAlignment = TextAlignment.Center }; - } + return _itemTemplate ?? _itemTemplateSelector?.SelectTemplate(item, layout as BindableObject) ?? DefaultItemTemplate; } View CreateEmptyView(object emptyView, DataTemplate dataTemplate) @@ -404,8 +496,29 @@ void ItemsSourceCollectionChanged(object sender, NotifyCollectionChangedEventArg return; } + if (e.Action == NotifyCollectionChangedAction.Replace) + { + var index = e.OldStartingIndex; + var layoutChildren = layout.Children; + foreach (var item in e.NewItems!) + { + ReplaceChild(item, layout, layoutChildren, index); + ++index; + } + return; + } + e.Apply( - insert: (item, index, _) => layout.Insert(CreateItemView(item, layout), index), + insert: (item, index, _) => + { + var layoutChildren = layout.Children; + if (layoutChildren.Count == 1 && layoutChildren[0] == _currentEmptyView) + { + layout.RemoveAt(0); + } + + layout.Insert(CreateItemView(item, SelectTemplate(item, layout)), index); + }, removeAt: (item, index) => { var child = (View)layout.Children[index]!; @@ -414,12 +527,40 @@ void ItemsSourceCollectionChanged(object sender, NotifyCollectionChangedEventArg // It's our responsibility to clear the BindingContext for the children // Given that we've set them manually in CreateItemView child.BindingContext = null; + + // If we removed the last item, we need to insert the empty view + if (layout.Children.Count == 0 && _currentEmptyView != null) + { + layout.Add(_currentEmptyView); + } }, reset: CreateChildren); + } - // UpdateEmptyView is called from within CreateChildren, therefor skip it for Reset - if (e.Action != NotifyCollectionChangedAction.Reset) - UpdateEmptyView(layout); + void ReplaceChild(object item, IBindableLayout layout, IList layoutChildren, int index) + { + var template = SelectTemplate(item, layout); + var child = (BindableObject) layoutChildren[index]!; + var currentTemplate = GetBindableLayoutTemplate(child); + if (currentTemplate == template) + { + child.BindingContext = item; + } + else + { + // It's our responsibility to clear the BindingContext for the children + // Given that we've set them manually in CreateItemView + child.BindingContext = null; + layout.Replace(CreateItemView(item, template), index); + } + } + + static View CreateItemView(object item, DataTemplate dataTemplate) + { + var view = (View)dataTemplate.CreateContent(); + SetBindableLayoutTemplate(view, dataTemplate); + view.BindingContext = item; + return view; } } } \ No newline at end of file diff --git a/src/Controls/tests/Core.UnitTests/BindableLayoutTests.cs b/src/Controls/tests/Core.UnitTests/BindableLayoutTests.cs index 8426d7cfbaac..a4af7ac2f12f 100644 --- a/src/Controls/tests/Core.UnitTests/BindableLayoutTests.cs +++ b/src/Controls/tests/Core.UnitTests/BindableLayoutTests.cs @@ -13,7 +13,6 @@ namespace Microsoft.Maui.Controls.Core.UnitTests { using StackLayout = Microsoft.Maui.Controls.Compatibility.StackLayout; - public class BindableLayoutTests : BaseTestFixture { [Fact] @@ -193,6 +192,109 @@ public void ItemTemplateSelectorIsSet() Assert.Equal(itemsSource.Count, layout.Children.Cast().Count()); } + [Fact] + public void ChangingTemplateRecreatesChildren() + { + var layout = new StackLayout + { + IsPlatformEnabled = true, + }; + + var itemsSource = new ObservableCollection(Enumerable.Range(0, 10)); + BindableLayout.SetItemsSource(layout, itemsSource); + + BindableLayout.SetItemTemplate(layout, new DataTemplate(() => new Label())); + Assert.All(layout.Children, c => Assert.IsType