diff --git a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs
new file mode 100644
index 0000000000..4b2aa6c3f7
--- /dev/null
+++ b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs
@@ -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
+{
+ 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
+}
diff --git a/src/MaterialDesignThemes.Wpf/Internal/PaddedBringIntoViewStackPanel.cs b/src/MaterialDesignThemes.Wpf/Internal/PaddedBringIntoViewStackPanel.cs
new file mode 100644
index 0000000000..62d3b34104
--- /dev/null
+++ b/src/MaterialDesignThemes.Wpf/Internal/PaddedBringIntoViewStackPanel.cs
@@ -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);
+
+ 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);
+ }
+ }
+}
diff --git a/src/MaterialDesignThemes.Wpf/TabAssist.cs b/src/MaterialDesignThemes.Wpf/TabAssist.cs
index 83f25c6f1b..b5ad4374a0 100644
--- a/src/MaterialDesignThemes.Wpf/TabAssist.cs
+++ b/src/MaterialDesignThemes.Wpf/TabAssist.cs
@@ -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));
}
diff --git a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml
index e094e337cb..0819b32a48 100644
--- a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml
+++ b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml
@@ -1,6 +1,9 @@
@@ -36,7 +39,13 @@
wpf:ScrollViewerAssist.PaddingMode="{Binding Path=(wpf:ScrollViewerAssist.PaddingMode), RelativeSource={RelativeSource TemplatedParent}}"
HorizontalScrollBarVisibility="Hidden"
VerticalScrollBarVisibility="Hidden">
-
+
+
+
+
-
+
@@ -227,6 +236,10 @@
+
+
+
+
diff --git a/tests/MaterialDesignThemes.UITests/WPF/TabControls/TabControlTests.cs b/tests/MaterialDesignThemes.UITests/WPF/TabControls/TabControlTests.cs
index fdd0d7ffce..4b9188d5fa 100644
--- a/tests/MaterialDesignThemes.UITests/WPF/TabControls/TabControlTests.cs
+++ b/tests/MaterialDesignThemes.UITests/WPF/TabControls/TabControlTests.cs
@@ -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("");
+ for (int i = 1; i <= numTabs; i++)
+ {
+ xaml.Append($"""
+
+
+
+ """);
+ }
+ xaml.Append("");
+ IVisualElement tabControl = await LoadXaml(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("");
+ for (int i = 1; i <= numTabs; i++)
+ {
+ xaml.Append($"""
+
+
+
+ """);
+ }
+ xaml.Append("");
+ IVisualElement tabControl = await LoadXaml(xaml.ToString());
+
+ //Act
+
+ //Assert
+
+ recorder.Success();
+ }
}