【WPF】一个类似于QQ面板的GroupShelf控件
最近做控件上了瘾,现在把做的一个类似于QQ面板的控件放上来。
【分析】
从整体来看,这个控件应该同ListBox,ListView这类控件一样,是一个ItemsControl,而中间的项,就是它的Item。
因此,为了完成一个这样的控件,至少需要两个东西:
GroupShelf:也就是充当容器角色的控件
GroupShelfItem:即这个控件中的项
其中,GroupShelf需要保证某项的展开同时,其他项被折叠。而GroupShelfItem需要提供Header和Content,同时,需要能支持展开的空能。
【控件的实现】
GroupShelfItem
首先,我们从GroupShelfItem入手,因为它比较单纯,在HeaderedContentControl的基础上提供展开,收缩功能即可:
/// <summary>
/// GroupShelfItem
/// </summary>
public class GroupShelfItem : HeaderedContentControl
{
#region IsExpanded
public bool IsExpanded
{
get { return (bool)GetValue(IsExpandedProperty); }
set { SetValue(IsExpandedProperty, value); }
}
// Using a DependencyProperty as the backing store for IsSelected. This enables animation, styling, binding, etc
public static readonly DependencyProperty IsExpandedProperty = DependencyProperty.Register(
"IsExpanded", typeof(bool), typeof(GroupShelfItem), new PropertyMetadata(false, new PropertyChangedCallback(OnIsExpandedChanged)));
private static void OnIsExpandedChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
GroupShelfItem item = sender as GroupShelfItem;
if (item != null)
{
item.OnIsExpandedChanged(e);
}
}
protected virtual void OnIsExpandedChanged(DependencyPropertyChangedEventArgs e)
{
bool newValue = (bool)e.NewValue;
if (newValue)
{
this.OnExpanded();
}
else
{
this.OnCollapsed();
}
}
#endregion
#region Selection Events
/// <summary>
/// Raised when selected
/// </summary>
public event RoutedEventHandler Expanded
{
add { AddHandler(ExpandedEvent, value); }
remove { RemoveHandler(ExpandedEvent, value); }
}
public static RoutedEvent ExpandedEvent = EventManager.RegisterRoutedEvent(
"Expanded", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(GroupShelfItem));
/// <summary>
/// Raised when unselected
/// </summary>
public event RoutedEventHandler Collapsed
{
add { AddHandler(CollapsedEvent, value); }
remove { RemoveHandler(CollapsedEvent, value); }
}
public static RoutedEvent CollapsedEvent = EventManager.RegisterRoutedEvent(
"Collapsed", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(GroupShelfItem));
protected virtual void OnExpanded()
{
GroupShelf parentGroupShelf = this.ParentGroupShelf;
if (parentGroupShelf != null)
{
parentGroupShelf.ExpandedItem = this;
}
RaiseEvent(new RoutedEventArgs(ExpandedEvent, this));
}
protected virtual void OnCollapsed()
{
RaiseEvent(new RoutedEventArgs(CollapsedEvent, this));
}
#endregion
#region ExpandCommand
public static RoutedCommand ExpandCommand = new RoutedCommand("Expand", typeof(GroupShelfItem));
private static void OnExecuteExpand(object sender, ExecutedRoutedEventArgs e)
{
GroupShelfItem item = sender as GroupShelfItem;
if (!item.IsExpanded)
{
item.IsExpanded = true;
}
}
private static void CanExecuteExpand(object sender, CanExecuteRoutedEventArgs e)
{
e.CanExecute = sender is GroupShelfItem;
}
#endregion
public GroupShelf ParentGroupShelf
{
get { return ItemsControl.ItemsControlFromItemContainer(this) as GroupShelf; }
}
static GroupShelfItem()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(GroupShelfItem), new FrameworkPropertyMetadata(typeof(GroupShelfItem)));
CommandBinding expandCommandBinding = new CommandBinding(ExpandCommand, OnExecuteExpand, CanExecuteExpand);
CommandManager.RegisterClassCommandBinding(typeof(GroupShelfItem), expandCommandBinding);
}
}
默认的Style
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:GroupShelfDemo.Controls">
<Style TargetType="{x:Type local:GroupShelfItem}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:GroupShelfItem}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<DockPanel>
<Button DockPanel.Dock="Top"
Content="{TemplateBinding Header}"
ContentTemplate="{TemplateBinding HeaderTemplate}"
ContentTemplateSelector="{TemplateBinding HeaderTemplateSelector}"
ContentStringFormat="{TemplateBinding HeaderStringFormat}"
Command="{Binding Source={x:Static local:GroupShelfItem.ExpandCommand}}"/>
<ContentPresenter x:Name="ContentHost" DockPanel.Dock="Bottom"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
ContentTemplateSelector="{TemplateBinding ContentTemplateSelector}"
ContentStringFormat="{TemplateBinding ContentStringFormat}">
<ContentPresenter.LayoutTransform>
<ScaleTransform x:Name="ContentHostHeightTransform" ScaleY="0.0"/>
</ContentPresenter.LayoutTransform>
</ContentPresenter>
</DockPanel>
</Border>
<ControlTemplate.Resources>
<Storyboard x:Key="OnExpanded">
<DoubleAnimationUsingKeyFrames BeginTime="00:00:00"
Storyboard.TargetName="ContentHostHeightTransform"
Storyboard.TargetProperty="ScaleY">
<SplineDoubleKeyFrame KeyTime="00:00:00.08" Value="1"/>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
<Storyboard x:Key="OnCollapsed">
<DoubleAnimationUsingKeyFrames BeginTime="00:00:00"
Storyboard.TargetName="ContentHostHeightTransform"
Storyboard.TargetProperty="ScaleY">
<SplineDoubleKeyFrame KeyTime="00:00:00.08" Value="0"/>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</ControlTemplate.Resources>
<ControlTemplate.Triggers>
<Trigger Property="IsExpanded" Value="True">
<Trigger.EnterActions>
<BeginStoryboard Storyboard="{StaticResource OnExpanded}"/>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard Storyboard="{StaticResource OnCollapsed}"/>
</Trigger.ExitActions>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
GroupShelfItem提供了一个Command来操作它的展开和收缩。同时,在Expand的时候通知GroupShelf处理。在默认的控件模板中通过按钮来触发这个Command。
GroupShelfPanel
GroupShelf的主要工作就是维护GroupShelfItem展开和收缩时的状态处理。但是,按照WPF的方式,这个布局的工作不应该由它来完成,而是由我们提供一个ItemsPanel给它。所以,在GroupShelf之前,GroupShelfPanel应运而生。
写一个Panel最重要的工作就是重载MeasureOverride和ArrangeOverride两个方法。分析GroupShelfPanel的行为,其实是“指定的孩子填充剩余空间”。就系统提供的Panel来说,DockPanel跟它的行为是最接近的,因为DockPanel提供了LastChildFill的行为。既然如此,我们就打开Reflector,从DockPanel里面“借”点代码过来用用:
public class GroupShelfPanel : Panel
{
/// <summary>
/// 要填充的孩子
/// </summary>
public UIElement ChildToFill
{
get { return (UIElement)GetValue(ChildToFillProperty); }
set { SetValue(ChildToFillProperty, value); }
}
public static readonly DependencyProperty ChildToFillProperty = DependencyProperty.Register(
"ChildToFill", typeof(UIElement), typeof(GroupShelfPanel),
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsMeasure));
protected override Size ArrangeOverride(Size arrangeSize)
{
UIElementCollection internalChildren = base.InternalChildren;
int count = internalChildren.Count;
// 未指定ChildToFill,则最后一个child填充
int childToFillIndex = ChildToFill == null ? count - 1 : internalChildren.IndexOf(ChildToFill);
double y = 0.0;
Rect rectForFill = new Rect(0, 0, arrangeSize.Width, arrangeSize.Height);
if (childToFillIndex != -1)
{
// 正序排列ChildToFill之前的元素
for (int i = 0; i < childToFillIndex + 1; i++)
{
UIElement element = internalChildren[i];
if (element != null)
{
Size desiredSize = element.DesiredSize;
Rect finalRect = new Rect(0, y, Math.Max(0.0, arrangeSize.Width), Math.Max(0.0, arrangeSize.Height - y));
if (i < childToFillIndex)
{
finalRect.Height = desiredSize.Height;
y += desiredSize.Height;
element.Arrange(finalRect);
}
else
{
// 留给剩下的元素的面积
rectForFill = finalRect;
}
}
}
y = 0.0;
// 逆序排列ChildToFill之后的元素(包括ChildToFill)
for (int i = count - 1; i > childToFillIndex; i--)
{
UIElement element = internalChildren[i];
if (element != null)
{
Size desiredSize = element.DesiredSize;
Rect finalRect = new Rect(0, arrangeSize.Height - y - desiredSize.Height, Math.Max(0.0, arrangeSize.Width), Math.Max(0.0, desiredSize.Height));
element.Arrange(finalRect);
y += desiredSize.Height;
}
}
rectForFill.Height -= y;
InternalChildren[childToFillIndex].Arrange(rectForFill);
}
return arrangeSize;
}
protected override Size MeasureOverride(Size constraint)
{
UIElementCollection internalChildren = base.InternalChildren;
double num = 0.0;
double num2 = 0.0;
double num3 = 0.0;
double num4 = 0.0;
int index = 0;
int count = internalChildren.Count;
while (index < count)
{
UIElement element = internalChildren[index];
if (element != null)
{
Size availableSize = new Size(Math.Max((double)0.0, (double)(constraint.Width - num3)), Math.Max((double)0.0, (double)(constraint.Height - num4)));
element.Measure(availableSize);
Size desiredSize = element.DesiredSize;
num = Math.Max(num, num3 + desiredSize.Width);
num4 += desiredSize.Height;
}
index++;
}
num = Math.Max(num, num3);
return new Size(num, Math.Max(num2, num4));
}
当然,改动还是比较大的。主要的改动集中在ArrangeOverride上。排列的逻辑应该是:把“要填充的孩子”之前的元素从上到下排列,把“要填充的孩子”之后的元素从下往上排列。剩余的空间都留给这个“要填充的孩子”。而对于MeasureOverride,我们要做的就是去掉DockPanel里面对于Left和Right的判断。(当然,如果考虑到以后要提供两种布局的方向:Horizontal和Vertical的话,还是需要保留一下的)
GroupShelf
上面两个都完成后,GroupShelf的工作就很简单了。它就是在Item的Expand发生变化时,通知别的Item Collapse,然后通知GroupPanel去重新布局。
/// <summary>
/// GroupShelf
/// </summary>
[TemplatePart(Name = "PART_ItemsHost", Type = typeof(GroupShelfPanel))]
public class GroupShelf : ItemsControl
{
private GroupShelfPanel _itemsHost;
#region ExpandedItem
public object ExpandedItem
{
get { return (object)GetValue(ExpandedItemProperty); }
set { SetValue(ExpandedItemProperty, value); }
}
// Using a DependencyProperty as the backing store for SelectedItem. This enables animation, styling, binding, etc
public static readonly DependencyProperty ExpandedItemProperty = DependencyProperty.Register(
"ExpandedItem", typeof(object), typeof(GroupShelf),
new UIPropertyMetadata(null, new PropertyChangedCallback(OnExpandedItemChanged)));
private static void OnExpandedItemChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
GroupShelf shelf = sender as GroupShelf;
if (shelf != null)
{
shelf.OnExpandedItemChanged(e.OldValue, e.NewValue);
}
}
protected virtual void OnExpandedItemChanged(object oldValue, object newValue)
{
GroupShelfItem oldItem = this.ItemContainerGenerator.ContainerFromItem(oldValue) as GroupShelfItem;
GroupShelfItem newItem = this.ItemContainerGenerator.ContainerFromItem(newValue) as GroupShelfItem;
if (oldItem != null)
{
oldItem.IsExpanded = false;
}
if (newItem != null)
{
if (this._itemsHost != null)
{
this._itemsHost.ChildToFill = newItem;
}
}
}
#endregion
static GroupShelf()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(GroupShelf), new FrameworkPropertyMetadata(typeof(GroupShelf)));
}
#region Overrides
protected override void ClearContainerForItemOverride(DependencyObject element, object item)
{
base.ClearContainerForItemOverride(element, item);
}
protected override DependencyObject GetContainerForItemOverride()
{
return new GroupShelfItem();
}
protected override bool IsItemItsOwnContainerOverride(object item)
{
return item is GroupShelfItem;
}
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
this._itemsHost = GetTemplateChild("PART_ItemsHost") as GroupShelfPanel;
}
#endregion
}
一个需要注意的地方是它的模板:
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:GroupShelfDemo.Controls">
<Style TargetType="{x:Type local:GroupShelf}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:GroupShelf}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<local:GroupShelfPanel x:Name="PART_ItemsHost" IsItemsHost="True"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
我放置了一个GroupPanel,并且指定了它是ItemsHost,而不是放置ItemsPresenter。这一点是比较重要的,因为只有当ItemsPanel是GroupPanel的时候,才能够有指定孩子填充的效果,因此这个GroupPanel需要是TemplatePart。如果修改了这个模板,换成别的Panel,比如StackPanel,则行为会有所不同。
【使用该控件】
这个控件的使用是非常简单的:
<l:GroupShelf>
<l:GroupShelfItem Header="我的好友">
<ListBox>
<TextBlock Text="好友1"/>
<TextBlock Text="好友2"/>
<TextBlock Text="好友3"/>
<TextBlock Text="好友4"/>
</ListBox>
</l:GroupShelfItem>
<l:GroupShelfItem Header="我的同学">
<ListBox>
<TextBlock Text="同学1"/>
<TextBlock Text="同学2"/>
<TextBlock Text="同学3"/>
<TextBlock Text="同学4"/>
</ListBox>
</l:GroupShelfItem>
<l:GroupShelfItem Header="我的同事">
<ListBox>
<TextBlock Text="同事1"/>
<TextBlock Text="同事2"/>
<TextBlock Text="同事3"/>
<TextBlock Text="同事4"/>
</ListBox>
</l:GroupShelfItem>
<l:GroupShelfItem Header="我的家人">
<ListBox>
<TextBlock Text="家人1"/>
<TextBlock Text="家人2"/>
<TextBlock Text="家人3"/>
<TextBlock Text="家人4"/>
</ListBox>
</l:GroupShelfItem>
<l:GroupShelfItem Header="我的老师">
<ListBox BorderThickness="1" BorderBrush="Black">
<TextBlock Text="老师1"/>
<TextBlock Text="老师2"/>
<TextBlock Text="老师3"/>
<TextBlock Text="老师4"/>
</ListBox>
</l:GroupShelfItem>
</l:GroupShelf>
代码下载https://files.cnblogs.com/RMay/AccordianDemo.rar
注:我用的是.Net 3.5 Sp1,如果是3.0-3.5请删除模板中的ContentStringFormat相关的东西
修正一下:
在GroupShelf的模板中,直接写
<local:GroupShelfPanel IsItemsHost="True" ChildToFill="{TemplateBinding ExpandedItem}"/>
即可,而在GroupShelf中的相关字段和方法都可以删除,UI和逻辑解耦。