Click here to Skip to main content
15,886,110 members
Articles / Multimedia / GDI+

Culture Aware Month Calendar and DatePicker

Rate me:
Please Sign up or sign in to vote.
4.99/5 (60 votes)
10 Nov 2014CPOL6 min read 278.8K   12.5K   100   138
A month calendar and date picker with culture awareness.

Image 1

Introduction

As you can see in the screenshot, this is a month calendar control and date picker control, a little like the built-in controls from Microsoft. It's not implemented using WPF, but is for WinForms applications.

Background

While developing my own calendar/appointment calendar similar to the one in Outlook 2007, I needed a month calendar. There are many good ones out there, but not quite the way I needed. So, I started to develop my own. Along the way, I implemented not only a month calendar but also a date picker control.

Also, I thought, why not implement culture awareness into the controls, so people in different countries can use it too. That was more complex than I thought, and I ran into many problems regarding culture support, including some nasty bugs in the framework; more in detail later.

I thought to share the results here, because CodeProject has offered me solutions to my problems several times.

Class Diagram

Image 2

There is a new property AllowPromptAsInput in the DatePicker control.
Setting it to true allows to enter dates including the current date separator as set in the
FormatProvider property.

Features

  • culture aware - meaning you can set the used culture and calendar
  • adjustable number of visible sub-calendars via the property CalendarDimensions
  • fully customizable in regard to colors and fonts
  • you can load or save the color table
  • adjustable RTL or LTR layout
  • can adjust whether to use shortest or abbreviated day names
  • can adjust whether to show week header and footer
  • adjustable non working days (for work week selection mode)
  • adjustable first day of week
  • adjustable day names (full/abbreviated/shortest)
  • adjustable month names (full/abbreviated)
  • adjustable month/day string patterns
  • supports settable min/max date
  • scrolling with mouse wheel
  • different selection modes (single day/work week/full week/manual)
  • adjustable maximum selectable days in manual selection mode
  • bolded days collection
  • new: extended bolded days (category and corresponding color definition)
  • new: the manual selection mode now supports selection of multiple ranges with the CTRL key
  • new: date separator now valid input in picker control
  • adjustable scroll change (how many months to scroll when clicking an arrow)
  • date picker control supports keyboard navigation
  • disabled state support

Using the Code

Simply add the control project to your existing solution and reference it. The control is built with VS 2010, and the targeted framework version is 3.5.

Points of Interest

Regarding the culture awareness of the controls, I must admit that I cannot guarantee that all is correctly displayed and calculated. If there are errors, please feel free to post a message.

As mentioned earlier, while implementing culture awareness, I encountered some nasty bugs in the framework. One of these is as follows:

Every System.Globalization.Calendar implementation has a method GetWeekOfYear(). And, every calendar has a property MinSupportedDateTime. If you want to get the week of the year from that date, then in some cases, you get an ArgumentOutOfRangeException although the date passed to the method is a valid one.

Calendar implementations affected are Hijri-, UmAlQura-, Hebrew-, Persian-, JulianCalendar, and all implementations of EastAsianLunisolarCalendar.

Another bug is in the JapaneseCalendar where the method GetYear() returns 0 for a specific date range.

It is highly possible that I didn't find all bugs regarding the calendar implementations, so if you set an unusual calendar like the JapaneseLunisolarCalendar, it can happen that exceptions are thrown.

Customization of the MonthControl

The rendering of the control is handled by a separate renderer class. If you want to customize the drawing, then you can implement your own renderer by deriving from the abstract base class or the default renderer.

That's also true for the color table class which implements IXmlSerializable.

Adjusting the MonthCalendar Control

One point you have to be aware of is that only non-neutral cultures can be set. The reason for that is, neutral cultures have no value for the DateTimeFormat property, which I need for different reasons.

To adjust the visible sub-month views, you can directly set the CalendarDimensions property, or use the designer.

Using the property ColorTable, you can adjust the used colors, including whether to use gradient colors and the gradient mode or not.

Adjusting the DatePicker Control

The date in the text box part of the picker is displayed using the short date pattern, which you can also adjust. This pattern is also used when parsing a date.

The picker control has the CheckDate event where you can check and set if the entered date is a valid one. If not valid, then the date is displayed using the colors of the InvalidBackColor and InvalidForeColor properties.

Another important property is ClosePickerOnDayClick. With it, you can adjust whether the picker closes when clicking on a day, regardless of whether the day is already selected or not.

Extended Bolded Dates

The DatePicker and MonthCalendar controls now have two new properties:

  • BoldedDateCategoryCollection
  • BoldedDatesCollection

The first collection holds the definitions of categories for bolded dates, and the second the bolded dates with corresponding category.

The original property for defining bold dates works as before.

Usage of Native Digits

Both controls have a new property :

  • UseNativeDigits

This property converts any displayed number in the controls to a string containing the
NativeDigits from the current Culture.

If setting the current Culture to Persian (fa-IR) then this culture has not arabic numerals
but persian numerals.

For instance the persian date 01.01.1390 in the format dd/MM/yyyy would then be displayed as

۰۱ / ۰۱ / ۱۳۹۰

Let me show you an image if setting a datepicker's Culture and the CultureCalendar to Persian:

Image 3

 

 

Furthermore the manual input of an date uses the native digits too.

Please note that the displayed day names (probably the month names too) could be wrong,
because these values are taken from the .Net Framework CultureInfo classes, specifically the
Persian day names (in this case the shortest day names) are incorrect due to a bug/feature of
the Persian (fa-IR) CultureInfo class.

Events

The month control has the following important events:

  • DateChanged: Occurs when the month or year is changed via the context menu
  • DateClicked: Occurs when a day is clicked in the 'Day' selection mode
  • DateSelected: Occurs when a date or date range is selected
  • SelectionExtendEnd: Occurs when the selection extension ended

The date picker has an important event:

  • ValueChanged: Occurs when the date value changes

Both controls have one event in common:

  • ActiveDateChanged: Occurs when the mouse is over a date

History

  • 1st December, 2009: Initial post.
  • 12th October, 2011: Extended bold dates with categories.
  • 22nd November, 2011: Extended selection to support multiple ranges.
  • 14th June, 2012 :
    • Implemented native digits usage
    • changed project to VS 2010 project
    • fixed bugs regarding the setting of month and day names of the FormatProvider
    • fixed bug in internal parsing/displaying of dates
    • now wheel scrolling with the mouse is prevented if the popup of the datepicker is open
    • fixed an focus issue of the datepicker control
    • some other small improvements and fixes
  • 17th June, 2012 :
    • the current date separator is now allowed as input in the DatePicker control
      valid input is e.g. 06172012, 061712, [0]6/17/[20]12 if the ShortDatePattern is set to
      MM/dd/yyyy

License

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


Written By
Germany Germany
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
QuestionNo CustomControls namespace Pin
Gluups6-Aug-23 5:23
Gluups6-Aug-23 5:23 
QuestionHow to get the highlight date range? Pin
Kelvin_Deng19-Dec-17 16:52
professionalKelvin_Deng19-Dec-17 16:52 
QuestionOn hebrew culture, month names appear incorrectly for non-leap years Pin
Member 120557733-Sep-17 7:47
Member 120557733-Sep-17 7:47 
AnswerRe: On hebrew culture, month names appear incorrectly for non-leap years Pin
Member 149485818-Oct-21 4:36
Member 149485818-Oct-21 4:36 
Questionmonth name in persian calture Pin
sh-a11-Jan-17 21:24
sh-a11-Jan-17 21:24 
QuestionHow can I make a DatePicker display an empty string? Pin
lopmartyn16-May-16 5:44
lopmartyn16-May-16 5:44 
QuestionFirst week of the year errors Pin
Nyerguds30-Nov-15 22:04
Nyerguds30-Nov-15 22:04 
AnswerRe: First week of the year errors Pin
the Kris25-Apr-16 4:45
the Kris25-Apr-16 4:45 
QuestionMaking today's font in line with others. Pin
IlyaSk22-Feb-15 9:53
IlyaSk22-Feb-15 9:53 
AnswerRe: Making today's font in line with others. Pin
IlyaSk23-Feb-15 0:07
IlyaSk23-Feb-15 0:07 
QuestionOther bug Pin
Member 1007607419-Feb-15 9:04
Member 1007607419-Feb-15 9:04 
QuestionAnother little bug... Pin
chrisbray14-Nov-14 13:13
chrisbray14-Nov-14 13:13 
GeneralMy vote of 5 Pin
JayantaChatterjee11-Nov-14 19:09
professionalJayantaChatterjee11-Nov-14 19:09 
GeneralRe: My vote of 5 Pin
Thomas Duwe11-Nov-14 20:39
Thomas Duwe11-Nov-14 20:39 
QuestionMy vote of 5 plus additions and fixes Pin
chrisbray9-Nov-14 3:47
chrisbray9-Nov-14 3:47 
Hi Thomas,

This is great work, and in many ways very close to what I needed. How MS could not forsee that people live in different parts of the world and might want to set multiple dates is beyond me!!

However, although I have only been working on it a short while there are a number of problems with the source that should ideally be addressed, at least one before the source will compile! Firstly, you have references in the source to BoldedDateType* which all need to be changed to BoldedDateCategory* to allow the project to build.

Secondly, the UpdateMonths method has a problem with MinDate and MaxDate being set to less than a month apart. To resolve this, check for monthList.Count being > zero before breaking:

C#
/// <summary>
///     Updates the shown months.
/// </summary>
public void UpdateMonths()
{
    int x = 0, y = 0, index = 0;
    int calendarWidthDimension = _calendarDimensions.Width;
    int calendarHeightDimension = _calendarDimensions.Height;

    var monthList = new List<MonthCalendarMonth>();

    var dt = new MonthCalendarDate(CultureCalendar, _viewStart);

    if (dt.GetEndDateOfWeek(_formatProvider).Month != dt.Month)
    {
        dt = dt.GetEndDateOfWeek(_formatProvider).FirstOfMonth;
    }

    if (UseRTL)
    {
        x = _monthWidth * (calendarWidthDimension - 1);

        for (int i = 0; i < calendarHeightDimension; i++)
        {
            for (int j = calendarWidthDimension - 1; j >= 0; j--)
            {
                if (monthList.Count > 0 && dt.Date >= _maxDate)
                {
                    break;
                }

                monthList.Add(new MonthCalendarMonth(this, dt.Date)
                {
                    Location = new Point(x, y),
                    Index = index++
                });

                x -= _monthWidth;
                dt = dt.AddMonths(1);
            }

            x = _monthWidth * (calendarWidthDimension - 1);
            y += _monthHeight;
        }
    }
    else
    {
        for (int i = 0; i < calendarHeightDimension; i++)
        {
            for (int j = 0; j < calendarWidthDimension; j++)
            {
                if (monthList.Count > 0 && dt.Date >= _maxDate)
                {
                    break;
                }

                monthList.Add(new MonthCalendarMonth(this, dt.Date)
                {
                    Location = new Point(x, y),
                    Index = index++
                });

                x += _monthWidth;
                dt = dt.AddMonths(1);
            }

            x = 0;
            y += _monthHeight;
        }
    }

    if (monthList.Count > 0)
    {
        _lastVisibleDate = monthList[monthList.Count - 1].LastVisibleDate;
        _months = monthList.ToArray();
    }
}


The next thing that did not work for me was that there was no way to get or set a list of selected dates which it seemed to me was the most likely way that users would want to populate and consume the dates. Certainly for my purposes (a dialog where the user can select a number of dates to be processed in some way) it was the obvious way to do things. Whilst I could have retrieved the ranges and processed them in my code that section of code would have to be repeated every time it was needed so it made sense to be able to get the dates in and out as an IEnumberable<datetime> which would be easy and save lots of repetitive code every time that was what was wanted.

The following is an early start, but all the unit tests I have set up confirm that it is working thus far - until someone finds otherwise Smile | :)

First thing is to define the property:

C#
/// <summary>
/// Gets or sets the selected dates.
/// </summary>
[Description("The selected dates in the month calendar.")]
public IEnumerable<DateTime> SelectedDates
{
    get { return GetSelectedDates(); }

    set { SetSelectedDates(value); }
}


Now we need the two main methods for the getter:

XML
private IEnumerable<DateTime> GetSelectedDates()
{
    // Use a HashSet to store the dates, as it will automatically prevent duplicates
    // and not throw and exception if a duplicate should arise
    var set = new HashSet<DateTime>();

    // Pick up all available SelectionRanges
    var ranges = new List<SelectionRange> { SelectionRange };
    ranges.AddRange(SelectionRanges);

    foreach (var selectionRange in ranges)
    {
        var rangeDates = GetDatesFromRange(selectionRange);
        foreach (var dateTime in rangeDates)
        {
            set.Add(dateTime);
        }
    }

    var dates = set.ToArray();
    Array.Sort(dates);
    return dates;
}

private static IEnumerable<DateTime> GetDatesFromRange(SelectionRange selectionRange)
{
    var set = new HashSet<DateTime>();
    var startDate = selectionRange.Start;
    var endDate = selectionRange.End;
    var currentDate = startDate;

    set.Add(currentDate);
    currentDate = currentDate.AddDays(1);

    while (currentDate <= endDate)
    {
        set.Add(currentDate);
        currentDate = currentDate.AddDays(1);
    }

    return set;
}


Here we use a HashSet to get quick elimination of duplicates i.e. we need the currently selected date as well as any dates in the SelectionRanges, and it is possible for one or more duplicates to arise. We grab the SelectionRange plus all SelectionRanges in a list, then iterate them grabbing out all the dates. Finally, sort them into date order and return the list.

The setter is a different issue, not least because we have to decide how to handle any dates passed in that are outside the range of MinDate to MaxDate? It might be critical that we know should this occur and therefore throwing an exception would be essential, or we might not care, and want to simply ignore any such dates. I could foresee situations where either might be appropriate, so my solution was to add an enum and a property to handle it:

XML
/// <summary>
/// Gets or sets the way that invalid dates are handled
/// </summary>
[DefaultValue(InvalidDateAction.Ignore)]
[Category("Behavior")]
[Description("Determines the way that invalid dates are handled.")]
public InvalidDateAction InvalidDateAction { get; set; }


XML
/// <summary>
/// The InvalidDateAction enum
/// </summary>
public enum InvalidDateAction
{
    Ignore,
    ThrowException
}


Note that the defalt value is Ignore, since I felt that was the most likely requirement. However, you can change the default in the code above or just set it how you want it in the constructor or initialisation code like this:

InvalidDateAction = InvalidDateAction.ThrowException;


You could also set it on the fly once the calendar exists on your form:

monthCalendar1.InvalidDateAction = InvalidDateAction.ThrowException;


OK. Now we are ready to deal with the Setter method:

C#
private void SetSelectedDates(IEnumerable<DateTime> dates)
{
    if (dates == null)
    {
        throw new ArgumentNullException("dates", @"Dates value must not be null");
    }

    try
    {
        BeginUpdate();

        // Clear out the existing ranges
        _selectionRanges.Clear();

        var list = dates as List<DateTime> ?? dates.ToList();
        if (!list.Any())
        {
            return;
        }

        // Ensure that the dates are in ascending date order
        list.Sort((a, b) => a.CompareTo(b));

        // Check for any invalid dates
        //Loop backwards so we can remove invalid dates if necessary
        for (int i = list.Count - 1; i >= 0; i--)
        {
            var currentDate = list[i];
            if (currentDate < _minDate || currentDate > _maxDate)
            {
                // Handle Invalid Date
                switch (InvalidDateAction)
                {
                    case InvalidDateAction.Ignore:
                        list.RemoveAt(i);
                        break;
                    case InvalidDateAction.ThrowException:
                        throw new ArgumentOutOfRangeException("dates", @"Passed set of dates contains values outside the permitted range of dates");
                    default:
                        throw new ArgumentOutOfRangeException();
                }
            }
        }

        // Select the first date to assign to the range
        var selectedDate = list[0];

        // Split them into ranges
        var ranges = GetRanges(list);

        // Add in the returned ranges
        foreach (var selectionRange in ranges)
        {
            _selectionRanges.Add(selectionRange);
        }

        _selectionStart = selectedDate;
        _selectionEnd = selectedDate;
    }
    finally
    {
        EndUpdate();
    }
}


First thing we do is to check for a null list, and thrown an exception if one is located. Next we call BeginUpdate to stop the calendar redrawing and clear out the selected dates, since that will be required regardless of the number of dates passed in.

The next step is to check for an empty list, and if we find one we get out as we have done all that is necessary - passing an empty list is equivalent to clearing the all the ranges.

Then we sort the dates into date order just in case they were passed in out of order. This is important because we need to convert the dates into one or more ranges in order to assign them to the calendar and that won't work properly with an unordered list.

Now we are at the point where we need to handle the invalid dates. We must either remove the dates from the list (InvalidDateAction.Ignore) or throw an exception (InvalidDateAction.ThrowException). By going through the list in reverse order using a For loop we gain the ability to remove unwanted dates with impunity, so that is the route to take.

Next, we grab the first date in the (possibly updated) list as we will use that to set the current date in a later step, and then convert the list of dates into a set of ranges. To do this we need a new method:

public IEnumerable<SelectionRange> GetRanges(IEnumerable<DateTime> dates)
{
    var ranges = new List<SelectionRange>();
    SelectionRange currentRange = null;

    // this presumes a list of dates ordered by day, if not then the list will need sorting first
    var dateTimes = dates as DateTime[] ?? dates.ToArray();
    for (int i = 0; i < dateTimes.Length; ++i)
    {
        var currentDate = dateTimes[i];

        if (i == 0 || dateTimes[i - 1] != currentDate.AddDays(-1))
        {
            // it's either the first date or the current date isn't consecutive to the previous so a new range is needed
            currentRange = new SelectionRange
            {
                Start = currentDate
            };

            ranges.Add(currentRange);
        }

        if (currentRange != null)
        {
            currentRange.End = (currentDate);
        }
    }

    return ranges;
}


Hopefully this is pretty self-explanatory, we just iterate the dates adding to one or more ranges as we go and then return the ranges.

Back in our calling method we now iterate the returned ranges and add them to the calendar:

SQL
// Add in the returned ranges
foreach (var selectionRange in ranges)
{
    _selectionRanges.Add(selectionRange);
}


Now all that remains is to set the current date and call EndUpdate, and we are all done.

I hope that this all proves useful to someone, and I will report back if I find any more issues that need to be addressed.
Chris Bray


modified 9-Nov-14 16:18pm.

GeneralRe: My vote of 5 plus additions and fixes Pin
Thomas Duwe10-Nov-14 3:05
Thomas Duwe10-Nov-14 3:05 
GeneralRe: My vote of 5 plus additions and fixes Pin
chrisbray10-Nov-14 3:55
chrisbray10-Nov-14 3:55 
GeneralRe: My vote of 5 plus additions and fixes Pin
Thomas Duwe10-Nov-14 22:35
Thomas Duwe10-Nov-14 22:35 
GeneralRe: My vote of 5 plus additions and fixes Pin
chrisbray13-Nov-14 9:34
chrisbray13-Nov-14 9:34 
AnswerRe: Nullable date value Pin
Cool Smith5-Oct-21 23:37
Cool Smith5-Oct-21 23:37 
GeneralRe: Nullable date value Pin
Member 148676326-Oct-21 2:50
Member 148676326-Oct-21 2:50 
QuestionPlease upload a full project source including .sln file Pin
Mojtaba Rezaeian7-Nov-14 9:38
Mojtaba Rezaeian7-Nov-14 9:38 
AnswerRe: Please upload a full project source including .sln file Pin
Thomas Duwe10-Nov-14 3:02
Thomas Duwe10-Nov-14 3:02 
QuestionPlease tell me how to execute it. Pin
Snehasish_Nandy1-Aug-14 0:26
professionalSnehasish_Nandy1-Aug-14 0:26 
GeneralRe: Please tell me how to execute it. Pin
Thomas Duwe4-Aug-14 2:13
Thomas Duwe4-Aug-14 2:13 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.