1
votes

I want to create a multi-timeline control with the following features:

  • Each timeline has a header region which remains static to the left
  • The remainder of the each timeline can be scrolled horizontally.
  • If the control doesn't fit the available horizontal space, a scrollbar should be displayed under the timeline section, but not the headers.
  • If the control doesn't fit the available vertical space, a scrollbar should be displayed along the right which scrolls both the headers and the timelines up and down.

The closest I have gotten to this layout is the following xaml:

<Grid>
  <HeaderedContentControl>
    <HeaderedContentControl.Template>
      <ControlTemplate TargetType="HeaderedContentControl">
        <Grid>
          <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition Width="*" />
          </Grid.ColumnDefinitions>
          <ContentPresenter ContentSource="Header" ContentTemplate="{TemplateBinding HeaderedContentControl.HeaderTemplate}" Content="{TemplateBinding HeaderedContentControl.Header}" />
          <ScrollViewer Grid.Column="1" ContentTemplate="{TemplateBinding ContentControl.ContentTemplate}" VerticalScrollBarVisibility="Hidden" HorizontalScrollBarVisibility="Auto" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Content="{TemplateBinding ContentControl.Content}"
          />
        </Grid>
      </ControlTemplate>
    </HeaderedContentControl.Template>
    <HeaderedContentControl.Header>
      <ItemsControl>
        <Button Height="80" Content="Fixed Header 1" />
        <Button Height="80" Content="Fixed Header 2" />
      </ItemsControl>
    </HeaderedContentControl.Header>
    <ItemsControl>
      <StackPanel Orientation="Horizontal" Height="80">
        <Button Width="100" Content="thing" />
        <Button Width="100" Content="thing" />
        <Button Width="100" Content="thing" />
        <Button Width="100" Content="thing" />
        <Button Width="100" Content="thing" />
      </StackPanel>
      <StackPanel Orientation="Horizontal" Height="80">
        <Button Width="100" Content="thing" />
        <Button Width="100" Content="thing" />
        <Button Width="100" Content="thing" />
        <Button Width="100" Content="thing" />
        <Button Width="100" Content="thing" />
      </StackPanel>
    </ItemsControl>
  </HeaderedContentControl>
</Grid>

This gives me the correct horizontal scrolling, but no vertical scrollbar:

horizontal scrolling

So to get vertical scrolling, I just need to wrap the outer HeaderedContentControl element with a ScrollViewer, right?

<Grid>
    <ScrollViewer>
        <HeaderedContentControl>
            <HeaderedContentControl.Template>
                <ControlTemplate TargetType="HeaderedContentControl">
                    <Grid>
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="Auto" />
                            <ColumnDefinition Width="*" />
                        </Grid.ColumnDefinitions>
                        <ContentPresenter
                            ContentSource="Header"
                            ContentTemplate="{TemplateBinding HeaderedContentControl.HeaderTemplate}"
                            Content="{TemplateBinding HeaderedContentControl.Header}" />
                        <ScrollViewer Grid.Column="1"
                            ContentTemplate="{TemplateBinding ContentControl.ContentTemplate}"
                            VerticalScrollBarVisibility="Hidden"
                            HorizontalScrollBarVisibility="Auto"
                            HorizontalAlignment="Stretch"
                            VerticalAlignment="Stretch"
                            Content="{TemplateBinding ContentControl.Content}" />
                    </Grid>
                </ControlTemplate>
            </HeaderedContentControl.Template>
            <HeaderedContentControl.Header>
                <ItemsControl>
                    <Button Height="80" Content="Fixed Header 1" />
                    <Button Height="80" Content="Fixed Header 2" />
                </ItemsControl>
            </HeaderedContentControl.Header>
            <ItemsControl>
                <StackPanel Orientation="Horizontal" Height="80">
                    <Button Width="100" Content="thing" />
                    <Button Width="100" Content="thing" />
                    <Button Width="100" Content="thing" />
                    <Button Width="100" Content="thing" />
                    <Button Width="100" Content="thing" />
                </StackPanel>
                <StackPanel Orientation="Horizontal" Height="80">
                    <Button Width="100" Content="thing" />
                    <Button Width="100" Content="thing" />
                    <Button Width="100" Content="thing" />
                    <Button Width="100" Content="thing" />
                    <Button Width="100" Content="thing" />
                </StackPanel>
            </ItemsControl>
        </HeaderedContentControl>
    </ScrollViewer>
</Grid>

The result:

enter image description here

Well it's almost right, but the horizontal scrollbar under my timelines is getting clipped when I reduce the window height. I want it to stay on top like the previous image so I can always horizontally scroll.

Does anyone know how I can achieve this?

1
Maybe use the toplevel scrollviewer for all the scrolling and get rid of the internal one? Or vice versa. Both scrollbars should belong to the same scrollviewer, or else the outer one can scroll the inner one out of view. - 15ee8f99-57ff-4f92-890c-b56153
@EdPlunkett If I removed the inner scrollviewer then the header column would also scroll, which is not what I want. - ChocolatePocket

1 Answers

2
votes

I found a solution (although it feels more like a workaround, you be the judge).

I added an external ScrollBar under the internal ScrollViewer and hid the internal ScrollBars entirely. When the UserControl is loaded, I find the horizontal ScrollBar belonging to the ScrollViewer and bind all the visual properties to my external ScrollBar. When my external ScrollBar is moved, I pass on the value to the internal ScrollViewer using ScrollToHorizontalOffset. I also added a grid splitter and some shared column widths so the header can be resized.

Here's the xaml:

<Grid Grid.IsSharedSizeScope="True">
    <Grid.RowDefinitions>
        <RowDefinition Height="*"/>
        <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="Auto" SharedSizeGroup="SharedHeader"/>
        <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>
    <ScrollViewer Grid.ColumnSpan="2" VerticalScrollBarVisibility="Auto">
        <HeaderedContentControl x:Name="HeaderedControl">
            <HeaderedContentControl.Template>
                <ControlTemplate TargetType="HeaderedContentControl">
                    <Grid>
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="Auto" SharedSizeGroup="SharedHeader" />
                            <ColumnDefinition Width="5" />
                            <ColumnDefinition Width="*" />
                        </Grid.ColumnDefinitions>
                        <ContentPresenter
                            ContentSource="Header"
                            ContentTemplate="{TemplateBinding HeaderedContentControl.HeaderTemplate}"
                            Content="{TemplateBinding HeaderedContentControl.Header}" />
                        <GridSplitter Grid.Column="1" Width="5" HorizontalAlignment="Stretch" />
                        <ScrollViewer Grid.Column="2"
                            ContentTemplate="{TemplateBinding ContentControl.ContentTemplate}"
                            VerticalScrollBarVisibility="Hidden"
                            HorizontalScrollBarVisibility="Hidden"
                            HorizontalAlignment="Stretch"
                            VerticalAlignment="Stretch"
                            Content="{TemplateBinding ContentControl.Content}" />
                    </Grid>
                </ControlTemplate>
            </HeaderedContentControl.Template>
            <HeaderedContentControl.Header>
                <ItemsControl>
                    <Button Height="80" Content="Fixed Header 1" />
                    <Button Height="80" Content="Fixed Header 2" />
                </ItemsControl>
            </HeaderedContentControl.Header>
            <ItemsControl>
                <StackPanel Orientation="Horizontal" Height="80">
                    <Button Width="100" Content="thing" />
                    <Button Width="100" Content="thing" />
                    <Button Width="100" Content="thing" />
                    <Button Width="100" Content="thing" />
                    <Button Width="100" Content="thing" />
                </StackPanel>
                <StackPanel Orientation="Horizontal" Height="80">
                    <Button Width="100" Content="thing" />
                    <Button Width="100" Content="thing" />
                    <Button Width="100" Content="thing" />
                    <Button Width="100" Content="thing" />
                    <Button Width="100" Content="thing" />
                </StackPanel>
            </ItemsControl>
        </HeaderedContentControl>
    </ScrollViewer>
    <ScrollBar Grid.Row="1" Grid.Column="1" x:Name="ExternalScroller" Orientation="Horizontal"/>
</Grid>

Here is the code-behind:

public partial class MainWindow : Window
{
    private ScrollViewer _internalScrollViewer;

    public MainWindow()
    {
        InitializeComponent();

        Loaded += MainWindow_Loaded;    
    }

    private void MainWindow_Loaded(object sender, RoutedEventArgs e)
    {
        // bind the external scrollbar to the internal ScrollViewer horizontal scrollbar
        _internalScrollViewer = GetParentScrollViewer(HeaderedControl.Content as UIElement);
        var hsb = GetHorizontalScrollbar(_internalScrollViewer);

        BindingOperations.SetBinding(ExternalScroller, System.Windows.Controls.Primitives.RangeBase.LargeChangeProperty, new Binding("LargeChange") { Source = hsb, Mode = BindingMode.TwoWay });
        BindingOperations.SetBinding(ExternalScroller, System.Windows.Controls.Primitives.RangeBase.MaximumProperty, new Binding("Maximum") { Source = hsb, Mode = BindingMode.TwoWay });
        BindingOperations.SetBinding(ExternalScroller, System.Windows.Controls.Primitives.RangeBase.MinimumProperty, new Binding("Minimum") { Source = hsb, Mode = BindingMode.TwoWay });
        BindingOperations.SetBinding(ExternalScroller, System.Windows.Controls.Primitives.RangeBase.SmallChangeProperty, new Binding("SmallChange") { Source = hsb, Mode = BindingMode.TwoWay });
        BindingOperations.SetBinding(ExternalScroller, System.Windows.Controls.Primitives.RangeBase.ValueProperty, new Binding("Value") { Source = hsb, Mode = BindingMode.TwoWay });
        BindingOperations.SetBinding(ExternalScroller, System.Windows.Controls.Primitives.ScrollBar.ViewportSizeProperty, new Binding("ViewportSize") { Source = hsb, Mode = BindingMode.TwoWay });

        // forward change events to the internal ScrollViewer
        ExternalScroller.ValueChanged += ExternalScroller_ValueChanged;
        ExternalScroller.Value = hsb.Value;
    }

    private void ExternalScroller_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
    {
        _internalScrollViewer.ScrollToHorizontalOffset(e.NewValue);
    }

    private static System.Windows.Controls.Primitives.ScrollBar GetHorizontalScrollbar(ScrollViewer sv)
    {
        return FindChild<System.Windows.Controls.Primitives.ScrollBar>(sv, (sb => sb.Orientation == Orientation.Horizontal));
    }

    private static ScrollViewer GetParentScrollViewer(UIElement uiElement)
    {
        DependencyObject item = VisualTreeHelper.GetParent(uiElement);
        while (item != null)
        {
            string name = "";
            var ctrl = item as Control;
            if (ctrl != null)
                name = ctrl.Name;
            if (item is ScrollViewer)
            {
                return item as ScrollViewer;
            }
            item = VisualTreeHelper.GetParent(item);
        }
        return null;
    }

    private static T FindChild<T>(DependencyObject parent, Func<T, bool> additionalCheck) where T : DependencyObject
    {
        int childrenCount = VisualTreeHelper.GetChildrenCount(parent);
        T child;

        for (int index = 0; index < childrenCount; index++)
        {
            child = VisualTreeHelper.GetChild(parent, index) as T;

            if (child != null)
            {
                if (additionalCheck == null)
                {
                    return child;
                }
                else
                {
                    if (additionalCheck(child))
                    {
                        return child;
                    }
                }
            }
        }

        for (int index = 0; index < childrenCount; index++)
        {
            child = FindChild(VisualTreeHelper.GetChild(parent, index), additionalCheck);

            if (child != null)
            {
                return child;
            }
        }

        return null;
    }
}

And finally, here is the visual result:

enter image description here