Search code examples
c#.netwpf

WPF prevent flickering when updating primitives on Canvas


I have an application where I use a WPF Canvas to display primitives (e.g. polygons, polylines, ec.) based on a list of objects in a viewmodel. When I want to update, I remove the children from the Canvas via Children.Clear(), and then redraw the objects based on the data (ie. adding the new elements to the Children collection). This short time between clearing the children and drawing results in a noticeable flicker.

Is there any mechanism to prevent this, e.g. preventing visual updates for a short while?


Solution

  • You must not clear the complete source collection. The correct way is to use an ItemsControl. Or if you want scrolling and item selection, use a ListBox.

    The solution involves the following steps:

    • Add shared resources to the App.xaml ResourceDictionary (for example Brush is frozen by default).

    • Create a data model as suggested in the comments by Clemens.

    • Use an ObservableCollection as items source for the ListBox and remove and add items instead of clearing/replacing the collection.

    • Create the shapes dynamically using a DataTemplate for the ListBox.ItemTemplate that contains a ContentControlto display the dynamic shape. Bind ContentControl.Content property to the data item and specify an IValueConverter implementation.

    • Implement IValueConverter to convert the data provided by the data model to an actual Geometry (assigned to a Shape, for example Path).
      The converter is also responsible to freeze any Freezable object it creates (e.g. Geometry.Freeze).

    ShapeItem.cs
    Simplified example of a data model that provides the data that allows the IValueConverter to build a Geometry from.

    class ShapeItem : INotifyPropertyChanged
    {
      public void Relocate(Point newLocation)
      {  
        this.location = newLocation;
        OnPropertyChanged(nameof(this. Location)); // TODO::Implement INotifyPropertyChanged
      }
    
      // used to position the item on the Canvas 
      // (via data binding that is set from the ItemContainerStyle)
      // Note that because Point is a struct (value type) you can't modify the value returned by the property as his will always be a copy.
      // Use Relocate() to change the location.
      public Point Location => this.location.
    
      // Provide a dedicated type to carry the data required to build the shape.
      // Modifying this property will trigger the ContentControl in the 
      // DataTemplate to update the content and render the shape 
      // with the updated data.
      public ShapeInfo ShapeInfo { get; set; } // TODO::Raise NotifyPropertyChanged.PropertyChanged event!
    }
    

    ShapeInfo.cs
    Base class to provide the data to draw a shape.

    abstract class ShapeInfo : INotifyPropertyChanged, ICloneable
    {
      public event PropertyChangedEventHandler PropertyChanged;
    }
    

    RectangleShapeInfo.cs
    Provides the data to draw a rectangle (e.g. RectangleGeometry).

    class RectangleShapeInfo : ShapeInfo
    {
      public Size Size { get; }
      public double CornerRadius { get; }
      public object FillBrushId { get; }
    
      object ICloneable.Clone() => Clone();
      public RectangleShapeInfo Clone() 
        => (RectangleShapeInfo)MemberwiseClone();
    }
    

    ShapeInfoToShapeConverter.cs

    public class ShapeInfoToShapeConverter : IValueConverter
    {
      public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
      {
        if (value is not ShapeInfo shapeInfo)
        {
          throw new ArgumentException($"Wrong argument type. Expected type must be {typeof(ShapeInfo)}", nameof(value));
        }
    
        switch (shapeInfo)
        {
          case RectangleShapeInfo rectangleShapeInfo:
            return CreateRectangleShape(rectangleShapeInfo);
          default:
            throw new NotSupportedException();
        }
      }
    
      public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) 
        => throw new NotSupportedException();
    
      private Shape CreateRectangleShape(RectangleShapeInfo shapeInfo)
      {
        ResourceDictionary applicationResources = Application.Current.Resources;
        var rectangleBounds = new Rect(shapeInfo.Size);      
        var rectangleGeometry = new RectangleGeometry(rectangleBounds, shapeInfo.CornerRadius, shapeInfo.CornerRadius);
        // Freeze the new Geometry 
        rectangleGeometry.Freeze();
    
        var rectangeShape = new Path()
        {
          Data = rectangleGeometry,
    
          // Reference the already frozen resource 
          // from the application resource dictionary
          Fill = (Brush)applicationResources[shapeInfo.FillBrushId],
        };
    
        return rectangeShape;
      }
    }
    

    App.xaml

    <Application>
      <Application.Resources>
    
        <!-- Resources defined in this dictionary are frozen by default -->
    
        <SolidColorBrush x:Key="RectangleFillBrush"
                         Color="Orange" />
        <ShapeInfoToShapeConverter x:Key="ShapeInfoToShapeConverter" />
      </Application.Resources>
    </Application>
    

    MainWindow.xaml

    <Window>
     
      <!-- 
           Restrict size to enable scrolling 
           e.g. by adding the ListBox to a Grid row/column 
           that does allow its content to stretch infinitely 
      -->
      <ListBox ItemsSource="{Binding RelativeSource={RelativeSource AncestorType=Window}, Path=ShapeItems}"
               Height="500"
               Width="500">
        <ListBox.ItemsPanel>
          <ItemsPanelTemplate>
    
            <!-- Assign explicit dimensions to enable scrolling -->
            <Canvas Width="1000"
                    Height="1000" />
          </ItemsPanelTemplate>
        </ListBox.ItemsPanel>
    
        <ListBox.ItemContainerStyle>
          <Style TargetType="ListBoxItem">
    
            <!-- 
                 Position the container on the Canvas 
                 based on the data provided by the item model 
            -->
            <Setter Property="Canvas.Left"
                    Value="{Binding Location.X}" />
            <Setter Property="Canvas.Top"
                    Value="{Binding Location.Y}" />
          </Style>
        </ListBox.ItemContainerStyle>
     
        <ListBox.ItemTemplate>
          <DataTemplate DataType="local:ShapeItem">
            <ContentControl Content="{Binding ShapeInfo, Converter={StaticResource ShapeInfoToShapeConverter}}" />
          </DataTemplate>
        </ListBox.ItemTemplate>
      </ListBox>
    </Window>
    

    MainWindow.xaml.cs
    Example that shows how to populate the Canvas and how to dynamically update an existing shape without modifying the source collection.
    This will significantly improve the performance and UX.
    In an MVVM application, this code would be located in the view model.

    partial class MainWindow : Window
    {
      public ObservableCollection<ShapeItem> ShapeItems { get; }
    
      public MainWindow()
      {
        InitializeComponent();
      
        this.SahpeItems = new ObservableCollection<ShapeItem>
        {
          new ShapeItem()
          {
            Location = new Point(100, 100),
            ShapeInfo = new RectangleShapeInfo()
            {
              Size = new Size(200, 200),
              CornerRadius = 20,
              FillBrushId = "RectangleFillBrush",
            },
          },
          new ShapeItem()
          {
            Location = new Point(100, 400),
            ShapeInfo = new RectangleShapeInfo()
            {
              Size = new Size(200, 200),
              CornerRadius = 20,
              FillBrushId = "RectangleFillBrush",
            },
          },
        };
      }
    
      // Example that shows how to dynamically update an existing shape
      // without modifying the source collection.
      // This will significantly improve the performance and UX.
      private void ChangeRectangleAttributesAndRedraw()
      {
        ShapeItem shapeItemToModify = this.ShapeItems[0];
        RectangleShapeInfo newShapeInfo = shapeItemToModify.ShapeInfo.Clone();
    
        // Change the size of the current item
        newShapeInfo.Size = new Size(50, 50);
    
        // Change the location of the current item
        var newLocation = new Point(300, 100);
        shapeItemToModify.Relocate(newLocation);
    
        // Redraw the modified shape because modifying the ShapeInfo property 
        // triggers the IValueConverter to return a new Shape object 
        // based on the new data
        shapeItemToModify.ShapeInfo = newShapeInfo;
      }
    }