Using DispatchTimer to help in UI Behavior

Wednesday, October 21, 2009 12:49:49 AM (GMT Daylight Time, UTC+01:00)

I have ran into this problem a number of times developing UI.  You have business logic that needs to execute when a user is done entering some piece of data or criteria.  The business logic may be complicated and takes longer then what is considered an acceptable threshold (somewhere around 3 seconds). 

Your first thought might be to use the TextChanged, SelectionChanged or KeyUp events to detect change in the targeted control and run the business logic… but after you have implemented the approach you find out that the logic is just too expensive and the UI appears locked or sluggish.  The LostFocus event doesn’t work well either… it doesn’t get fired in some situations (clicking on a toolbar or selecting a menu).  

The approach that I have used both in WinForms and WPF development is to monitor the TextChanged event and when event has gone dormant for a specified duration, run the business logic. The key is not to wait an extended period of time to run the logic, but rather execute the logic at a tight enough interval that the application appears responsive after the user is has completed their data entry.

This is what the code does.

In the constructor we setup a DispatchTimer to fire at a default interval and subscribe to the TextChange event of our target control.

public ExecuteOnDormant(TextBox ctl, Action action)
{
    _control = ctl;
    _action = action;
    _dormantTimer = new DispatcherTimer();
 
    _defaultTimeout = new TimeSpan(0, 0, 0, 0, _timeOut);
 
    if (_timeOut == 0)
        _dormantTimer.IsEnabled = false;
    else
    {
        _dormantTimer.Interval = _defaultTimeout;
        _dormantTimer.IsEnabled = false;
        _dormantTimer.Tick += new EventHandler(RunBusinessLogic);
 
        //assumging that it is only a textbox we are watching... if we want to make this more generic... then add a  to KeyUp or SelectionChanged
        _control.TextChanged += new TextChangedEventHandler(ctl_TextChanged);
    }
}

The handler for the TextChange event then resets the DispatchTimer… this allows the user to complete typing the value before complex business logic is run.

/// <summary>
/// Handles the TextChanged event of the control that we are monitoring to run business logic on.
/// </summary>
/// <param name="sender">The source of the event.</param>
/// <param name="e">The <see cref="System.Windows.Controls.TextChangedEventArgs"/> instance containing the event data.</param>
private void ctl_TextChanged(object sender, TextChangedEventArgs e)
{
    ProcessActivity();
}
 
/// <summary>
/// Processes the activity(keyboard and mouse) in the shell to ensure that it stays active and does not fire the 
/// IdleTimeOut event.
/// </summary>
private void ProcessActivity()
{
    //reset the timer, the control has changed.
    _dormantTimer.IsEnabled = false;
    _dormantTimer.Interval = _defaultTimeout;
 
    //now enable the timer, as we want it to fire when it times out.
    _dormantTimer.IsEnabled = true;
}

When the user has stopped typing long enough for the interval to expire the business logic is executed.  To make this class usable across different controls we use an Action delegate to define the business logic.

private void RunBusinessLogic(object sender, EventArgs e)
       {
           //disable the timer, so it doesn't continue to run the business logic
           _dormantTimer.IsEnabled = false;
           Dispatcher.CurrentDispatcher.InvokeOnUIThread(_action);
       }

The business logic definition looks like this:

public partial class Window1 : Window
    {
        private ExecuteOnDormant _dormantWatcher;
        private Random _randomVal = new Random();
        public Window1()
        {
            InitializeComponent();
 
            _dormantWatcher = new ExecuteOnDormant(txtCriteria, ExecuteComplexBusinessLogic(txtCriteria));
        }
 
        private Action ExecuteComplexBusinessLogic(TextBox control)
        {
           return  () =>
                       {
                           Mouse.SetCursor(Cursors.Wait);
                           
                           //run complex business logic...
                           int val = _randomVal.Next(1,3);
 
                           //make it seem like a long time
                           Thread.Sleep(1000);
                           
                           //give the rich feedback on whether the item passed validation
                           control.Background = (val == 1) ? Brushes.LightGreen : Brushes.Salmon;
 
                           Mouse.SetCursor(Cursors.None);
                       };
        }
    }

You will notice that it uses a extension method off of the CurrentDispatcher called InvokeOnUIThread.  This method ensures that the business logic is executed in the correct thread context.  Below is the implementation for the extension method.

/// <summary>
/// Invokes on the UI thread if necessary.
/// this.Dispatcher.InvokeIfNecessary(() =>
/// {
        //do something that might need to be invoked
/// });
/// </summary>
/// <param name="dispatcher">The dispatcher.</param>
/// <param name="action">The action.</param>
public static void InvokeOnUIThread(this Dispatcher dispatcher, Action action)
{
    if (Thread.CurrentThread == dispatcher.Thread)
    {
        action();
    }
    else
    {
        dispatcher.Invoke(DispatcherPriority.Normal, (Action)delegate { action(); });
    }
}

You can download the demo project.

Insulating against grid row order changes in WPF

Tuesday, December 16, 2008 1:40:21 AM (GMT Standard Time, UTC+00:00)

One of the things that I don't like about the WPF Grid is the fact that if you insert new rows into a grid layout control you have to update the Grid.Row attached property to ensure that the rows render in proper order.

For example, if you supplied xaml that showed contact information and looked something like this:

image

here is the xaml representation:

<Border BorderBrush="LightGray" BorderThickness="1" Padding="10" CornerRadius="10">
            <Grid>
                <Grid.Resources>
                    <Thickness x:Key="Rowspacing">7</Thickness>
                </Grid.Resources>
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"  />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="Auto" />
                    <ColumnDefinition Width="*" />
                </Grid.ColumnDefinitions>
 
                <Label  Grid.Row="0" Content="First Name:" Margin="{StaticResource Rowspacing}" HorizontalAlignment="Right" />
                <Label  Grid.Row="1" Content="Last Name:" Margin="{StaticResource Rowspacing}" HorizontalAlignment="Right"/>
                <Label  Grid.Row="2" Content="Street Address:" Margin="{StaticResource Rowspacing}" HorizontalAlignment="Right"/>
                <Label  Grid.Row="3" Content="City:" Margin="{StaticResource Rowspacing}" HorizontalAlignment="Right"/>
                <Label  Grid.Row="4" Content="State:" Margin="{StaticResource Rowspacing}" HorizontalAlignment="Right"/>
                <Label  Grid.Row="5" Content="Zip:" Margin="{StaticResource Rowspacing}" HorizontalAlignment="Right"/>
                <Label  Grid.Row="6" Content="Company:" Margin="{StaticResource Rowspacing}" HorizontalAlignment="Right"/>
 
 
                <TextBox  Grid.Column="1" Grid.Row="0" Text="Shannon" MinWidth="300" VerticalAlignment="Center" />
                <TextBox  Grid.Column="1" Grid.Row="1" Text="Braun" AcceptsReturn="True"   VerticalAlignment="Center"></TextBox>
                <TextBox  Grid.Column="1" Grid.Row="2" Text="111 Main Street" AcceptsReturn="True"   VerticalAlignment="Center"></TextBox>
                <TextBox  Grid.Column="1" Grid.Row="3" Text="Minneapolis" AcceptsReturn="True"   VerticalAlignment="Center"></TextBox>
                <TextBox  Grid.Column="1" Grid.Row="4" Text="Mn" AcceptsReturn="True"   VerticalAlignment="Center"></TextBox>
                <TextBox  Grid.Column="1" Grid.Row="5" Text="55414" AcceptsReturn="True"  VerticalAlignment="Center"></TextBox>
                <TextBox  Grid.Column="1" Grid.Row="6" Text="Sysknowlogy" AcceptsReturn="True"  VerticalAlignment="Center"></TextBox>
                
            </Grid>
        </Border>

If I wanted to insert a row between Grid.Row="0" (First Name) and Grid.Row="1" (Last Name) I would have to also update the Grid.Row attached property for all of the rows after the insert. 

One of the ways to get around this is to bind the Grid.Row attached property on the Labels and Textboxes to the Index of the RowDefinition. When a RowDefinition is inserted or moved there is no need to update the indexes.  Unfortunately the RowDefinition doesn't expose a Index, so using the Binding facilities of WPF won't work here.  Instead we will leverage a MarkupExtension to translate the the reference to the RowDefinition name to the actual index of that RowDefinition, insulating us from any row inserts or changes in order.

The first step is to add a name to each of the RowDefinitions so that we can reference them.

<RowDefinition x:Name="Row_FirstName" Height="Auto"  />
<RowDefinition x:Name="Row_LastName" Height="Auto" />
<RowDefinition x:Name="Row_Street" Height="Auto" />
<RowDefinition x:Name="Row_City" Height="Auto" />
<RowDefinition x:Name="Row_State" Height="Auto" />
<RowDefinition x:Name="Row_Zip" Height="Auto" />
<RowDefinition x:Name="Row_Company" Height="Auto" />

Write the MarkUpExtension that will support the translation from element name to row index... the result looks like the following:

[MarkupExtensionReturnType(typeof(int))]
public class MapTo : MarkupExtension
{
    /// <summary>
    /// Initializes a new instance of the <see cref="MapTo"/> class.
    /// </summary>
    public MapTo()
    {
    }
 
    /// <summary>
    /// Initializes a new instance of the <see cref="MapTo"/> class.
    /// </summary>
    /// <param name="rowName">Name of the row.</param>
    public MapTo(string rowName)
    {
        _name = rowName;
    }
 
    private string _name;
    /// <summary>
    /// Gets or sets the name of the element.
    /// </summary>
    /// <value>The name of the element.</value>
    public string ElementName
    {
        get { return _name; }
        set { _name = value; }
    }
 
    /// <summary>
    /// When implemented in a derived class, returns an object that is set as the value of the target property for this markup extension.
    /// </summary>
    /// <param name="serviceProvider">Object that can provide services for the markup extension.</param>
    /// <returns>
    /// The object value to set on the property where the extension is applied.
    /// </returns>
    public override object ProvideValue(IServiceProvider serviceProvider)
    {
 
        IProvideValueTarget ipvt = (IProvideValueTarget)serviceProvider.GetService(typeof(IProvideValueTarget));
 
        int idx = -1;
        if (ipvt != null)
        {
            
            FrameworkElement target = ipvt.TargetObject as FrameworkElement;
            if (target != null)
            {
                
                Grid grd = target.Parent as Grid;
                if (grd != null)
                {
                    idx = GetRowIndex(grd);
                }
                else
                    throw new NullReferenceException("Grid was not found as the parent of element " + _name);
            }
        }
 
        return idx;
 
    }
 
    /// <summary>
    /// Gets the index of the row based on the name of the element.
    /// </summary>
    /// <param name="parent">The parent.</param>
    /// <returns></returns>
    private int GetRowIndex(Grid parent)
    {
        int idx = -1;
        if (parent != null)
        {
            RowDefinition rowDefinition = parent.FindName(_name) as RowDefinition;
 
            if (rowDefinition != null)
            {
                idx = parent.RowDefinitions.IndexOf(rowDefinition);
            }
            else
                throw new NullReferenceException("RowDefinition was not found for name " + _name);
        }
 
        return idx;
    }
}

Next we use the MapTo MarkUpExtension in xaml to identify the row index for both the Labels and TextBoxes:

<Border BorderBrush="LightGray" BorderThickness="1" Padding="10" CornerRadius="10">
            <Grid>
                <Grid.Resources>
                    <Thickness x:Key="Rowspacing">7</Thickness>
                </Grid.Resources>
                <Grid.RowDefinitions>
                    <RowDefinition x:Name="Row_FirstName" Height="Auto"  />
                    <RowDefinition x:Name="Row_LastName" Height="Auto" />
                    <RowDefinition x:Name="Row_Street" Height="Auto" />
                    <RowDefinition x:Name="Row_City" Height="Auto" />
                    <RowDefinition x:Name="Row_State" Height="Auto" />
                    <RowDefinition x:Name="Row_Zip" Height="Auto" />
                    <RowDefinition x:Name="Row_Company" Height="Auto" />
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="Auto" />
                    <ColumnDefinition Width="*" />
                </Grid.ColumnDefinitions>
 
                <Label  Grid.Row="{local:MapTo Row_FirstName}" Content="First Name:" Margin="{StaticResource Rowspacing}" HorizontalAlignment="Right" />
                <Label  Grid.Row="{local:MapTo Row_LastName}" Content="Last Name:" Margin="{StaticResource Rowspacing}" HorizontalAlignment="Right"/>
                <Label  Grid.Row="{local:MapTo Row_Street}" Content="Street Address:" Margin="{StaticResource Rowspacing}" HorizontalAlignment="Right"/>
                <Label  Grid.Row="{local:MapTo Row_City}" Content="City:" Margin="{StaticResource Rowspacing}" HorizontalAlignment="Right"/>
                <Label  Grid.Row="{local:MapTo Row_State}" Content="State:" Margin="{StaticResource Rowspacing}" HorizontalAlignment="Right"/>
                <Label  Grid.Row="{local:MapTo Row_Zip}" Content="Zip:" Margin="{StaticResource Rowspacing}" HorizontalAlignment="Right"/>
                <Label  Grid.Row="{local:MapTo Row_Company}" Content="Company:" Margin="{StaticResource Rowspacing}" HorizontalAlignment="Right"/>
 
 
                <TextBox  Grid.Column="1" Grid.Row="{local:MapTo Row_FirstName}" Text="Shannon" MinWidth="300" VerticalAlignment="Center" />
                <TextBox  Grid.Column="1" Grid.Row="{local:MapTo Row_LastName}" Text="Braun" AcceptsReturn="True"   VerticalAlignment="Center"></TextBox>
                <TextBox  Grid.Column="1" Grid.Row="{local:MapTo Row_Street}" Text="111 Main Street" AcceptsReturn="True"   VerticalAlignment="Center"></TextBox>
                <TextBox  Grid.Column="1" Grid.Row="{local:MapTo Row_City}" Text="Minneapolis" AcceptsReturn="True"   VerticalAlignment="Center"></TextBox>
                <TextBox  Grid.Column="1" Grid.Row="{local:MapTo Row_State}" Text="Mn" AcceptsReturn="True"   VerticalAlignment="Center"></TextBox>
                <TextBox  Grid.Column="1" Grid.Row="{local:MapTo Row_Zip}" Text="55414" AcceptsReturn="True"  VerticalAlignment="Center"></TextBox>
                <TextBox  Grid.Column="1" Grid.Row="{local:MapTo Row_Company}" Text="Sysknowlogy" AcceptsReturn="True"  VerticalAlignment="Center"></TextBox>
 
            </Grid>
        </Border>

To add additional rows, add a new RowDefinition with a name and then map the Grid.Row attached property to the name of the row.

Download Project: GridLayoutApproach.zip