Dreamescaper / BlazorBindings.Maui

MAUI Blazor Bindings - Build native and hybrid MAUI apps with Blazor
MIT License
242 stars 16 forks source link

CollectionView: Remove the requirement to use ObservableCollection for ItemsSource updates #131

Closed mrpmorris closed 1 year ago

mrpmorris commented 1 year ago

I've been trying to reproduce this article about @key https://blazor-university.com/components/render-trees/optimising-using-key/

But the UI doesn't rerender when the user interacts with the component, even if I call StateHasChanged.

I realise I can implement INotifyPropertyChanged, but as I edit DTOs from an API Contracts library I would like not to have to do this (introduce UI concerns into the API).

@page "/main"

<ContentPage>
    <ScrollView>
        <VerticalStackLayout>
            <Button Text="Rotate data" OnClick="RotateData" />
            <Button Text="Change data" OnClick="ChangeData" />
            <CollectionView ItemsSource="People">
                <ItemTemplate>
                    <HorizontalStackLayout Spacing="4">
                        <Label Text="@context.GivenName" />
                        <Label Text="@context.FamilyName" />
                    </HorizontalStackLayout>
                </ItemTemplate>
            </CollectionView>
        </VerticalStackLayout>
    </ScrollView>
</ContentPage>

@code {
    List<Person> People = new List<Person>
    {
        new Person(1, "Peter", "Morris"),
        new Person(2, "Bob", "Monkhouse"),
        new Person(3, "Frank", "Sinatra"),
        new Person(4, "David", "Banner")
    };

    void ChangeData()
    {
        People[0].GivenName += "!";
        StateHasChanged();
    }

    void RotateData()
    {
        var person = People[0];
        People.RemoveAt(0);
        People.Add(person);
        StateHasChanged();
    }

    public class Person
    {
        public int ID { get; set; }
        public string GivenName { get; set; }
        public string FamilyName { get; set; }

        public Person(int id, string givenName, string familyName)
        {
            ID = id;
            GivenName = givenName;
            FamilyName = familyName;
        }
    }
}
mrpmorris commented 1 year ago

Oh, VERY NICE!!!

You check if the source is a different instance. That is a very good way to do change detection IMHO, it makes it easy for the control to know it doesn't need to re-render the contents - I can see that confusing people who are used to Blazor WASM working differently though.

Does this mean you aren't building a render tree and diff'ing it?

(The following code works as expected)

@page "/main"
@using System.Collections.Immutable;

<ContentPage>
    <ScrollView>
        <VerticalStackLayout>
            <Button Text="Rotate data" OnClick="RotateData" />
            <Button Text="Change data" OnClick="ChangeData" />
            <CollectionView ItemsSource="People">
                <ItemTemplate>
                    <HorizontalStackLayout Spacing="4">
                        <Label Text="@context.GivenName" />
                        <Label Text="@context.FamilyName" />
                    </HorizontalStackLayout>
                </ItemTemplate>
            </CollectionView>
        </VerticalStackLayout>
    </ScrollView>
</ContentPage>

@code {
    ImmutableList<Person> People = new List<Person>
    {
        new Person
        {
            ID = 1,
            GivenName = "Peter",
            FamilyName = "Morris"
        },
        new Person
        {
            ID = 2,
            GivenName = "Bob",
            FamilyName = "Monkhouse"
        },
        new Person
        {
            ID = 3,
            GivenName = "Frank",
            FamilyName = "Sinatra"
        },
        new Person
        {
            ID = 4,
            GivenName = "David",
            FamilyName = "Banner"
        }
    }.ToImmutableList();

    void ChangeData()
    {
        Person person = People[0];
        person = person with { GivenName = person.GivenName + "!" };
        People = People.RemoveAt(0).Insert(0, person);
    }

    void RotateData()
    {
        var person = People[0];
        People = People.RemoveAt(0);
        People = People.Add(person);
    }

    public record Person
    {
        public required int ID { get; init; }
        public required string GivenName { get; init; }
        public required string FamilyName { get; init; }
    }
}
Dreamescaper commented 1 year ago

Yeah, I need to update the documentation regarding CollectionView))

You don't need to update the DTO itself, but you need to use ObservableCollection so that CollectionView would understand the updates.

Unfortunately, I wasn't able to use @key directive here (or anywhere else in MAUI, in fact). @key directive works with StackLayout, but, frankly, StackLayout is not a great fit for cases with lots of items anyway.

Dreamescaper commented 1 year ago

Your updated code works, because you're replacing the whole ItemsSource on each update.

That works, but probably not as efficient, it will probably re-render all the items.

mrpmorris commented 1 year ago

I know why the latter works, I am pointing out that the former does not work the same as in Blazor.

The rule should be 1: Is it a simple type like int/string etc? If so, then only render if the value has changed. 2: Is it an Observable? If so, only render on notification. 3: Otherwise, always render.

Blazor doesn't do step 2, but I can see it would be needed for MAUI, however, it wouldn't be a good approach for Blazor WASM and this difference makes it difficult to write an app once and have it run in both.

Dreamescaper commented 1 year ago

BlazorBindings.Maui uses the same renderer internals as regular Blazor. The problem here is that CollectionView is not fully managed by Blazor Renderer. Renderer manages CollectionView instance itself, and it manages each rendered from template item separately. However, the decision when to add or remove items, and which itemContext should be used by a template - that is managed by a CollectionView itself.

I was thinking maybe to create some kind of a diff mechanism, which would compare the items before and after the render - to avoid the requirement to use ObservableCollections. That doesn't sound easy though.

Dreamescaper commented 1 year ago

But if you want to run the app once and run it in both, BlazorBindings.Maui is probably not a great pick anyway. It renders "native" Maui controls, therefore you won't be able to use it without Maui. MAUI Blazor Hybrid (yeah, naming is a bit confusing here) probably would be a better pick - as you would use web controls instead of MAUI contols.

mrpmorris commented 1 year ago

I am writing the business logic + state using Fluxor, which is UI agnostic. I then only need to create views in WASM and equivalent views in MAUI and I will get the same app.

My boss doesn't want to use hybrid, he doesn't like the feel of it compared to a native experience.

Dreamescaper commented 1 year ago

@mrpmorris I have merged changes to allow to track updates in ItemsSource collection. It's not yet released to Nuget, but it would be great if you'd be able to test in the latest MyGet build. https://dreamescaper.github.io/MobileBlazorBindingsDocs/maui-blazor-bindings/contribute/nightly-builds.html