Search code examples
wpfvb.netitemscontrolupdating

ItemsControl bound to ObservableColellection not updating UI on property change


After a lot of headache and late hours, I've given up on trying to solve the problem to this answer myself. While there is a lot of literature for very similar issues that can be found, I haven't been able to find an exact solution to my particular problem.

I am having trouble getting my ItemsControl using a canvas as the ItemsPanel to update the UI after a property of an item within its ItemsSource has been modified.

I have created a very clean sample application to demonstrate exactly what's going on.

In my sample application, I have a view 'MainWindow.xaml', a viewmodel 'MainWindowViewModel.vb' which is inheriting 'ViewModelBase.vb', and lastly a command delegate 'DelegateCommand.vb' which is used to create RelayCommands to update the ItemSource of my ItemsControl.

First, MainWindow.xaml:

<Window
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:SampleApp"
    x:Class="MainWindow" Title="MainWindow" Height="347" Width="525" Background="Black">
    <Window.DataContext>
        <local:MainWindowViewModel/>
    </Window.DataContext>
    <Grid>
        <!-- LINE SEGMENTS -->
        <ItemsControl x:Name="ic1" ItemsSource="{Binding LineData, Mode=OneWay, NotifyOnTargetUpdated=True}" HorizontalAlignment="Left" Height="246" VerticalAlignment="Top" Width="517" Background="#FF191919" BorderBrush="#FF444444">
            <ItemsControl.ItemsPanel>
                <ItemsPanelTemplate>
                    <Canvas IsItemsHost="True"/>
                </ItemsPanelTemplate>
            </ItemsControl.ItemsPanel>
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <Line X1="{Binding X1}" Y1="{Binding Y1}" X2="{Binding X2}" Y2="{Binding Y2}" Stroke="White" StrokeThickness="6"/>
                </DataTemplate>
            </ItemsControl.ItemTemplate>
        </ItemsControl>

        <Button Content="Refresh Canvas" HorizontalAlignment="Left" Margin="350,261,0,0" VerticalAlignment="Top" Width="124" Height="40" FontFamily="Verdana" FontWeight="Bold" Click="Button_Click"/>
        <Button Content="Command 1" Command="{Binding Command1}" HorizontalAlignment="Left" Margin="45,261,0,0" VerticalAlignment="Top" Width="124" Height="40" FontFamily="Verdana" FontWeight="Bold"/>
        <Button Content="Command 2" Command="{Binding Command2}" HorizontalAlignment="Left" Margin="198,261,0,0" VerticalAlignment="Top" Width="124" Height="40" FontWeight="Bold" FontFamily="Verdana"/>
    </Grid>
</Window>

As you can see, the DataContext of my Window is MainWindowViewModel, and the binding of the ItemSource is LineData (located within that VM).

In addition, I have three buttons. The first two buttons execute ICommands, while the third button executes a behind-code refresh of the ItemsControl (This is for debugging purposes, to prove that a bound property within the ItemSource is being updated while the UI is not). More on that later.

The first button is bound to Command1 in the VM, while the second button is bound to Command2 in the VM.

Next, MainWindowViewModel.vb:

Imports System.Collections.ObjectModel

Public Class MainWindowViewModel
    Inherits ViewModelBase

    ' Sample line data variable
    Private _LineData As ObservableCollection(Of LineStructure) = GetLineData()
    Public Property LineData As ObservableCollection(Of LineStructure)
        Get
            Return _LineData
        End Get
        Set(value As ObservableCollection(Of LineStructure))
            _LineData = value
            OnPropertyChanged("LineData")
        End Set
    End Property

    ' ICommands
    Private _Command1 As ICommand
    Public ReadOnly Property Command1 As ICommand
        Get
            If _Command1 Is Nothing Then
                _Command1 = New MVVM.RelayCommand(AddressOf ExecuteCommand1)
            End If
            Return _Command1
        End Get
    End Property

    Private _Command2 As ICommand
    Public ReadOnly Property Command2 As ICommand
        Get
            If _Command2 Is Nothing Then
                _Command2 = New MVVM.RelayCommand(AddressOf ExecuteCommand2)
            End If
            Return _Command2
        End Get
    End Property

    ' ICommand Methods
    Private Sub ExecuteCommand1()
        ' Re-arrange LineData(0) to make a plus sign on the canvas
        ' This works - Assigning a new value to an item of the collection updates the canvas
        LineData(0) = New LineStructure With {.X1 = "175", .Y1 = "50", .X2 = "175", .Y2 = "150"}
    End Sub

    Private Sub ExecuteCommand2()
        ' Put LineData(0) back into its original position
        ' This doesn't work - Modifying the PROPERTY of an item in the collection does not update the canvas.. even with INotifyPropertyChange being called
        LineData(0).X1 = "50"
        LineData(0).Y1 = "50"
        LineData(0).X2 = "300"
        LineData(0).Y2 = "50"

        OnPropertyChanged("LineData")
    End Sub

    ' Misc methods
    Private Function GetLineData() As ObservableCollection(Of LineStructure)
        Dim tmpList As New ObservableCollection(Of LineStructure)

        ' Create two horizontal parallel lines
        tmpList.Add(New LineStructure With {.X1 = "50", .Y1 = "50", .X2 = "300", .Y2 = "50"})
        tmpList.Add(New LineStructure With {.X1 = "50", .Y1 = "100", .X2 = "300", .Y2 = "100"})

        Return tmpList
    End Function
End Class

Public Class LineStructure
    Public Property X1
    Public Property Y1
    Public Property X2
    Public Property Y2
End Class

In my viewmodel, I have defined LineData immediately (this is what my ItemsSource is bound to), so we have some data for our ItemSource ready to be displayed in the canvas upon execution. It is defined by a GetLineData() function, which simply returns a populated ObservableCollection of 2 lines.

When the application first starts, there are two horizontal, parallel lines displayed.

The LineData variable is an ObservableObject of a LineStructure class that I have defined, which simply contains X1, Y1, X2, Y2 strings for the respective objects to bind to and display within the canvas.

Command1 (again, this is bound to the first button) assigns a new LineStructure to the first index of LineData. When this is executed, everything works fantastic; the UI updates as expected and everyone is happy. This makes the lines appear as a plus sign on the canvas.

Here's where the problem begins:

Command2 is not going to assign a new LineStructure to the first LineData index like Command1 does, instead it's going to re-define the properties within the first LineData index individually. If this were to work, it would re-arrange the first line, and both lines on the canvas would be horizontally parallel again.

This however does not update the canvas/UI - and I can't figure out why. I have read numerous articles and tried many different solutions to no avail.

If anyone can explain why the binding does not update upon modifying a property rather than re-declaring the LineStructure index all together, please let me know, I would greatly appreciate it.

One final thing to note, I have managed to find a solution which will get what I need done, however I don't believe I should have to use it.. I would think the bindings should be able to take care of detecting any property changes.

For anyone interested, see the following snippet for a makeshift solution to update the canvas on a property change.

I have added NotifyOnTargetUpdated=True and TargetUpdated="RefreshCanvas" to my ItemsControl declaration in xaml.

What this does is calls a method named RefreshCanvas(), which executes ic1.Items.Refresh() from the MainWindow's code-behind (you can find the code-behind at the end of this post). This refreshes the ItemsControl items, and thus the canvas is refreshed and displays updates to the bound collection.

<ItemsControl x:Name="ic1" TargetUpdated="RefreshCanvas" ItemsSource="{Binding LineData, Mode=OneWay, UpdateSourceTrigger=PropertyChanged, NotifyOnTargetUpdated=True}" HorizontalAlignment="Left" Height="246" VerticalAlignment="Top" Width="517" Background="#FF191919" BorderBrush="#FF444444">

I will include my other files just for reference, since it might be relevant:

ViewModelBase.vb:

Imports System.ComponentModel

Public MustInherit Class ViewModelBase
    Implements INotifyPropertyChanged, IDisposable

#Region "Constructor"
    Protected Sub New()
    End Sub
#End Region ' Constructor

#Region "DisplayName"

    ' Returns the user-friendly name of this object.
    ' Child classes can set this property to a new value, or override it to determine the value on-demand.

    Private privateDisplayName As String

    Public Overridable Property DisplayName() As String
        Get
            Return privateDisplayName
        End Get
        Protected Set(ByVal value As String)
            privateDisplayName = value
        End Set
    End Property
#End Region ' DisplayName

#Region "Debugging Aids"
    ' Warns the developer if this object does not have a public property with the specified name. 
    ' This method does not exist in a Release build.
    <Conditional("DEBUG"), DebuggerStepThrough()> _
    Public Sub VerifyPropertyName(ByVal propertyName As String)
        ' Verify that the property name matches a real, public, instance property on this object.
        If TypeDescriptor.GetProperties(Me)(propertyName) Is Nothing Then
            Dim msg As String = "Invalid property name: " & propertyName

            If Me.ThrowOnInvalidPropertyName Then
                Throw New Exception(msg)
            Else
                Debug.Fail(msg)
            End If
        End If
    End Sub

    ' Returns whether an exception is thrown, or if a Debug.Fail() is used when an invalid property name is passed to the VerifyPropertyName method.
    ' The default value is false, but subclasses used by unit tests might override this property's getter to return true.
    Private privateThrowOnInvalidPropertyName As Boolean
    Protected Overridable Property ThrowOnInvalidPropertyName() As Boolean
        Get
            Return privateThrowOnInvalidPropertyName
        End Get
        Set(ByVal value As Boolean)
            privateThrowOnInvalidPropertyName = value
        End Set
    End Property
#End Region ' Debugging Aides

#Region "INotifyPropertyChanged Members"
    ' Raised when a property on this object has a new value.
    Public Event PropertyChanged As PropertyChangedEventHandler Implements INotifyPropertyChanged.PropertyChanged

    ' Raises this object's PropertyChanged event.
    ' <param name="propertyName">The property that has a new value.</param>
    Protected Overridable Sub OnPropertyChanged(ByVal propertyName As String)
        Me.VerifyPropertyName(propertyName)

        Dim handler As PropertyChangedEventHandler = Me.PropertyChangedEvent
        If handler IsNot Nothing Then
            Dim e = New PropertyChangedEventArgs(propertyName)
            handler(Me, e)
        End If
    End Sub
#End Region ' INotifyPropertyChanged Members

#Region "IDisposable Support"
    Private disposedValue As Boolean ' To detect redundant calls

    ' IDisposable
    Protected Overridable Sub Dispose(disposing As Boolean)
        If Not Me.disposedValue Then
            If disposing Then
                ' TODO: dispose managed state (managed objects).
            End If

            ' TODO: free unmanaged resources (unmanaged objects) and override Finalize() below.
            ' TODO: set large fields to null.
        End If
        Me.disposedValue = True
    End Sub

    ' Invoked when this object is being removed from the application and will be subject to garbage collection.
    Public Sub Dispose() Implements IDisposable.Dispose
        Me.OnDispose()
    End Sub

    ' Child classes can override this method to perform clean-up logic, such as removing event handlers.
    Protected Overridable Sub OnDispose()
    End Sub

    ' Controla el tancament del ViewModel.
    ' <returns></returns>
    ' <remarks></remarks>
    Public Overridable Function CanClose() As Boolean
        Return Nothing
    End Function

#If DEBUG Then
    ' Useful for ensuring that ViewModel objects are properly garbage collected.
    Protected Overrides Sub Finalize()
        Dim msg As String = String.Format("{0} ({1}) ({2}) Finalized", Me.GetType().Name, Me.DisplayName, Me.GetHashCode())
        System.Diagnostics.Debug.WriteLine(msg)
    End Sub
#End If
#End Region

End Class

DelegateCommand.vb:

Imports System.Windows.Input

Namespace MVVM
    Public NotInheritable Class RelayCommand
        Implements ICommand

#Region " Declarations "
        Private ReadOnly _objCanExecuteMethod As Predicate(Of Object) = Nothing
        Private ReadOnly _objExecuteMethod As Action(Of Object) = Nothing
#End Region

#Region " Events "
        Public Custom Event CanExecuteChanged As EventHandler Implements System.Windows.Input.ICommand.CanExecuteChanged
            AddHandler(ByVal value As EventHandler)
                If _objCanExecuteMethod IsNot Nothing Then
                    AddHandler CommandManager.RequerySuggested, value
                End If
            End AddHandler

            RemoveHandler(ByVal value As EventHandler)
                If _objCanExecuteMethod IsNot Nothing Then
                    RemoveHandler CommandManager.RequerySuggested, value
                End If
            End RemoveHandler

            RaiseEvent(ByVal sender As Object, ByVal e As System.EventArgs)
                If _objCanExecuteMethod IsNot Nothing Then
                    CommandManager.InvalidateRequerySuggested()
                End If
            End RaiseEvent
        End Event
#End Region

#Region " Constructor "
        Public Sub New(ByVal objExecuteMethod As Action(Of Object))
            Me.New(objExecuteMethod, Nothing)
        End Sub

        Public Sub New(ByVal objExecuteMethod As Action(Of Object), ByVal objCanExecuteMethod As Predicate(Of Object))
            If objExecuteMethod Is Nothing Then
                Throw New ArgumentNullException("objExecuteMethod", "Delegate comamnds can not be null")
            End If

            _objExecuteMethod = objExecuteMethod
            _objCanExecuteMethod = objCanExecuteMethod
        End Sub
#End Region

#Region " Methods "
        Public Function CanExecute(ByVal parameter As Object) As Boolean Implements System.Windows.Input.ICommand.CanExecute
            If _objCanExecuteMethod Is Nothing Then
                Return True
            Else
                Return _objCanExecuteMethod(parameter)
            End If
        End Function

        Public Sub Execute(ByVal parameter As Object) Implements System.Windows.Input.ICommand.Execute
            If _objExecuteMethod Is Nothing Then
                Return
            Else
                _objExecuteMethod(parameter)
            End If
        End Sub
#End Region
    End Class
End Namespace


Namespace MVVM
    Public NotInheritable Class RelayCommand(Of T)
        Implements ICommand

#Region " Declarations "
        Private ReadOnly _objCanExecuteMethod As Predicate(Of T) = Nothing
        Private ReadOnly _objExecuteMethod As Action(Of T) = Nothing
#End Region

#Region " Events "
        Public Custom Event CanExecuteChanged As EventHandler Implements System.Windows.Input.ICommand.CanExecuteChanged
            AddHandler(ByVal value As EventHandler)
                If _objCanExecuteMethod IsNot Nothing Then
                    AddHandler CommandManager.RequerySuggested, value
                End If
            End AddHandler

            RemoveHandler(ByVal value As EventHandler)
                If _objCanExecuteMethod IsNot Nothing Then
                    RemoveHandler CommandManager.RequerySuggested, value
                End If
            End RemoveHandler

            RaiseEvent(ByVal sender As Object, ByVal e As System.EventArgs)
                If _objCanExecuteMethod IsNot Nothing Then
                    CommandManager.InvalidateRequerySuggested()
                End If
            End RaiseEvent
        End Event
#End Region

#Region " Constructors "
        Public Sub New(ByVal objExecuteMethod As Action(Of T))
            Me.New(objExecuteMethod, Nothing)
        End Sub

        Public Sub New(ByVal objExecuteMethod As Action(Of T), ByVal objCanExecuteMethod As Predicate(Of T))
            If objExecuteMethod Is Nothing Then
                Throw New ArgumentNullException("objExecuteMethod", "Delegate comamnds can not be null")
            End If

            _objExecuteMethod = objExecuteMethod
            _objCanExecuteMethod = objCanExecuteMethod
        End Sub
#End Region

#Region " Methods "
        Public Function CanExecute(ByVal parameter As Object) As Boolean Implements ICommand.CanExecute
            If _objCanExecuteMethod Is Nothing Then
                Return True
            Else
                Return _objCanExecuteMethod(DirectCast(parameter, T))
            End If
        End Function

        Public Sub Execute(ByVal parameter As Object) Implements ICommand.Execute
            _objExecuteMethod(DirectCast(parameter, T))
        End Sub
#End Region
    End Class
End Namespace

MainWindow.xaml.vb (the code-behind of MainWindow):

Class MainWindow 
    Private Sub Button_Click(sender As Object, e As RoutedEventArgs)
        ic1.Items.Refresh()
    End Sub

    Private Sub RefreshCanvas(sender As Object, e As DataTransferEventArgs)
        sender.Items.Refresh()
    End Sub
End Class

Thank you for any help that might be offered to point me in the right direction, and hopefully this can help someone else out as well.


***** UPDATE, ISSUE SOLVED *****


E-Bat has so kindly pointed out that the properties of the LineData structure themselves need to implement INotifyPropertyChanged. I have implemented this change and added the updated and working 'MainWindowViewModel.xaml' code below:

Imports System.ComponentModel
Imports System.Collections.ObjectModel

Public Class MainWindowViewModel
    Inherits ViewModelBase

    ' Sample line data variable
    Private _LineData As ObservableCollection(Of LineData) = GetLineData()
    Public Property LineData As ObservableCollection(Of LineData)
        Get
            Return _LineData
        End Get
        Set(value As ObservableCollection(Of LineData))
            _LineData = value
            OnPropertyChanged("LineData")
        End Set
    End Property

    ' ICommands
    Private _Command1 As ICommand
    Public ReadOnly Property Command1 As ICommand
        Get
            If _Command1 Is Nothing Then
                _Command1 = New MVVM.RelayCommand(AddressOf ExecuteCommand1)
            End If
            Return _Command1
        End Get
    End Property

    Private _Command2 As ICommand
    Public ReadOnly Property Command2 As ICommand
        Get
            If _Command2 Is Nothing Then
                _Command2 = New MVVM.RelayCommand(AddressOf ExecuteCommand2)
            End If
            Return _Command2
        End Get
    End Property

    ' ICommand Methods
    Private Sub ExecuteCommand1()
        ' Re-arrange LineData(0) to make a plus sign on the canvas
        ' This works - Assigning a new value to an item of the collection updates the canvas
        LineData(0) = New LineData With {.X1 = "175", .Y1 = "50", .X2 = "175", .Y2 = "150"}
    End Sub

    Private Sub ExecuteCommand2()
        ' Put LineData(0) back into its original position
        ' Now it works, it's voodoo!
        LineData(0).X1 = "50"
        LineData(0).Y1 = "50"
        LineData(0).X2 = "300"
        LineData(0).Y2 = "50"
    End Sub

    ' Misc methods
    Private Function GetLineData() As ObservableCollection(Of LineData)
        Dim tmpList As New ObservableCollection(Of LineData)

        ' Create two horizontal parallel lines
        tmpList.Add(New LineData With {.X1 = "50", .Y1 = "50", .X2 = "300", .Y2 = "50"})
        tmpList.Add(New LineData With {.X1 = "50", .Y1 = "100", .X2 = "300", .Y2 = "100"})

        OnPropertyChanged("LineData")

        Return tmpList
    End Function
End Class

Public Class LineData
    Implements INotifyPropertyChanged

    Private _X1 As String
    Public Property X1 As String
        Get
            Return _X1
        End Get
        Set(value As String)
            _X1 = value
            OnPropertyChanged("X1")
        End Set
    End Property

    Private _Y1 As String
    Public Property Y1 As String
        Get
            Return _Y1
        End Get
        Set(value As String)
            _Y1 = value
            OnPropertyChanged("Y1")
        End Set
    End Property

    Private _X2 As String
    Public Property X2 As String
        Get
            Return _X2
        End Get
        Set(value As String)
            _X2 = value
            OnPropertyChanged("X2")
        End Set
    End Property

    Private _Y2 As String
    Public Property Y2 As String
        Get
            Return _Y2
        End Get
        Set(value As String)
            _Y2 = value
            OnPropertyChanged("Y2")
        End Set
    End Property

    Public Event PropertyChanged As PropertyChangedEventHandler Implements INotifyPropertyChanged.PropertyChanged

    Protected Sub OnPropertyChanged(ByVal name As String)
        RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs(name))
    End Sub
End Class

Solution

  • When you replace an item from an ObservableCollection, the old reference will be removed first and then it adds the new one, so ObservableCollection will be rising its events and that is why the first commands works as magic.

    Now for the second command to refresh the UI you have to make the items itself, LineStructure, implementers of INotifyPropertyChanged so any changes to its properties will be refreshed by the binding. So say goodbye to automated properties for this class.

    Public Class LineStructure
        Implements INotifyPropertyChanged
    
        Private _x1 As String
        Public Property X1 As String
            Get
                Return _x1
            End Get
            Set(value As String)
                If _x1 = value Then Return
                _x1 = value
                OnPropertyChanged("X1")
            End Set
        End Property
    End Class