Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
using System.Diagnostics;
using System.Windows.Media.Animation;
using Microsoft.Xaml.Behaviors;

namespace MaterialDesignThemes.Wpf.Behaviors.Internal;

public class TabControlHeaderScrollBehavior : Behavior<ScrollViewer>
{
public static readonly DependencyProperty CustomHorizontalOffsetProperty =
DependencyProperty.RegisterAttached("CustomHorizontalOffset", typeof(double),
typeof(TabControlHeaderScrollBehavior), new PropertyMetadata(0d, CustomHorizontalOffsetChanged));
public static double GetCustomHorizontalOffset(DependencyObject obj) => (double)obj.GetValue(CustomHorizontalOffsetProperty);
public static void SetCustomHorizontalOffset(DependencyObject obj, double value) => obj.SetValue(CustomHorizontalOffsetProperty, value);
private static void CustomHorizontalOffsetChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var scrollViewer = (ScrollViewer)d;
scrollViewer.ScrollToHorizontalOffset((double)e.NewValue);
}

public static readonly DependencyProperty ScrollDirectionProperty =
DependencyProperty.RegisterAttached("ScrollDirection", typeof(TabScrollDirection),
typeof(TabControlHeaderScrollBehavior), new PropertyMetadata(TabScrollDirection.Unknown));
public static TabScrollDirection GetScrollDirection(DependencyObject obj) => (TabScrollDirection)obj.GetValue(ScrollDirectionProperty);
public static void SetScrollDirection(DependencyObject obj, TabScrollDirection value) => obj.SetValue(ScrollDirectionProperty, value);

public TabControl TabControl
{
get => (TabControl)GetValue(TabControlProperty);
set => SetValue(TabControlProperty, value);
}

public static readonly DependencyProperty TabControlProperty =
DependencyProperty.Register(nameof(TabControl), typeof(TabControl),
typeof(TabControlHeaderScrollBehavior), new PropertyMetadata(null, OnTabControlChanged));


private static void OnTabControlChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var behavior = (TabControlHeaderScrollBehavior)d;
if (e.OldValue is TabControl oldTabControl)
{
oldTabControl.SelectionChanged -= behavior.OnTabChanged;
oldTabControl.SizeChanged -= behavior.OnTabControlSizeChanged;
}
if (e.NewValue is TabControl newTabControl)
{
newTabControl.SelectionChanged += behavior.OnTabChanged;
newTabControl.SizeChanged += behavior.OnTabControlSizeChanged;
}
}

public FrameworkElement ScrollableContent
{
get => (FrameworkElement)GetValue(ScrollableContentProperty);
set => SetValue(ScrollableContentProperty, value);
}

public static readonly DependencyProperty ScrollableContentProperty =
DependencyProperty.Register(nameof(ScrollableContent), typeof(FrameworkElement),
typeof(TabControlHeaderScrollBehavior), new PropertyMetadata(null, OnScrollableContentChanged));

private static void OnScrollableContentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var behavior = (TabControlHeaderScrollBehavior)d;
behavior.AddPaddingToScrollableContentIfWiderThanViewPort();
}

private double? _desiredScrollStart;
private bool _isAnimatingScroll;

private void OnTabChanged(object sender, SelectionChangedEventArgs e)
{
var tabControl = (TabControl)sender;

if (e.AddedItems.Count > 0)
{
_desiredScrollStart = AssociatedObject.ContentHorizontalOffset;
SetScrollDirection(tabControl, (IsMovingForward() ? TabScrollDirection.Forward : TabScrollDirection.Backward));
}

bool IsMovingForward()
{
if (e.RemovedItems.Count == 0) return true;
int previousIndex = GetItemIndex(e.RemovedItems[0]);
int nextIndex = GetItemIndex(e.AddedItems[^1]);
return nextIndex > previousIndex;
}

int GetItemIndex(object? item) => tabControl.Items.IndexOf(item);
}

private void OnTabControlSizeChanged(object sender, SizeChangedEventArgs _) => AddPaddingToScrollableContentIfWiderThanViewPort();
private void AssociatedObject_SizeChanged(object sender, SizeChangedEventArgs _) => AddPaddingToScrollableContentIfWiderThanViewPort();

private void AddPaddingToScrollableContentIfWiderThanViewPort()
{
if (TabAssist.GetUseHeaderPadding(TabControl) == false)
return;
if (ScrollableContent is null)
return;

if (ScrollableContent.ActualWidth > TabControl.ActualWidth)
{
double offset = TabAssist.GetHeaderPadding(TabControl);
ScrollableContent.Margin = new(offset, 0, offset, 0);
}
else
{
ScrollableContent.Margin = new();
}
}

protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.ScrollChanged += AssociatedObject_ScrollChanged;
AssociatedObject.SizeChanged += AssociatedObject_SizeChanged;
Dispatcher.BeginInvoke(() => AddPaddingToScrollableContentIfWiderThanViewPort());
}

protected override void OnDetaching()
{
base.OnDetaching();
if (AssociatedObject is { } ao)
{
ao.ScrollChanged -= AssociatedObject_ScrollChanged;
ao.SizeChanged -= AssociatedObject_SizeChanged;
}
}

private void AssociatedObject_ScrollChanged(object sender, ScrollChangedEventArgs e)
{
if (TabAssist.GetUseHeaderPadding(TabControl) == false)
return;
TimeSpan duration = TabAssist.GetScrollDuration(TabControl);
if (duration == TimeSpan.Zero)
return;
if ( _isAnimatingScroll || _desiredScrollStart is not { } desiredOffsetStart)
return;

double originalValue = desiredOffsetStart;
double newValue = e.HorizontalOffset;
_isAnimatingScroll = true;

// HACK: Temporarily disable user interaction while the animated scroll is ongoing. This prevents the double-click of a tab stopping the animation prematurely.
bool originalIsHitTestVisibleValue = TabControl.IsHitTestVisible;
TabControl.SetCurrentValue(FrameworkElement.IsHitTestVisibleProperty, false);

AssociatedObject.ScrollToHorizontalOffset(originalValue);
DoubleAnimation scrollAnimation = new(originalValue, newValue, new Duration(duration));
scrollAnimation.Completed += (_, _) =>
{
_desiredScrollStart = null;
_isAnimatingScroll = false;

// HACK: Set the hit test visibility back to its original value
TabControl.SetCurrentValue(FrameworkElement.IsHitTestVisibleProperty, originalIsHitTestVisibleValue);
};
AssociatedObject.BeginAnimation(TabControlHeaderScrollBehavior.CustomHorizontalOffsetProperty, scrollAnimation);
}
}

public enum TabScrollDirection
{
Unknown,
Backward,
Forward
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@

using MaterialDesignThemes.Wpf.Behaviors.Internal;

namespace MaterialDesignThemes.Wpf.Internal;

public class PaddedBringIntoViewStackPanel : StackPanel
{
public TabScrollDirection ScrollDirection
{
get => (TabScrollDirection)GetValue(ScrollDirectionProperty);
set => SetValue(ScrollDirectionProperty, value);
}

public static readonly DependencyProperty ScrollDirectionProperty =
DependencyProperty.Register(nameof(ScrollDirection), typeof(TabScrollDirection),
typeof(PaddedBringIntoViewStackPanel), new PropertyMetadata(TabScrollDirection.Unknown));

public double HeaderPadding
{
get => (double)GetValue(HeaderPaddingProperty);
set => SetValue(HeaderPaddingProperty, value);
}

public static readonly DependencyProperty HeaderPaddingProperty =
DependencyProperty.Register(nameof(HeaderPadding),
typeof(double), typeof(PaddedBringIntoViewStackPanel), new PropertyMetadata(0d));

public bool UseHeaderPadding
{
get => (bool)GetValue(UseHeaderPaddingProperty);
set => SetValue(UseHeaderPaddingProperty, value);
}

public static readonly DependencyProperty UseHeaderPaddingProperty =
DependencyProperty.Register(nameof(UseHeaderPadding), typeof(bool), typeof(PaddedBringIntoViewStackPanel), new PropertyMetadata(false));

public PaddedBringIntoViewStackPanel()
=> AddHandler(FrameworkElement.RequestBringIntoViewEvent, new RoutedEventHandler(OnRequestBringIntoView), false);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Keboo We may want to handle the AddHandler()/RemoveHandler() on loaded/unloaded instead, to avoid a potential handler leak. Thoughts? Could also consider if registering a class handler is the better approach?


private void OnRequestBringIntoView(object sender, RoutedEventArgs e)
{
if (!UseHeaderPadding)
return;

if (e.OriginalSource is FrameworkElement child && child != this)
{
e.Handled = true;

// TODO: Consider making the "ScrollDirection" a destructive read (i.e. reset the value once it is read) to avoid leaving a Backward/Forward value that may be misinterpreted at a later stage.
double offset = ScrollDirection switch {
TabScrollDirection.Backward => -HeaderPadding,
TabScrollDirection.Forward => HeaderPadding,
_ => 0
};
var point = child.TranslatePoint(new Point(), this);
var newTargetRect = new Rect(new Point(point.X + offset, point.Y), child.RenderSize);
BringIntoView(newTargetRect);
}
}
}
29 changes: 29 additions & 0 deletions src/MaterialDesignThemes.Wpf/TabAssist.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,33 @@ public static void SetHeaderBehavior(DependencyObject obj, TabControlHeaderBehav
public static readonly DependencyProperty HeaderBehaviorProperty =
DependencyProperty.RegisterAttached("HeaderBehavior", typeof(TabControlHeaderBehavior), typeof(TabAssist),
new PropertyMetadata(TabControlHeaderBehavior.Scrolling));

public static double GetHeaderPadding(DependencyObject obj)
=> (double)obj.GetValue(HeaderPaddingProperty);

public static bool GetUseHeaderPadding(DependencyObject obj)
=> (bool)obj.GetValue(UseHeaderPaddingProperty);

public static void SetUseHeaderPadding(DependencyObject obj, bool value)
=> obj.SetValue(UseHeaderPaddingProperty, value);

public static readonly DependencyProperty UseHeaderPaddingProperty =
DependencyProperty.RegisterAttached("UseHeaderPadding", typeof(bool), typeof(TabAssist), new PropertyMetadata(false));

public static void SetHeaderPadding(DependencyObject obj, double value)
=> obj.SetValue(HeaderPaddingProperty, value);

public static readonly DependencyProperty HeaderPaddingProperty =
DependencyProperty.RegisterAttached("HeaderPadding", typeof(double),
typeof(TabAssist), new PropertyMetadata(0d));

public static TimeSpan GetScrollDuration(DependencyObject obj)
=> (TimeSpan)obj.GetValue(ScrollDurationProperty);

public static void SetScrollDuration(DependencyObject obj, TimeSpan value)
=> obj.SetValue(ScrollDurationProperty, value);

public static readonly DependencyProperty ScrollDurationProperty =
DependencyProperty.RegisterAttached("ScrollDuration", typeof(TimeSpan),
typeof(TabAssist), new PropertyMetadata(TimeSpan.Zero));
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:MaterialDesignThemes.Wpf.Converters"
xmlns:internal="clr-namespace:MaterialDesignThemes.Wpf.Internal"
xmlns:behaviorsInternal="clr-namespace:MaterialDesignThemes.Wpf.Behaviors.Internal"
xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
xmlns:wpf="clr-namespace:MaterialDesignThemes.Wpf">

<ResourceDictionary.MergedDictionaries>
Expand Down Expand Up @@ -36,7 +39,13 @@
wpf:ScrollViewerAssist.PaddingMode="{Binding Path=(wpf:ScrollViewerAssist.PaddingMode), RelativeSource={RelativeSource TemplatedParent}}"
HorizontalScrollBarVisibility="Hidden"
VerticalScrollBarVisibility="Hidden">
<StackPanel>
<b:Interaction.Behaviors>
<behaviorsInternal:TabControlHeaderScrollBehavior TabControl="{Binding RelativeSource={RelativeSource TemplatedParent}}" ScrollableContent="{Binding ElementName=ScrollableContent}" />
</b:Interaction.Behaviors>
<internal:PaddedBringIntoViewStackPanel x:Name="ScrollableContent"
ScrollDirection="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=(behaviorsInternal:TabControlHeaderScrollBehavior.ScrollDirection)}"
HeaderPadding="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=(wpf:TabAssist.HeaderPadding)}"
UseHeaderPadding="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=(wpf:TabAssist.UseHeaderPadding)}">
<UniformGrid x:Name="CenteredHeaderPanel"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
Margin="{Binding Path=(wpf:TabAssist.HeaderPanelMargin), RelativeSource={RelativeSource TemplatedParent}}"
Expand All @@ -53,7 +62,7 @@
Focusable="False"
KeyboardNavigation.TabIndex="1"
Orientation="Horizontal" />
</StackPanel>
</internal:PaddedBringIntoViewStackPanel>
</ScrollViewer>
</wpf:ColorZone>

Expand Down Expand Up @@ -227,6 +236,10 @@
<Setter Property="wpf:ElevationAssist.Elevation" Value="Dp4" />
<Setter Property="wpf:RippleAssist.Feedback" Value="{DynamicResource MaterialDesign.Brush.Button.Ripple}" />
<Setter Property="wpf:TabAssist.HasUniformTabWidth" Value="False" />
<!-- MD spec says 52 DP, but that seems a little excessive in practice -->
<Setter Property="wpf:TabAssist.HeaderPadding" Value="40" />
<Setter Property="wpf:TabAssist.UseHeaderPadding" Value="True" />
<Setter Property="wpf:TabAssist.ScrollDuration" Value="0:0:0.250" />

<Style.Triggers>
<Trigger Property="wpf:TabAssist.HeaderBehavior" Value="Wrapping">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,56 @@ public async Task TabControl_ShouldRespectSelectedContentTemplate_WhenSetDirectl

recorder.Success();
}

[Test]
public async Task ScrollingTabs_UniformGrid()
{
await using var recorder = new TestRecorder(App);

//Arrange
const int numTabs = 10;
StringBuilder xaml = new("<TabControl>");
for (int i = 1; i <= numTabs; i++)
{
xaml.Append($"""
<TabItem Header="TAB {i}">
<TextBlock Margin="8" Text="Tab {i}" />
</TabItem>
""");
}
xaml.Append("</TabControl>");
IVisualElement<TabControl> tabControl = await LoadXaml<TabControl>(xaml.ToString());

//Act

//Assert

recorder.Success();
}

[Test]
public async Task ScrollingTabs_VirtualizingStackPanel()
{
await using var recorder = new TestRecorder(App);

//Arrange
const int numTabs = 10;
StringBuilder xaml = new("<TabControl HorizontalContentAlignment=\"Left\">");
for (int i = 1; i <= numTabs; i++)
{
xaml.Append($"""
<TabItem Header="TAB {i}">
<TextBlock Margin="8" Text="Tab {i}" />
</TabItem>
""");
}
xaml.Append("</TabControl>");
IVisualElement<TabControl> tabControl = await LoadXaml<TabControl>(xaml.ToString());

//Act

//Assert

recorder.Success();
}
}
Loading