65.9K
CodeProject 正在变化。 阅读更多。
Home

扩展 WPF 日历控件

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.84/5 (28投票s)

2010年8月23日

CPOL

26分钟阅读

viewsIcon

163059

downloadIcon

5032

本文展示了如何创建一个扩展现有 WPF 控件的自定义控件。它通过添加日期高亮功能来扩展 WPF 日历控件。

FsCalendar_Screen_Shot.png

引言

我对 WPF 日历控件有一种爱恨交织的情感。很高兴能有这个控件,但它缺少一些本应包含在其中的基本功能。其中一个功能是能够在不选择日期的情况下突出显示一组日期。这提供了一个很好的机会来展示如何扩展 WPF 日历控件,以及一般的 WPF 控件,特别是更复杂的控件。这就是本文要做的。

与以前版本的更改

版本 1.1 的更改: 版本 2.0 是 FS 日历控件的全面重写,其中有几项重大更改。为什么要进行这些更改?一旦我开始在围绕 MVVM 模式设计的应用程序中使用该控件,我就遇到了一个相当令人不快的惊喜。我最初的日历是围绕一个 Dictionary<TKey, TValue> 属性构建的,用于存储一组日期及其对应的工具提示。但我很快发现 Dictionary 对象根本无法很好地进行数据绑定。

在徒劳地尝试了一天左右来想出解决方案后,我被一个显而易见的解决方案震惊了:工具提示集合不需要存储在 Dictionary 中;一个简单的数组就可以很好地完成任务,而且它能很好地进行数据绑定。因此,我在此基础上编写了 FS 日历控件的 2.0 版。它现在可以很好地与 MVVM 配合使用,但也有一个权衡:为 1.x 版编写的代码需要进行更改。以下是最大的更改:

  • 旧的 HighlightedDates 属性(一个 Dictionary<DateTime, String>)已被一个名为 HighlightedDateTextstring 数组属性取代。
  • 版本 1.x 将所有月份的所有日期加载到其字典中,并且 Calendar 控件在月份更改时从字典中检索每个月份的突出显示日期。版本 2.0 在日历月份更改时,一次加载一个月份的突出显示日期到其 string 数组中。

此外,演示应用程序也已更改。在 1.x 版中,它显示了一个假期列表。在 2.0 版中,它突出显示奇数日期,以模拟从数据库加载数据。所有这些更改将在下面讨论。

早期更改: Richard Deeming 在 1.0 版中发现了一个错误,如果在一个窗口、页面或用户控件中放置多个日历,该控件将无法正常工作。在修复该错误的同时,我又发现了一个导致相同问题的错误。这两个错误都在 1.1 版中得到修复,并且文章中讨论了所做的更改。

在 WPF 日历中突出显示日期

在基于日历的应用程序中,能够突出显示日期通常很有用。例如,Outlook 会将任何有约会的日期加粗。社交网络应用程序可能会突出显示生日,等等——突出显示功能的应用程序是无穷无尽的。不幸的是,WPF 日历不具备此功能。

我想要一个 HighlightedDates 属性,让我可以指定要突出显示的日期。与此同时,我还希望能够为每个突出显示显示一个工具提示,其中包含我决定提供的任何上下文信息。例如,我正在编写一个笔记应用程序,我希望 string 显示特定日期的笔记数量。我希望日历突出显示任何包含一个或多个笔记的日期,并在工具提示中显示笔记数量。

嗯,想要是一回事,得到又是另一回事,所以我着手从 WPF 日历派生一个自定义控件,以提供这些功能。幸运的是,Charles Petzold 在他 2009 年 6 月在 MSDN 杂志上发表的文章《自定义新的 WPF 日历控件》中完成了大部分繁重的工作。那篇文章包含一个“Red Letter Days”示例,可以很容易地进行修改以实现我想要的功能。谢谢,查尔斯!

Petzold 的文章很好地解释了 WPF 日历控件的结构和工作原理,因此我将在本文中跳过这些主题。相反,我将重点介绍适应 Petzold 的工作以适用于自定义控件(具有一些增强功能)所需的步骤。我向那些希望了解更多 WPF 日历控件详细信息的人推荐 Petzold 的文章。

如果您只是想使用该控件,则无需仔细阅读本文。只需快速查看控件提供的附加属性,您就可以开始了。添加的属性位于一个名为“突出显示”的单独属性类别中,因此您只需在 VS 2010 中查看该类别即可很好地了解这些属性的作用。本文的大部分内容解释了自定义控件中修改的实现方式,如果您正在学习如何执行自己的 WPF 控件修改,这将很有帮助。

请注意,该控件不要求您使用 MVVM 模式。该控件足够灵活,可以适应几乎任何架构。

第 1 步:设计

以下是我的控件的要求:

  1. 宿主应用程序必须能够突出显示日期。
  2. 宿主应用程序必须能够为每个突出显示的日期显示一个唯一的工具提示。
  3. 宿主应用程序必须能够在没有工具提示的情况下显示突出显示。
  4. 宿主应用程序必须能够在不显示突出显示和工具提示的情况下显示日历。

这些要求表明需要向 WPF 日历添加四个属性:

  • HighlightedDateText:一个包含 31 个字符串的数组。如果字符串为 null,则其关联日期不突出显示。否则,该字符串将显示为该日期的工具提示,除非工具提示被禁用。
  • DateHighlightBrush:用于突出显示日期的颜色。
  • ShowHighlightedDateText:突出显示日期的文本是否显示为工具提示。
  • ShowDateHighlighting:是否显示突出显示。如果突出显示被禁用,则不会显示工具提示。

在 FS 日历的 1.x 版中,我使用 Dictionary<DateTime, String> 来存储日期突出显示字符串。考虑到需要按日期查找字符串,这种方法看起来相当合理。只要我使用过程代码加载字典,这种方法就非常有效。但它根本无法很好地进行数据绑定,这意味着它无法很好地与 MVVM 模式配合使用。

考虑到数据绑定问题,我决定对于高亮日期列表,我实际上不需要 dictionary 对象——一个字符串数组也能很好地工作。原因如下:月份中的天数最多从 1 到 31。这意味着我可以使用月份中的日期作为索引,从一个包含 31 个元素的数组中获取一个 string。字符串数组比字典简单,并且数据绑定效果更好。因此,我将旧属性重命名为 HighlightedDateText 并将其更改为 string 数组。

现在是高亮本身。理想情况下,我希望能够加粗日期、更改其文本颜色等等。不幸的是,正如我们将在下面看到的,日历只允许我通过更改其背景颜色来高亮日期。因此,DateHighlightBrush 属性只是简单地更改用于背景的颜色。

最后两个属性只是让开发人员可以选择在不显示工具提示的情况下进行高亮显示,或者关闭高亮显示和工具提示。

第 2 步:创建自定义控件

这一步非常简单。我在 Visual Studio 中创建一个“自定义控件库”项目,并将 CustomControl1 类重命名为我的自定义控件的名称。

第 3 步:向自定义控件添加依赖项属性

下一步是向我的控件添加属性。我将它们添加为依赖项属性,以方便它们与 MVVM 模式一起使用。这些属性如上所述。

HighlightedDateText 属性是一个包含 31 个元素的 string 数组。如果某个日期要被高亮显示,则与该日期对应的数组索引将包含一个文本 string。假设 ShowHighlightedDateText 属性设置为 true,则当鼠标悬停在该日期上时,与每个日期关联的文本将作为工具提示显示。请注意,由于数组的索引从零开始,因此每个日期的索引比日期号小一。例如,月份的第一天由 HighlightedDateText[0] 表示,依此类推。

属性声明出现在 FsCalendar.cs 中。这些声明是相当常规的依赖项属性声明,因此我不会在此处重现它们。不过,请查看 CLR 属性包装器。它们展示了如何实现属性类别——我们对属性使用了一个属性

[Browsable(true)]
[Category("Highlighting")]
public Brush DateHighlightBrush
{
     ...
}

请注意,版本 2.0 中有几项重大更改

  • HighlightedDateText 属性取代了旧的 HighlightedDates 属性;
  •  旧的 DateHighlightColor 属性已重命名为 DateHighlightBrush;
  • 旧的 ShowDateHighlights 属性已重命名为 ShowHighlightedDateText;以及
  • 旧的 ShowHighlightTooltips 属性已重命名为 ShowHighlightedDateText。

第 4 步:添加值转换器

现在,事情开始变得有趣了。我们将在向 WPF 日历添加高亮显示的过程中使用值转换器,这可能不足为奇。但我们将使用多值转换器,并且我们将以一种您可能意想不到的方式使用它。在大多数项目中,值转换器只不过是一个执行常规类型转换的代码小部件,例如将 List 转换为带分隔符的 string。在我们的自定义控件中,它做得更多。要理解 FsCalendar 项目中的值转换器,了解 WPF 日历控件的一个怪癖会有所帮助。

每个日期都有一个数据上下文:WPF 日历控件设置了显示月份中每个日期的 DataContextDataContext 被设置为一个 DateTime 对象。思考一下——这真是一件奇怪的事情。但它所做的就是让我们能够访问日历控件中的每个单独日期,尽管方式有些不同寻常。我们可以在日期和其 DataContext 之间插入一个 IConverter 对象,并通过 IConverter 操作日期。

我再重复一遍,因为我第一次读 Petzold 的文章时错过了这一点。WPF 日历控件本身会为控件中的每个单独日期设置一个 DataContext——这不是我们设置的 DataContext。控件设置这个 DataContext 是为了让控件用户(也就是我们)能够访问日期对象。我们可以插入一个 IConverter 来将我们自己的代码注入到日历中。这很不直观,但 Petzold 就是这样创建他的“Red Letter Days”的,我们也将以这种方式连接我们的高亮属性。

多值转换器:值转换器需要访问 FsCalendar 的几个属性。最明显的解决方案是在绑定的 ConverterParameter 属性中传递对 FsCalendar 控件当前实例的引用。不幸的是,ValueConverter 属性不允许这样做——它不是一个依赖项属性,所以我们不能用它来将 RelativeReference 传递给 FsCalendar 控件的当前实例。在 FsCalendar 控件的 1.0 版中,我们在值转换器上创建了一个 static Parent 属性来保存此引用,并在 FsCalendar 构造函数中设置该属性。

不幸的是,那个 static 属性有一个相当令人讨厌的副作用,读者 Richard Deeming 在 1.0 版中发现了这一点。由于该属性是 static 的,这意味着 FsCalendar 用于设置日期高亮显示的突出显示日期集合的单个实例在控件的所有实例之间共享。这显然不是我们想要的——控件的每个实例都应该有自己的突出显示日期集合。

理查德还提出了一个解决方案,我将其整合到 1.1 版本中。解决方案是使用 IMultiValueConverter,而不是更常见的 IValueConverter,来执行值转换。我以前见过 IMultiValueConverter,但它总是在从视图模型读取两个不同的属性,然后以某种方式对其进行处理并将结果传递给视图的上下文中。但这里有一个巧妙的技巧:您可以使用 IMultiValueConverterRelativeReference 传递给值转换器中的调用控件,就像我们希望通过 ConverterParameter 属性所做的那样

<MultiBinding Converter="{StaticResource HighlightDate}">
    <MultiBinding.Bindings>
        <Binding />
        <Binding 
          RelativeSource="{RelativeSource FindAncestor, 
                          AncestorType={x:Type local:FsCalendar}}" />
    </MultiBinding.Bindings>
</MultiBinding>

稍后我将详细讨论那个看起来很奇怪的第一个绑定。现在,只需注意我们正在向转换器传递两个值,第二个值是对调用它的 FsCalendar 的引用。谢谢 Richard,感谢您发现问题并提出了一个很棒的解决方案。

依赖项属性陷阱:在介绍值转换器的工作原理之前,我想提一下我在实现 Richard Deeming 的解决方案时发现的另一个 bug。在实现 Richard 的解决方案后,我惊讶地发现我仍然面临与以前相同的问题——所有控件实例共享一个高亮日期集合!问题不在于 Richard 的解决方案,而在于我的 1.0 版本代码。一番调查后,我回到了 DependencyProperty.Register() 方法。

在 1.0 版本中,我在 DependencyProperty.Register() 方法中初始化了旧的 HighlightedDates 属性(在 2.0 版本中更改为 HighlightedDateText),如下所示

// The list of dates to be highlighted.
public static DependencyProperty HighlightedDatesProperty = DependencyProperty.Register
(
     "HighlightedDates",
     typeof (Dictionary<DateTime, String>),
     typeof (FsCalendar),
         new PropertyMetadata(new Dictionary<DateTime, String>())
);

注意 new PropertyMetadata() 参数——我使用的重载会初始化正在注册的属性。

由于我在 static 注册方法中初始化了 HighlightedDatesProperty,因此该属性被初始化为 static 属性,导致了共享集合。解决这个问题很简单——在 1.1 版本中,我将 DependencyProperty.Register() 调用更改为接受 new PropertyMetadata() 参数的空构造函数的重载,这样属性就不会在 DependencyProperty.Register() 方法中初始化。在初始化替换的 HighlightedDateText 属性时,我将相同的方法沿用到 2.0 版本

// The list of dates to be highlighted.
public static DependencyProperty HighlightedDateTextProperty = 
					DependencyProperty.Register
	(
		"HighlightedDateText",
		typeof (String[]),
		typeof (FsCalendar),
		new PropertyMetadata()
	);

然后我添加了一个实例构造函数,以便在每次创建 FsCalendar 实例时初始化该属性。以下是 2.0 版本

public FsCalendar()
{
    /* We initialize the HighlightedDateText property to an array of 31
     * strings, since 31 is the maximum number of days in any month. */

    // Initialize HighlightedDateText property
     this.HighlightedDateText = new string[31];
}

这个故事的寓意是,除非您想将属性初始化为 static,否则应从实例构造函数中初始化依赖项属性,而不是从 DependencyProperty.Register() 方法中初始化。

IMultiValueConverter 如何工作:好的,现在我们可以回到值转换器了。HighlightDateConverter 已针对 2.0 版完全重写。它通常基于 Petzold 的代码,尽管 Petzold 使用 IValueConverter 而不是 IMultiValueConverter,并且 HighlightDateConverter 进行了更多的 null 条件和设计时检查。以下是新的完整转换器

using System;
using System.Windows.Data;

namespace FsControls
{
    public class HighlightDateConverter : IMultiValueConverter
    {
        #region IMultiValueConverter Members

        /// <summary>
        /// Gets a tool tip for a date passed in
        /// </summary>
        /// <param name="values">The array of values that the source bindings 
        /// in the System.Windows.Data.MultiBinding produces.</param>
        /// <param name="targetType">The type of the binding target property.</param>
        /// <param name="parameter">The converter parameter to use.</param>
        /// <param name="culture">The culture to use in the converter.</param>
        /// <returns>A string representing the tool tip for the date passed in.</returns>
        /// <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 
        /// FsCalendar control that is invoking this converter.
        /// </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)) return null;

            // Get values passed in
            var targetDate = (DateTime)values[0];
            var parent = (FsCalendar) values[1];
            
            // Exit if highlighting turned off
            if (parent.ShowDateHighlighting == false) return null;

            /* At design-time, after we reset the calendar to the current date, 
             * we still may not have a HighlightedDateText array. 
             * If that's the case, we ignore the target date and exit. */

            // Exit if no HighlightedDateText array
            if (parent.HighlightedDateText == null) return null;

            /* The WPF calendar always displays six rows of dates, 
             * and it fills out those rows with dates from the preceding and 
             * following month. These 'gray' dates duplicate dates in the current 
             * month, so we ignore them. Their tool tips will appear in their 
             * own display months. */

            // Exit if target date not in the current display month
            if (!targetDate.IsSameMonthAs(parent.DisplayDate)) return null;

            // Get tool tip for date passed in
            string toolTip = null;
            var day = targetDate.Day;

            /* The HighlightedDateText array is indexed from zero, 
             * while the calendar is indexed from
             * one. So, we have to adjust the index between the array and the calendar. */

            // Get array index
            var n = day - 1;

            var dateIsHighlighted = !String.IsNullOrEmpty(parent.HighlightedDateText[n]);
            if (dateIsHighlighted) toolTip = parent.HighlightedDateText[n];

            // Set return value
            return toolTip;
        }

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

        #endregion
    }
}

日历会将一个 DateTime 传递给转换器;它将作为 Convert() 方法中的 values[0] 参数出现。对 FsCalendar 控件(父控件)当前实例的引用将作为 values[1] 传递。

转换器首先检查是否传入了值。如果 value[0]value[1]null,转换器将退出并向日历返回 null。如果转换器通过了此点,它会将两个值都转换为正确的类型。它现在拥有对其父日历的引用,因此它会检查日历以查看日期高亮显示是否已关闭。如果已关闭,它将退出并向日历返回 null

接下来,转换器执行几个必要的测试,以确保正确的设计时性能。这些测试在 Convert() 方法注释中解释。然后我们遇到了一个有趣的问题。WPF 日历总是显示六行日期,并用上个月的领先日期和下个月的末尾日期填充日历。这意味着我们可能会有多个具有相同索引的日期。例如,日历很可能会显示两个不同月份的月初。这种重复可能会对我们的索引造成严重破坏。

解决方案是忽略任何不在实际显示月份中的日期。WPF 日历包含一个名为 DisplayDate 的属性,它始终是显示月份中的一个日期。这意味着我们可以使用 DisplayDate 来获取显示月份。从那里,测试日历传入的日期是否在显示月份中就变得很简单。如果不在,转换器将返回 null

请注意,我们使用了一个扩展方法 IsSameMonthAs() 来确定日期是否在显示月份中。该扩展方法可以在 FsCalendar 项目的 Utility 文件夹中的 DateTimeExtensions 类中找到。

最后,转换器准备好获取传入日期的突出显示文本。转换器使用日期来确定适当的索引,然后从父日历的 HighlightedDateText 属性中获取相应的元素。如果日期要突出显示,则与日期对应的数组元素将包含一个 string,日历最终会将其显示为工具提示。如果日期未突出显示,则该元素将为 null

那么,父控件究竟如何处理 HighlightDateConverter 返回的值呢?我们接下来将讨论这个问题。

第 5 步:重新设置日历控件样式

从这一点开始,2.0 版本与 1.x 版本基本没有变化。我们的自定义控件有一个 Themes 文件夹,其中包含一个 Generic.xaml 标记文件。该文件和文件夹是“神奇”的,因为它们是自定义控件所必需的。我在我的文章中讨论了这个问题:创建 WPF 自定义控件,第 2 部分

对于本讨论的下一部分,您需要了解 WPF 日历控件的结构。它有几个控制模板,这些模板按层次结构排列。Petzold 的文章对控件的结构有很好的讨论,我建议您在继续之前研究一下。

获取您需要的样式:本质上,WPF 日历有一个 Style 属性,它包含控件的通用控制模板,还有几个 Style 属性用于管理子样式。我们的修改将只影响 CalendarDayButton 控制模板,该模板包含在 CalendarDayButtonStyle 中。尽管如此,我们需要 WPF 日历控件的两个默认样式:

  • WPF 日历的默认样式,以及
  • 默认的 CalendarDayButtonStyle

正如我上面提到的,CalendarDayButton 样式包含我们将要修改的控制模板。我们需要 Calendar 样式,以便将我们的自定义控件指向我们的自定义 CalendarDayButton 样式。下面将对此进行更多讨论。

CalendarDayButton 对象在 Petzold 的文章中有所讨论。我使用 Expression Blend 获得了模板副本;您可以在这篇 MSDN 文章中找到使用 Blend 复制模板的说明:试用:创建自定义 WPF 控件。如果您没有 Expression Blend,网上有几种免费工具可以抓取控件模板。

建立到修改后的控件模板的链:WPF 不会使用我们修改后的 CalendarDayButton 控件模板,除非我们逐步将其引导到该模板。我们的起点是主 Calendar 控件的默认样式。我们将整个样式复制到我们的 Generic.xaml 文件中,只进行两处更改:

  • 首先,我们将样式的 TargetType 设置为我们的自定义控件。
  • 然后,我们为日历的 CalendarDayButtonStyle 属性添加一个属性设置器,并将其设置为指向我们的自定义 CalendarDayButton 样式。

经过这些更改,Calendar 样式看起来像这样

<Style TargetType="{x:Type local:FsCalendar}">
    <Setter Property="CalendarDayButtonStyle" Value="{StaticResource
        FsCalendarDayButtonStyle}" />
    ...
</Style>

Calendar 样式的其余部分与默认样式保持不变。

这些更改指示我们的自定义 FsCalendar 使用我们的自定义 FsCalendarDayButtonStyle。自定义控件将对控件的其他部分(如 CalendarItemCalendarButton 对象)使用默认控制模板。

此时,您的 Generic.xaml 文件应包含一个如上所示修改的 Calendar 样式,以及一个完全复制默认 CalendarDayButtonStyleFsCalendarDayButtonStyle。在进行任何编辑之前,最好验证到目前为止一切正常。创建一个简单的 WPF 应用程序,其中包含一个 MainWindow,为其提供对自定义控件项目的引用,并将自定义控件添加到 MainWindow。您应该会看到一个标准的 WPF 日历控件。如果情况确实如此,那么您就可以开始编辑 CalendarDayButton 控件模板了。

修改 CalendarDayButton 控件模板:一旦您拥有了模板并验证它们正常工作,您就可以开始编辑 FsCalendarDayButtonStyle 以添加高亮功能了。

CalendarDayButton 控件模板包含一个 Grid 控件,其中包含一个 VisualStateManager,然后是几个矩形和其他对象。折叠 VisualStateManager 后,它看起来像这样

添加高亮矩形:让我们从添加一个用于高亮的矩形开始。我们将其插入到“TodayBackground”矩形和“SelectedBackground”矩形之间。CalendarDayButton 已经有一个名为“HighlightBackground”的矩形,它在某些动画中使用,所以我们将我们的矩形命名为“AccentBackground

<Rectangle x:Name="TodayBackground" Fill="#FFAAAAAA" Opacity="0" RadiusY="1" RadiusX="1"/>

<!-- Added for FsCalendar -->
<Rectangle x:Name="AccentBackground"
           RadiusX="1" RadiusY="1"
          IsHitTestVisible="False"
          Fill="{Binding RelativeSource={RelativeSource AncestorType=local:FsCalendar}, 
    Path=DateHighlightBrush}" />
<!-- End addition -->

<Rectangle x:Name="SelectedBackground" Fill="#FFBADDE9" 
			Opacity="0" RadiusY="1" RadiusX="1"/>

再次打开演示应用程序的 MainWindow。此时,假设您将控件的 DateHighlightBrush 属性保留为其默认值,则日历上的所有日期都应为红色。

添加值转换器:在我们继续之前,我们需要添加上面讨论的值转换器。我们将在控件模板中,紧跟在开头的 <ControlTemplate> 标签下方,添加一个 <ControlTemplate.Resources> 部分,并向新部分添加值转换器的声明

<ControlTemplate TargetType="{x:Type CalendarDayButton}">

    <!-- Added for FsCalendar -->
    <ControlTemplate.Resources>
        <local:HighlightDateConverter x:Key="HighlightDate" />
    </ControlTemplate.Resources>
    <!-- End addition -->

    <Grid>

添加工具提示:值转换器就位后,我们就可以添加工具提示了。我们将工具提示添加到开头的 <Grid> 标签中

<Grid x:Name="CalendarDayButtonGrid">
                         
    <!-- Added for FsCalendar -->
    <Grid.ToolTip>
          <MultiBinding Converter="{StaticResource HighlightDate}">
              <MultiBinding.Bindings>
                  <Binding />
                    <Binding RelativeSource="{RelativeSource FindAncestor, 
                                   AncestorType={x:Type local:FsCalendar}}" />
              </MultiBinding.Bindings>
          </MultiBinding>
    </Grid.ToolTip>
    <!-- End addition -->
    ...
</Grid>

ToolTip 多重绑定可能对某些人来说看起来有点奇怪,因为第一个绑定没有声明绑定目标。这是正在发生的事情

首先,让我们处理简单部分:为什么第一个绑定没有声明 Path?请记住,WPF 日历控件以 DateTime 对象的形式为每个日期提供一个 DataContext。由于 DataContext 是一个简单对象 (DateTime),因此只有一个可能的路径。所以,不是像这样

<Binding Path=MyProperty />

我们得到了上面所示的声明,没有 Path 属性。

现在是第二个问题——ToolTip 声明是如何工作的,为什么它不会干扰日期显示?这就是事情开始变得有点复杂的地方。请记住,我们位于 CalendarDayButton 控件模板内。日期对象的不同部分获取要显示的日期,并且该部分仍然可以像以前一样访问 DataContext。它类似于 MVVM 模式,其中几个不同的属性可以访问相同的 DataContext。我们只是从不同的位置利用该 DataContext,并使用我们的值转换器在日期出现在高亮日期列表上时获取一些文本。

这样,我们就剩下最后一个任务——创建触发器来显示和隐藏高亮和工具提示。我们添加一个新的 <ControlTemplate.Triggers> 标签,紧接在关闭的 </Grid> 标签下方

<!-- Added for FsCalendar -->
<ControlTemplate.Triggers>

    <!-- No tooltips if tooltips turned off -->
    <DataTrigger Binding="{Binding RelativeSource={RelativeSource 
                           AncestorType=local:FsCalendar}, Path=ShowHighlightToolTips}" 
                 Value="False">
        <Setter TargetName="CalendarDayButtonGrid" 
                Property="ToolTipService.IsEnabled" Value="False" />
    </DataTrigger>

    <!-- No highlighting if IValueConverter returned null -->
     <DataTrigger Value="{x:Null}">
          <DataTrigger.Binding>
               <MultiBinding Converter="{StaticResource HighlightDate}">
                    <MultiBinding.Bindings>
                         <Binding />
                         <Binding RelativeSource="{RelativeSource FindAncestor, 
                                            AncestorType={x:Type local:FsCalendar}}" />
                    </MultiBinding.Bindings>
               </MultiBinding>

          </DataTrigger.Binding>
        <Setter TargetName="AccentBackground" Property="Visibility" Value="Hidden" />
    </DataTrigger>

</ControlTemplate.Triggers>
<!-- End addition -->

触发器是自解释的,多重绑定与工具提示中显示的一样。第一个触发器绑定到自定义控件的 ShowHighlightToolTips 属性,并根据属性设置打开或关闭工具提示。第二个触发器来自 Petzold 的文章。它通过将转换器的返回值设置为 null,关闭没有工具提示的日期的高亮显示。

刷新日历

在使用日历时,我很快发现它有一个显著的怪癖。它并不总是按预期更新其高亮显示。在某些情况下,用户必须通过点击其中一个箭头来更改月份,然后再将日历改回来。坦率地说,我不确定是什么原因导致了这个问题。我尝试了强制重绘的常用方法(InvalidateArrange()InvalidateLayout()UpdateLayout() 等),但它们都没有任何效果。如果读者对这个问题和解决方案有任何反馈,我将不胜感激。

作为一种变通方法,我向控件添加了一个 Refresh() 方法。该方法将日历的 DisplayDate 属性重置为 DateTime.MinValue,然后立即将其设置回实际的显示日期。此操作与用户点击到不同的月份,然后点击回来具有相同的效果。不同之处在于它发生得足够快,对用户来说是透明的。因此,如果高亮显示没有按预期更新,请调用 Refresh() 方法。

在我的应用程序中,我使用 DisplayDate 来触发日历的 HighlightedDateText 数组的加载。我将日历的 DisplayDate 属性绑定到视图模型的 DisplayDate 属性,然后我监视视图模型的 DisplayDate 属性的变化。当 DisplayDate 更改为不同的月份时(当用户点击日历的一个箭头时会发生这种情况),视图模型会将新月份的数据加载到 HighlightedDateText 属性中。

这是一个很好的方法,但它有一个副作用。调用 Refresh() 方法会触发两次加载数组的尝试;一次是针对 0001 年 1 月(DateTime.MinValue 所在的月份),另一次是针对正在刷新的月份。如果您使用相同的方法将数据加载到日历中,您可以通过监视 DateTime.MinValue 并忽略该特定更改来提高性能。您可以在本文的以下部分中查看执行此操作的代码。

演示应用程序

演示应用程序已针对 2.0 版完全重写,展示了如何使用该控件。在 1.x 版中,我使用字典来保存日期列表和关联的字符串。这种方法的优点是它允许开发人员一次性加载所有数据,或者逐月加载数据。例如,我可以在应用程序初始化时加载五年的数据,或者在日历更改为新月份时加载每个月的数据。切换到 string 数组消除了 1.x 版演示中使用的一体化方法。2.0 版演示使用 MVVM 模式说明了逐月加载数据的方法。

在新演示中,我想展示如何使用 MVVM 模式一次获取一个月的日期数据,但我不想让演示程序承担数据库代码的负担。所以演示程序只是简单地突出显示奇数日期,并将每个高亮日期的文本设置为其长日期字符串。所有工作都在视图模型中完成,尽管在生产应用程序中我可能会将繁重的工作移到业务层中的服务方法中。

视图和视图模型在 App.xaml.csApp.OnStartup() 方法的重写中实例化并连接

public partial class App : Application
{
    protected override void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);

        var mainWindow = new MainWindow();
        var mainWindowViewModel = new MainWindowViewModel();
        mainWindow.DataContext = mainWindowViewModel;
        mainWindow.Show();
    }
}

视图模型只有一个技巧。它在 Initialize() 方法中订阅了自己的 PropertyChanged 事件,该方法由视图模型的构造函数调用

private void Initialize()
{
    ...

    // Subscribe to PropertyChanged event
    this.PropertyChanged += OnPropertyChanged;
}

DisplayDate 属性触发事件时,视图模型的 OnPropertyChanged() 方法处理该事件

private void OnPropertyChanged
(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
    // Ignore properties other than DisplayDate
    if (e.PropertyName != "DisplayDate") return;

    // Ignore change if date is DateTime.MinValue
    if (p_DisplayDate == DateTime.MinValue) return;

    // Ignore change if month is the same
    if (p_DisplayDate.IsSameMonthAs(m_OldDisplayDate)) return;

    // Populate month
    this.SetMonthHighlighting();

    // Update OldDisplayDate
    m_OldDisplayDate = p_DisplayDate;
}

事件处理程序首先测试新的显示日期是否为 DateTime.MinValue。如果是,则表示控件正在刷新自身,事件处理程序将忽略该日期。接下来,事件处理程序测试该日期是否与存储在成员变量中的前一个显示日期落在同一个月份。它使用了前面讨论过的 IsSameMonthAs() 扩展方法,该方法在演示项目的 Utility 文件夹中找到。如果两个日期都落在同一个月份,则事件处理程序将忽略新日期并退出。如果新的显示日期落在不同的月份,则事件处理程序调用视图模型的 SetMonthHighlighting() 方法,将新月份的高亮日期文本加载到视图模型的 HighlightedDateText 属性中

private void SetMonthHighlighting()
{
    var displayMonth = this.DisplayDate.Month;
    var displayYear = this.DisplayDate.Year;

    // Get the last day of the display month
    var month = this.DisplayDate.Month;
    var year = this.DisplayDate.Year;
    var lastDayOfMonth = DateTime.DaysInMonth(year, month);

    // Set the highlighted date text
    for (var i = 0; i < 31; i++)
    {
        // First set this array element to null
        p_HighlightedDateText[i] = null;

        /* This demo simply highlights odd dates. So, if the array element represents 
         * an even date, we leave the element at its null setting and skip to the next 
         * increment of the loop. Note that the array is indexed from zero, while a 
         * calendar is indexed from one. That means odd-numbered elements represent 
         * even-numbered dates. So, if the index is odd, we skip. */

        // If index is odd, skip to next
        if (i % 2 == 1) continue;

        /* An element may be out of range for the current month. For example, element
         * 30 would represent the 31st, which would be out of range for a month that 
         * has only 30 days. If that's the case for the current element, we leave it
         * set to null and skip to the next increment of the loop. */

        // If element is out of range, skip to next
        if (i >= lastDayOfMonth) continue;

        /* Since the array is indexed from zero, and a calendar is indexed from one, 
         * we have to add one to the array index to get the calendar day to which it 
         * corresponds. All we do in this demo is put the Long Date String is the
         * HighlightedDateText array. */

        // Set highlight date text
        var targetDate = new DateTime(displayYear, displayMonth, i + 1);
        p_HighlightedDateText[i] = targetDate.ToLongDateString();
        
        // Refresh the calendar
        this.RequestRefresh();
    }
}

请注意,如果您想显示高亮但不显示工具提示,只需将 ShowHighlightedDateText 属性设置为 false。但是,您仍然需要为每个高亮日期在数组元素中插入某种 string 值。由于不会显示工具提示,因此您可以将 string 设置为您想要的任何内容,只要它不是 null 或空即可。

另请注意,在更新 HighlightedDateText 属性后,我们会刷新日历。我们通过调用视图模型的 RequestRefresh() 方法来完成此操作,该方法会引发一个 RefreshRequested 事件,视图会订阅该事件。我们采取这种迂回路线,而不是在视图上调用刷新方法,是因为 MVVM 模式。

MVVM 是一种非常灵活的模式,有几种不同的实现方式。我更倾向于这样实现该模式:视图依赖于其视图模型,反之则不然——视图模型独立于视图。这使得依赖关系保持在一个方向上,我发现它大大简化了维护,因为视图模型不关心连接到它的视图是什么。我对 MVVM 的实现在这方面非常严格,以至于视图模型甚至不包含对视图或视图接口的注入引用。

这种方法最大限度地减少了视图与其视图模型之间的耦合,这使得从应用程序中卸下视图并替换为完全不同的东西变得容易得多。但这也意味着视图模型无法了解使用它的视图。鉴于此限制,视图与视图通信的唯一方法是引发事件,就像我们在这里所做的那样。我们很快就会看到视图如何处理该事件。

视图 (MainWindow.xaml) 非常简单。它包含一个 FsCalendar 控件,该控件与视图模型绑定如下

<FsControls:FsCalendar x:Name="DemoCalendar" 
                         DisplayDate="{Binding Path=DisplayDate, 
			Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
                         HighlightedDateText="{Binding Path=HighlightedDateText, 
			Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
                         ... />

视图的代码隐藏文件 (MainWindow.xaml.cs) 包含两个事件处理程序,它们协同工作以处理视图模型发布的 RefreshRequested 事件。MainWindow.OnDataContextChanged 事件在 XAML 中绑定到一个 OnDataContextChanged 事件处理程序,该处理程序订阅 MainWindowViewModel.RefreshRequested 事件。当 RefreshRequested 事件触发时,第二个事件处理程序在日历上调用 Refresh() 方法

#region Event Handlers

/// <summary>
/// Subscribes to the view model's RefreshRequested event.
/// </summary>
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
    var mainWindowViewModel = (MainWindowViewModel) DataContext;
    mainWindowViewModel.RefreshRequested += OnRefreshRequested;
}

/// <summary>
/// Refreshes the calendar.
/// </summary>
private void OnRefreshRequested(object sender, EventArgs e)
{
    this.DemoCalendar.Refresh();
}

#endregion

现在,我知道一些 MVVM 纯粹主义者会因为这些事件处理程序而跳脚。MVVM 的一种方言要求所有事情都在 XAML 中完成,不包含代码隐藏。对于那些偏爱这种 MVVM 风格的人,我表示尊重和敬意。但有些事情是无法在 XAML 中完成的。在这些情况下,我遵循的规则是,只要代码隐藏只处理视图关注点,就是可以接受的。只有当后端关注点混入代码隐藏时,问题才会出现。当然,如果这些事件处理程序可以被 XAML 替换,我很乐意接受,并欢迎您的评论。

显然,在此演示中,视图和视图模型故意简化。生产应用程序将使用日历的更多属性,例如 SelectedDate 属性,以便在用户选择日期时显示该日期的数据。但这里应该有足够的内容向您展示如何将控件连接到视图模型。

结论

在本文中,我展示了如何扩展复杂的 WPF 用户控件,特别是当需要修改控件深层嵌套的控件模板时。本文中描述的技术可以应用于 Calendar 控件之外的其他 WPF 控件。关键是了解特定控件的结构,以及如何建立从最顶层控件模板到需要修改的控件模板的链。

我一直在寻求对这些文章的同行评审,因此我邀请您在本文下方发表您的评论、问题和建议。感谢您的意见!

历史

  • 2010 年 8 月 20 日:版本 1.0。初始版本
  • 2010 年 8 月 29 日:版本 1.1。从值转换器中删除了 Parent 属性;替换为 IMultiValueConverter
  • 2010 年 9 月 9 日:版本 2.0。将 Dictionary<DateTime, String> 替换为字符串数组;添加了 Refresh() 方法;重写了演示
© . All rights reserved.