Search code examples
c#mauiskiasharp

.NET Maui Custom SkiaSharp control bindable properties not behaving as expected


I suck at titles, feel free to change it.

I found a tutorial for making custom controls in Maui using SkiaSharp. I modified it a little by using C# Markup instead of XAML. Except for having to instantiate the progress bar with ProgressBar.Maui.ProgressBar progressBar instead of ProgressBar progressBar, everything worked great.

I wanted to modify it a little by limiting the redraws to only happen after a certain progress change threshold was met. (i.e. only redraw every x% progress). I added a button that increments the progress by less then the threshold, so I should have to click the buttons a few times to see the progress bar update. However, the progress bar updates every time I click the button.

If I change the progress manually, like adding progressBar.Progress = 0.01f; to the end of my page constructor, the progress bar behaves as expected.

Here is all the relevant code [I think]

My library project only has 2 files

ProgressBar.cs

using SkiaSharp;
using SkiaSharp.Views.Maui;
using SkiaSharp.Views.Maui.Controls;
using System.Diagnostics;

namespace ProgressBar.Maui;

// All the code in this file is included in all platforms.
public class ProgressBar : SKCanvasView
{
   public static readonly BindableProperty ProgressProperty =
   BindableProperty.Create(nameof(Progress), typeof(float), typeof(ProgressBar), 0f, propertyChanged: OnBindablePropertyChanged);

   public static readonly BindableProperty ProgressColorProperty =
      BindableProperty.Create(nameof(ProgressColor), typeof(Color), typeof(ProgressBar), Colors.CornflowerBlue, propertyChanged: OnBindablePropertyChanged);

   public static readonly BindableProperty BaseColorProperty =
      BindableProperty.Create(nameof(BaseColor), typeof(Color), typeof(ProgressBar), Colors.LightGray, propertyChanged: OnBindablePropertyChanged);

   public float Progress
   {
      get => (float)GetValue(ProgressProperty);
      set
      {
         if (Math.Abs(value - _previousProgress) >= _RedrawThreshold)
         {
            SetValue(ProgressProperty, value);
            _previousProgress = value;
            Debug.WriteLine("Progress updated and surface invalidated.");
         }
         else
         {
            Debug.WriteLine("Progress change below threshold. No redraw.");
         }
      }
   }

   public Color ProgressColor
   {
      get => (Color)GetValue(ProgressColorProperty);
      set => SetValue(ProgressColorProperty, value);
   }
   public Color BaseColor
   {
      get => (Color)GetValue(BaseColorProperty);
      set => SetValue(BaseColorProperty, value);
   }


   // actual canvas instance to draw on
   private SKCanvas _canvas;

   // rectangle which will be used to draw the Progress Bar
   private SKRect _drawRect;

   // holds information about the dimensions, etc.
   private SKImageInfo _info;

   private const float _RedrawThreshold = 0.5f; // 1% change
   private float _previousProgress = 0f;

   private SKPaint _basePaint = new()
   {
      Style = SKPaintStyle.Fill,
      IsAntialias = true,
   };

   private SKPaint _progressPaint = new()
   {
      Style = SKPaintStyle.Fill,
      IsAntialias = true,
   };



   protected override void OnPaintSurface (SKPaintSurfaceEventArgs e)
   {
      base.OnPaintSurface(e);

      _canvas = e.Surface.Canvas;
      _canvas.Clear();

      _info = e.Info;

      _drawRect = new SKRect(0, 0, _info.Width, _info.Height);

      DrawBase();
      DrawProgress();
   }

   private void DrawBase ()
   {
      using SKPath basePath = new();

      basePath.AddRect(_drawRect);

      _basePaint.Color = BaseColor.ToSKColor();

      _canvas.DrawPath(basePath, _basePaint);
   }

   private void DrawProgress ()
   {
      using SKPath progressPath = new();

      var progressRect = new SKRect(0, 0, _info.Width * Progress, _info.Height);

      progressPath.AddRect(progressRect);

      _progressPaint.Color = ProgressColor.ToSKColor();

      _canvas.DrawPath(progressPath, _progressPaint);
   }

   private static void OnBindablePropertyChanged (BindableObject bindable, object oldValue, object newValue)
   {
      if (oldValue != newValue)
         ((ProgressBar)bindable).InvalidateSurface();
   }
}

and

Registration.cs

using SkiaSharp.Views.Maui.Handlers;

namespace ProgressBar.Maui;
public static class Registration
{
   public static MauiAppBuilder UseProgressBar(this MauiAppBuilder builder)
   {
      builder.ConfigureMauiHandlers(h =>
      {
         h.AddHandler<ProgressBar, SKCanvasViewHandler>();
      });

      return builder;
   }
}

My sample project contains

SamplePage.cs


namespace ProgressBarSample.Views;

public partial class SamplePage : ContentPage
{
   public SamplePage (SampleViewModel viewModel)
   {
      BindingContext = viewModel;

      Content = new VerticalStackLayout
      {
         Spacing = 30,
         Padding = new Thickness(30, 0),
         VerticalOptions = LayoutOptions.Center,

         Children =
         {
            new ProgressBar.Maui.ProgressBar()
            {
               WidthRequest = 300,
               HeightRequest = 5,
               ProgressColor = Colors.DeepSkyBlue,
            }
            .Assign(out ProgressBar.Maui.ProgressBar progressBar)
            .Bind(ProgressBar.Maui.ProgressBar.ProgressProperty, nameof(SampleViewModel.Progress), BindingMode.OneWay),

            new Button
            {
               Text = "Increment",
            }
            .BindCommand(nameof(SampleViewModel.IncrementProgressCommand))
         }
      };
   }
}

and

SampleViewModel.cs

namespace ProgressBarSample.ViewModels;

public partial class SampleViewModel : BaseViewModel
{
    [ObservableProperty]
    private float progress = 0.01f;

    [RelayCommand]
    private void IncrementProgress()
    {
        Debug.WriteLine("IncrementProgress()");
        Progress +=0.01f;
    }
}

BaseViewModel.cs is an empty class that inherits ObservableObject

I also added builder.UseProgressBar() to MauiProgram.cs


Solution

  • I think I figured it out. In C# Markup, the .Bind extensions binds to the ProgressProperty directly and not the public property Progress. This means, I think, that the threshold logic is never run when I click the button.

    I just reworked the logic a bit

    ProgressBar.cs

    using SkiaSharp;
    using SkiaSharp.Views.Maui;
    using SkiaSharp.Views.Maui.Controls;
    using System.Diagnostics;
    
    namespace ProgressBar.Maui;
    
    // All the code in this file is included in all platforms.
    public class ProgressBar : SKCanvasView
    {
       public static readonly BindableProperty ProgressProperty =
       BindableProperty.Create(nameof(Progress), typeof(float), typeof(ProgressBar), 0f, propertyChanged: OnProgressPropertyChanged, coerceValue: ClampProgressValue);
    
       public static readonly BindableProperty ProgressColorProperty =
          BindableProperty.Create(nameof(ProgressColor), typeof(Color), typeof(ProgressBar), Colors.CornflowerBlue, propertyChanged: OnColorPropertyChanged);
    
       public static readonly BindableProperty BaseColorProperty =
          BindableProperty.Create(nameof(BaseColor), typeof(Color), typeof(ProgressBar), Colors.LightGray, propertyChanged: OnColorPropertyChanged);
    
       public float Progress
       {
          get => (float)GetValue(ProgressProperty);
          set => SetValue(ProgressProperty, value);
       }
    
       public Color ProgressColor
       {
          get => (Color)GetValue(ProgressColorProperty);
          set => SetValue(ProgressColorProperty, value);
       }
       public Color BaseColor
       {
          get => (Color)GetValue(BaseColorProperty);
          set => SetValue(BaseColorProperty, value);
       }
    
    
       // actual canvas instance to draw on
       private SKCanvas _canvas;
    
       // rectangle which will be used to draw the Progress Bar
       private SKRect _drawRect;
    
       // holds information about the dimensions, etc.
       private SKImageInfo _info;
    
       private const float _RedrawThreshold = 0.1f; // 1% change
       private float _displayedProgress = 0f;
    
       private SKPaint _basePaint = new()
       {
          Style = SKPaintStyle.Fill,
          IsAntialias = true,
       };
    
       private SKPaint _progressPaint = new()
       {
          Style = SKPaintStyle.Fill,
          IsAntialias = true,
       };
    
    
       protected override void OnPaintSurface (SKPaintSurfaceEventArgs e)
       {
          base.OnPaintSurface(e);
    
          _canvas = e.Surface.Canvas;
          _canvas.Clear();
    
          _info = e.Info;
    
          _drawRect = new SKRect(0, 0, _info.Width, _info.Height);
    
          DrawBase();
          DrawProgress();
       }
    
       private void DrawBase ()
       {
          using SKPath basePath = new();
    
          basePath.AddRect(_drawRect);
    
          _basePaint.Color = BaseColor.ToSKColor();
    
          _canvas.DrawPath(basePath, _basePaint);
       }
    
       private void DrawProgress ()
       {
          using SKPath progressPath = new();
    
          var progressRect = new SKRect(0, 0, _info.Width * Progress, _info.Height);
    
          progressPath.AddRect(progressRect);
    
          _progressPaint.Color = ProgressColor.ToSKColor();
    
          _canvas.DrawPath(progressPath, _progressPaint);
       }
    
       private static void OnProgressPropertyChanged (BindableObject bindable, object oldValue, object newValue)
       {
          if (oldValue == newValue)
             return;
    
          if ((float)newValue - ((ProgressBar)bindable)._displayedProgress >= _RedrawThreshold)
             ((ProgressBar)bindable).Update();
       }
    
       private static void OnColorPropertyChanged (BindableObject bindable, object oldValue, object newValue)
       {
          if (oldValue != newValue)
             ((ProgressBar)bindable).InvalidateSurface();
       }
    
       public void Update ()
       {
          if (_displayedProgress != Progress)
          {
             _displayedProgress = Progress;
             InvalidateSurface();
          }
       }
    
    
       private static object ClampProgressValue (BindableObject bindable, object value)
       {
          return (float)value switch
          {
             < 0 => 0,
             > 1 => 1,
             _ => value,
          };
       }
    }