.netxamldata-bindingmauidraw

Trouble binding data to an IDrawable


I'm trying to put together an app, but I've run into problems trying to get data into an IDrawable graph display. The app is just supposed to show a plot of random data from a float[] which is then updated when the refresh button is clicked.

The approach that seemed most Mvvm-ish was to make the IDrawable a BindableObject (mainly from How to pass variable data into .NET MAUI GraphicsView canvas and In .NET MAUI, how do I pass variables to a GraphicsView.Drawable in a ContentView).

The problem is that the view is never updated: Draw is called once when the app starts (but the data is null), and then never again. The data held in the ViewModel is being updated correctly, but those changes don't appear to be getting passed on. It's my understanding that when a change in the data is detected, the view will automatically be Invalidate()-d and the graph should be redrawn.`

(I'm using VS2022 Community 17.14.12, .NET 8.0 with the CommunityToolkit.Mvvm 8.3.2.)

Here's the code for the `IDrawable':

namespace TestNET8.Drawables; 

public partial class GraphDrawable : BindableObject, IDrawable
{
    
    public float[] Data
    {
        get => (float[])GetValue(DataProperty);
        set => SetValue(DataProperty, value);
    }

    public static readonly BindableProperty DataProperty =
        BindableProperty.Create(nameof(Data), typeof(float[]), typeof(GraphDrawable));

    public void Draw(ICanvas canvas, RectF dirtyRect)
    {
        if (Data != null && Data.Length > 0)
        {
            for (int i = 0; i < Data.Length - 1; i++)
            {
                canvas.DrawLine(5 * i, 100 * Data[i], 5 * (i + 1), 100 * Data[i + 1]);
            }
        }
    }
}

The view model:

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace TestNET8.ViewModels;

public partial class MainViewModel : ObservableObject
{
    private float[] dataHolder = new float[100];
    public float[] DataHolder
    {
        get => dataHolder;
        set
        {
            if (dataHolder == value) return;
            dataHolder = value;
            OnPropertyChanged();
        }
    }

    [RelayCommand]
    public void Refresh()
    {
        var rand = new Random();
        float[] temp = new float[100];
        for (int i = 0; i < 100; i++)
        {
            temp[i] = rand.NextSingle();
        }
        DataHolder = temp;
    }
}

and the xaml:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:viewmodel="clr-namespace:TestNET8.ViewModels"
             xmlns:drawables="clr-namespace:TestNET8.Drawables"
             x:DataType="viewmodel:MainViewModel"
             x:Class="TestNET8.MainPage">

    <VerticalStackLayout>
        <GraphicsView
        HeightRequest="200"
        WidthRequest="500">
            <GraphicsView.Drawable>
                <drawables:GraphDrawable Data="{Binding DataHolder}"/>
            </GraphicsView.Drawable>
        </GraphicsView>
        <Button 
            Text="Refresh"
            Command="{Binding RefreshCommand}"/>
    </VerticalStackLayout>
</ContentPage>

The MainPage code-behind is just:

public partial class MainPage : ContentPage
{
    public MainPage(MainViewModel vm)
    {
        InitializeComponent();
        BindingContext = vm;
        
    }
}

and MauiProgram.cs contains the lines

builder.Services.AddSingleton<MainPage>();
builder.Services.AddSingleton<MainViewModel>();
builder.Services.AddSingleton<GraphDrawable>();

Edit: As everyone pointed out, I still need to Invalidate() the view when the property changes. So I added a reference to the view in the xaml:

x:Name="GraphView"

and updated the code-behind to respond to the property change event:

using TestNET8.ViewModels;

namespace TestNET8
{
    public partial class MainPage : ContentPage
    {
        readonly MainViewModel viewModel;

        public MainPage(MainViewModel vm)
        {
            InitializeComponent();
            BindingContext = vm;
            viewModel = vm;
        }

        protected override void OnAppearing()
        {
            base.OnAppearing();
            viewModel.PropertyChanged += ViewModel_PropertyChanged;
        }

        protected override void OnDisappearing()
        {
            viewModel.PropertyChanged -= ViewModel_PropertyChanged;
            base.OnDisappearing();
        }

        private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
        {
            GraphView.Invalidate();
        }
    }
}

This seemed like a Mvvm way of doing it, but please let me know if there's a better way.

The button click now redraws the GraphicsView and refreshes the DataHolder array in the view model, but when Draw is called, the Data array in the GraphDrawable is null. So there seems to be something wrong with my binding.


Solution

  • The simplest way to work with IDrawable and GraphicsView is to create a class that inherits from GraphicsView and implements IDrawable as follows:

    public partial class MyGraphicsView : GraphicsView, IDrawable
    {
        // Implement your Bindable properties here, e.g. public float[]? Data
        // Constructor sets GraphicsView.Drawable to (IDrawable) this.
        public MyGraphicsView()
        {
             Drawable = this;
        }
        // Implement IDrawable.Draw
        public void Draw(ICanvas canvas, RectF dirtyRect)
        {
        }
    }
    

    GraphicsView is already a BindableObject, so Draw can access both its properties and your custom BindableProperties. This also makes it easy to call Invalidate() whenever a property changes.

    public partial class MyGraphicsView : GraphicsView, IDrawable
    {
        public static readonly BindableProperty DataProperty =
            BindableProperty.Create(nameof(Data), typeof(float[]), typeof(MyGraphicsView),
                propertyChanged: (b, o, n) => ((MyGraphicsView)(b)).Invalidate());
        public float[]? Data
        {
            get => (float[]?)GetValue(DataProperty);
            set => SetValue(DataProperty, value);
        }
        public static readonly BindableProperty StrokeColorProperty =
            BindableProperty.Create(nameof(StrokeColor), typeof(Color), typeof(MyGraphicsView), Colors.Red,
                propertyChanged: (b, o, n) => ((MyGraphicsView)(b)).Invalidate());
        public Color StrokeColor
        {
            get => (Color)GetValue(StrokeColorProperty);
            set => SetValue(StrokeColorProperty, value);
        }
        public MyGraphicsView()
        {
            Drawable = this;
        }
        public void Draw(ICanvas canvas, RectF dirtyRect)
        {
            canvas.StrokeColor = this.StrokeColor;
            if (Data is not null && Data.Length > 0)
            {
                for (int i = 0; i < Data.Length - 1; i++)
                {
                    canvas.DrawLine(
                        (float)(i * this.Width / Data.Length),
                        (float)(Data[i] * this.Height),
                        (float)((i + 1) * this.Width / Data.Length),
                        (float)(Data[i + 1] * this.Height));
                }
            }
        }
    }
    
    public partial class MainViewModel : ObservableObject
    {
        [ObservableProperty]
        public partial float[]? DataHolder { get; set; }
        static Random rand = new Random();
        [RelayCommand]
        public void Refresh()
        {
            /*
            float[] temp = new float[100];
            for (int i = 0; i < 100; i++)
            {
                temp[i] = rand.NextSingle();
            }
            DataHolder = temp;
            */
            DataHolder = Enumerable.Range(0, 100).Select(i => rand.NextSingle()).ToArray();
        }
    }
    
    <ContentPage
        x:Class="SO79738507.MainPage"
        xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
        xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
        xmlns:local="clr-namespace:SO79738507"
        x:DataType="local:MainViewModel">
        <Grid RowDefinitions="*,Auto">
            <local:MyGraphicsView x:Name="myGraphicsView" Data="{Binding DataHolder}" />
            <Button Grid.Row="1" Command="{Binding RefreshCommand}" Text="Click me" />
        </Grid>
    </ContentPage>
    
    public partial class MainPage : ContentPage
    {
        public MainPage(MainViewModel vm)
        {
            BindingContext = vm;
            InitializeComponent();
        }
    }
    

    For a working source see: https://github.com/stephenquan/StackOverflow.Maui/tree/main/src/SO79738507

    MyGraphicsView.png

    With an understanding of properties and OnPropertyChanged, we can shorten MainViewModel even further:

    public partial class MainViewModel : ObservableObject
    {
        static Random rand = new Random();
        public float[]? DataHolder => Enumerable.Range(0, 100).Select(i => rand.NextSingle()).ToArray();
        [RelayCommand]
        public void Refresh() => OnPropertyChanged(nameof(DataHolder));
    }