Search code examples
c#data-bindingmaui

.NET MAUI binding to GraphicsView


I have problems understanding how binding works in .NET MAUI. My goal is to have two ways of displaying a score inside a CollectionView, a label with the number and a graphical representation. The binding of the score to the label works just fine, but the binding to the Drawable does not. If I write a number instead of using a binding, it is passed just fine.

What am I doing wrong?

ProceduresPage.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:model="clr-namespace:MediSkillApp.Model"
             xmlns:drawables="clr-namespace:MediSkillApp.Drawables"
             xmlns:viewmodel="clr-namespace:MediSkillApp.ViewModel"
             x:Class="MediSkillApp.View.ProceduresPage"
             x:DataType="viewmodel:ProceduresViewModel"
             Title="Alle mine indgreb">

    <Grid ColumnDefinitions="*"
          ColumnSpacing="5"
          RowSpacing="0">

        <CollectionView
            BackgroundColor="Transparent"
            ItemsSource="{Binding Procedures}"
            RemainingItemsThresholdReachedCommand="{Binding GetProceduresCommand}"
            RemainingItemsThreshold="5">
            <CollectionView.ItemTemplate>
                <DataTemplate x:DataType="model:Procedure">
                    <VerticalStackLayout>
                        <Frame Margin="5">
                            <Grid ColumnDefinitions="64,*, 64">
                                <Image 
                                    Grid.Column="0"
                                    Source="{Binding Icon}"
                                    HeightRequest="48"
                                    WidthRequest="48"
                                    HorizontalOptions="Start"/>
                                
                                <VerticalStackLayout
                                    Grid.Column="1">
                                    <Label Text="{Binding ProcedureTypeString}"
                                           Style="{StaticResource Heading}"/>
                                    <Label Text="{Binding OpRoleString}"
                                           Style="{StaticResource NormalLabel}"/>
                                    <Label Text="{Binding Date, StringFormat='{0:dd/MM-yyyy}'}"
                                           Style="{StaticResource NormalLabel}"/>
                                </VerticalStackLayout>
                                
                                <VerticalStackLayout
                                    Grid.Column="2"
                                    IsVisible="{Binding IsScored}">

                                    <!-- this binding works -->
                                    <Label Text="{Binding AvgScore, StringFormat='{0:F2}'}" 
                                           HorizontalOptions="Center"/>

                                    <Image Source="scoremeter.png"/>
                                    
                                    <GraphicsView>
                                        <GraphicsView.Drawable>

                                            <!-- this binding does not -->
                                            <drawables:ScoreGaugeDrawable
                                                    Score="{Binding AvgScore}"/>
                                        </GraphicsView.Drawable>
                                    </GraphicsView>
                                </VerticalStackLayout>
                            </Grid>
                        </Frame>
                    </VerticalStackLayout>

                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>

        <ActivityIndicator IsVisible="{Binding IsBusy}"
                           IsRunning="{Binding IsBusy}"
                           HorizontalOptions="FillAndExpand"
                           VerticalOptions="CenterAndExpand"/>
    </Grid>
    
    
</ContentPage>

ScoreGaugeDrawable.cs

namespace MediSkillApp.Drawables;

public class ScoreGaugeDrawable : BindableObject, IDrawable
{
    public static readonly BindableProperty ScoreProperty = BindableProperty.Create(nameof(Score),
        typeof(double),
        typeof(ScoreGaugeDrawable));

    public double Score {
        get => (double)GetValue(ScoreProperty);
        set => SetValue(ScoreProperty, value);
    }

    public void Draw(ICanvas canvas, RectF dirtyRect)
    {
        var centerPoint = new PointF(32, 0);
        var circleRadius = 5;

        canvas.FillColor = Colors.Black;
        canvas.FillCircle(centerPoint, circleRadius);

        canvas.StrokeColor = Colors.Black;
        canvas.DrawLine(centerPoint, new Point(0, Score * 10)); //Just draw something for testing
    }
}

Procedure.cs

namespace MediSkillApp.Model;

public class Procedure
{
    public string Identifier { get; set; }
    public DateTime Date { get; set; }
    public string GetDate {
        get => Date.ToString("d/M-yyyy");
    }

    public int ProcedureType { get; set; }
    public string ProcedureTypeString { get; set; }
    public double AvgScore { get; set; }

    public string GetAvgScore {
        get {
            if (AvgScore == 0) return "";
            return AvgScore.ToString();
        }
    }

    public int OpRole { get; set; }
    public string OpRoleString { get; set; }

    public string Icon {
        get {
            switch (ProcedureType) {
                case 7:
                    return Icons.IconBleed;
                case 8:
                    return Icons.IconTEA;
                case 18:
                    return Icons.IconTEA;
                default:
                    return Icons.IconSurgery;
            }
        }
    }

    public bool IsScored => AvgScore > 0;
}

ProceduresViewModel.cs

using MediSkillApp.Model;
using MediSkillApp.Services;

namespace MediSkillApp.ViewModel;

public partial class ProceduresViewModel : BaseViewModel
{
    public ObservableCollection<Procedure> Procedures { get; } = new();
    private APIService APIservice;

    public ProceduresViewModel(APIService aPIservice) {
        APIservice = aPIservice;
    }

    [RelayCommand]
    public async Task GetProceduresAsync() {
        if(IsBusy) return;

        try {
            IsBusy = true;
            var procedures = await APIservice.GetProceduresAsync("8", "dawda", Procedures.Count, 15);

            foreach (Procedure procedure in procedures) {
                Procedures.Add(procedure);
            }
        } catch(Exception ex) {
            Debug.WriteLine(ex);
        } finally {
            IsBusy = false;
        }
    }

    public void ClearProcedures() {
        Procedures.Clear();
    }
}

Solution

  • I was able to reproduce the problem. 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.

    I managed to find a workaround for this issue, however. Instead of adding the Score property to the ScoreGaugeDrawable, you can add it to the GraphicsView by extending it via inheritance.

    You can remove the BindableObject base class as well as the bindable ScoreProperty from the ScoreGaugeDrawable and turn the Score property into a regular property with default getter and setter:

    namespace MediSkillApp.Drawables;
    
    public class ScoreGaugeDrawable : IDrawable
    {
        public double Score { get; set; }
    
        public void Draw(ICanvas canvas, RectF dirtyRect)
        {
            var centerPoint = new PointF(32, 0);
            var circleRadius = 5;
    
            canvas.FillColor = Colors.Black;
            canvas.FillCircle(centerPoint, circleRadius);
    
            canvas.StrokeColor = Colors.Black;
            canvas.DrawLine(centerPoint, new Point(0, Score * 10)); //Just draw something for testing
        }
    }
    

    Then, create a ScoreGraphicsView that inherits from GraphicsView and add the bindable ScoreProperty to it:

    namespace MediSkillApp.Drawables;
    
    public class ScoreGraphicsView : GraphicsView
    {
        public double Score
        {
            get => (double)GetValue(ScoreProperty);
            set => SetValue(ScoreProperty, value);
        }
    
        public static readonly BindableProperty ScoreProperty = BindableProperty.Create(nameof(Score), typeof(double), typeof(ScoreGraphicsView), propertyChanged: ScorePropertyChanged);
    
        private static void ScorePropertyChanged(BindableObject bindable, object oldValue, object newValue)
        {
            if (bindable is not ScoreGraphicsView { Drawable: ScoreGaugeDrawable drawable } view)
            {
                return;
            }
    
            drawable.Score = (double)newValue;
            view.Invalidate();
        }
    }
    

    This way, the score needs to be passed to the GraphicsView, which (unfortunately) must now know about the ScoreGaugeDrawable. What this code does is, it receives any updates to the bindable ScoreProperty and relays the value to the ScoreGaugeDrawable. If the value has changed and the Drawable is of type ScoreGaugeDrawable, the new value is set and then the view gets invalidated, which triggers a redraw.

    You can use the ScoreGraphicsView and ScoreGaugeDrawable like this in your XAML then:

    <drawables:ScoreGraphicsView
        Score="{Binding AvgScore}">
        <drawables:ScoreGraphicsView.Drawable>
            <drawables:ScoreGaugeDrawable/>
        </drawables:ScoreGraphicsView.Drawable>
    </drawables:ScoreGraphicsView>
    

    This is not ideal, but should solve your problem for the time being. I've tested this myself in my MAUI Samples repository and it works quite well.