Search code examples
c#wpfuser-interfacedispatcher

What is missing in this update UI via dispatcher/databinding


I have a simple WPF window with: Loaded="StartTest" and

<Grid>
        <ListBox ItemsSource="{Binding Logging, IsAsync=True}"></ListBox>
</Grid>

In code behind I have in method StartTest:

LogModel LogModel = new LogModel();

void StartTest(object sender, RoutedEventArgs e)
{
    DataContext = LogModel;

    for (int i = 1; i<= 10; i++)
    {
       LogModel.Add("Test");
       Thread.Sleep(100);
    }
}

And class LogModel is:

public class LogModel : INotifyPropertyChanged
{
    public LogModel()
    {
        Dispatcher = Dispatcher.CurrentDispatcher;
        Logging = new ObservableCollection<string>();
    }
    Dispatcher Dispatcher;

    public ObservableCollection<string> Logging { get; set; }

    public event PropertyChangedEventHandler PropertyChanged;

    public void Add(string text)
    {
        Dispatcher.BeginInvoke((Action)delegate ()
        {
            Logging.Add(text);
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Logging"));
        });
    }
}

Of course the problem is that the UI doesn't update in the loop. What am I missing?
How can I achieve the UI update?


Solution

  • ObservableCollection already raises the PropertyChanged event when it's modified. You don't have to raise the event in the UI thread either.

    Your model can be as simple as :

    class LogModel
    {
        public ObservableCollection<string> Logging { get; } = new ObservableCollection<string>();
    
        public void Add(string text)
        {
            Logging.Add(text);
        }
    }
    

    All you need to do is set it as the DataContext of your view, eg :

    LogModel model = new LogModel();
    public MainWindow()
    {
        InitializeComponent();
        this.DataContext = model;
    }
    

    I assume StartTest is a click handler which means it runs on the UI thread. That means it will block the UI thread until the loop finishes. Once the loop finishes the UI will be updated.

    If you want the UI to remain responsive during the loop, use Task.Delay instead of Thread.Slepp, eg :

    private async void Button_Click(object sender, RoutedEventArgs e)
    {
        for(int i=0;i<10;i++)
        {
            await Task.Delay(100);
            model.Add("Blah!");
        }
    }
    

    Update

    You don't need to use an ObservableCollection as a data binding source. You could use any object, including an array or List. In this case though you'd have to raise the PropertyChanged event in code :

    class LogModel:INotifyPropertyChanged
    {
        public List<string> Logging { get; } = new List<string>();
    
        public event PropertyChangedEventHandler PropertyChanged;
    
        public void Add(string text)
        {
            Logging.Add(text);
            PropertyChanged.Invoke(this,new PropertyChangedEventArgs("Logging"));
        }
    }
    

    This will tell the view to load all the contents and display them again. This is perfectly fine when you only want to display data loaded eg from the database without modifying them, as it makes mapping entities to ViewModels a lot easier. In this case you only need to update the view when a new ViewModel is attached as a result of a command.

    This is not efficient when you need to update the coolection though. ObservableCollection implements the INotifyCollectionChanged interface that raises an event for each change. If you add a new item, only that item will be rendered.

    On the other hand you should avoid modifying the collection in tight loops because it will raise multiple events. If you load 50 new items, don't call Add 50 times in a loop. Create a new ObservableCollection, replace the old one and raise the PropertyChanged event, eg :

    class LogModel:INotifyPropertyChanged
    {
        public ObservableCollection<string> Logging { get; set; } = new ObservableCollection<string>();
    
        public event PropertyChangedEventHandler PropertyChanged;
    
        public void Add(string text)
        {            
            Logging.Add(text);
            PropertyChanged.Invoke(this,new PropertyChangedEventArgs("Logging"));
        }
    
        public void BulkLoad(string[] texts)
        {
            Logging = new ObservableCollection<string>(texts);
            PropertyChanged.Invoke(this, new PropertyChangedEventArgs("Logging"));
        }
    }
    

    The explicit implementation is still needed because the Logging property is getting replaced and can't raise any events itself