2

I am currently looking for a way to make a wpf window with multiple UserControls, which slide in and out of the visible area one after another, simular to the "Stellaris" Launcher (which was the best example i could find as to what I want):

Stellaris Launcher

I previously used this Question to sucessfully create a window with 2 visual elements sliding in and out, but i couldn't figure out the best practice for more than 2 elements.

My plan was to use 4 Storyboards sliding from the current position to the position of each control within the stackpanel like this:

            <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>

but this leads to a exception:

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

I could - theoretically - make one storyboard for each possible state (1->2, 1->3, 1->4, 2->1, 2->3 ...), but that would already be 12 storyboards for 4 controls. There must be an easier way.

How can I use Storyboards to slide between multiple elements based on the current position?

Azzarrel
  • 537
  • 5
  • 20
  • 1
    Check this : https://stackoverflow.com/questions/20731402/animated-smooth-scrolling-on-scrollviewer – Cihan Yakar Jul 20 '20 at 08:25
  • It's not clear from the bit of code you posted why you should get the exception. Have you tried focusing on not freezing the `Storyboard`? Please provide a [mcve], and make sure you have narrowed your question to the _specific_ issue you need help with, rather than a broad "how can I do this?" specification? – Peter Duniho Jul 20 '20 at 16:40

1 Answers1

2

You should create a ? UserControlor customControlthat hosts aListBoxto display the sections and the buttons to navigate between them. You then animate theScrollViewer` to navigate to the selected section.

This makes the implementation dynamic, means you don't have to add new animations etc when adding new sections.

  1. create the abstract base class or interface e.g. SectionItem. Its the template for all section items (data models) and contains common properties and logic.
  2. Each section (e.g., News, DLC, Mods) implements this base class/interface and is added to a common collection e.g. Sections in the view model.
  3. Create a UserControl or custom Control SectionsView. SectionsView hosts the navigation buttons and will display the individual sections or SectionItem items. When a button is pressed, the animated navigation to the section is executed.
  4. This SectionView exposes a ItemsSource proerty which binds to the view model's Sections collection.
  5. Create a DataTemplate for each SectionItem. This template defines the appearance of the actual section. Those templates are added to the ResourceDictionary of the SectionView.
  6. To animate the ScrollViewer of the ListBox the SectionsView must implement a DependencyProperty e.g. NavigationOffset. This is necessary, because ScrollViewer only offers a method to modify its offsets.

Create the section items

Each item must extend the base 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));
  }
}

Implement the actual section models

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)
  {
  }
}

The enum which is used as section ID of SectionItem and CommandParameter

Section.cs

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

Implement the SectionsView

The SectionsView extends UserControl (or Control) and encapsulates the display of the SectionItem items and their navigation. To trigger navigation it exposes a routed comnmand 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>

Usage

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>
BionicCode
  • 1
  • 4
  • 28
  • 44