Implement a FlipView-like control that supports looping.
Introduction
正如我们所知道的,内置控件 FlipView
并不支持循环滚动——也就是无法从最后一项滚动到第一项,就像第一项本来就跟在最后一项一样。不过针对 FlipView
控件本身,通过某些技巧可以做到这一点:
当
FlipView
的SelectedIndex
属性到达 Count-1 的时候,手动将其设为0;同样地,在到达 -1 的时候,手动设为最后一项。但是,FlipView 内置的项目之间的平滑滚动动画,只有在 +1 或者 -1 的时候才能触发。因此这种 Workaround 并不能触发动画。
当
SelectedIndex
到达列表边界的时候,手动为列表添加数据:也就是在最后一项的后面添加第一项,在第一项的前面添加最后一项。但是这样做的话,可能在循环多次列表后,列表的项数就会非常大,而在添加项的同时去Remove
之前的项然后设置SelectedIndex
的话,又会触发多余的内建动画...
因此,以上都不是完美的解决方法。
于是乎,我就开始自己写一个控件,去完成这个需求。
首先,列一下这个控件所拥有的功能:
- 像
FlipView
一样,能左右滑动切换; - 同时支持触控跟鼠标操作;
- 底部有指示器指示当前的位置;
- 支持循环滑动;
接下来分析一下原理。
如图所示,本控件由3个部分组成:中间是可视区域,左右两侧是不可见的,这通过 Clip 属性实现。在静止的时候,只有列表中的一项显示在中间,前后都分别放这个当前项的前后项(当当前项为第一项的时候,前一项就是列表的最后一项了)。我依然通过 SelectedIndex 属性去改变当前需要显示的项,在 SelectedIndex 改变的时候,去触发动画:
SelectedIndex +1的时候,中间项跟下一项一起向左移动;减一同理。
在动画结束后,重新设置三个项目的位置,然后根据当前 SelectedIndex 去显示3个项目的内容。
图示就是这样:
Code time
注意,本文不会提供完整代码...仅提供部分关键代码做参考。
以下为完整的 XAML代码,在这里,我们的控件叫做 LoopingBannerControl
<Style TargetType="local:LoopingBannerControl">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:LoopingBannerControl">
<Grid x:Name="RootGrid" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Background="#FFF0F0F0" d:DesignWidth="199.667" d:DesignHeight="215.222">
<Grid.Resources>
<Storyboard x:Name="NextStory">
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.RenderTransform).(CompositeTransform.TranslateX)" Storyboard.TargetName="NextImageBtn">
<EasingDoubleKeyFrame x:Name="NextRightOriTranslateXFrame" KeyTime="0" Value="200"/>
<EasingDoubleKeyFrame KeyTime="0:0:0.3" Value="0"/>
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.RenderTransform).(CompositeTransform.TranslateX)" Storyboard.TargetName="CurrentImageBtn">
<EasingDoubleKeyFrame x:Name="NextMiddleOriTranslateXFrame" KeyTime="0" Value="0"/>
<EasingDoubleKeyFrame x:Name="NextMiddleTargetTranslateXFrame" KeyTime="0:0:0.3" Value="-200"/>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
<Storyboard x:Name="PreviousStory">
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.RenderTransform).(CompositeTransform.TranslateX)" Storyboard.TargetName="CurrentImageBtn">
<EasingDoubleKeyFrame x:Name="PreviousMiddleOriTranslateXFrame" KeyTime="0" Value="0"/>
<EasingDoubleKeyFrame x:Name="PreviousMiddleTargetTranslateXFrame" KeyTime="0:0:0.3" Value="200"/>
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.RenderTransform).(CompositeTransform.TranslateX)" Storyboard.TargetName="PreviousImageBtn">
<EasingDoubleKeyFrame x:Name="PreviousLeftOriTranslateXFrame" KeyTime="0" Value="-200"/>
<EasingDoubleKeyFrame KeyTime="0:0:0.3" Value="0"/>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
<Storyboard x:Name="ShowBtnStory">
<ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Visibility)" Storyboard.TargetName="PreviousBtn">
<DiscreteObjectKeyFrame KeyTime="0">
<DiscreteObjectKeyFrame.Value>
<Visibility>Visible</Visibility>
</DiscreteObjectKeyFrame.Value>
</DiscreteObjectKeyFrame>
<DiscreteObjectKeyFrame KeyTime="0:0:0.2">
<DiscreteObjectKeyFrame.Value>
<Visibility>Visible</Visibility>
</DiscreteObjectKeyFrame.Value>
</DiscreteObjectKeyFrame>
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Visibility)" Storyboard.TargetName="NextBtn">
<DiscreteObjectKeyFrame KeyTime="0">
<DiscreteObjectKeyFrame.Value>
<Visibility>Visible</Visibility>
</DiscreteObjectKeyFrame.Value>
</DiscreteObjectKeyFrame>
<DiscreteObjectKeyFrame KeyTime="0:0:0.2">
<DiscreteObjectKeyFrame.Value>
<Visibility>Visible</Visibility>
</DiscreteObjectKeyFrame.Value>
</DiscreteObjectKeyFrame>
</ObjectAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Opacity)" Storyboard.TargetName="PreviousBtn">
<EasingDoubleKeyFrame KeyTime="0" Value="0"/>
<EasingDoubleKeyFrame KeyTime="0:0:0.2" Value="0.5"/>
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Opacity)" Storyboard.TargetName="NextBtn">
<EasingDoubleKeyFrame KeyTime="0" Value="0"/>
<EasingDoubleKeyFrame KeyTime="0:0:0.2" Value="0.5"/>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
<Storyboard x:Name="HideBtnStory">
<ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Visibility)" Storyboard.TargetName="PreviousBtn">
<DiscreteObjectKeyFrame KeyTime="0">
<DiscreteObjectKeyFrame.Value>
<Visibility>Visible</Visibility>
</DiscreteObjectKeyFrame.Value>
</DiscreteObjectKeyFrame>
<DiscreteObjectKeyFrame KeyTime="0:0:0.2">
<DiscreteObjectKeyFrame.Value>
<Visibility>Collapsed</Visibility>
</DiscreteObjectKeyFrame.Value>
</DiscreteObjectKeyFrame>
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Visibility)" Storyboard.TargetName="NextBtn">
<DiscreteObjectKeyFrame KeyTime="0">
<DiscreteObjectKeyFrame.Value>
<Visibility>Visible</Visibility>
</DiscreteObjectKeyFrame.Value>
</DiscreteObjectKeyFrame>
<DiscreteObjectKeyFrame KeyTime="0:0:0.2">
<DiscreteObjectKeyFrame.Value>
<Visibility>Collapsed</Visibility>
</DiscreteObjectKeyFrame.Value>
</DiscreteObjectKeyFrame>
</ObjectAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Opacity)" Storyboard.TargetName="PreviousBtn">
<SplineDoubleKeyFrame KeyTime="0" Value="0.5"/>
<SplineDoubleKeyFrame KeyTime="0:0:0.2" Value="0"/>
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Opacity)" Storyboard.TargetName="NextBtn">
<SplineDoubleKeyFrame KeyTime="0" Value="0.5"/>
<SplineDoubleKeyFrame KeyTime="0:0:0.2" Value="0"/>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</Grid.Resources>
<Grid.Clip>
<RectangleGeometry Rect="0 0 200 200"/>
</Grid.Clip>
<Button x:Name="PreviousImageBtn" Background="{x:Null}" Padding="0" BorderThickness="0">
<Button.RenderTransform>
<CompositeTransform TranslateX="-200"/>
</Button.RenderTransform>
<Image x:Name="PreviousImage" Stretch="UniformToFill"/>
</Button>
<Button x:Name="NextImageBtn" Background="{x:Null}" Padding="0" BorderThickness="0">
<Button.RenderTransform>
<CompositeTransform TranslateX="200"/>
</Button.RenderTransform>
<Image x:Name="NextImage" Stretch="UniformToFill"/>
</Button>
<Button x:Name="CurrentImageBtn" Background="{x:Null}" Padding="0" BorderThickness="0">
<Button.RenderTransform>
<CompositeTransform/>
</Button.RenderTransform>
<Image x:Name="CurrentImage" Stretch="UniformToFill" RenderTransformOrigin="0.5,0.5"/>
</Button>
<Button x:Name="PreviousBtn" Background="#FFEAEAEA" BorderThickness="0" VerticalAlignment="Stretch" Width="25" Opacity="0" Padding="0" Visibility="Collapsed">
<TextBlock Text="<"/>
</Button>
<Button x:Name="NextBtn" Background="#FFEAEAEA" BorderThickness="0" HorizontalAlignment="Right" VerticalAlignment="Stretch" Width="25" Padding="0" Opacity="0" Visibility="Collapsed">
<TextBlock Text=">"/>
</Button>
<StackPanel x:Name="IndexIndicatorSP" Orientation="Horizontal" VerticalAlignment="Bottom" HorizontalAlignment="Center">
</StackPanel>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
你可以粘贴代码到 VS 然后通过设计器看看它的构造是如何的。值得注意的是,目前所有 TranslateX 的值都是设计时的数值...也就是说,这个数值,我应该根据控件大小去动态更改。
另外如果目测上面那段代码困难,可以去掉 Storyboard 部分,会看起来清晰好多 :-)
以下为一些依赖属性:
public int SelectedIndex
{
get { return (int)GetValue(SelectedIndexProperty); }
set { SetValue(SelectedIndexProperty, value); }
}
public static readonly DependencyProperty SelectedIndexProperty =
DependencyProperty.Register("SelectedIndex", typeof(int), typeof(LoopingBannerControl), new PropertyMetadata(-1,
new PropertyChangedCallback(SelectedIndexPropertyChanged)));
public object ItemsSource
{
get { return (object)GetValue(ItemsSourceProperty); }
set { SetValue(ItemsSourceProperty, value); }
}
public static readonly DependencyProperty ItemsSourceProperty =
DependencyProperty.Register("ItemsSource", typeof(object), typeof(LoopingBannerControl), new PropertyMetadata(null,
new PropertyChangedCallback(ItemsSourcePropertyChanged)));
public bool AutoCycle
{
get { return (bool)GetValue(AutoCycleProperty); }
set { SetValue(AutoCycleProperty, value); }
}
public static readonly DependencyProperty AutoCycleProperty =
DependencyProperty.Register("AutoCycle", typeof(bool), typeof(LoopingBannerControl), new PropertyMetadata(true,
((sendert, et) =>
{
var control = sendert as LoopingBannerControl;
control.UpdateTimer();
})));
public ICommand ItemTapped
{
get { return (ICommand)GetValue(ItemTappedProperty); }
set { SetValue(ItemTappedProperty, value); }
}
public static readonly DependencyProperty ItemTappedProperty =
DependencyProperty.Register("ItemTapped", typeof(ICommand), typeof(LoopingBannerControl), new PropertyMetadata(null));
SelectedIndex
用于表示控件实现列表中第几项的内容,AutoCycle
表示是否定时滚动到下一项,ItemsSource
表示绑定到的列表。
控件的图片切换动画通过 XAML 里的 Storyboard 实现:而播放动画,是通过以下时间发生:
-
点击左右2个切换按钮切换的时候;
-
手势滑动项目,滑动结束手指离开的时候;
-
自动轮播,每过一个时间间隔后。
因为这个控件结构只包含左中右3个元素,而我们的 Storyboard 动画是把3个元素整体往右或左移,因此,在动画结束后我们还还原动画所做的更改,并且更新3个元素的内容。
这部分的代码如下,具体请看注释。
private async Task UpdateItems()
{
await tcs.Task;
if (ImageList == null) return;
if (ImageList.Count() <= 0) return;
//超过最后一项的时候回到第一项
if (SelectedIndex >= ImageList.Count())
{
SelectedIndex = 0;
return;
}
//低于第一项的时候到最后一项
else if (SelectedIndex <= -1)
{
SelectedIndex = ImageList.Count() - 1;
return;
}
if (SelectedIndex < 0 || SelectedIndex > ImageList.Count())
{
return;
}
await UpdateIndicator();
//还原动画所做的更改
ResetTransformX();
UpdateFrameValue();
//根据当前的位置设置相应的图
CurrentImage.Source = ImageList.ElementAt(SelectedIndex).TitleImage;
if (SelectedIndex == 0) PreviousImage.Source = ImageList.ElementAt(ImageList.Count() - 1).TitleImage;
else PreviousImage.Source = ImageList.ElementAt(SelectedIndex - 1).TitleImage;
if (SelectedIndex == ImageList.Count() - 1) NextImage.Source = ImageList.ElementAt(0).TitleImage;
else NextImage.Source = ImageList.ElementAt(SelectedIndex + 1).TitleImage;
}
/// <summary>
/// 重置3个元素的位置
/// </summary>
private void ResetTransformX()
{
if (double.IsNaN(RootGrid.ActualWidth) || RootGrid.ActualWidth == 0) return;
(NextImageBtn.RenderTransform as CompositeTransform).TranslateX = RootGrid.ActualWidth;
(PreviousImageBtn.RenderTransform as CompositeTransform).TranslateX = -RootGrid.ActualWidth;
(CurrentImageBtn.RenderTransform as CompositeTransform).TranslateX = 0;
}
/// <summary>
/// 更新 Storyboard 里的参数
/// </summary>
private void UpdateFrameValue()
{
if (double.IsNaN(RootGrid.ActualWidth) || RootGrid.ActualWidth == 0) return;
if (NextRightOriTranslateXFrame != null) NextRightOriTranslateXFrame.Value = RootGrid.ActualWidth;
if (NextMiddleTargetTranslateXFrame != null) NextMiddleTargetTranslateXFrame.Value = -RootGrid.ActualWidth;
if (PreviousMiddleTargetTranslateXFrame != null) PreviousMiddleTargetTranslateXFrame.Value = RootGrid.ActualWidth;
if (PreviousLeftOriTranslateXFrame != null) PreviousLeftOriTranslateXFrame.Value = -RootGrid.ActualWidth;
}
当然要做到随窗口大小变化,需要在 SizeChanged 里改变 Clip 以及左边跟右边元素的 TranslateX:
/// <summary>
/// 根容器的大小改变的时候,去处理 Clip 属性以及更新当前项和前后项的位置
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private async void RootGrid_SizeChanged(object sender, SizeChangedEventArgs e)
{
await tcs.Task;
var newSize = e.NewSize;
RectangleGeometry rectClip = new RectangleGeometry();
rectClip.Rect = new Rect(new Point(0, 0), newSize);
RootGrid.Clip = rectClip;
ResetTransformX();
UpdateFrameValue();
}
至于手势切换,做过手势的都应该知道怎么写了。为了文章不过长,这里也暂不做展示。
Subscribe to JuniperPhoton's Blog
Get the latest posts delivered right to your inbox