Click here to Skip to main content
15,867,488 members
Articles / Desktop Programming / WPF

WPF: DatePicker With Holiday Blackouts And ToolTips

Rate me:
Please Sign up or sign in to vote.
5.00/5 (1 vote)
3 Mar 2014CPOL2 min read 16K   4  
WPF: DatePicker With Holiday Blackouts And ToolTips

Where I work, we deal with FX (Foreign Exchange), and as such we have to deal with a lot of different holidays both for all the countries of the world and the 2 currencies normally involved in a FX deal. We would also typically like to show this to the user by way of a blacked out Date within the standard WPF DatePicker. Luckily, the standard WPF DatePicker does support the idea of a BlackoutDate collection which makes the WPF DatePicker Calendar look like this:

image

All good so far, but our users would like to know WHY they can’t do a trade on this date. Some sort of ToolTip would be nice. This blog will show you how to achieve that.

So how can we do that exactly?

Step 1

We need some sort of NonWorking day item that we could use to bind against. Here is one I think fits the bill.

C#
[DataContract]
[DebuggerDisplay("{Date.ToShortDateString()}({Description})")]
public class NonWorkingDayDto : IEquatable<NonWorkingDayDto>
{
    public NonWorkingDayDto(DateTime date, string description)
    {
        Date = date;
        Description = description;
    }

    [DataMember]
    public DateTime Date { get; set; }

    [DataMember]
    public string Description { get; set; }

    public bool Equals(NonWorkingDayDto other)
    {
        if (ReferenceEquals(null, other))
        {
            return false;
        }
        if (ReferenceEquals(this, other))
        {
            return true;
        }
        return this.Date.Equals(other.Date);
    }

    public override bool Equals(object obj)
    {
        if (ReferenceEquals(null, obj))
        {
            return false;
        }
        if (ReferenceEquals(this, obj))
        {
            return true;
        }
        if (obj.GetType() != this.GetType())
        {
            return false;
        }
        return Equals((NonWorkingDayDto)obj);
    }

    public override int GetHashCode()
    {
        return this.Date.GetHashCode();
    }

    public static bool operator ==(NonWorkingDayDto left, NonWorkingDayDto right)
    {
        return Equals(left, right);
    }

    public static bool operator !=(NonWorkingDayDto left, NonWorkingDayDto right)
    {
        return !Equals(left, right);
    }
}

public class NonWorkingDayComparer : IEqualityComparer<NonWorkingDayDto>
{
    public bool Equals(NonWorkingDayDto x, NonWorkingDayDto y)
    {
        return x.Date == y.Date;
    }

    public int GetHashCode(NonWorkingDayDto obj)
    {
        return obj.Date.GetHashCode();
    }
} 

Step 2

We need to create a few attached properties for working with the Calendar. You will notice that one is going to be some sort of text lookup. We will be using that for the tooltips later. It is an Attached property that we can hook into. The 2nd one allows us to bind a number of NonWorkingDayDto objects, which will then create the DatePicker/Calendar BlackoutDate collection. This collection should be treated with a lot of care, as if you attempt to Clear() the collection and then add the new items in again you will see very bad performance. There must be an awful lot of binding stuff going on based on that, it also seems to be a factor if you have opened several Calendar months from the UI, the DatePicker seems to cache these previously shown Calendar instances internally. I found the best way was to do some set type intersections and just remove the ones I didn’t want anymore, and add the ones I wanted that were not present right now.

C#
public static class CalendarBehavior
{
    #region BlackOutDatesTextLookup

    public static readonly DependencyProperty BlackOutDatesTextLookupProperty =
        DependencyProperty.RegisterAttached("BlackOutDatesTextLookup",
            typeof(Dictionary<DateTime, string>), typeof(CalendarBehavior),
                new FrameworkPropertyMetadata(new Dictionary<DateTime, string>()));

    public static Dictionary<DateTime, 
    string> GetBlackOutDatesTextLookup(DependencyObject d)
    {
        return (Dictionary<DateTime, string>)d.GetValue(BlackOutDatesTextLookupProperty);
    }

    public static void SetBlackOutDatesTextLookup
    (DependencyObject d, Dictionary<DateTime, string> value)
    {
        d.SetValue(BlackOutDatesTextLookupProperty, value);
    }

    #endregion

    #region NonWorkingDays

    public static readonly DependencyProperty NonWorkingDaysProperty =
        DependencyProperty.RegisterAttached("NonWorkingDays",
            typeof(IEnumerable<NonWorkingDayDto>), typeof(CalendarBehavior),
                new FrameworkPropertyMetadata(null, OnNonWorkingDaysChanged));

    public static IEnumerable<NonWorkingDayDto> GetNonWorkingDays(DependencyObject d)
    {
        return (IEnumerable<NonWorkingDayDto>)d.GetValue(NonWorkingDaysProperty);
    }

    public static void SetNonWorkingDays(DependencyObject d, IEnumerable<NonWorkingDayDto> value)
    {
        d.SetValue(NonWorkingDaysProperty, value);
    }

    private static void OnNonWorkingDaysChanged
    (DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        DatePicker datePicker = d as DatePicker;
        if (e.NewValue != null && datePicker != null)
        {
            IEnumerable<NonWorkingDayDto> 
            localNonWorkingDays = (IEnumerable<NonWorkingDayDto>)e.NewValue;
            Dictionary<DateTime, 
            string> blackoutDatesTextLookup = new Dictionary<DateTime, string>();

            // IMPORTANT NOTE
            // Due to the way the DatePicker Calendar BlackoutDates collection works
            // It is dog slow to clear the BlackoutDates collection and add items in one by one
            // so we have to perform some voodoo, where we remove the Blackout dates that are not 
            // in the new NonWorking value, 
            // and then add in ONLY those that are missing from the new
            // NonWorking days value into the BlackoutDates collection. 
            // It sucks but it makes a BIG difference
            // Using Clear() and Add in for loop its like 1500ms (Per DatePicker), 
            // and using this method its down to
            // something like 35ms (Per DatePicker).......So please do not change this logic
            var toRemove = datePicker.BlackoutDates.Select
            (x => x.Start).Except(localNonWorkingDays.Select(y => y.Date)).ToList();

            foreach (DateTime dateTime in toRemove)
            {
                datePicker.BlackoutDates.Remove
                (datePicker.BlackoutDates.Single(x => x.Start == dateTime));
            }

            foreach (NonWorkingDayDto nonWorkingDay in localNonWorkingDays)
            {
                if (!datePicker.BlackoutDates.Contains(nonWorkingDay.Date))
                {
                    CalendarDateRange range = new CalendarDateRange(nonWorkingDay.Date);
                    datePicker.BlackoutDates.Add(range);
                }
                blackoutDatesTextLookup[nonWorkingDay.Date] = nonWorkingDay.Description;
            }
            datePicker.SetValue(BlackOutDatesTextLookupProperty, blackoutDatesTextLookup);
        }
    }
    #endregion
}

Step 3

We also need a simple value converter which we can use to obtain the ToolTip. This simply looks up the text from the attached properties we declared above.

C#
public class CalendarToolTipConverter : IMultiValueConverter
{
    private CalendarToolTipConverter()
    {

    }

    static CalendarToolTipConverter()
    {
        Instance = new CalendarToolTipConverter();
    }

    public static CalendarToolTipConverter Instance { get; private set; }


    #region IMultiValueConverter Members

    /// <summary>
    /// Gets a tool tip for a date passed in. Could also return null
    /// </summary>
    /// <remarks>
    /// The 'values' array parameter has the following elements:
    /// 
    /// values[0] = Binding #1: The date to be looked up. 
    /// This should be set up as a pathless binding; 
    /// the Calendar control will provide the date.
    /// 
    /// values[1] = Binding #2: A binding reference to the 
    /// DatePicker control that is invoking this converter.
    ///
    /// values[2] = Binding #3: Attached property CalendarBehavior.BlackOutDatesTextLookup for DatePicker
    /// </remarks>
    public object Convert(object[] values, Type targetType, 
        object parameter, System.Globalization.CultureInfo culture)
    {
        // Exit if values not set
        if (values[0] == null || values[1] == null || values[2] == null)
            return null;

        // Get values passed in
        DateTime targetDate = (DateTime)values[0];
        DatePicker dp = (DatePicker)values[1];
        Dictionary<DateTime, 
           string> blackOutDatesTextLookup = (Dictionary<DateTime, string>)values[2];
        string tooltip = null;
        blackOutDatesTextLookup.TryGetValue(targetDate, out tooltip);
        return tooltip;
    }

    /// <summary>
    /// Not used.
    /// </summary>
    public object[] ConvertBack(object value, Type[] targetTypes, 
         object parameter, System.Globalization.CultureInfo culture)
    {
        return new object[0];
    }
    #endregion
}

Step 4

We would need to create a collection of NonWorking days and add them to a DatePicker. This would normally be a ViewModel bindings, but for brevity I have just done this in code as follows:

C#
List<NonWorkingDayDto> nonWorkingDayDtos = new List<NonWorkingDayDto>();
nonWorkingDayDtos.Add(new NonWorkingDayDto(DateTime.Now.AddDays(1).Date, "Today +1 is no good"));
nonWorkingDayDtos.Add(new NonWorkingDayDto(DateTime.Now.AddDays(2).Date, "Today +2 is no good"));

dp.SetValue(CalendarBehavior.NonWorkingDaysProperty, nonWorkingDayDtos);

Step 5

Next, we need a custom CalendarStyle for the DatePicker.

XML
<Grid x:Name="LayoutRoot">
    <DatePicker x:Name="dp" HorizontalAlignment="Left" 
    Margin="192,168,0,0" VerticalAlignment="Top" 
        CalendarStyle="{StaticResource NonWorkingDayCalendarStyle}"/>
</Grid>

Step 6

The last piece to the puzzle is how to apply the ToolTip to the Calendar, which is done below. Have a look at the Calendar which uses a special “CalendarDayButtonStyle” which is the specific Style that deals with showing the ToolTip.

XML
<Style x:Key="NonWorkingDayTooltipCalendarDayButtonStyle" 
        TargetType="{x:Type CalendarDayButton}">
    <Setter Property="MinWidth" Value="5"/>
    <Setter Property="MinHeight" Value="5"/>
    <Setter Property="FontSize" Value="10"/>
    <Setter Property="HorizontalContentAlignment" Value="Center"/>
    <Setter Property="VerticalContentAlignment" Value="Center"/>
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type CalendarDayButton}">
                <Grid x:Name="ToolTipTargetBorder">
                    .......
                    .......
                    .......
                    .......
                    <Rectangle x:Name="TodayBackground" 
                                Fill="#FFAAAAAA" 
                                Opacity="0" 
                                RadiusY="1" 
                                RadiusX="1"/>
                    <Rectangle x:Name="SelectedBackground" 
                                Fill="#FFBADDE9" 
                                Opacity="0" 
                                RadiusY="1" 
                                RadiusX="1"/>
                    <Border BorderBrush="{TemplateBinding BorderBrush}" 
                            BorderThickness="{TemplateBinding BorderThickness}" 
                            Background="{TemplateBinding Background}"/>
                    <Rectangle x:Name="HighlightBackground" 
                                Fill="#FFBADDE9" 
                                Opacity="0" 
                                RadiusY="1" 
                                RadiusX="1"/>
                    <ContentPresenter x:Name="NormalText" 
                                        TextElement.Foreground="Black" 
                                        HorizontalAlignment="
                                        {TemplateBinding HorizontalContentAlignment}" 
                                        Margin="5,1,5,1" 
                                        VerticalAlignment="
                                        {TemplateBinding VerticalContentAlignment}"/>
                    <Path x:Name="Blackout" 
                            Data="M8.1772461,11.029181 L10.433105,11.029181 L11.700684,12.801641 .....z" 
                Fill="Black"  
                HorizontalAlignment="Stretch" 
                Margin="3" 
                Opacity="0" 
                RenderTransformOrigin="0.5,0.5" 
                Stretch="Fill" 
                VerticalAlignment="Stretch"/>
                    <Rectangle x:Name="DayButtonFocusVisual" 
                                IsHitTestVisible="false" 
                                RadiusY="1" 
                                RadiusX="1" 
                                Stroke="Black" 
                                Visibility="Collapsed"/>
                </Grid>

                <ControlTemplate.Triggers>
                    <Trigger Property="IsBlackedOut" Value="True">
                        <Setter TargetName="ToolTipTargetBorder" Property="ToolTip">
                            <Setter.Value>
                                <MultiBinding Converter="
                                {x:Static local:CalendarToolTipConverter.Instance}">
                                    <MultiBinding.Bindings>
                                        <Binding />
                                        <Binding RelativeSource="{RelativeSource FindAncestor, 
                                        AncestorType={x:Type DatePicker}}" />
                                        <Binding RelativeSource="{RelativeSource FindAncestor, 
                                        AncestorType={x:Type DatePicker}}" 
                                            Path="(local:CalendarBehavior.BlackOutDatesTextLookup)" />
                                    </MultiBinding.Bindings>
                                </MultiBinding>
                            </Setter.Value>
                        </Setter>
                    </Trigger>
                </ControlTemplate.Triggers>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

<Style x:Key="NonWorkingDayCalendarStyle" TargetType="{x:Type Calendar}">
    <Setter Property="CalendarDayButtonStyle" 
            Value="{StaticResource NonWorkingDayTooltipCalendarDayButtonStyle}"/>
    <Setter Property="Foreground" Value="Black"/>
    <Setter Property="Background" Value="WhiteSmoke"/>
    <Setter Property="BorderBrush" Value="Black"/>
    <Setter Property="BorderThickness" Value="1"/>

</Style>

As always, here is a small demo project:

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior)
United Kingdom United Kingdom
I currently hold the following qualifications (amongst others, I also studied Music Technology and Electronics, for my sins)

- MSc (Passed with distinctions), in Information Technology for E-Commerce
- BSc Hons (1st class) in Computer Science & Artificial Intelligence

Both of these at Sussex University UK.

Award(s)

I am lucky enough to have won a few awards for Zany Crazy code articles over the years

  • Microsoft C# MVP 2016
  • Codeproject MVP 2016
  • Microsoft C# MVP 2015
  • Codeproject MVP 2015
  • Microsoft C# MVP 2014
  • Codeproject MVP 2014
  • Microsoft C# MVP 2013
  • Codeproject MVP 2013
  • Microsoft C# MVP 2012
  • Codeproject MVP 2012
  • Microsoft C# MVP 2011
  • Codeproject MVP 2011
  • Microsoft C# MVP 2010
  • Codeproject MVP 2010
  • Microsoft C# MVP 2009
  • Codeproject MVP 2009
  • Microsoft C# MVP 2008
  • Codeproject MVP 2008
  • And numerous codeproject awards which you can see over at my blog

Comments and Discussions

 
-- There are no messages in this forum --