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

Silverlight 自定义控件 - 数字滚筒

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.82/5 (6投票s)

2009年4月26日

CDDL

10分钟阅读

viewsIcon

37400

downloadIcon

470

展示了 Silverlight 3 Beta 中 NumberTumbler 自定义控件的设计和界面考虑以及实现细节

引言

我开发的几个 RIA 应用在某个方面有一些共同的需求:对于关键的计算结果 UI 以及显示给用户的关键数字,当它们变化时进行动画处理可以提高用户体验,因为滚动的效果会吸引用户注意变化中的字段,帮助用户更好地理解他们的输入。NumberTumbler 自定义控件将这些通用的逻辑和(默认的)视觉效果抽象出来,用于动画处理变化中的数字,它可以轻松地在不同的 Silverlight 应用程序中重复使用。

本文介绍了 Silverlight 3 Beta 中 NumberTumbler 自定义控件的设计和界面考虑,以及实现细节。它还记录了一些在自定义控件开发过程中学到的经验。附带的可下载代码包含一个 Visual Studio 2008 SP1 解决方案,其中有 3 个项目:包装 Silverlight 3 应用程序的 ASP.NET Web 项目,演示 NumberTumbler 控件功能的 Silverlight 3 应用程序项目,最后是 NumberTumbler 自定义控件类库项目。虽然这都是关于一个特定的自定义控件,但其过程和技术也适用于其他自定义控件的开发。

如果您安装了 Silverlight 3 Beta 插件,可以查看 示例 Silverlight 3 Beta 应用程序。如果您对它是如何工作的感兴趣,请继续阅读。:-)

NumberTumbler 做什么?

通常,RIA UI 中的计算或监控数字字段是只读的标签式字段,其文本属性通过数据绑定到应用程序数据模型,例如 model.calcResult。应用程序业务逻辑处理用户数据,然后更新 model.calcResult 的值。每当 model.calcResult 改变时,Silverlight 数据绑定引擎会通知 UI 元素更新显示。这是一种快速而突然的显示更新,有时用户可能没有注意到更新。NumberTumbler 具备所有常规的数据绑定功能,每当 model.calcResult 改变时,它不仅会滚动显示数字从旧值到更新值,还会向用户提供一些微妙的视觉指示,表明数字是在上升还是下降——例如,监控股票报价时非常有用。

总的来说,应用程序逻辑无需任何更改,计算或监控机制照常运行,数据绑定表达式也和以前一样,只需将常规的标签式控件替换为 NumberTumbler,它会在内部封装所有逻辑和状态管理,您的应用程序将为“变化中的数字”提供更好的用户体验。

NumberTumbler 仍然可以自定义外观和感觉,通过自定义控件,我们免费获得了这种定制。此外,字体、大小、颜色等常见属性也将是可定制的。至少,当实例没有设置具体的属性值时,NumberTumbler 将以默认值运行。

至于滚动效果本身,数字从旧值到新值的动画持续时间也可以自定义。因为有些应用程序可能需要更长的时间来显示计算过程,而另一些则可能需要简短的视觉更新。

NumberTumbler 的默认控件模板还提供了“微妙”的视觉指示,当它滚动时:左侧是闪烁的红色向下箭头,右侧是闪烁的绿色向上箭头。这些视觉指示在没有数字更新时是不可见的。

这基本上就是 NumberTumbler 要做的所有“工作”,让我们开始构建它吧。

控件契约

控件契约是程序接口,它规定了应用程序代码如何与 NumberTumbler 交互。根据上一节列出的规范,NumberTumbler 应提供 2 个自定义数据绑定属性:一个用于显示数字(Amount 属性),另一个用于滚动持续时间(TumbleSeconds 属性)。由于这些属性是数据绑定的并且参与动画,因此它们被实现为 2 个公共依赖属性。图 1 显示了 NumberTumbler.cs 中的代码。

图 1. NumberTumbler 中的公共绑定属性
#region Amount Dependency Property ---- Interaction
public double Amount
{
	get { return (double)GetValue(AmountProperty); }
	set { this.FromAmount = this.Amount; SetValue(AmountProperty, value); }
}

// Using a DependencyProperty as the backing store for Amount. 
// This enables animation, styling, binding, etc...
public static readonly DependencyProperty AmountProperty =
	DependencyProperty.Register("Amount", typeof(double), typeof(NumberTumbler),
	new PropertyMetadata(new PropertyChangedCallback
		(NumberTumbler.OnAmountPropertyChange)));

private static void OnAmountPropertyChange
	(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
	NumberTumbler ctrl = d as NumberTumbler;
	ctrl.OnAmountChange((double)e.NewValue);
}
#endregion

#region TumbleSeconds Dependency Property
public double TumbleSeconds
{
	get { return (double)GetValue(TumbleSecondsProperty); }
	set { SetValue(TumbleSecondsProperty, value); }
}

// Using a DependencyProperty as the backing store for TumbleSeconds.  
// This enables animation, styling, binding, etc...
public static readonly DependencyProperty TumbleSecondsProperty =
	DependencyProperty.Register("TumbleSeconds", 
		typeof(double), typeof(NumberTumbler),
	new PropertyMetadata(2.0, new PropertyChangedCallback
		(NumberTumbler.OnTumbleSecondsPropertyChange)));

private static void OnTumbleSecondsPropertyChange
	(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
	NumberTumbler ctrl = d as NumberTumbler;
	ctrl.OnDurationChange();
}
#endregion

这两个属性都注册了回调方法,每当属性值改变时,相应的实例方法(OnAmountChangeOnDurationChange)将被调用。当 Silverlight 数据绑定引擎通知 NumberTumbler Amount 已更改时,此回调机制对于启动滚动动画至关重要,我们将在动画逻辑部分详细讨论。

值得注意的是,如何通过 PropertyMetadataTumbleSeconds 注册默认值 2,这将允许滚动动画在 2 秒内运行,即使应用程序代码未提供 TumbleSeconds 的具体值。当属性绑定到其他 UI 元素时,这也很重要,稍后在讨论演示项目时会详细介绍。

以上是 NumberTumbler 的程序契约。提供“设计器契约”也很重要,以便设计人员和工具更好地理解控件结构,并在 Expression Blend 中启用皮肤和模板支持。

NumberTumbler 的“设计器契约”仍然基于上一节列出的需求。它需要一个用于显示数字的部分,以及三个视觉状态:NormalTumbleUpTumbleDown

图 2. 零件和状态的控件契约
[TemplatePart(Name = "TumblerViewer", Type = typeof(FrameworkElement))]
[TemplatePart(Name = "TumblerDownMark", Type = typeof(FrameworkElement))]
[TemplatePart(Name = "TumblerUpMark", Type = typeof(FrameworkElement))]

[TemplateVisualState(Name = "Normal", GroupName = "CommonStates")]
[TemplateVisualState(Name = "TumbleUp", GroupName = "CommonStates")]
[TemplateVisualState(Name = "TumbleDown", GroupName = "CommonStates")]
public class NumberTumbler : Control
{
	...
}

虽然 Silverlight 自定义控件属性的零件和状态在运行时不是必需的,但在 NumberTumbler 的情况下,TumblerViewer 部分旨在(在视觉上)显示数字(Amount 属性),这是控件逻辑所必需的。如果没有 TumblerDownMarkTumblerUpMark 部分,当数字向下或向上移动时将没有视觉指示,但数字仍在滚动和显示,基本功能仍然有效。但如果 TumblerViewer 部分缺失,将不会显示任何数字。override OnApplyTemplate 方法中的代码将确保 TumblerViewer 部分的存在。

图 3. 确保零件存在
public override void OnApplyTemplate()
{
	base.OnApplyTemplate();

	this._tumblerViewer = (FrameworkElement)GetTemplateChild("TumblerViewer");
	if (null == this._tumblerViewer)
		throw new NotImplementedException("Template Part TumblerViewer 
			is required to display the tumbling number.");

	VisualStateManager.GoToState(this, "Normal", false);
}

回顾上一节中列出的 NumberTumbler 应执行的“工作”,大部分工作已由公共属性和设计器契约中的 3 个零件和 3 个状态涵盖。那么字体、大小、颜色等的自定义呢?这些属性是从基类(Control 类)继承的,所以我们免费获得了它们。

实际上,如果基类是 TextBlock,我们可以继承更多有用的属性,例如 WordWraps 等。

从技术角度来看,从 TextBlock 而不是 Control 派生 NumberTumbler 更合理,因为 NumberTumbler 只需要扩展 TextBlock 中的功能。不幸的是,Silverlight 3 Beta 的 TextBlock 是一个密封类。我预料到会有更多密封的控件类,我不太理解为什么 TextBlock 需要是密封的,这是构建自定义控件的第一个经验教训:框架中包含的 Silverlight 控件中只有少数可以派生,其中许多是密封的。自定义控件的基类通常是 ControlContentControlItemsControl。希望这在 Silverlight 3 RTW 中会有所改变。

默认控件模板

现在我们已经定义了 NumberTumbler 控件的程序和设计器契约。现在是时候在 NumberTumbler 类库项目中的 themes 文件夹下的 generic.xaml 文件中提供默认控件模板了。默认控件模板提供了类属性中列出的所有零件和状态,模板中的每个视觉树元素都可以通过应用程序皮肤或在 Expression Blend 中进行自定义。

图 4. 默认控件模板的 generic.xaml 骨架代码
<ResourceDictionary
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x=http://schemas.microsoft.com/winfx/2006/xaml
  xmlns:hanray="clr-namespace:Hanray">

 <!-- Built-In Style for NumberTumbler -->
  <Style TargetType="hanray:NumberTumbler">
  <Setter Property="Template">
  <Setter.Value>
  <!-- ControlTemplate -->
  <ControlTemplate TargetType="hanray:NumberTumbler">
  <!-- Template's Root Visual -->
  <Grid x:Name="LayoutRoot">
  <StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
  <!--TumblerDownMark PART-->
  <Image x:Name="TumblerDownMark" Margin="10,10,0,10"
  Source="NumberTumbler;component/themes/downimg.png" />

 <!--TumblerViewer PART-->
  <TextBlock x:Name="TumblerViewer" Margin="0,10,0,10"
  HorizontalAlignment="Center" VerticalAlignment="Center"
  Text="{TemplateBinding AmountStr}" />

 <!--TumblerUpMark PART-->
  <Image x:Name="TumblerUpMark" Margin="0,10,10,10"
  Source="NumberTumbler;component/themes/upimg.png" />

 </StackPanel>


 <!--VisualStateManager-->
  <VisualStateManager.VisualStateGroups>

 <!--CommonStates StateGroup-->
  <VisualStateGroup x:Name="CommonStates">

 <!--CommonStates States-->
  <VisualState x:Name="Normal">
  ...
  </VisualState>

  <VisualState x:Name="TumbleUp">
  ...
  </VisualState>

 <VisualState x:Name="TumbleDown">
  ...
  </VisualState>


  </VisualStateGroup>
  </VisualStateManager.VisualStateGroups>

  </Grid>
  </ControlTemplate>
  </Setter.Value>
  </Setter>
  </Style>
  </ResourceDictionary>

您可能已经注意到 TumblerView 部分(TextBlock)的 Text 属性通过 TemplateBinding 绑定到 AmountStr 而不是 Amount。为什么不通过 TemplateBinding 绑定到 public Amount 属性,并使用 IValueConverter 实例将 double 类型的 Amount 转换为 string 格式呢?比如:

图 5. TemplateBinding 实验
<Grid.Resources>
	<hanray:AmountFormatter x:Key="amtFormatter" />
</Grid.Resources>

<!--vText PART and IValueConverter-->
<TextBlock x:Name="TumblerViewer" Canvas.Top="0" Canvas.Left="0"
   HorizontalAlignment="Center" VerticalAlignment="Center"
   Text="{TemplateBinding Amount, Converter={StaticResource amtFormatter}, 
	ConverterParameter=\{0:n\}}" />

不幸的是,上述“技术上正确”的方法在运行时会抛出 AG_E_UNKNOWN_ERROR 异常。在从 TemplateBinding 表达式中删除 ConverterConverterParameter 后,异常消失了,但 Amount 完全没有显示。

这是第二个经验教训:在默认 ControlTemplateTemplateBinding 中,如果源属性的数据类型与目标属性的数据类型不同,那么另一个“中间”属性可以通过转换类型来解决。源属性 Amount 的类型是 double,目标 Text 属性的类型是 string,所以我创建了一个 private 属性 AmountStr,类型为 string,以使 TemplateBinding 工作正常。

滚动动画逻辑

现在我们已经在 generic.xaml 中基于预定义的控件契约定义了默认的 ControlTemplate,现在让我们来设计滚动动画逻辑。

显然,由于 public 属性 Amount 的数据类型是 double,通过 DoubleAnimation 计算插值的 Amount 来模拟“滚动”非常容易,并且动画 StoryBoard 的持续时间由 TumbleSeconds 属性的值设置。

图 6. 设置滚轮
private Storyboard _tumblerBorard = null;
private DoubleAnimation _tumbler = null;
private void PrepareTumbler()
{
	if (null != _tumblerBorard)
	{
		_tumblerBorard.Stop();
		return;
	}

	_tumblerBorard = new Storyboard();
	_tumbler = new DoubleAnimation();
	SetTumbleDuration();

	_tumbler.Completed += new EventHandler(_tumbler_Completed);

	Storyboard.SetTarget(_tumbler, this);
	Storyboard.SetTargetProperty(_tumbler, 
		new PropertyPath("NumberTumbler.TumbleAmount"));

	_tumblerBorard.Children.Add(_tumbler);
}

private void _tumbler_Completed(object sender, EventArgs e)
{
	VisualStateManager.GoToState(this, "Normal", false);
}

private void SetTumbleDuration()
{
	Duration duration = new Duration(TimeSpan.FromSeconds(this.TumbleSeconds));
	_tumbler.Duration = duration;
}

SetTumbleDuration() 方法不仅由 PrepareTumbler() 方法执行,还由属性更改回调方法(OnTumbleSecondsPropertyChange)调用。PrepareTumbler() 方法确保 StoryBoardDoubleAnimation 实例按需实例化一次,而 SetTumbleDuration() 方法始终将 TumbleSeconds 属性的新值设置给 DoubleAnimation 的 Duration。

由于 Silverlight 尚不支持 XAML 中的触发器,我们需要设置自己的触发器来启动滚动 DoubleAnimation。每当 public 属性 Amount 更改时,属性回调方法 OnAmountPropertyChange() 将调用实例方法 OnAmountChange()。(参见图 1.)如果这是首次显示 AmountOnAmountChange() 将直接显示数字而不进行动画,否则它将调用 StartTumbler() 来显示滚动效果。

图 7. 滚动触发器
private bool _firstRun = true;
protected virtual void OnAmountChange(double newAmount)
{
	this.ToAmount = newAmount;
	if (_firstRun)
	{
		this.TumbleAmount = this.ToAmount;
		_firstRun = false;
	}
	else
		StartTumbler();
}

private void StartTumbler()
{
	string stateName = "Normal";
	if (this.FromAmount < this.ToAmount)
		stateName = "TumbleUp";
	else if (this.FromAmount > this.ToAmount)
		stateName = "TumbleDown";
	else
		this.TumbleAmount = this.ToAmount;

	if (stateName != "Normal")
	{
		this.PrepareTumbler();

		this._tumbler.From = this.FromAmount;
		this._tumbler.To = this.ToAmount;

		this._tumblerBorard.Begin();
	}

	VisualStateManager.GoToState(this, stateName, false);
}

请注意,StoryBoard 的目标属性不是 Amount,而是 TumbleAmount。这是我学到的第三个经验:动画属性不应该是作为动画触发器的 public 属性。如果是这样,那么时间轴上的每个插值都会导致触发器和动画重新启动,这绝对不是我们想要的。解决方案是添加 3 个额外的 private 属性(全部为 double 类型):当 public Amount 更改时,其先前的值将复制到 FromAmount;更新后的新 Amount 将是 ToAmount 的值;最后,每当 TumbleAmountDoubleAnimation 插值时,其属性更改回调方法(OnTumbleAmountPropertyChange))将更新 AmountStr,它被数据绑定到 TumblerViewer 部分作为 string

图 8. 滚动连接
private static void OnTumbleAmountPropertyChange
	(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
	NumberTumbler ctrl = d as NumberTumbler;
	ctrl.AmountStr = String.Format(CultureInfo.InvariantCulture, 
			"{0:n}", (double)e.NewValue);
}

使用 NumberTumbler 控件

在演示项目“引用”列表中引用控件程序集没有什么特别之处,还需要在 XAML 中指定 NumberTumbler 控件的命名空间,这些步骤与其他 Silverlight 应用程序中引用的自定义控件相同。由于只需要 Amount 属性(TumbleSeconds 默认值为 2 秒,还记得吗?参见图 1.),XAML 非常简单。

图 9. 在 XAML 中使用 NumberTumbler
<hanray:NumberTumbler x:Name="numTumbler"
	Amount="2009.04"
    Margin="100" HorizontalAlignment="Center"
	FontWeight="Bold" Foreground="Navy" FontSize="26">
</hanray:NumberTumbler>

我们没有为 MarginHorizontalAlignmentFontWeightFontSizeForeground 属性编写自定义代码,它们是从 Control 基类继承的。

为了尝试 Silverlight 3 Beta 中的一些新功能,可下载的演示项目代码使用了元素到元素的绑定来实现 TumbleSeconds 属性。代码隐藏的 CS 文件中没有额外的代码来实现两个控件之间的双向绑定。

图 10. TumbleSeconds 属性的元素到元素绑定
<hanray:NumberTumbler x:Name="numTumbler"
  Amount="2009.04" TumbleSeconds="{Binding Text, ElementName=tumbleSecs}"
  Margin="100" HorizontalAlignment="Center"
  FontWeight="Bold" Foreground="Navy" FontSize="26">
  </hanray:NumberTumbler>

<Canvas Margin="0,0,0,10" />

<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" 
	VerticalAlignment="Center" >
  <TextBlock Text="Tumbling Duration: " />
  <TextBox x:Name="tumbleSecs" Text="{Binding TumbleSeconds, 
	ElementName=numTumbler}" Width="30" Margin="0,-4, 0, 0" />
  <TextBlock Text=" seconds." />
  </StackPanel>

演示应用程序还包含一些 UI 元素,用于以编程方式向上和向下更改 Amount,试试吧!

结论

通过构建这个简单的小自定义控件,我感觉它不像在 Silverlight 中构建用户控件那样容易。尽管自定义控件在自定义和可重用性方面具有优势,但关于密封类和 TemplateBinding 数据类型方面学到的经验教训,在您构建新的自定义控件时值得关注。希望 Microsoft 在未来的版本中能让 Silverlight 自定义控件的构建更容易。但在此之前,如果您需要一个小的控件来滚动 UI 中变化的数字,这个控件可以帮助您入门!

历史

  • 2009.04.20 - 初稿
  • 2009.04.26 - 准备评审
© . All rights reserved.