自定义大小的 ScatterViewItems
演示如何根据内容请求的大小来设置 ScatterViewItems 的初始大小。
引言
Surface SDK 和 Surface Toolkit 中的 ScatterView
是可视化用户可以自由操作的内容的常用方式。它的问题在于,添加的子元素的默认大小几乎总是太小。虽然可以在显式添加 ScatterViewItem
时设置大小,但在现代的、数据驱动的应用程序中,其中大部分 UI 是在后台自动生成的,没有灵活的方法来做到这一点。
本文介绍了一种解决此问题的方法。
问题
ScatterView
控件的核心是一个 ItemsControl
,它将其子项渲染为用户可以通过多点触控进行旋转、缩放或移动的浮动对象。就像 ListBox
(它也是一个 ItemsControl
)一样,它会自动为其子项生成容器,在 ScatterView
的情况下,这些子项始终是 ScatterViewItem
类型。默认情况下,ScatterViewItem
会尝试自行计算大小,但根据我的经验,它几乎总是错误的。即使设置 ScatterViewItem
内控件的宽度和高度也无济于事。
显式设置 ScatterViewItem
初始大小的唯一方法是修改其 Width
和 Height
属性。如果项目是从 XAML 或 C# 代码创建的,则可以轻松完成此操作,但我们真正需要的是一种方法,让控件能够向其父 ScatterViewItem
指定它希望渲染的大小。这使得在使用 Model-View-ViewModel (MVVM) 设计时,UI 可以完全由 ViewModel 中的数据驱动。
解决方案
解决此问题的方法是在 ScatterViewItem
和我们的内容之间放置一个控件。该控件将负责提供一种方式,让子内容请求一个初始大小,当 ScatterView
创建它们时,将以该大小渲染它们。虽然我们可以将此逻辑放在将放置在 ScatterView
中的每个控件内,但在大型应用程序中,这并不能很好地扩展。通过将其放在通用控件中,我们不仅可以避免代码重复,还可以获得额外的好处,即可以为所有项目提供一致的外观。
在本文中,我们将赋予它们浮动窗口的外观,包括标题栏和关闭按钮。但首先,让我们定义一个非常简单的弹出窗口,它提供我们所需的大小调整功能,然后再考虑外观。
<UserControl x:Class="ScatterViewSizingSample.PopupWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:s="http://schemas.microsoft.com/surface/2008">
<Grid Background="White">
<ContentControl x:Name="c_contentHolder"
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch" />
</Grid>
</UserControl>
上面有趣的部分是 ContentControl
,它充当实际内容的宿主。理想情况下,它会通过数据绑定获取内容,但为了保持示例简单,我们在构造函数中进行了设置。
public PopupWindow(object content)
{
InitializeComponent();
c_contentHolder.Loaded += new RoutedEventHandler(c_contentHolder_Loaded);
c_contentHolder.Content = content;
}
PopupWindow
控件负责查看子项(即实际内容,这是一个 UIElement
)并询问它需要什么大小。它通过公开一个名为 InitialSizeRequest
的附加属性来实现这一点,子内容需要设置此属性。附加属性的实现遵循 Visual Studio 可以生成的样板代码。
public static Size GetInitialSizeRequest(DependencyObject obj)
{
return (Size)obj.GetValue(InitialSizeRequestProperty);
}
public static void SetInitialSizeRequest(DependencyObject obj, Size value)
{
obj.SetValue(InitialSizeRequestProperty, value);
}
// Using a DependencyProperty as the backing store for InitialSizeRequest.
// This enables animation, styling, binding, etc...
public static readonly DependencyProperty InitialSizeRequestProperty =
DependencyProperty.RegisterAttached("InitialSizeRequest", typeof(Size),
typeof(PopupWindow), new UIPropertyMetadata(Size.Empty));
其他期望托管在 ScatterView
内的控件可以将其设置在 XAML 或代码中(通常,XAML 最有意义)。
<UserControl x:Class="ScatterViewSizingSample.FixedSizeChild"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ScatterViewSizingSample"
local:PopupWindow.InitialSizeRequest="300,250"
>
PopupWindow
将等待它完全加载(因为此时,它的视觉树将全部设置好),然后查看其子内容是否设置了此属性。
为了遍历视觉树,我使用了一个名为 GuiHelpers
的实用类,该类使用 VisualTreeHelper
和 LogicalTreeHelper
来可靠地向任一方向遍历树。我不会在此详细解释该代码,但它包含在示例中,如果有人想了解它的工作原理。
// In case the child didn't specify a requested size, fallback to this size
private static Size DefaultPopupSize = new Size(300, 200);
private Size CalculateScatterViewItemSize()
{
// Get the part of the ContentControl that hosts the child
var presenter = GuiHelpers.GetChildObject<ContentPresenter>(c_contentHolder);
if (presenter == null)
return DefaultPopupSize;
// It seems it's safe to assume the ContentPresenter will always only have one child
// and that child is the visual representation of the content of c_contentHolder.
var child = VisualTreeHelper.GetChild(presenter, 0);
if (child == null)
return DefaultPopupSize;
var requestedSize = PopupWindow.GetInitialSizeRequest(child);
if (!requestedSize.IsEmpty
&& requestedSize.Width != 0
&& requestedSize.Height != 0)
{
// Calculate how much this PopupWindow is adding around the content
var borderHeight = this.ActualHeight - c_contentHolder.ActualHeight;
var borderWidth = this.ActualWidth - c_contentHolder.ActualWidth;
return new Size(requestedSize.Width + borderWidth,
requestedSize.Height + borderHeight);
}
else
return DefaultPopupSize;
}
从此方法返回的大小可以直接设置为 ScatterView
,可以通过向上遍历视觉树直到找到该类型的控件来获取该 ScatterView
。
void c_contentHolder_Loaded(object sender, RoutedEventArgs e)
{
var requestedSize = CalculateScatterViewItemSize();
var svi = GuiHelpers.GetParentObject<ScatterViewItem>(this, false);
if (svi != null)
{
svi.Width = requestedSize.Width;
svi.Height = requestedSize.Height;
}
}
结果现在是
这就是获得所需功能的全部内容。现在,放置在 ScatterView
内的控件可以指定它们最初应该有多大,然后用户可以自由地调整它们的大小。但我也提到过,我们可以使用 PopupWindow
为我们的项目提供更好的外观和行为。让我们添加一个窗口边框和一个关闭按钮,同时,让我们也添加一个漂亮的动画,使其看起来像是弹出窗口神奇地出现。
第一步是更新 PopupWindow
的 XAML。我们在其周围添加了一个边框,带有一个标题栏和一个关闭按钮。
<UserControl x:Class="ScatterViewSizingSample.PopupWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:s="http://schemas.microsoft.com/surface/2008"
Foreground="Black">
<Border CornerRadius="3" BorderBrush="Black" BorderThickness="2"
Background="DarkGray">
<Border BorderBrush="LightGray" CornerRadius="1"
BorderThickness="1" Background="DarkGray">
<DockPanel LastChildFill="True" >
<Border DockPanel.Dock="Top" >
<Grid>
<TextBlock Text="My Popup" FontWeight="Bold"
VerticalAlignment="Center" Margin="15,0" FontSize="20" />
<s:SurfaceButton Content="Close" HorizontalAlignment="Right"
Margin="3" x:Name="btnClose" Click="btnClose_Click"/>
</Grid>
</Border>
<Border x:Name="border" Margin="15,0,15,15" BorderBrush="#FFC9C9C9"
BorderThickness="2">
<Grid Background="White">
<ContentControl x:Name="c_contentHolder" />
</Grid>
</Border>
</DockPanel>
</Border>
</Border>
</UserControl>
这应该会给它一个这样的外观:
下一步是为 ScatterViewItem
的宽度、高度和不透明度添加动画。宽度和高度将从零变为其计算的目标大小,不透明度将从 0% 变为 100%。动画将由一个缓动函数控制,该函数将使其感觉更自然。(注意:缓动函数已在 .NET 4.0 中添加,因此如果您想在 .NET 3.5 上运行此代码,只需删除那些部分;其余动画代码与 3.5 兼容)。
private void AnimateEntry(Size targetSize)
{
var svi = GuiHelpers.GetParentObject<ScatterViewItem>(this, false);
if (svi != null)
{
IEasingFunction ease = new BackEase
{ EasingMode = EasingMode.EaseOut, Amplitude = 0.3 };
var duration = new Duration(TimeSpan.FromMilliseconds(500));
var w = new DoubleAnimation(0.0, targetSize.Width, duration)
{ EasingFunction = ease };
var h = new DoubleAnimation(0.0, targetSize.Height, duration)
{ EasingFunction = ease };
var o = new DoubleAnimation(0.0, 1.0, duration);
// Remove the animation after it has completed so that its possible to
// manually resize the scatterviewitem
w.Completed += (s, e) => svi.BeginAnimation(ScatterViewItem.WidthProperty, null);
h.Completed += (s, e) => svi.BeginAnimation(ScatterViewItem.HeightProperty, null);
// Set the size manually, otherwise once the animation is removed the size
// will revert back to the minimum size
svi.Width = targetSize.Width;
svi.Height = targetSize.Height;
svi.BeginAnimation(ScatterViewItem.WidthProperty, w);
svi.BeginAnimation(ScatterViewItem.HeightProperty, h);
svi.BeginAnimation(ScatterViewItem.OpacityProperty, o);
}
}
处理动画的唯一注意事项是,除非您在动画完成后显式删除它们,否则动画效果将保留在动画属性上。通常,这没关系,但由于用户对 ScatterViewItem
的操作也会修改 Width
和 Height
属性,因此用户将根本无法调整项目的大小。
在上面的代码中,一个事件处理程序被挂接到每个动画的 Completed
事件,该处理程序将有效地从该属性中删除动画。代码还在动画开始之前手动设置了值,并且由于依赖属性对其源进行优先级排序的方式,手动设置的值在动画被删除之前(但仍存储在属性中)不会生效。
示例代码
本文附带一个演示此功能的小型应用程序。该解决方案是使用 Visual Studio 2010 创建的,并且目标是 .NET 4.0,但代码本身应该可以轻松地改编为 Visual Studio 2008/.NET 3.5,这是 Microsoft Surface 所期望的。
要构建它,您需要安装适用于 Windows Touch 的 Surface Toolkit,因为这是所有 Surface 控件(包括 ScatterView
)声明的地方。
该应用程序由一个 SurfaceWindow
组成,其中包含一个网格,该网格包含我们将要添加项目的 ScatterView
,以及顶部的几个用于添加项目的按钮。为了充分展示 ScatterViewItem
的不同大小设置方式,可以向 ScatterView
添加三种不同的用户控件。
NoSizeChild
- 此子项演示ScatterView
在没有本文所述功能的情况下如何行为。FixedSizeChild
- 此子项演示如何在 XAML 中为子项设置固定的初始大小。RandomSizeChild
- 此子项演示在创建子项时如何动态设置其初始大小。
引入数据绑定
在此示例中,没有数据驱动应用程序。在更大的应用程序中,ScatterView
通常会由 ViewModel 支持,并且其所有项都绑定到 ObservableCollection
。上述解决方案非常适合这样的设计 - 事实上,它最初就是为此设计的,但为了在本文中保持简洁而进行了简化。
如果兴趣浓厚,我可以写一篇后续文章,展示这种方法是如何在 MVVM 设计中使用(请在下方评论),但在此期间,这里有一些设计建议:
- 创建一个仅用于弹出窗口的 ViewModel(例如,
PopupWindowViewModel
),其中包含关闭命令、标题和内容属性。使用具有DataType
属性的DataTemplate
来告知 UI 如何渲染此 ViewModel。 - 创建一个主 ViewModel,其中包含一个
ObservableCollection
,用于存储应该显示在ScatterView
中的PopupWindowViewModel
。 - 为每种类型的内容创建单独的 ViewModel(以及相应的视图,通过类型化的
DataTemplate
分配)。 - 添加弹出窗口时,只需创建一个新的
PopupWindowViewModel
,并将其内容设置为上述任何 ViewModel,然后将其添加到ObservableCollection
。
关注点
我第一次尝试解决这个问题是创建自己的 ScatterViewItem
(和 ScatterView
),通过继承原始控件来实现。新的 ScatterViewItem
会使用 MeasureOverride
方法测量其内容并相应地调整大小。我从未以令人满意的方式使它正常工作,因为 UIElement
会根据您提供的空间量以不同的方式测量自身。这导致某些项目(尤其是图像)变得非常大,而另一些项目则完全忽略了提供的额外空间,只报告了它们的最小要求。
我认为这种方法是可以接受的解决方案,并且它提供了额外的优势,即可以一致地设置所有项目的样式,而无需修改 ScatterViewItem
的控件模板。设计师显式设置大小也比让计算机猜测大小更有意义。
历史
- v1.0 (2010/9/12) - 初始发布。