Search code examples
c#winformsdata-binding

Databinding between Model and Label using a Timer


I am trying to bind my Label's Text property to the Model's Counter property, but it's not updating with every timer increment.

This might be a duplicated question but I really do not understand where I've made a mistake.

It looks like it can take the initial value, but it doesn't update every second like it is supposed to.

In the Form Constructor:

public partial class Form1 : Form
{
    Model model;

    public Form1()
    {
        InitializeComponent();

        model = new Model();

        Binding binding = new Binding("Text", model, "Counter", true, DataSourceUpdateMode.OnPropertyChanged);
        label1.DataBindings.Add(binding);
    }
}

My Model class:

public class Model: INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    // This method is called by the Set accessor of each property.
    // The CallerMemberName attribute that is applied to the optional propertyName
    // parameter causes the property name of the caller to be substituted as an argument.
    private void NotifyPropertyChanged([CallerMemberName] String propertyName = "")
    {
        if (PropertyChanged != null)
        {
            //Console.WriteLine(propertyName);
            PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }

    private static Timer timer;
    private int counter;

    public int Counter
    {
        get { return counter; }
        set 
        { 
            counter = value;
            NotifyPropertyChanged();
        }
    }

    public Model()
    {
        counter = 0;
        SetTimer();
    }

    public void SetTimer()
    {
        timer = new Timer(1000);
        timer.Elapsed += Timer_Elapsed;
        timer.AutoReset = true;
        timer.Enabled = true;
    }

    private void Timer_Elapsed(object sender, ElapsedEventArgs e)
    {
        Counter++;            
    }
}

Solution

  • A couple of examples, using a System.Windows.Forms.Timer that replaces the System.Timers.Timer you're using now.
    The latter raises its Elapsed event in ThreadPool Threads. DataBindings, as many other objects, don't work cross-Thread.

    You can read some other details here:
    Why does invoking an event in a timer callback cause following code to be ignored?

    The System.Windows.Forms.Timer is instead already synchronized, its Tick event is raised in the UI Thread.

    New Model class using this Timer:

    using System;
    using System.ComponentModel;
    using System.Runtime.CompilerServices;
    using System.Windows.Forms;
    
    public class Model : INotifyPropertyChanged, IDisposable
    {
        public event PropertyChangedEventHandler PropertyChanged;
        private void NotifyPropertyChanged([CallerMemberName] string propertyName = "") 
            => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    
        private readonly Timer timer;
        private int counter;
    
        public Model() {
            counter = 0;
            timer = new Timer() { Interval = 1000 };
            timer.Tick += this.Timer_Elapsed;
            StartTimer();
        }
    
        public int Counter {
            get => counter;
            set {
                if (counter != value) {
                    counter = value;
                    NotifyPropertyChanged();
                }
            }
        }
    
        public void StartTimer() => timer.Start();
    
        public void StopTimer() => timer.Stop();
    
        private void Timer_Elapsed(object sender, EventArgs e) => Counter++;
    
        public void Dispose(){
            Dispose(true);
            GC.SuppressFinalize(this);
        }
    
        protected virtual void Dispose(bool disposing)
        {
            if (disposing) {
                lock (this) {
                    if (timer != null) timer.Tick -= Timer_Elapsed;
                    timer?.Stop();
                    timer?.Dispose();
                }
            }
        }
    }
    

    In case you want to use a System.Threading.Timer, you need to synchronize its Elapsed event with the UI Thread, since, as mentioned, a PropertyChanged notification cannot be marshalled across Threads and your DataBindings won't work.

    You can use (mainly) two methods:

    • Use the SynchronizationContext class to capture the current WindowsFormsSynchronizationContext and Post to it to update a Property value.
    • Add a Constructor to your class that also accepts an UI element that implements ISynchronizeInvoke (any Control, in practice). You can use this object to set the System.Threading.Timer's SynchronizingObject property.
      When set, the Elapsed event will be raised in the same Thread as the Sync object.

    Note: You cannot declare a Model object as a Field and initialize at the same time: there's no SynchronizationContext until after the starting Form has been initialized. You can initialize a new Instance in the Constructor of a Form or any time after:

    public partial class Form1 : Form
    {
        Model model = new Model(); // <= Won't work
        // ------------------------------------------
        Model model = null;        // <= It's OK
        public Form1()
        {
            InitializeComponent();
    
            // Using the SynchronizationContext
            model = new Model();
    
            // Or, using A Synchronizing Object
            model = new Model(this);
    
            var binding = new Binding("Text", model, "Counter", true, DataSourceUpdateMode.OnPropertyChanged);
            label1.DataBindings.Add(binding);
        }
    }
    

    A modified Model class that makes use of both:

    using System;
    using System.ComponentModel;
    using System.Runtime.CompilerServices;
    using System.Threading;
    using System.Timers;
    
    public class Model : INotifyPropertyChanged, IDisposable
    {
        public event PropertyChangedEventHandler PropertyChanged;
        internal readonly SynchronizationContext syncContext = null;
        internal ISynchronizeInvoke syncObj = null;
    
        private void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
            => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    
        private System.Timers.Timer timer;
        private int counter;
    
        public Model() : this(null) { }
    
        public Model(ISynchronizeInvoke synchObject)
        {
            syncContext = SynchronizationContext.Current;
            syncObj = synchObject;
            timer = new System.Timers.Timer();
            timer.SynchronizingObject = syncObj;
            timer.Elapsed += Timer_Elapsed;
            StartTimer(1000);
        }
    
        public int Counter {
            get => counter;
            set {
                if (counter != value) {
                    counter = value;
                    NotifyPropertyChanged();
                }
            }
        }
    
        public void StartTimer(int interval) {
            timer.Interval = interval;
            timer.AutoReset = true;
            timer.Start();
        }
    
        public void StopTimer(int interval) => timer.Stop();
    
        private void Timer_Elapsed(object sender, ElapsedEventArgs e)
        {
            if (syncObj is null) {
                syncContext.Post((spcb) => Counter += 1, null);
            }
            else {
                Counter += 1;
            }
        }
    
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }
        protected virtual void Dispose(bool disposing)
        {
            if (disposing) {
                lock (this) {
                    if (timer != null) timer.Elapsed -= Timer_Elapsed;
                    timer?.Stop();
                    timer?.Dispose();
                }
            }
        }
    }