Skip to content

Commit

Permalink
Handle ItemsSource updates without an ObservableCollection (#138)
Browse files Browse the repository at this point in the history
  • Loading branch information
Dreamescaper authored Sep 6, 2023
1 parent ba00b02 commit 0103dd2
Show file tree
Hide file tree
Showing 16 changed files with 575 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
</ContentPage>

@code {
ObservableCollection<int> _intItems = new ObservableCollection<int>(Enumerable.Range(1, 100));
List<int> _intItems = Enumerable.Range(1, 100).ToList();
int _selectedItem;

void Add()
Expand Down
2 changes: 1 addition & 1 deletion samples/FluxorSample/FluxorSample.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Fluxor.Blazor.Web" Version="5.9.0" />
<PackageReference Include="Fluxor.Blazor.Web" Version="5.9.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="Microsoft.Build.Locator" Version="1.5.5" />
<PackageReference Include="Microsoft.Build.Locator" Version="1.6.1" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.5.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.5.0" />
</ItemGroup>
Expand Down
132 changes: 132 additions & 0 deletions src/BlazorBindings.Maui/Elements/Internal/ItemsSourceComponent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using System.Collections.ObjectModel;
using System.Diagnostics;

namespace BlazorBindings.Maui.Elements.Internal;

/// <summary>
/// This component creates an observable collection, which is updated by blazor renderer.
/// This allows to use it for cases, when MAUI expects an ObservableCollection to handle the updates,
/// but instead of forcing the user to use ObservableCollection on their side, we manage the updates by Blazor.
/// Probably not the most performant way, is there any other option?
/// </summary>
internal class ItemsSourceComponent<TControl, TItem> : NativeControlComponentBase, IElementHandler, IContainerElementHandler, INonPhysicalChild
{
private readonly ObservableCollection<TItem> _observableCollection = new();

[Parameter]
public IEnumerable<TItem> Items { get; set; }

[Parameter]
public Action<TControl, ObservableCollection<TItem>> CollectionSetter { get; set; }

[Parameter]
public Func<TItem, object> KeySelector { get; set; }


private TControl _parent;
public object TargetElement => _parent;

private HashSet<object> _keys;

protected override RenderFragment GetChildContent() => builder =>
{
_keys?.Clear();
bool shouldAddKey = true;
int index = 0;
foreach (var item in Items)
{
var key = KeySelector == null ? item : KeySelector(item);
if (KeySelector == null)
{
// Blazor doesn't allow duplicate keys. Therefore we add keys until the first duplicate.
// In case KeySelector is provided, we don't check for that here, since it's user's responsibility now.
_keys ??= new();
shouldAddKey &= _keys.Add(key);
if (!shouldAddKey)
key = null;
}
builder.OpenComponent<ItemHolderComponent>(1);
builder.SetKey(key);
builder.AddAttribute(2, nameof(ItemHolderComponent.Item), item);
builder.AddAttribute(3, nameof(ItemHolderComponent.Index), index);
builder.AddAttribute(4, nameof(ItemHolderComponent.ObservableCollection), _observableCollection);
if (key != null)
builder.AddAttribute(5, nameof(ItemHolderComponent.HasKey), true);
builder.CloseComponent();
index++;
}
};

void IContainerElementHandler.AddChild(object child, int physicalSiblingIndex)
{
_observableCollection.Insert(physicalSiblingIndex, (TItem)child);
}

void IContainerElementHandler.RemoveChild(object child, int physicalSiblingIndex)
{
Debug.Assert(Equals(_observableCollection[physicalSiblingIndex], child));
_observableCollection.RemoveAt(physicalSiblingIndex);
}

void IContainerElementHandler.ReplaceChild(int physicalSiblingIndex, object oldChild, object newChild)
{
Debug.Assert(Equals(_observableCollection[physicalSiblingIndex], oldChild));
if (!Equals(_observableCollection[physicalSiblingIndex], newChild))
_observableCollection[physicalSiblingIndex] = (TItem)newChild;
}

public void RemoveFromParent(object parentElement)
{
}

public void SetParent(object parentElement)
{
_parent = (TControl)parentElement;
CollectionSetter(_parent, _observableCollection);
}

private class ItemHolderComponent : NativeControlComponentBase, IElementHandler
{
[Parameter]
public TItem Item { get; set; }

[Parameter]
public ObservableCollection<TItem> ObservableCollection { get; set; }

[Parameter]
public int? Index { get; set; }

[Parameter]
public bool HasKey { get; set; }

public object TargetElement => Item;

public override Task SetParametersAsync(ParameterView parameters)
{
var previousIndex = Index;
var previousItem = Item;

// Task should be completed immediately
var task = base.SetParametersAsync(parameters);

if (previousIndex == null)
return task;

if (previousIndex == Index && Equals(previousItem, Item))
return task;

// Generally it will not be invoked, but it is needed when Source has duplicate items, or component has key.
// The problem here is that we don't know whether previous items are going to be removed or added.
// We use previousIndex here, because this part of the code is executed before items are actually added/removed to ObservableCollection.
if ((HasKey || previousIndex == Index) && !Equals(ObservableCollection[previousIndex.Value], Item))
ObservableCollection[previousIndex.Value] = Item;

return task;
}
}
}
53 changes: 53 additions & 0 deletions src/BlazorBindings.Maui/Elements/ItemsView.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Components.Rendering;
using System.Collections.Immutable;
using System.Collections.Specialized;
using MC = Microsoft.Maui.Controls;

namespace BlazorBindings.Maui.Elements;

public abstract partial class ItemsView<T>
{
[Parameter] public IEnumerable<T> ItemsSource { get; set; }
[Parameter] public Func<T, object> ItemKeySelector { get; set; }
[Parameter] public RenderFragment<T> ItemTemplateSelector { get; set; }

// Whether we should attempt to create ObservableCollection on our own (via diffing), or assign it directly.
private bool AssignItemsSourceDirectly => ItemsSource is INotifyCollectionChanged || ItemsSource is IImmutableList<T>;

protected override bool HandleAdditionalParameter(string name, object value)
{
switch (name)
{
case nameof(ItemsSource):
if (!Equals(ItemsSource, value))
{
ItemsSource = (IEnumerable<T>)value;

if (AssignItemsSourceDirectly)
NativeControl.ItemsSource = ItemsSource;
}
return true;

case nameof(ItemKeySelector):
ItemKeySelector = (Func<T, object>)value;
return true;

case nameof(ItemTemplateSelector):
ItemTemplateSelector = (RenderFragment<T>)value;
return true;
}

return base.HandleAdditionalParameter(name, value);
}

protected override void RenderAdditionalPartialElementContent(RenderTreeBuilder builder, ref int sequence)
{
base.RenderAdditionalPartialElementContent(builder, ref sequence);

RenderTreeBuilderHelper.AddDataTemplateSelectorProperty<MC.ItemsView, T>(builder, sequence++, ItemTemplateSelector, (x, template) => x.ItemTemplate = template);

sequence++;
if (!AssignItemsSourceDirectly)
RenderTreeBuilderHelper.AddItemsSourceProperty<MC.ItemsView, T>(builder, sequence, ItemsSource, ItemKeySelector, (x, items) => x.ItemsSource = items);
}
}
14 changes: 0 additions & 14 deletions src/BlazorBindings.Maui/Elements/ItemsView.generated.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.Maui;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

#pragma warning disable CA2252
Expand All @@ -29,13 +28,11 @@ static ItemsView()
}

[Parameter] public ScrollBarVisibility? HorizontalScrollBarVisibility { get; set; }
[Parameter] public IEnumerable<T> ItemsSource { get; set; }
[Parameter] public MC.ItemsUpdatingScrollMode? ItemsUpdatingScrollMode { get; set; }
[Parameter] public int? RemainingItemsThreshold { get; set; }
[Parameter] public ScrollBarVisibility? VerticalScrollBarVisibility { get; set; }
[Parameter] public RenderFragment EmptyView { get; set; }
[Parameter] public RenderFragment<T> ItemTemplate { get; set; }
[Parameter] public RenderFragment<T> ItemTemplateSelector { get; set; }
[Parameter] public EventCallback<MC.ScrollToRequestEventArgs> OnScrollToRequested { get; set; }
[Parameter] public EventCallback<MC.ItemsViewScrolledEventArgs> OnScrolled { get; set; }
[Parameter] public EventCallback OnRemainingItemsThresholdReached { get; set; }
Expand All @@ -54,13 +51,6 @@ protected override void HandleParameter(string name, object value)
NativeControl.HorizontalScrollBarVisibility = HorizontalScrollBarVisibility ?? (ScrollBarVisibility)MC.ItemsView.HorizontalScrollBarVisibilityProperty.DefaultValue;
}
break;
case nameof(ItemsSource):
if (!Equals(ItemsSource, value))
{
ItemsSource = (IEnumerable<T>)value;
NativeControl.ItemsSource = ItemsSource;
}
break;
case nameof(ItemsUpdatingScrollMode):
if (!Equals(ItemsUpdatingScrollMode, value))
{
Expand Down Expand Up @@ -88,9 +78,6 @@ protected override void HandleParameter(string name, object value)
case nameof(ItemTemplate):
ItemTemplate = (RenderFragment<T>)value;
break;
case nameof(ItemTemplateSelector):
ItemTemplateSelector = (RenderFragment<T>)value;
break;
case nameof(OnScrollToRequested):
if (!Equals(OnScrollToRequested, value))
{
Expand Down Expand Up @@ -133,7 +120,6 @@ protected override void RenderAdditionalElementContent(RenderTreeBuilder builder
base.RenderAdditionalElementContent(builder, ref sequence);
RenderTreeBuilderHelper.AddContentProperty<MC.ItemsView>(builder, sequence++, EmptyView, (x, value) => x.EmptyView = (object)value);
RenderTreeBuilderHelper.AddDataTemplateProperty<MC.ItemsView, T>(builder, sequence++, ItemTemplate, (x, template) => x.ItemTemplate = template);
RenderTreeBuilderHelper.AddDataTemplateSelectorProperty<MC.ItemsView, T>(builder, sequence++, ItemTemplateSelector, (x, template) => x.ItemTemplate = template);
}

static partial void RegisterAdditionalHandlers();
Expand Down
2 changes: 1 addition & 1 deletion src/BlazorBindings.Maui/Properties/AttributeInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
[assembly: GenerateComponent(typeof(ItemsView),
GenericProperties = new[] { nameof(ItemsView.ItemsSource), nameof(ItemsView.ItemTemplate) },
ContentProperties = new[] { nameof(ItemsView.EmptyView) },
Exclude = new[] { nameof(ItemsView.EmptyViewTemplate) })]
Exclude = new[] { nameof(ItemsView.EmptyViewTemplate), nameof(ItemsView.ItemsSource) })]
[assembly: GenerateComponent(typeof(Label))]
[assembly: GenerateComponent(typeof(Layout))]
[assembly: GenerateComponent(typeof(LinearGradientBrush))]
Expand Down
24 changes: 24 additions & 0 deletions src/BlazorBindings.Maui/RenderTreeBuilderHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,28 @@ internal static void AddSyncDataTemplateProperty<T>(
builder.CloseRegion();
}
}

internal static void AddItemsSourceProperty<TControl, TItem>(
RenderTreeBuilder builder,
int sequence,
IEnumerable<TItem> items,
Func<TItem, object> keySelector,
Action<TControl, ICollection<TItem>> collectionSetter)
{
if (items is null)
return;

builder.OpenRegion(sequence);

builder.OpenComponent<ItemsSourceComponent<TControl, TItem>>(0);
builder.AddAttribute(1, nameof(ItemsSourceComponent<TControl, TItem>.Items), items);
builder.AddAttribute(2, nameof(ItemsSourceComponent<TControl, TItem>.CollectionSetter), collectionSetter);

if (keySelector != null)
builder.AddAttribute(3, nameof(ItemsSourceComponent<TControl, TItem>.KeySelector), keySelector);

builder.CloseComponent();

builder.CloseRegion();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
</PackageReference>
<PackageReference Include="nunit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
</ItemGroup>

<ItemGroup>
Expand Down
60 changes: 60 additions & 0 deletions src/BlazorBindings.UnitTests/Elements/CollectionViewTests.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
@using System.Collections.Specialized;
@using System.Collections.ObjectModel;
@using System.Collections.Immutable;

@inherits ElementTestBase

@code {
[Test]
public async Task CreateCollectionViewWithItemTemplate()
{
var items = new (int Index, string Name)[] { (1, "First"), (2, "Seconds"), (3, ("Third")) };
var collectionView = await Render<MC.CollectionView>(
@<CollectionView ItemsSource="items">
<ItemTemplate>
<VerticalStackLayout>
<Label>@context.Index</Label>
<Label>@context.Name</Label>
</VerticalStackLayout>
</ItemTemplate>
</CollectionView>);

// It's nice that it doesn't crash at least, but is there any way to get templated items here?...
Assert.That(collectionView.ItemsSource, Is.EqualTo(items));

// For a regular collections we attempt to detect changes in the collection.
Assert.That(collectionView.ItemsSource is INotifyCollectionChanged);
}

[Test]
public async Task ObservableItemsViewIsAssignedDirectly()
{
var items = new ObservableCollection<int>(new[] { 1, 2, 3, 4 });

var collectionView = await Render<MC.CollectionView>(
@<CollectionView ItemsSource="items">
<ItemTemplate>
<Label>@context</Label>
</ItemTemplate>
</CollectionView>);

// Since collection is already observable, no need to diff additionally.
Assert.That(collectionView.ItemsSource, Is.SameAs(items));
}

[Test]
public async Task ImmutableItemsViewIsAssignedDirectly()
{
var items = ImmutableList.Create(1, 2, 3, 4);

var collectionView = await Render<MC.CollectionView>(
@<CollectionView ItemsSource="items">
<ItemTemplate>
<Label>@context</Label>
</ItemTemplate>
</CollectionView>);

// Since collection is immutable, there is no point in detecting changes there.
Assert.That(collectionView.ItemsSource, Is.SameAs(items));
}
}
Loading

0 comments on commit 0103dd2

Please sign in to comment.