adospace / reactorui-maui

MauiReactor is a MVU UI framework built on top of .NET MAUI
MIT License
555 stars 46 forks source link

Invalidate Item component in `CollectionView` #182

Closed nor0x closed 7 months ago

nor0x commented 7 months ago

Hello again 👋 I have a CollectionView with various items and now I want to polish the UI. The plan is to animate the topmost upon scrolling (Scale In and Out). Similar to the MAUI coupons sample https://github.com/jsuarezruiz/netmaui-coupons-app-challenge/tree/main

I was wondering how I can invalidate the item in the CollectionView. I have already tried adding a Scale property to the model

public class MyItem
{
  public double Scale { get; set; }
}

and modify that value in the OnScrolled event. But this doesn't updated with every scroll event.

Is there a sample or some info on how to achieve such a scroll animation scenario? Thank you

Code-DJ commented 7 months ago

Were you modifying the value using:

SetState(state => state.Scale = ###);

Look at the Receipe Sampe App video here: https://github.com/adospace/mauireactor-samples/tree/main

https://github.com/adospace/mauireactor-samples/blob/main/RecipeApp/Pages/RecipeComponent.cs

adospace commented 7 months ago

Do you mean something like this:

class MainPageScrollingAnimationState
{
    public ObservableCollection<IndexedPersonWithAddress> Persons { get; set; }

    public double ScrollY { get; set; }
}

class MainPageScrollingAnimation : Component<MainPageScrollingAnimationState>
{
    private const double _itemSize = 128;

    private static List<IndexedPersonWithAddress> GenerateSamplePersons(int count)
    {
        var random = new Random();
        var firstNames = new[] { "John", "Jim", "Joe", "Jack", "Jane", "Jill", "Jerry", "Jude", "Julia", "Jenny" };
        var lastNames = new[] { "Brown", "Green", "Black", "White", "Blue", "Red", "Gray", "Smith", "Doe", "Jones" };
        var cities = new[] { "New York", "London", "Sidney", "Tokyo", "Paris", "Berlin", "Mumbai", "Beijing", "Cairo", "Rio" };

        var persons = new List<IndexedPersonWithAddress>();

        for (int i = 0; i < count; i++)
        {
            var firstName = firstNames[random.Next(0, firstNames.Length)];
            var lastName = lastNames[random.Next(0, lastNames.Length)];
            var age = random.Next(20, 60);
            var city = cities[random.Next(0, cities.Length)];
            var address = $"{city} No. {random.Next(1, 11)} Lake Park";

            persons.Add(new IndexedPersonWithAddress(i, firstName, lastName, age, address));
        }

        return persons;
    }

    protected override void OnMounted()
    {
        State.Persons = new ObservableCollection<IndexedPersonWithAddress>(GenerateSamplePersons(100));
        base.OnMounted();
    }

    public override VisualNode Render()
    {
        return new ContentPage
        {
            new CollectionView()
                .ItemsSource(State.Persons, RenderItem)
                .OnScrolled((s,e) => SetState(s => s.ScrollY = e.VerticalOffset, false))

            //new ScrollView
            //{
            //    new VStack
            //    {
            //        State.Persons.Select(RenderItem)
            //    }
            //}
            //.OnScrolled((s,e) => SetState(s => s.ScrollY = e.ScrollY, false))
        };
    }

    private VisualNode RenderItem(IndexedPersonWithAddress item)
    {
        return new Border
        {
            new Label($"Item {item.Index}")
                .TextColor(Colors.White)
                .Center()
        }
        .StrokeThickness(0)
        .StrokeCornerRadius(34, 34, 0, 0)
        .HeightRequest(_itemSize)
        .BackgroundColor(Colors.BlueViolet)
        .ScaleX(() => 0.5 + GetPercOffset(item) * 0.5)
        .Opacity(() => 0.2 + GetPercOffset(item) * 0.8)
        ;
    }

    private double GetPercOffset(IndexedPersonWithAddress item)
    {
        var itemScrollY = item.Index * _itemSize;

        if (itemScrollY < State.ScrollY - _itemSize)
        {
            return 0.0;
        }
        else if (itemScrollY > State.ScrollY + _itemSize) 
        {
            return 1.0;
        }

        return (itemScrollY - (State.ScrollY - _itemSize)) / (_itemSize * 2);
    }
}

ScrollAnimation

PS: the sample contains both implementations CollectionView vs ScrollView+Stack

nor0x commented 7 months ago

thank you very much @adospace for this. didn't notice that there is sample code available for my question. Will have a look at this now. https://github.com/adospace/mauireactor-samples/blob/main/Controls/CollectionViewTestApp/Pages/MainPageScrollingAnimation.cs

EDIT: i tried modifying the sample to have a separate component as the item in the CollectionViewand got the following - could you point me to what I'm doing wrong here? The updates from OnScrollingare not continously passed to the PersonComponent

https://github.com/adospace/reactorui-maui/assets/3210391/afd2f634-6ea5-4179-906c-c1a358a0c60f

here is my attempt:

class MainPageScrollingAnimationState
{
    public ObservableCollection<IndexedPersonWithAddress> Persons { get; set; }

    public double ScrollY { get; set; }
}

class MainPageScrollingAnimation : Component<MainPageScrollingAnimationState>
{
    private const double _itemSize = 128;

    private static List<IndexedPersonWithAddress> GenerateSamplePersons(int count)
    {
        var random = new Random();
        var firstNames = new[] { "John", "Jim", "Joe", "Jack", "Jane", "Jill", "Jerry", "Jude", "Julia", "Jenny" };
        var lastNames = new[] { "Brown", "Green", "Black", "White", "Blue", "Red", "Gray", "Smith", "Doe", "Jones" };
        var cities = new[] { "New York", "London", "Sidney", "Tokyo", "Paris", "Berlin", "Mumbai", "Beijing", "Cairo", "Rio" };

        var persons = new List<IndexedPersonWithAddress>();

        for (int i = 0; i < count; i++)
        {
            var firstName = firstNames[random.Next(0, firstNames.Length)];
            var lastName = lastNames[random.Next(0, lastNames.Length)];
            var age = random.Next(20, 60);
            var city = cities[random.Next(0, cities.Length)];
            var address = $"{city} No. {random.Next(1, 11)} Lake Park";

            persons.Add(new IndexedPersonWithAddress(i, firstName, lastName, age, address));
        }

        return persons;
    }

    protected override void OnMounted()
    {
        State.Persons = new ObservableCollection<IndexedPersonWithAddress>(GenerateSamplePersons(100));
        base.OnMounted();
    }

    public override VisualNode Render()
    {
        return new ContentPage
        {
            new CollectionView()
                .ItemsSource(State.Persons, RenderItem)
                .OnScrolled((s,e) => SetState(s => s.ScrollY = e.VerticalOffset, false))
        };
    }

    private VisualNode RenderItem(IndexedPersonWithAddress item)
         => new PersonComponent()
                .Item(item)
                .PercOffset(GetPercOffset(item));

    private double GetPercOffset(IndexedPersonWithAddress item)
    {
        var itemScrollY = item.Index * _itemSize;

        if (itemScrollY < State.ScrollY - _itemSize)
        {
            return 0.0;
        }
        else if (itemScrollY > State.ScrollY + _itemSize)
        {
            return 1.0;
        }

        return (itemScrollY - (State.ScrollY - _itemSize)) / (_itemSize * 2);
    }
}
class PesonComponentState
{
    public double PercOffset { get; set; }
}

class PersonComponent : Component<PesonComponentState>
{

    IndexedPersonWithAddress _item;
    double _itemSize = 128;
    double _percOffset;
    public PersonComponent Item(IndexedPersonWithAddress item)
    {
        _item = item;
        return this;
    }

    public PersonComponent PercOffset(double percOffset)
    {
        SetState(s => s.PercOffset = percOffset);
        return this;
    }

    public override VisualNode Render()
    {
        return new Border
        {
            new Label($"Item {_item.Index}")
                .TextColor(Colors.White)
                .Center()
        }
        .StrokeThickness(0)
        .StrokeCornerRadius(34, 34, 0, 0)
        .HeightRequest(_itemSize)
        .BackgroundColor(Colors.BlueViolet)
        .ScaleX(() => 0.5 + State.PercOffset * 0.5)
        .Opacity(() => 0.2 + State.PercOffset * 0.8)
        .WithAnimation()
        .WithKey(_item.Index);
    }
}
adospace commented 7 months ago

Hi, if you want to use a component for your items you have to wrap it inside a container (i.e. Grid) and modify its opacity and scale based on the scroll vertical offset:

class MainPageScrollingAnimationWithComponentState
{
    public ObservableCollection<IndexedPersonWithAddress> Persons { get; set; }

    public double ScrollY { get; set; }
}

class MainPageScrollingAnimationWithComponent : Component<MainPageScrollingAnimationWithComponentState>
{
    private const double _itemSize = 128;

    private static List<IndexedPersonWithAddress> GenerateSamplePersons(int count)
    {
        var random = new Random();
        var firstNames = new[] { "John", "Jim", "Joe", "Jack", "Jane", "Jill", "Jerry", "Jude", "Julia", "Jenny" };
        var lastNames = new[] { "Brown", "Green", "Black", "White", "Blue", "Red", "Gray", "Smith", "Doe", "Jones" };
        var cities = new[] { "New York", "London", "Sidney", "Tokyo", "Paris", "Berlin", "Mumbai", "Beijing", "Cairo", "Rio" };

        var persons = new List<IndexedPersonWithAddress>();

        for (int i = 0; i < count; i++)
        {
            var firstName = firstNames[random.Next(0, firstNames.Length)];
            var lastName = lastNames[random.Next(0, lastNames.Length)];
            var age = random.Next(20, 60);
            var city = cities[random.Next(0, cities.Length)];
            var address = $"{city} No. {random.Next(1, 11)} Lake Park";

            persons.Add(new IndexedPersonWithAddress(i, firstName, lastName, age, address));
        }

        return persons;
    }

    protected override void OnMounted()
    {
        State.Persons = new ObservableCollection<IndexedPersonWithAddress>(GenerateSamplePersons(100));
        base.OnMounted();
    }

    public override VisualNode Render()
    {
        return new ContentPage
        {
            new CollectionView()
                .ItemsSource(State.Persons, RenderItem)
                .OnScrolled((s,e) => SetState(s => s.ScrollY = e.VerticalOffset, false))
        };
    }

    private VisualNode RenderItem(IndexedPersonWithAddress item)
         => new Grid
         {
             new PersonComponent()
                .Item(item)
         }
        .ScaleX(() => 0.5 + GetPercOffset(item) * 0.5)
        .Opacity(() => 0.2 + GetPercOffset(item) * 0.8);

    private double GetPercOffset(IndexedPersonWithAddress item)
    {
        var itemScrollY = item.Index * _itemSize;

        if (itemScrollY < State.ScrollY - _itemSize)
        {
            return 0.0;
        }
        else if (itemScrollY > State.ScrollY + _itemSize)
        {
            return 1.0;
        }

        return (itemScrollY - (State.ScrollY - _itemSize)) / (_itemSize * 2);
    }
}

class PersonComponent : Component
{

    IndexedPersonWithAddress _item;
    readonly double _itemSize = 128;

    public PersonComponent Item(IndexedPersonWithAddress item)
    {
        _item = item;
        return this;
    }

    public override VisualNode Render()
    {
        return new Border
        {
            new Label($"Item {_item.Index}")
                .TextColor(Colors.White)
                .Center()
        }
        .StrokeThickness(0)
        .StrokeCornerRadius(34, 34, 0, 0)
        .HeightRequest(_itemSize)
        .BackgroundColor(Colors.BlueViolet);
    }
}

This is because we have to update Opacity and Scale without invalidating the entire component (which will cause a serious performance issue in this specific case).

To avoid full component refresh you have to call SetState with invalidateComponent parameter set to false, plus, you need to use a callback function to update directly the Opacity and Scale dependency properties:

image

nor0x commented 7 months ago

alright - understood! thanks again, still getting used to how stuff works in MVU 👋

nor0x commented 7 months ago

Sorry @adospace for coming back to this issue, I don't want to open new ones for every thing that I notice which is somewhat related to this.

I have implemented your suggestion for wrapping the component in a Grid and applying the Scale and Opactiy changes to this wrapper - works just fine 👍

I now have continued the development of my usecase and I somehow need to apply an animation to a child VisualNode in the component as well (the blue border in my sample code). Is this something that can be done without invalidating the whole component? It works fine without using a component for the CollectionView Item like this:

 private VisualNode RenderItem(IndexedPersonWithAddress item)
  {
      return new Border
      {
          new VStack()
          {
              new Border()
              .BackgroundColor(Colors.Blue)
              .WidthRequest(50)
              .HeightRequest(50)
              .VCenter()
              .HCenter()
/* ______  this animation but inside a component ______   */
              .Opacity(() => 0.2 + GetPercOffset(item) * 0.8)
              .WithAnimation()
              .WithKey(item.Index),

              new Label($"Item {item.Index}")
                  .TextColor(Colors.White)
                  .Center()
          }
      }
      .StrokeThickness(0)
      .StrokeCornerRadius(34, 34, 0, 0)
      .HeightRequest(_itemSize)
      .BackgroundColor(Colors.BlueViolet)
      .ScaleX(() => 0.5 + GetPercOffset(item) * 0.5)
      .WithAnimation()
      .WithKey(item.Index)
      ;
  }

image

adospace commented 7 months ago

Hi, sorry for the late reply, I was busy this week. Regarding your issue, I think it's more a c# task than a specific MauiReactor thing. I mean, the problem is (considering this specific case) is to not invalidate the entire component to make a smooth animation.

So I guess, we can use a code like the following where I pass the scrolling event down to the items. Each item component can attach the event, and on call back update its UI without invalidating the entire component.

class MainPageScrollingAnimationWithComponentState
{
    public ObservableCollection<IndexedPersonWithAddress> Persons { get; set; }
}

class MainPageScrollingAnimationWithComponent : Component<MainPageScrollingAnimationWithComponentState>
{
    event EventHandler<double> Scrolled;

    private static List<IndexedPersonWithAddress> GenerateSamplePersons(int count)
    {
        var random = new Random();
        var firstNames = new[] { "John", "Jim", "Joe", "Jack", "Jane", "Jill", "Jerry", "Jude", "Julia", "Jenny" };
        var lastNames = new[] { "Brown", "Green", "Black", "White", "Blue", "Red", "Gray", "Smith", "Doe", "Jones" };
        var cities = new[] { "New York", "London", "Sidney", "Tokyo", "Paris", "Berlin", "Mumbai", "Beijing", "Cairo", "Rio" };

        var persons = new List<IndexedPersonWithAddress>();

        for (int i = 0; i < count; i++)
        {
            var firstName = firstNames[random.Next(0, firstNames.Length)];
            var lastName = lastNames[random.Next(0, lastNames.Length)];
            var age = random.Next(20, 60);
            var city = cities[random.Next(0, cities.Length)];
            var address = $"{city} No. {random.Next(1, 11)} Lake Park";

            persons.Add(new IndexedPersonWithAddress(i, firstName, lastName, age, address));
        }

        return persons;
    }

    protected override void OnMounted()
    {
        State.Persons = new ObservableCollection<IndexedPersonWithAddress>(GenerateSamplePersons(100));
        base.OnMounted();
    }

    public override VisualNode Render()
    {
        return new ContentPage
        {
            new CollectionView()
                .ItemsSource(State.Persons, RenderItem)
                .OnScrolled((s,e) => Scrolled?.Invoke(s, e.VerticalOffset))
        };
    }

    private VisualNode RenderItem(IndexedPersonWithAddress item)
         => new PersonComponent()
                .WhenScroll(ref Scrolled)
                .Item(item);
}

class PersonComponentState
{
    public double ScrollY { get; set; }
}

class PersonComponent : Component<PersonComponentState>
{
    IndexedPersonWithAddress _item;
    readonly double _itemSize = 128;

    public PersonComponent Item(IndexedPersonWithAddress item)
    {
        _item = item;
        return this;
    }

    public PersonComponent WhenScroll(ref EventHandler<double> scrolled)
    {
        scrolled += (s, verticalOffset) => SetState(s => s.ScrollY = verticalOffset, false);
        return this;        
    }

    public override VisualNode Render()
    {
        double GetPercOffset()
        {
            var itemScrollY = _item.Index * _itemSize;

            if (itemScrollY < State.ScrollY - _itemSize)
            {
                return 0.0;
            }
            else if (itemScrollY > State.ScrollY + _itemSize)
            {
                return 1.0;
            }

            return (itemScrollY - (State.ScrollY - _itemSize)) / (_itemSize * 2);
        }

        return new Border
        {
            new VStack()
            {
                new Border()
                .BackgroundColor(Colors.Blue)
                .WidthRequest(50)
                .HeightRequest(50)
                .VCenter()
                .HCenter()
                .ScaleX(() => 1.0 + GetPercOffset()),

                new Label($"Item {_item.Index}")
                    .TextColor(Colors.White)
                    .Center()
            }
        }
        .StrokeThickness(0)
        .StrokeCornerRadius(34, 34, 0, 0)
        .HeightRequest(_itemSize)
        .BackgroundColor(Colors.BlueViolet)
        //.ScaleX(() => 0.5 + GetPercOffset() * 0.5)
        .Opacity(() => 0.2 + GetPercOffset() * 0.8)
        ;
    }

}

Feel free to leave the task open or re-open it in case you need more support.

adospace commented 7 months ago

Just revisited the sample and I found that the above code contains a memory leak, please look at the updated sample:

https://github.com/adospace/mauireactor-samples/blob/main/Controls/CollectionViewTestApp/Pages/MainPageScrollingAnimationWithComponent.cs

this is the part changed:

    IndexedPersonWithAddress _item;
    private Action<EventHandler<double>> _subscribeToScrollEvent;

    readonly double _itemSize = 128;

    public PersonComponent Item(IndexedPersonWithAddress item)
    {
        _item = item;
        return this;
    }

    public PersonComponent WhenScroll(Action<EventHandler<double>> subscribeToScrollEvent)
    {
        _subscribeToScrollEvent = subscribeToScrollEvent;
        return this;        
    }

    protected override void OnMounted()
    {
        //System.Diagnostics.Debug.WriteLine($"OnMounted (Item {_item.Index})");
        _subscribeToScrollEvent?.Invoke(OnParentScrolled);
        base.OnMounted();
    }

    void OnParentScrolled(object sender, double verticalOffset)
    {
        SetState(s => s.ScrollY = verticalOffset);
    }
nor0x commented 7 months ago

i'm following the code here (https://github.com/adospace/mauireactor-samples/commit/631cff0b21b5f993571ca0eec7f304bac1e779b7#diff-dacab46b064ae55b6f850e4b47d44b421c4c36bb7ac05b469a8e0c34e015a276) and it works perfectly. thanks again 🙌