WPF 动画:如何在堆栈面板中的多个元素之间滑动?

WPF Animation: How to slide between multiple elements within a stackpanel?

我目前正在寻找一种制作具有多个用户控件的 wpf window 的方法,这些控件依次滑入和滑出可见区域,类似于“群星”启动器 (这是我能找到的最好的例子):

我以前使用 成功地创建了一个 window 有 2 个视觉元素滑入和滑出,但我无法找出超过 2 个元素的最佳实践。

我的计划是使用 4 个故事板,从当前位置滑动到堆栈面板中每个控件的位置,如下所示:

            <Grid Grid.Column="1">
            <Grid.Resources>
                <Storyboard x:Key="SlideFirst">
                    <DoubleAnimation Storyboard.TargetProperty="(UIElement.RenderTransform).(TranslateTransform.Y)"
                                     From="{Binding CurrentPosition}" To="0" Duration="0:0:0:3" />

                </Storyboard>
                <Storyboard x:Key="SlideSecond">
                    <DoubleAnimation Storyboard.TargetProperty="(UIElement.RenderTransform).(TranslateTransform.Y)"
                                     From="{Binding CurrentPosition}" To="650" Duration="0:0:0:3" />

                </Storyboard>
                <Storyboard x:Key="SlideThird">
                    <DoubleAnimation Storyboard.TargetProperty="(UIElement.RenderTransform).(TranslateTransform.Y)"
                                     From="{Binding CurrentPosition}" To="1300" Duration="0:0:0:3" />

                </Storyboard>
                <Storyboard x:Key="SlideForth">
                    <DoubleAnimation Storyboard.TargetProperty="(UIElement.RenderTransform).(TranslateTransform.Y)"
                                     From="{Binding CurrentPosition}" To="1950" Duration="0:0:0:3" />

                </Storyboard>
            </Grid.Resources>
            <StackPanel>
                <StackPanel.Style>
                    <Style TargetType="StackPanel">
                        <Style.Triggers>
                            <DataTrigger Binding="{Binding CurrentControl}" Value="0">
                                <DataTrigger.EnterActions>
                                    <BeginStoryboard Storyboard="{StaticResource SlideFirst}" />
                                </DataTrigger.EnterActions>
                            </DataTrigger>
                        </Style.Triggers>
                    </Style>
                </StackPanel.Style>

但这会导致异常:

InvalidOperationException: Cannot freeze this Storyboard timeline tree for use across threads.

我可以 - 理论上 - 为每个可能的状态(1->2、1->3、1->4、2->1、2->3 ...)制作一个故事板,但那样会已经是 4 个控件的 12 个故事板。一定有更简单的方法。

如何使用故事板根据当前位置在多个元素之间滑动?

您应该创建一个 ? UserControlor customControlthat hosts aListBoxto display the sections and the buttons to navigate between them. You then animate theScrollViewer` 导航到所选部分。

这使得实现动态化,这意味着您在添加新部分时不必添加新动画等。

  1. 创建抽象基础 class 或接口,例如SectionItem。它是所有部分项目(数据模型)的模板并包含通用属性和逻辑。
  2. 每个部分(例如新闻、DLC、模组)都实现了这个基础 class/interface 并被添加到一个公共集合中,例如Sections 在视图模型中。
  3. 创建 UserControl 或自定义 Control SectionsViewSectionsView 托管导航按钮,并将显示各个部分或 SectionItem 项。按下按钮时,将执行到该部分的动画导航。
  4. SectionView 公开了一个 ItemsSource 属性,该属性绑定到视图模型的 Sections 集合。
  5. 为每个 SectionItem 创建一个 DataTemplate。该模板定义了实际部分的外观。这些模板被添加到 SectionViewResourceDictionary
  6. 要为 ListBoxScrollViewer 设置动画,SectionsView 必须实现 DependencyProperty,例如NavigationOffset。这是必要的,因为ScrollViewer只提供了修改其偏移量的方法。

创建分区项目

每个项目都必须扩展基础 class SectionItem:

SectionItem.cs

public abstract class SectionItem : INotifyPropertyChanged
{
  public SectionItem(Section id)
  {
    this.id = id;
  }

  private Section id;   
  public Section Id
  {
    get => this.id;
    set 
    { 
      this.id = value; 
      OnPropertyChanged();
    }
  }

  private string title;   
  public string Title
  {
    get => this.title;
    set 
    { 
      this.title = value; 
      OnPropertyChanged();
    }
  }

  public event PropertyChangedEventHandler PropertyChanged;
  protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
  {
    this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
  }
}

实施实际的部分模型

class DlcSection : SectionItem
{
  public DlcSection(Section id) : base(id)
  {
  }
}

class SettingsSection : SectionItem
{
  public SettingsSection(Section id) : base(id)
  {
  }
}

class NewsSection : SectionItem
{
  public NewsSection(Section id) : base(id)
  {
  }
}

enum用作SectionItemCommandParameter

的section ID

Section.cs

public enum Section
{
  None = 0,
  Dlc,
  Settings,
  News
}

实施SectionsView

SectionsView 扩展了 UserControl(或 Control)并封装了 SectionItem 项的显示及其导航。为了触发导航,它公开了一个路由命令 NavigateToSectionRoutedCommand:

SectionsView.xaml.cs

public partial class SectionsView : UserControl
{
  #region Routed commands

  public static readonly RoutedUICommand NavigateToSectionRoutedCommand = new RoutedUICommand(
    "Navigates to section by section ID which is an enum value of the enumeration 'Section'.",
    nameof(SectionsView.NavigateToSectionRoutedCommand),
    typeof(SectionsView));

  #endregion Routed commands

  public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.Register(
    "ItemsSource",
    typeof(IEnumerable),
    typeof(SectionsView),
    new PropertyMetadata(default(IEnumerable)));

  public IEnumerable ItemsSource
  {
    get => (IEnumerable) GetValue(SectionsView.ItemsSourceProperty);
    set => SetValue(SectionsView.ItemsSourceProperty, value);
  }

  public static readonly DependencyProperty NavigationOffsetProperty = DependencyProperty.Register(
    "NavigationOffset",
    typeof(double),
    typeof(SectionsView),
    new PropertyMetadata(default(double), SectionNavigator.OnNavigationOffsetChanged));

  public double NavigationOffset
  {
    get => (double) GetValue(SectionsView.NavigationOffsetProperty);
    set => SetValue(SectionsView.NavigationOffsetProperty, value);
  }

  private ScrollViewer Navigator { get; set; }

  public SectionsView()
  {
    InitializeComponent();

    this.Loaded += OnLoaded;
  }

  private void OnLoaded(object sender, RoutedEventArgs e)
  {
    if (TryFindVisualChildElement(this.SectionItemsView, out ScrollViewer scrollViewer))
    {
      this.Navigator = scrollViewer;
    }
  }

  private static void OnNavigationOffsetChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
  {
    (d as SectionsView).Navigator.ScrollToVerticalOffset((double) e.NewValue);
  }

  private void NavigateToSection_OnExecuted(object sender, ExecutedRoutedEventArgs e)
  {
    SectionItem targetSection = this.SectionItemsView.Items
      .Cast<SectionItem>()
      .FirstOrDefault(section => section.Id == (Section) e.Parameter);
    if (targetSection == null)
    {
      return;
    }

    double verticalOffset = 0;
    if (this.Navigator.CanContentScroll)
    {
      verticalOffset = this.SectionItemsView.Items.IndexOf(targetSection);
    }
    else
    {
      var sectionContainer =
        this.SectionItemsView.ItemContainerGenerator.ContainerFromItem(targetSection) as UIElement;
      Point absoluteContainerPosition = sectionContainer.TransformToAncestor(this.Navigator).Transform(new Point());
      verticalOffset = this.Navigator.VerticalOffset + absoluteContainerPosition.Y;
    }

    var navigationAnimation = this.Resources["NavigationAnimation"] as DoubleAnimation;
    navigationAnimation.From = this.Navigator.VerticalOffset;
    navigationAnimation.To = verticalOffset;
    BeginAnimation(SectionNavigator.NavigationOffsetProperty, navigationAnimation);
  }

  private void NavigateToSection_OnCanExecute(object sender, CanExecuteRoutedEventArgs e)
  {
    e.CanExecute = e.Parameter is Section;
  }

  private bool TryFindVisualChildElement<TChild>(DependencyObject parent, out TChild resultElement)
    where TChild : DependencyObject
  {
    resultElement = null;
    for (var childIndex = 0; childIndex < VisualTreeHelper.GetChildrenCount(parent); childIndex++)
    {
      DependencyObject childElement = VisualTreeHelper.GetChild(parent, childIndex);

      if (childElement is Popup popup)
      {
        childElement = popup.Child;
      }

      if (childElement is TChild)
      {
        resultElement = childElement as TChild;
        return true;
      }

      if (TryFindVisualChildElement(childElement, out resultElement))
      {
        return true;
      }
    }

    return false;
  }
}

SectionsView.xaml

<UserControl x:Class="SectionsView">
  <UserControl.Resources>
   
    <!-- Animation can be changed, but name must remain the same -->
    <DoubleAnimation x:Key="NavigationAnimation" Storyboard.TargetName="Root" Storyboard.TargetProperty="NavigationOffset"
                     Duration="0:0:0.3">
      <DoubleAnimation.EasingFunction>
        <PowerEase EasingMode="EaseIn" Power="5" />
      </DoubleAnimation.EasingFunction>
    </DoubleAnimation>

    <!-- DataTemplates for different section items -->
    <DataTemplate DataType="{x:Type local:DlcSection}">
      <Grid Height="200" Background="Green">
        <TextBlock Text="{Binding Title}" FontSize="18" />
      </Grid>
    </DataTemplate>

    <DataTemplate DataType="{x:Type local:SettingsSection}">
      <Grid Height="200" Background="OrangeRed">
        <TextBlock Text="{Binding Title}" FontSize="18" />
      </Grid>
    </DataTemplate>

    <DataTemplate DataType="{x:Type viewModels:NewsSection}">
      <Grid Height="200" Background="Yellow">
        <TextBlock Text="{Binding Title}" FontSize="18" />
      </Grid>
    </DataTemplate>
  </UserControl.Resources>

  <UserControl.CommandBindings>
    <CommandBinding Command="{x:Static local:SectionNavigator.NavigateToSectionRoutedCommand}"
                    Executed="NavigateToSection_OnExecuted" CanExecute="NavigateToSection_OnCanExecute" />
  </UserControl.CommandBindings>

  <Grid>
    <StackPanel>
      <Button Content="Navigate" Command="{x:Static local:SectionNavigator.NavigateToSectionRoutedCommand}"
              CommandParameter="{x:Static local:Section.News}" />
      <Button Content="Navigate" Command="{x:Static local:SectionNavigator.NavigateToSectionRoutedCommand}"
              CommandParameter="{x:Static local:Section.Dlc}" />
      <Button Content="Navigate" Command="{x:Static local:SectionNavigator.NavigateToSectionRoutedCommand}"
              CommandParameter="{x:Static local:Section.Settings}" />

      <!-- ScrollViewer.CanContentScroll is set to False to enable smooth scrolling for large (high) items -->
      <ListBox x:Name="SectionItemsView" 
               Height="250"
               ItemsSource="{Binding RelativeSource={RelativeSource AncestorType=local:SectionNavigator}, Path=Sections}"
               ScrollViewer.CanContentScroll="False" />
    </StackPanel>
  </Grid>
</UserControl>

用法

ViewModel.cs

class ViewModel : INotifyPropertyChanged
{
  public ObservableCollection<SectionItem> Sections { get; set; }

  public ViewModel()
  {
    this.Sections = new ObservableCollection<SectionItem>
    {
      new NewsSection(Section.News) {Title = "News"},
      new DlcSection(Section.Dlc) {Title = "DLC"},
      new SettingsSection(Section.Settings) {Title = "Settings"}
    };
  }
}

MainWindow.xaml

<Window>
  <Window.Resources>
    <ViewModel />
  </Window.Resources>

  <SectionsView ItemsSource="{Binding Sections}" />
</Window>