/ uwp

Implement a FlipView-like control that supports looping.

Introduction

正如我们所知道的,内置控件 FlipView 并不支持循环滚动——也就是无法从最后一项滚动到第一项,就像第一项本来就跟在最后一项一样。不过针对 FlipView 控件本身,通过某些技巧可以做到这一点:

FlipViewSelectedIndex 属性到达 Count-1 的时候,手动将其设为0;同样地,在到达 -1 的时候,手动设为最后一项。但是,FlipView 内置的项目之间的平滑滚动动画,只有在 +1 或者 -1 的时候才能触发。因此这种 Workaround 并不能触发动画。

SelectedIndex 到达列表边界的时候,手动为列表添加数据:也就是在最后一项的后面添加第一项,在第一项的前面添加最后一项。但是这样做的话,可能在循环多次列表后,列表的项数就会非常大,而在添加项的同时去 Remove 之前的项然后设置 SelectedIndex 的话,又会触发多余的内建动画...

因此,以上都不是完美的解决方法。

于是乎,我就开始自己写一个控件,去完成这个需求。

首先,列一下这个控件所拥有的功能:

  1. FlipView 一样,能左右滑动切换;
  2. 同时支持触控跟鼠标操作;
  3. 底部有指示器指示当前的位置;
  4. 支持循环滑动;

接下来分析一下原理。

如图所示,本控件由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="&lt;"/>
                        </Button>
                        <Button x:Name="NextBtn" Background="#FFEAEAEA" BorderThickness="0"  HorizontalAlignment="Right" VerticalAlignment="Stretch" Width="25" Padding="0" Opacity="0" Visibility="Collapsed">
                            <TextBlock Text="&gt;"/>
                        </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();
        }

至于手势切换,做过手势的都应该知道怎么写了。为了文章不过长,这里也暂不做展示。