创建 WPF 中的 Microsoft Outlook 约会视图 - 第二部分






4.50/5 (9投票s)
一系列文章的第二部分,
文章系列
本文是关于开发一个高级 WPF 控件的系列文章的第二部分,该控件旨在像 Microsoft Outlook 中的日历视图一样运行。
- 第 1 部分 - 介绍、基本类设计和自定义项目模板
- 第 2 部分 - 单个约会项目的布局
引言
上一篇文章介绍了控件的整体类布局。在本文中,将创建构成最终控件外观和感觉的一些较小的构建块。
步骤 4 - 基于周期和约会的开始/结束时间,单个约会的概念设计布局
我早期面临的最大挑战之一是如何布局表示单个约会的视觉控件。问题包括约会重叠的可能性、单个约会的大小会因开始和结束时间而异、整个控件开始和结束时间的可能变化(显示几个小时的视图与显示二十四小时的视图),以及视图的时间刻度(15 分钟块与 30 分钟块与...)。虽然并非所有这些问题都会立即出现,但我想提出一个足够灵活的设计,以避免以后出现问题时需要完全重写。
通常,在创建控件时,只要有意义,就应该尝试使用现有的布局控件。我想到的一些想法是使用一个 `StackPanel`,其中空白区域将用不可见的控件填充;一个网格,每行代表一个“15/30/等等分钟块”,将每行的宽度设置为一个时间块,使用 `RowSpan` 属性设置每个约会的总高度,并使用多列来处理重叠的约会。然而,每个解决方案都像试图将方钉子强行塞进圆孔中。每个都有问题,例如需要为布局目的创建“假”控件;不完全适合“块”的约会,例如在使用 15 分钟块时,约会从下午 4:05 开始。
我做的第一个决定是如何在单个布局面板中处理重叠的约会。答案来自 KISS 原则(保持简单,愚蠢)。组装小型的单一用途组件比创建单个复杂的组件要容易得多,也更容易修改。因此,我决定使用一个控件来布局单个不重叠约会的“列”,并通过并排使用此控件的多个实例来覆盖重叠。父控件中的逻辑将获取表示每个约会的视觉树,创建足够的面板以避免重叠,并将每个约会视觉添加到适当的子面板中。
第二个决定是如何在单个面板中布局不重叠的约会。然后我思考了大多数布局面板的工作方式,通过向每个子控件添加附加依赖属性来定义各种布局特性(例如 `DockPanel.Dock`、`Grid.Row`、`Grid.Column`)。我意识到每个约会的大小和位置主要反映了其相对于控件开始和结束时间的持续时间(如果控件覆盖 24 小时,一个 6 小时的约会将占用控件高度的 50%),以及其相对于控件开始和结束时间的开始时间(如果控件覆盖从凌晨 12 点到上午 10 点的 10 小时,一个从凌晨 1 点开始的约会将在控件顶部下方 10% 的位置)。
步骤 5 - RangePanel 控件
在第 4 步做出决定后,我开始开发 `RangePanel`,这是一个可以根据子控件上定义的属性相对于父控件上的属性来布局子控件的面板。这将需要在 `RangePanel` 上用于表示控件开始和结束值的依赖属性,以及附加到表示子控件开始和结束的子控件的附加依赖属性。我决定支持 `Vertical` 和 `Horizontal` 方向,并为所有依赖属性提供默认值。为了使此控件可重用,我将定义控件最小/最大值和每个子控件开始/结束值的属性类型设置为 `double`(尽管在 `CalendarView` 的情况下,`DateTime` 同样适用,尽管计算起来会稍微麻烦一些),以增加控件的可重用性。还使用 `Double`(而不是 `int`),因为 `Point` 和 `Location` 类也分别使用 `double` 值作为 X/Y 和 Height/Width 属性。
另一个决定是在控件上设置明确的 `Minimum` 和 `Maximum` 属性,而不是仅设置一个“范围高度”属性,其 `Minimum` 隐式为零。这将使数据绑定更容易。例如,如果隐式最小值为 0,最大值为 3600(一天中的分钟数),则约会需要在绑定中使用自定义的 `IValueConverter` 来计算该约会开始和结束时间距离午夜的分钟数。但是,如果将最小绑定到周期开始的 `Ticks` 属性(作为 `Long`,它将自动转换为 `Double`),将最大绑定到周期结束,则约会将自动处于相同的数字比例。
我仍在争论的一个问题是默认值的行为应该是什么。目前,控件的最小值和最大值为 `0` 和 `100`,这使得处理基于百分比的子级开始/结束值变得容易。然而,对于子控件开始/结束属性的良好默认值,并没有什么特别突出的。我最终可能会将所有这些属性设为可为空的双精度浮点数,并在布局之前验证它们是否已设置。
using System.Windows;
using System.Windows.Controls;
namespace OutlookWpfCalendar.Windows.Controls
{
public class RangePanel : Panel
{
public static DependencyProperty MinimumProperty =
DependencyProperty.Register("Minimum", typeof(double),
typeof(RangePanel), new FrameworkPropertyMetadata(0.0,
FrameworkPropertyMetadataOptions.AffectsArrange));
public static DependencyProperty MaximumProperty =
DependencyProperty.Register("Maximum", typeof(double),
typeof(RangePanel), new FrameworkPropertyMetadata(100.0,
FrameworkPropertyMetadataOptions.AffectsArrange));
public static DependencyProperty OrientationProperty =
DependencyProperty.Register("Orientation",
typeof(Orientation), typeof(RangePanel),
new FrameworkPropertyMetadata(Orientation.Vertical,
FrameworkPropertyMetadataOptions.AffectsArrange));
public static DependencyProperty BeginProperty =
DependencyProperty.RegisterAttached("Begin", typeof(double),
typeof(UIElement), new FrameworkPropertyMetadata(0.0,
FrameworkPropertyMetadataOptions.AffectsArrange));
public static DependencyProperty EndProperty =
DependencyProperty.RegisterAttached("End", typeof(double),
typeof(UIElement), new FrameworkPropertyMetadata(100.0,
FrameworkPropertyMetadataOptions.AffectsArrange));
public static void SetBegin(UIElement element, double value)
{
element.SetValue(BeginProperty, value);
}
public static double GetBegin(UIElement element)
{
return (double)element.GetValue(BeginProperty);
}
public static void SetEnd(UIElement element, double value)
{
element.SetValue(EndProperty, value);
}
public static double GetEnd(UIElement element)
{
return (double)element.GetValue(EndProperty);
}
public double Maximum
{
get { return (double)this.GetValue(MaximumProperty); }
set { this.SetValue(MaximumProperty, value); }
}
public double Minimum
{
get { return (double)this.GetValue(MinimumProperty); }
set { this.SetValue(MinimumProperty, value); }
}
public Orientation Orientation
{
get { return (Orientation)this.GetValue(OrientationProperty); }
set { this.SetValue(OrientationProperty, value); }
}
protected override Size ArrangeOverride(Size finalSize)
{
double containerRange = (this.Maximum - this.Minimum);
foreach (UIElement element in this.Children)
{
double begin = (double)element.GetValue(RangePanel.BeginProperty);
double end = (double)element.GetValue(RangePanel.EndProperty);
double elementRange = end - begin;
Size size = new Size();
size.Width = (Orientation == Orientation.Vertical) ?
finalSize.Width : elementRange / containerRange * finalSize.Width;
size.Height = (Orientation == Orientation.Vertical) ?
elementRange / containerRange * finalSize.Height : finalSize.Height;
Point location = new Point();
location.X = (Orientation == Orientation.Vertical) ? 0 :
(begin - this.Minimum) / containerRange * finalSize.Width;
location.Y = (Orientation == Orientation.Vertical) ?
(begin - this.Minimum) / containerRange * finalSize.Height : 0;
element.Arrange(new Rect(location, size));
}
return finalSize;
}
protected override Size MeasureOverride(Size availableSize)
{
foreach (UIElement element in this.Children)
{
element.Measure(availableSize);
}
return availableSize;
}
}
}
下面显示了此控件的示例用法。屏幕截图分别基于垂直和水平方向。请注意,尽管控件具有更大的首选高度或宽度,但控件如何根据开始/结束值进行大小调整。
<Window x:Class="OutlookWpfCalendar.UI.VerticalRangePanelWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:controls="clr-namespace:OutlookWpfCalendar.Windows.Controls;
assembly=OutlookWpfCalendar"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Vertical Range Panel Window" Height="300" Width="300">
<controls:RangePanel Minimum="100" Maximum="200" Orientation="Vertical">
<Border BorderBrush="Blue" BorderThickness="1,1,1,1"
controls:RangePanel.Begin="110" controls:RangePanel.End="120">
<TextBlock HorizontalAlignment="Center"
VerticalAlignment="Center" Text="Begin: 100, End: 120" />
</Border>
<Border BorderBrush="Red" BorderThickness="1,1,1,1"
controls:RangePanel.Begin="130" controls:RangePanel.End="135">
<TextBlock HorizontalAlignment="Center"
VerticalAlignment="Center" Text="Begin: 130, End: 135" />
</Border>
<Border BorderBrush="Orange" BorderThickness="1,1,1,1"
controls:RangePanel.Begin="180" controls:RangePanel.End="200">
<TextBlock HorizontalAlignment="Center"
VerticalAlignment="Center" Text="Begin: 180, End: 200" />
</Border>
</controls:RangePanel>
</Window>
RangePanel 与 ListBox 的示例用法
由于在单个视图中显示多个周期(通常是天)的复杂要求,集成 `RangePanel` 的步骤将在下一篇文章中介绍。然而,为了提供上述材料的示例用法,我们可以在重新设计样式的 `ListBox` 中使用 `RangePanel`,它将显示单个周期,没有重叠的约会。
<Window x:Class="OutlookWpfCalendar.UI.RestyledListBoxWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:OutlookWpfCalendar.UI"
xmlns:controls="clr-namespace:OutlookWpfCalendar.Windows.Controls;
assembly=OutlookWpfCalendar"
xmlns:sys="clr-namespace:System;assembly=mscorlib"
Title="Restyled ListBox" Height="300" Width="300">
<Window.Resources>
<sys:DateTime x:Key="Minimum">03/02/2009 12:00 AM</sys:DateTime>
<sys:DateTime x:Key="Maximum">03/02/2009 7:00 AM</sys:DateTime>
<Style x:Key="OutlookStyle" TargetType="{x:Type ListBox}">
<Style.Resources>
<Style TargetType="{x:Type ListBoxItem}">
<Style.Setters>
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="controls:RangePanel.Begin"
Value="{Binding Path=Start.Ticks}" />
<Setter Property="controls:RangePanel.End"
Value="{Binding Path=Finish.Ticks}" />
</Style.Setters>
</Style>
</Style.Resources>
<Style.Setters>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility"
Value="Disabled" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility"
Value="Disabled" />
<Setter Property="ItemTemplate">
<Setter.Value>
<DataTemplate>
<Border BorderBrush="#5076A7"
BorderThickness="1,1,1,1" CornerRadius="4,4,4,4">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#FFFFFF" Offset="0.0" />
<GradientStop Color="#C0D3EA" Offset="1.0" />
</LinearGradientBrush>
</Border.Background>
<StackPanel TextElement.FontFamily="Segoe UI"
TextElement.FontSize="12">
<TextBlock FontWeight="Bold" Padding="3,0,0,0"
Text="{Binding Path=Subject}" />
</StackPanel>
</Border>
</DataTemplate>
</Setter.Value>
</Setter>
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<controls:RangePanel Minimum="{Binding Source=
{StaticResource Minimum}, Path=Ticks}"
Maximum="{Binding Source={StaticResource
Maximum}, Path=Ticks}" />
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
</Style.Setters>
</Style>
</Window.Resources>
<DockPanel>
<ListBox Style="{StaticResource OutlookStyle}">
<ListBox.Items>
<local:Appointment Start="03/02/2009 2:00 AM"
Finish="03/02/2009 3:00 AM" Subject="Meet with John"
Location="Southwest Meeting Room" Organizer="Jim Smith" />
<local:Appointment Start="03/02/2009 4:00 AM"
Finish="03/02/2009 5:00 AM" Subject="Meet with Rick"
Location="Southwest Meeting Room" Organizer="Jim Smith" />
<local:Appointment Start="03/02/2009 6:00 AM"
Finish="03/02/2009 6:30 AM" Subject="Meet with Dave"
Location="Southwest Meeting Room" Organizer="Jim Smith" />
</ListBox.Items>
</ListBox>
</DockPanel>
</Window>
关于上面 XAML 的几点
- 为 `ListBox` 控件本身创建了一个样式,并在创建 `ListBox` 实例时按名称引用它。
- 该样式通过 `ItemsPanel` 属性覆盖了用于布局单个 `ListBoxItem` 的默认面板,以使用 `RangePanel`。`RangePanel` 的 `Minimum`/`Maximum` 已绑定到两个 `static` 资源。这并不理想,但我无法找出如何绑定到 `ListBox` 实例本身上定义的资源。重点是展示我们如何将 `RangePanel` 属性绑定到 `DateTime` 值。
- `ItemTemplate` 已从上一个示例中复制(尽管用于显示位置和组织者的 `TextBox` 已被删除)。
- 滚动条的可见性已禁用。这是一个重要的部分,因为默认情况下,`ListBox` 将允许 `ItemsPanel` 尽可能大以包含所有项目,并在该大小大于可见区域时使用滚动条。然而,这与 `RangePanel` 的概念冲突,`RangePanel` 旨在根据可用空间调整每个项目的大小和位置。因此,`RangePanel` 不能拥有“所需的尽可能多的空间”。
- 每个 `ListBoxItem` 都垂直和水平拉伸,以便它使用 `RangePanel` 分配的全部空间。否则,`ListBox` 会将其大小调整为仅 `ItemTemplate` 内容所需的空间。每个 `ListBoxItem` 还根据约会的 `Start` 和 `End` 属性设置了 `RangePanel` 附加属性。同样,我们使用了 `Ticks` 属性为 `RangePanel` 提供 `double` 类型的变量。
后续步骤
以 `RangePanel` 作为关键布局面板,下一步是利用此控件在一个布局面板中,该面板通过创建 `RangePanel` 的多个实例并将 `ListViewItem` 放置在适当的 `RangePanel` 中来处理约会重叠。
历史
- 2009/04/17:初始版本