2

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.

2
  • 2
    You will need to call Invalidate whenever your Data property changes. Commented Aug 18 at 10:44
  • @Bijington Thanks. I've edited my post to add code for invalidating when the property changes. Still having issues with the binding, though. Commented Aug 19 at 2:09

3 Answers 3

1

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));
}
Sign up to request clarification or add additional context in comments.

1 Comment

So much better! Thanks for the bonus ViewModel stuff, too.
0

According to @Julian from this answer:

It seems that Drawables cannot be used with BindableProperty, at least it doesn't have any effect, the value of the property doesn't get updated.

The workaround suggested by @Julian is to put the BindableProperty into a class derived from GraphicsView instead of the one implementing IDrawable.

I have adapted my code to the answer he gave there, and I'm posting it in its entirety in case someone is looking for a stripped down example of how to do this.

First, here is the the code for GraphView, incorporating the BindableProperty:

namespace TestNET8.Drawables;

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

    public static readonly BindableProperty DataProperty = BindableProperty.Create(nameof(Data), typeof(float[]), typeof(GraphView), propertyChanged: DataPropertyChanged);

    private static void DataPropertyChanged(BindableObject bindable, object oldValue, object newValue)
    {
        if (bindable is not GraphView { Drawable: GraphDrawable drawable } view)
        {
            return;
        }

        drawable.Data = (float[])newValue;
        view.Invalidate();
    }
}

Then the Drawable just has a public property for the data and the Draw function:

namespace TestNET8.Drawables; 

public partial class GraphDrawable : IDrawable
{
    public float[] Data { get; set; } = new float[100];

    public void Draw(ICanvas canvas, RectF dirtyRect)
    {
        // Creates a time series plot from the data values. Replace with whatever makes sense
        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]);
            }
        }
    }
}

GraphView calls Invalidate(), so there's no need to call it from the code-behind:

using TestNET8.ViewModels;

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

In the view model, when DataHolder is updated following a button click, the [ObservableProperty] (from the CommunityToolkit.Mvvm) automatically triggers the OnPropertyChanged event:

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

namespace TestNET8.ViewModels;

public partial class MainViewModel : ObservableObject
{
    [ObservableProperty]
    float[] dataHolder = new float[100];

    [RelayCommand]
    public void Refresh()
    {
        // Generates an array of random values when the button is clicked
        var rand = new Random();
        float[] temp = new float[100];
        for (int i = 0; i < 100; i++)
        {
            temp[i] = rand.NextSingle();
        }
        DataHolder = temp;
    }
}

Finally, GraphView.Data is bound to ViewModel.DataHolder (and the button click is bound to the Refresh command) in 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>
        <drawables:GraphView
            x:Name="GraphView"
            HeightRequest="200"
            WidthRequest="500"
            Data="{Binding DataHolder}">
            <drawables:GraphView.Drawable>
                <drawables:GraphDrawable/>
            </drawables:GraphView.Drawable>
        </drawables:GraphView>
        <Button 
            HeightRequest="40"
            WidthRequest="150"
            Text="Refresh"
            Command="{Binding RefreshCommand}"/>
    </VerticalStackLayout>
    
</ContentPage>

1 Comment

Creating a subclass of GraphicsView is halfway there. The other is making your subclass also implement IDrawable. I've shown how to do this in my answer.
-1

IDrawable with BindableProperty doesn't automatically trigger GraphicsView.Invalidate() . The binding system updates the property but doesn't know it needs to redraw the graphics. There's no connection between the property change and the visual invalidation

Try this

public class MainViewModel
{
    public float[] GraphData { get; set; }
    public GraphicsView GraphView { get; set; }
    
    public void RefreshData()
    {
        var random = new Random();
        GraphData = new float[20];
        for (int i = 0; i < GraphData.Length; i++)
        {
            GraphData[i] = (float)random.NextDouble();
        }
        
        // Update and redraw
        if (GraphView?.Drawable is GraphDrawable drawable)
        {
            drawable.Data = GraphData;
            GraphView.Invalidate();
        }
    }
}

public MainPage()
{
    InitializeComponent();
    var vm = new MainViewModel { GraphView = MyGraphicsView };
    BindingContext = vm;
    vm.RefreshData();
}

2 Comments

Thanks for your suggestion. I went another route with Invalidate and edited the question with my changes. The binding still doesn't seem to work, though.
This is a terrible suggestion. ViewModels should not hold references to View-elements, that's a violation of MVVM and will lead to circular dependencies.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.