From df185e4a88295509f4bfa61752daeebca2093ae6 Mon Sep 17 00:00:00 2001 From: Nicolai Henriksen Date: Tue, 9 Dec 2025 21:13:36 +0100 Subject: [PATCH 01/16] PoC for hijacking RequestBringIntoView event --- ...IntoViewHijackingVirtualizingStackPanel.cs | 19 +++++++++++++++++++ .../MaterialDesignTheme.TabControl.xaml | 3 ++- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingVirtualizingStackPanel.cs diff --git a/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingVirtualizingStackPanel.cs b/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingVirtualizingStackPanel.cs new file mode 100644 index 0000000000..9f9af73bb8 --- /dev/null +++ b/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingVirtualizingStackPanel.cs @@ -0,0 +1,19 @@ + +namespace MaterialDesignThemes.Wpf.Internal; + +public class BringIntoViewHijackingVirtualizingStackPanel : VirtualizingStackPanel +{ + public BringIntoViewHijackingVirtualizingStackPanel() + => AddHandler(FrameworkElement.RequestBringIntoViewEvent, new RoutedEventHandler(OnRequestBringIntoView), false); + + private void OnRequestBringIntoView(object sender, RoutedEventArgs e) + { + if (e.OriginalSource is FrameworkElement child && child != this) + { + e.Handled = true; + var point = child.TranslatePoint(new Point(), this); + var newTargetRect = new Rect(new Point(point.X + 52, point.Y), child.RenderSize); + BringIntoView(newTargetRect); + } + } +} diff --git a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml index e094e337cb..78edc13945 100644 --- a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml +++ b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml @@ -1,6 +1,7 @@  @@ -45,7 +46,7 @@ Focusable="False" KeyboardNavigation.TabIndex="1" Rows="1" /> - Date: Tue, 9 Dec 2025 21:42:55 +0100 Subject: [PATCH 02/16] Improves tab header scrolling behavior Adds a behavior to handle tab selection changes intended to adjust scrolling for better user experience. Introduces a custom panel to hijack the BringIntoView event, allowing for offsetting the Rect being scrolled to based on the tab selection direction. --- .../TabControlHeaderScrollBehavior.cs | 82 +++++++++++++++++++ .../BringIntoViewHijackingStackPanel.cs | 36 ++++++++ ...IntoViewHijackingVirtualizingStackPanel.cs | 19 ----- .../MaterialDesignTheme.TabControl.xaml | 11 ++- 4 files changed, 126 insertions(+), 22 deletions(-) create mode 100644 src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs create mode 100644 src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingStackPanel.cs delete mode 100644 src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingVirtualizingStackPanel.cs diff --git a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs new file mode 100644 index 0000000000..fda5acdd1b --- /dev/null +++ b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; +using Microsoft.Xaml.Behaviors; + +namespace MaterialDesignThemes.Wpf.Behaviors.Internal; + +public class TabControlHeaderScrollBehavior : Behavior +{ + public static readonly DependencyProperty TabScrollDirectionProperty = + DependencyProperty.RegisterAttached("TabScrollDirection", typeof(TabScrollDirection), typeof(TabControlHeaderScrollBehavior), new PropertyMetadata(TabScrollDirection.Unknown)); + public static TabScrollDirection GetTabScrollDirection(DependencyObject obj) => (TabScrollDirection)obj.GetValue(TabScrollDirectionProperty); + public static void SetTabScrollDirection(DependencyObject obj, TabScrollDirection value) => obj.SetValue(TabScrollDirectionProperty, 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; + } + if (e.NewValue is TabControl newTabControl) + { + newTabControl.SelectionChanged += behavior.OnTabChanged; + } + } + + private void OnTabChanged(object sender, SelectionChangedEventArgs e) + { + TabControl tabControl = (TabControl)sender; + + if (e.AddedItems.Count > 0) + { + SetTabScrollDirection(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); + } + + protected override void OnAttached() + { + base.OnAttached(); + AssociatedObject.ScrollChanged += AssociatedObject_ScrollChanged; + } + + protected override void OnDetaching() + { + base.OnDetaching(); + if (AssociatedObject is { } ao) + { + ao.ScrollChanged -= AssociatedObject_ScrollChanged; + } + } + + private void AssociatedObject_ScrollChanged(object sender, ScrollChangedEventArgs e) + { + Debug.WriteLine($"HorizontalOffset: {e.HorizontalOffset}, ViewportWidth: {e.ViewportWidth}, ExtentWidth: {e.ExtentWidth}"); + } +} + +public enum TabScrollDirection +{ + Unknown, + Backward, + Forward +} diff --git a/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingStackPanel.cs b/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingStackPanel.cs new file mode 100644 index 0000000000..ffc46a9a83 --- /dev/null +++ b/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingStackPanel.cs @@ -0,0 +1,36 @@ + +using MaterialDesignThemes.Wpf.Behaviors.Internal; + +namespace MaterialDesignThemes.Wpf.Internal; + +public class BringIntoViewHijackingStackPanel : StackPanel +{ + public TabScrollDirection TabScrollDirection + { + get => (TabScrollDirection)GetValue(TabScrollDirectionProperty); + set => SetValue(TabScrollDirectionProperty, value); + } + + public static readonly DependencyProperty TabScrollDirectionProperty = + DependencyProperty.Register(nameof(TabScrollDirection), typeof(TabScrollDirection), + typeof(BringIntoViewHijackingStackPanel), new PropertyMetadata(TabScrollDirection.Unknown)); + + public BringIntoViewHijackingStackPanel() + => AddHandler(FrameworkElement.RequestBringIntoViewEvent, new RoutedEventHandler(OnRequestBringIntoView), false); + + private void OnRequestBringIntoView(object sender, RoutedEventArgs e) + { + if (e.OriginalSource is FrameworkElement child && child != this) + { + e.Handled = true; + double offset = TabScrollDirection switch { + TabScrollDirection.Backward => -52, + TabScrollDirection.Forward => 52, + _ => 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/Internal/BringIntoViewHijackingVirtualizingStackPanel.cs b/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingVirtualizingStackPanel.cs deleted file mode 100644 index 9f9af73bb8..0000000000 --- a/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingVirtualizingStackPanel.cs +++ /dev/null @@ -1,19 +0,0 @@ - -namespace MaterialDesignThemes.Wpf.Internal; - -public class BringIntoViewHijackingVirtualizingStackPanel : VirtualizingStackPanel -{ - public BringIntoViewHijackingVirtualizingStackPanel() - => AddHandler(FrameworkElement.RequestBringIntoViewEvent, new RoutedEventHandler(OnRequestBringIntoView), false); - - private void OnRequestBringIntoView(object sender, RoutedEventArgs e) - { - if (e.OriginalSource is FrameworkElement child && child != this) - { - e.Handled = true; - var point = child.TranslatePoint(new Point(), this); - var newTargetRect = new Rect(new Point(point.X + 52, point.Y), child.RenderSize); - BringIntoView(newTargetRect); - } - } -} diff --git a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml index 78edc13945..e2e5bcb315 100644 --- a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml +++ b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml @@ -2,6 +2,8 @@ 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"> @@ -37,7 +39,10 @@ wpf:ScrollViewerAssist.PaddingMode="{Binding Path=(wpf:ScrollViewerAssist.PaddingMode), RelativeSource={RelativeSource TemplatedParent}}" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden"> - + + + + - - + From 05111176fc49d568be7be1c5e0d7131489e6cc9e Mon Sep 17 00:00:00 2001 From: Nicolai Henriksen Date: Tue, 9 Dec 2025 21:52:37 +0100 Subject: [PATCH 03/16] Adding smooth/animated scrolling to the behavior --- .../TabControlHeaderScrollBehavior.cs | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs index fda5acdd1b..ef662675f5 100644 --- a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs +++ b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs @@ -1,12 +1,25 @@ 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 TabScrollDirectionProperty = - DependencyProperty.RegisterAttached("TabScrollDirection", typeof(TabScrollDirection), typeof(TabControlHeaderScrollBehavior), new PropertyMetadata(TabScrollDirection.Unknown)); + DependencyProperty.RegisterAttached("TabScrollDirection", typeof(TabScrollDirection), + typeof(TabControlHeaderScrollBehavior), new PropertyMetadata(TabScrollDirection.Unknown)); public static TabScrollDirection GetTabScrollDirection(DependencyObject obj) => (TabScrollDirection)obj.GetValue(TabScrollDirectionProperty); public static void SetTabScrollDirection(DependencyObject obj, TabScrollDirection value) => obj.SetValue(TabScrollDirectionProperty, value); @@ -33,12 +46,16 @@ private static void OnTabControlChanged(DependencyObject d, DependencyPropertyCh } } + private double? _desiredScrollStart; + private bool _isAnimatingScroll; + private void OnTabChanged(object sender, SelectionChangedEventArgs e) { TabControl tabControl = (TabControl)sender; if (e.AddedItems.Count > 0) { + _desiredScrollStart = AssociatedObject.ContentHorizontalOffset; SetTabScrollDirection(tabControl, (IsMovingForward() ? TabScrollDirection.Forward : TabScrollDirection.Backward)); } @@ -70,7 +87,23 @@ protected override void OnDetaching() private void AssociatedObject_ScrollChanged(object sender, ScrollChangedEventArgs e) { - Debug.WriteLine($"HorizontalOffset: {e.HorizontalOffset}, ViewportWidth: {e.ViewportWidth}, ExtentWidth: {e.ExtentWidth}"); + Debug.WriteLine($"ContentHorizontalOffset: {AssociatedObject.ContentHorizontalOffset}, HorizontalOffset: {e.HorizontalOffset}, HorizontalChange: {e.HorizontalChange}, ViewportWidth: {e.ViewportWidth}, ExtentWidth: {e.ExtentWidth}"); + if (_isAnimatingScroll || _desiredScrollStart is not { } desiredOffsetStart) return; + + double originalValue = desiredOffsetStart; + double newValue = e.HorizontalOffset; + + _isAnimatingScroll = true; + AssociatedObject.ScrollToHorizontalOffset(originalValue); + Debug.WriteLine($"Initiating animated scroll from {originalValue} to {newValue}. Change is: {e.HorizontalChange}"); + DoubleAnimation scrollAnimation = new(originalValue, newValue, new Duration(TimeSpan.FromMilliseconds(250))); + scrollAnimation.Completed += (_, _) => + { + Debug.WriteLine("Animation completed"); + _desiredScrollStart = null; + _isAnimatingScroll = false; + }; + AssociatedObject.BeginAnimation(TabControlHeaderScrollBehavior.CustomHorizontalOffsetProperty, scrollAnimation); } } From 721a25ccffc3a8e6425090eab3a4ab03bd310591 Mon Sep 17 00:00:00 2001 From: Nicolai Henriksen Date: Tue, 9 Dec 2025 22:27:47 +0100 Subject: [PATCH 04/16] Ensure padding on tab headers is only applied when they overflow There is no need for the padding if all tabs can fit inside the visible region. --- .../TabControlHeaderScrollBehavior.cs | 44 +++++++++++++++++++ .../BringIntoViewHijackingStackPanel.cs | 4 +- .../MaterialDesignTheme.TabControl.xaml | 4 +- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs index ef662675f5..b31951280b 100644 --- a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs +++ b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs @@ -33,19 +33,42 @@ public TabControl TabControl 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(); + } + + internal const double ScrollOffset = 40; // MD spec says 52 DP, but that seems a little excessive in practice + internal static readonly Thickness ScrollableContentPadding = new(ScrollOffset, 0, ScrollOffset, 0); + internal static readonly Thickness NoScrollableContentPadding = new(0); + private double? _desiredScrollStart; private bool _isAnimatingScroll; @@ -70,10 +93,30 @@ bool IsMovingForward() 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 (ScrollableContent is null) + return; + + if (ScrollableContent.ActualWidth > TabControl.ActualWidth) + { + ScrollableContent.Margin = ScrollableContentPadding; + } + else + { + ScrollableContent.Margin = NoScrollableContentPadding; + } + } + protected override void OnAttached() { base.OnAttached(); AssociatedObject.ScrollChanged += AssociatedObject_ScrollChanged; + AssociatedObject.SizeChanged += AssociatedObject_SizeChanged; + Dispatcher.BeginInvoke(() => AddPaddingToScrollableContentIfWiderThanViewPort()); } protected override void OnDetaching() @@ -82,6 +125,7 @@ protected override void OnDetaching() if (AssociatedObject is { } ao) { ao.ScrollChanged -= AssociatedObject_ScrollChanged; + ao.SizeChanged -= AssociatedObject_SizeChanged; } } diff --git a/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingStackPanel.cs b/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingStackPanel.cs index ffc46a9a83..7475616c0e 100644 --- a/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingStackPanel.cs +++ b/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingStackPanel.cs @@ -24,8 +24,8 @@ private void OnRequestBringIntoView(object sender, RoutedEventArgs e) { e.Handled = true; double offset = TabScrollDirection switch { - TabScrollDirection.Backward => -52, - TabScrollDirection.Forward => 52, + TabScrollDirection.Backward => -TabControlHeaderScrollBehavior.ScrollOffset, + TabScrollDirection.Forward => TabControlHeaderScrollBehavior.ScrollOffset, _ => 0 }; var point = child.TranslatePoint(new Point(), this); diff --git a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml index e2e5bcb315..1fc07a6980 100644 --- a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml +++ b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml @@ -40,9 +40,9 @@ HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden"> - + - + Date: Wed, 10 Dec 2025 10:25:45 +0100 Subject: [PATCH 05/16] Adding UI tests without assertions to easily test behavior The intention is to eventually add some assertions, but we'll need to figure out what is relevant to assert on. --- .../WPF/TabControls/TabControlTests.cs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) 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(); + } } From 9cace119fb36b407d51aaee77b1a867d2266ba7e Mon Sep 17 00:00:00 2001 From: Nicolai Henriksen Date: Wed, 10 Dec 2025 10:33:44 +0100 Subject: [PATCH 06/16] Minor hack! Prevent double-click on tab (control) while animating Without this hack, if the user double-clicks on a tab that is partially out of view, it will not scroll fully into view (with the desired additional "padding"). It seems to stop the animation prematurely and leave the TabControl headers in a undesirable state. This minor hack prevents this behavior. --- .../Behaviors/Internal/TabControlHeaderScrollBehavior.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs index b31951280b..109619f8de 100644 --- a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs +++ b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs @@ -138,6 +138,11 @@ private void AssociatedObject_ScrollChanged(object sender, ScrollChangedEventArg 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); Debug.WriteLine($"Initiating animated scroll from {originalValue} to {newValue}. Change is: {e.HorizontalChange}"); DoubleAnimation scrollAnimation = new(originalValue, newValue, new Duration(TimeSpan.FromMilliseconds(250))); @@ -146,6 +151,9 @@ private void AssociatedObject_ScrollChanged(object sender, ScrollChangedEventArg Debug.WriteLine("Animation 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); } From 7c0c9ee88a2ce8360445554a2b563cf3094fff77 Mon Sep 17 00:00:00 2001 From: Nicolai Henriksen Date: Wed, 10 Dec 2025 10:47:22 +0100 Subject: [PATCH 07/16] Add TabAssist.AnimateTabScrolling to toggle tab switch animation feature Defaulted to True in a style setter allowing for easy override at the call site. --- .../Behaviors/Internal/TabControlHeaderScrollBehavior.cs | 6 +++++- src/MaterialDesignThemes.Wpf/TabAssist.cs | 9 +++++++++ .../Themes/MaterialDesignTheme.TabControl.xaml | 1 + 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs index 109619f8de..d616349e4b 100644 --- a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs +++ b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs @@ -132,7 +132,11 @@ protected override void OnDetaching() private void AssociatedObject_ScrollChanged(object sender, ScrollChangedEventArgs e) { Debug.WriteLine($"ContentHorizontalOffset: {AssociatedObject.ContentHorizontalOffset}, HorizontalOffset: {e.HorizontalOffset}, HorizontalChange: {e.HorizontalChange}, ViewportWidth: {e.ViewportWidth}, ExtentWidth: {e.ExtentWidth}"); - if (_isAnimatingScroll || _desiredScrollStart is not { } desiredOffsetStart) return; + bool useAnimation = TabAssist.GetAnimateTabScrolling(TabControl); + if (!useAnimation) + return; + if ( _isAnimatingScroll || _desiredScrollStart is not { } desiredOffsetStart) + return; double originalValue = desiredOffsetStart; double newValue = e.HorizontalOffset; diff --git a/src/MaterialDesignThemes.Wpf/TabAssist.cs b/src/MaterialDesignThemes.Wpf/TabAssist.cs index 83f25c6f1b..6b69df1547 100644 --- a/src/MaterialDesignThemes.Wpf/TabAssist.cs +++ b/src/MaterialDesignThemes.Wpf/TabAssist.cs @@ -69,4 +69,13 @@ 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 bool GetAnimateTabScrolling(DependencyObject obj) + => (bool)obj.GetValue(AnimateTabScrollingProperty); + + public static void SetAnimateTabScrolling(DependencyObject obj, bool value) + => obj.SetValue(AnimateTabScrollingProperty, value); + + public static readonly DependencyProperty AnimateTabScrollingProperty = + DependencyProperty.RegisterAttached("AnimateTabScrolling", typeof(bool), typeof(TabAssist), new PropertyMetadata(false)); } diff --git a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml index 1fc07a6980..c0a51d97cb 100644 --- a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml +++ b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml @@ -233,6 +233,7 @@ + From c27cc43ca852ac72682300addaf4780d9f1f004a Mon Sep 17 00:00:00 2001 From: Nicolai Henriksen Date: Wed, 10 Dec 2025 10:50:33 +0100 Subject: [PATCH 08/16] Add TODO comment regarding destructive-read on TabScrollDirection AP --- .../Internal/BringIntoViewHijackingStackPanel.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingStackPanel.cs b/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingStackPanel.cs index 7475616c0e..3af6b091ed 100644 --- a/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingStackPanel.cs +++ b/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingStackPanel.cs @@ -23,6 +23,8 @@ private void OnRequestBringIntoView(object sender, RoutedEventArgs e) if (e.OriginalSource is FrameworkElement child && child != this) { e.Handled = true; + + // TODO: Consider making the "TabScrollDirection" 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 = TabScrollDirection switch { TabScrollDirection.Backward => -TabControlHeaderScrollBehavior.ScrollOffset, TabScrollDirection.Forward => TabControlHeaderScrollBehavior.ScrollOffset, From d18d4d4ef789b7a39e3b5e12745b59c40f5f32ae Mon Sep 17 00:00:00 2001 From: Nicolai Henriksen Date: Wed, 10 Dec 2025 11:03:11 +0100 Subject: [PATCH 09/16] Add TabAssist.TabScrollOffset to give control of the offset Default value (40, although spec says 52) is set in style to allow easy override at the call site. --- .../Internal/TabControlHeaderScrollBehavior.cs | 9 +++------ .../Internal/BringIntoViewHijackingStackPanel.cs | 14 ++++++++++++-- src/MaterialDesignThemes.Wpf/TabAssist.cs | 10 ++++++++++ .../Themes/MaterialDesignTheme.TabControl.xaml | 6 +++++- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs index d616349e4b..638447f809 100644 --- a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs +++ b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs @@ -65,10 +65,6 @@ private static void OnScrollableContentChanged(DependencyObject d, DependencyPro behavior.AddPaddingToScrollableContentIfWiderThanViewPort(); } - internal const double ScrollOffset = 40; // MD spec says 52 DP, but that seems a little excessive in practice - internal static readonly Thickness ScrollableContentPadding = new(ScrollOffset, 0, ScrollOffset, 0); - internal static readonly Thickness NoScrollableContentPadding = new(0); - private double? _desiredScrollStart; private bool _isAnimatingScroll; @@ -103,11 +99,12 @@ private void AddPaddingToScrollableContentIfWiderThanViewPort() if (ScrollableContent.ActualWidth > TabControl.ActualWidth) { - ScrollableContent.Margin = ScrollableContentPadding; + double offset = TabAssist.GetTabScrollOffset(TabControl); + ScrollableContent.Margin = new(offset, 0, offset, 0); } else { - ScrollableContent.Margin = NoScrollableContentPadding; + ScrollableContent.Margin = new(); } } diff --git a/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingStackPanel.cs b/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingStackPanel.cs index 3af6b091ed..4aa89ce83d 100644 --- a/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingStackPanel.cs +++ b/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingStackPanel.cs @@ -15,6 +15,16 @@ public TabScrollDirection TabScrollDirection DependencyProperty.Register(nameof(TabScrollDirection), typeof(TabScrollDirection), typeof(BringIntoViewHijackingStackPanel), new PropertyMetadata(TabScrollDirection.Unknown)); + public double TabScrollOffset + { + get => (double)GetValue(TabScrollOffsetProperty); + set => SetValue(TabScrollOffsetProperty, value); + } + + public static readonly DependencyProperty TabScrollOffsetProperty = + DependencyProperty.Register(nameof(TabScrollOffset), + typeof(double), typeof(BringIntoViewHijackingStackPanel), new PropertyMetadata(0d)); + public BringIntoViewHijackingStackPanel() => AddHandler(FrameworkElement.RequestBringIntoViewEvent, new RoutedEventHandler(OnRequestBringIntoView), false); @@ -26,8 +36,8 @@ private void OnRequestBringIntoView(object sender, RoutedEventArgs e) // TODO: Consider making the "TabScrollDirection" 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 = TabScrollDirection switch { - TabScrollDirection.Backward => -TabControlHeaderScrollBehavior.ScrollOffset, - TabScrollDirection.Forward => TabControlHeaderScrollBehavior.ScrollOffset, + TabScrollDirection.Backward => -TabScrollOffset, + TabScrollDirection.Forward => TabScrollOffset, _ => 0 }; var point = child.TranslatePoint(new Point(), this); diff --git a/src/MaterialDesignThemes.Wpf/TabAssist.cs b/src/MaterialDesignThemes.Wpf/TabAssist.cs index 6b69df1547..5b1d54b96e 100644 --- a/src/MaterialDesignThemes.Wpf/TabAssist.cs +++ b/src/MaterialDesignThemes.Wpf/TabAssist.cs @@ -78,4 +78,14 @@ public static void SetAnimateTabScrolling(DependencyObject obj, bool value) public static readonly DependencyProperty AnimateTabScrollingProperty = DependencyProperty.RegisterAttached("AnimateTabScrolling", typeof(bool), typeof(TabAssist), new PropertyMetadata(false)); + + public static double GetTabScrollOffset(DependencyObject obj) + => (double)obj.GetValue(TabScrollOffsetProperty); + + public static void SetTabScrollOffset(DependencyObject obj, double value) + => obj.SetValue(TabScrollOffsetProperty, value); + + public static readonly DependencyProperty TabScrollOffsetProperty = + DependencyProperty.RegisterAttached("TabScrollOffset", typeof(double), + typeof(TabAssist), new PropertyMetadata(0d)); } diff --git a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml index c0a51d97cb..ac81904829 100644 --- a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml +++ b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml @@ -42,7 +42,9 @@ - + + + From 4a960c0bb98ceda50a382740b9f003e5c234a14e Mon Sep 17 00:00:00 2001 From: Nicolai Henriksen Date: Wed, 10 Dec 2025 17:17:05 +0100 Subject: [PATCH 10/16] Replace TabAssist.AnimateTabScrolling with TabAssist.TabScrollDuration Setting a duration of TimeSpan.Zero, effectively disables the animated scrolling. --- .../TabControlHeaderScrollBehavior.cs | 6 +++--- src/MaterialDesignThemes.Wpf/TabAssist.cs | 19 ++++++++++--------- .../MaterialDesignTheme.TabControl.xaml | 2 +- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs index 638447f809..1fc998f268 100644 --- a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs +++ b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs @@ -129,8 +129,8 @@ protected override void OnDetaching() private void AssociatedObject_ScrollChanged(object sender, ScrollChangedEventArgs e) { Debug.WriteLine($"ContentHorizontalOffset: {AssociatedObject.ContentHorizontalOffset}, HorizontalOffset: {e.HorizontalOffset}, HorizontalChange: {e.HorizontalChange}, ViewportWidth: {e.ViewportWidth}, ExtentWidth: {e.ExtentWidth}"); - bool useAnimation = TabAssist.GetAnimateTabScrolling(TabControl); - if (!useAnimation) + TimeSpan duration = TabAssist.GetTabScrollDuration(TabControl); + if (duration == TimeSpan.Zero) return; if ( _isAnimatingScroll || _desiredScrollStart is not { } desiredOffsetStart) return; @@ -146,7 +146,7 @@ private void AssociatedObject_ScrollChanged(object sender, ScrollChangedEventArg AssociatedObject.ScrollToHorizontalOffset(originalValue); Debug.WriteLine($"Initiating animated scroll from {originalValue} to {newValue}. Change is: {e.HorizontalChange}"); - DoubleAnimation scrollAnimation = new(originalValue, newValue, new Duration(TimeSpan.FromMilliseconds(250))); + DoubleAnimation scrollAnimation = new(originalValue, newValue, new Duration(duration)); scrollAnimation.Completed += (_, _) => { Debug.WriteLine("Animation completed"); diff --git a/src/MaterialDesignThemes.Wpf/TabAssist.cs b/src/MaterialDesignThemes.Wpf/TabAssist.cs index 5b1d54b96e..7b7e7deebb 100644 --- a/src/MaterialDesignThemes.Wpf/TabAssist.cs +++ b/src/MaterialDesignThemes.Wpf/TabAssist.cs @@ -70,15 +70,6 @@ public static void SetHeaderBehavior(DependencyObject obj, TabControlHeaderBehav DependencyProperty.RegisterAttached("HeaderBehavior", typeof(TabControlHeaderBehavior), typeof(TabAssist), new PropertyMetadata(TabControlHeaderBehavior.Scrolling)); - public static bool GetAnimateTabScrolling(DependencyObject obj) - => (bool)obj.GetValue(AnimateTabScrollingProperty); - - public static void SetAnimateTabScrolling(DependencyObject obj, bool value) - => obj.SetValue(AnimateTabScrollingProperty, value); - - public static readonly DependencyProperty AnimateTabScrollingProperty = - DependencyProperty.RegisterAttached("AnimateTabScrolling", typeof(bool), typeof(TabAssist), new PropertyMetadata(false)); - public static double GetTabScrollOffset(DependencyObject obj) => (double)obj.GetValue(TabScrollOffsetProperty); @@ -88,4 +79,14 @@ public static void SetTabScrollOffset(DependencyObject obj, double value) public static readonly DependencyProperty TabScrollOffsetProperty = DependencyProperty.RegisterAttached("TabScrollOffset", typeof(double), typeof(TabAssist), new PropertyMetadata(0d)); + + public static TimeSpan GetTabScrollDuration(DependencyObject obj) + => (TimeSpan)obj.GetValue(TabScrollDurationProperty); + + public static void SetTabScrollDuration(DependencyObject obj, TimeSpan value) + => obj.SetValue(TabScrollDurationProperty, value); + + public static readonly DependencyProperty TabScrollDurationProperty = + DependencyProperty.RegisterAttached("TabScrollDuration", 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 ac81904829..86fed7b317 100644 --- a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml +++ b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml @@ -235,9 +235,9 @@ - + From 9631e7a66c774469706d82e10beaa65dcfad93a7 Mon Sep 17 00:00:00 2001 From: Nicolai Henriksen Date: Sun, 14 Dec 2025 20:37:41 +0100 Subject: [PATCH 11/16] Rename hijacking StackPanel --- ...kingStackPanel.cs => PaddedBringIntoViewStackPanel.cs} | 8 ++++---- .../Themes/MaterialDesignTheme.TabControl.xaml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) rename src/MaterialDesignThemes.Wpf/Internal/{BringIntoViewHijackingStackPanel.cs => PaddedBringIntoViewStackPanel.cs} (84%) diff --git a/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingStackPanel.cs b/src/MaterialDesignThemes.Wpf/Internal/PaddedBringIntoViewStackPanel.cs similarity index 84% rename from src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingStackPanel.cs rename to src/MaterialDesignThemes.Wpf/Internal/PaddedBringIntoViewStackPanel.cs index 4aa89ce83d..5071380538 100644 --- a/src/MaterialDesignThemes.Wpf/Internal/BringIntoViewHijackingStackPanel.cs +++ b/src/MaterialDesignThemes.Wpf/Internal/PaddedBringIntoViewStackPanel.cs @@ -3,7 +3,7 @@ namespace MaterialDesignThemes.Wpf.Internal; -public class BringIntoViewHijackingStackPanel : StackPanel +public class PaddedBringIntoViewStackPanel : StackPanel { public TabScrollDirection TabScrollDirection { @@ -13,7 +13,7 @@ public TabScrollDirection TabScrollDirection public static readonly DependencyProperty TabScrollDirectionProperty = DependencyProperty.Register(nameof(TabScrollDirection), typeof(TabScrollDirection), - typeof(BringIntoViewHijackingStackPanel), new PropertyMetadata(TabScrollDirection.Unknown)); + typeof(PaddedBringIntoViewStackPanel), new PropertyMetadata(TabScrollDirection.Unknown)); public double TabScrollOffset { @@ -23,9 +23,9 @@ public double TabScrollOffset public static readonly DependencyProperty TabScrollOffsetProperty = DependencyProperty.Register(nameof(TabScrollOffset), - typeof(double), typeof(BringIntoViewHijackingStackPanel), new PropertyMetadata(0d)); + typeof(double), typeof(PaddedBringIntoViewStackPanel), new PropertyMetadata(0d)); - public BringIntoViewHijackingStackPanel() + public PaddedBringIntoViewStackPanel() => AddHandler(FrameworkElement.RequestBringIntoViewEvent, new RoutedEventHandler(OnRequestBringIntoView), false); private void OnRequestBringIntoView(object sender, RoutedEventArgs e) diff --git a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml index 86fed7b317..404fe0134a 100644 --- a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml +++ b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml @@ -42,9 +42,9 @@ - + - + From f001a07781354c6acd9c4d103775a687748a77d8 Mon Sep 17 00:00:00 2001 From: Nicolai Henriksen Date: Sun, 14 Dec 2025 20:40:12 +0100 Subject: [PATCH 12/16] Rename TabScrollDirection --- .../Internal/TabControlHeaderScrollBehavior.cs | 12 ++++++------ .../Internal/PaddedBringIntoViewStackPanel.cs | 14 +++++++------- .../Themes/MaterialDesignTheme.TabControl.xaml | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs index 1fc998f268..860df81390 100644 --- a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs +++ b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Windows.Media.Animation; using Microsoft.Xaml.Behaviors; @@ -17,11 +17,11 @@ private static void CustomHorizontalOffsetChanged(DependencyObject d, Dependency scrollViewer.ScrollToHorizontalOffset((double)e.NewValue); } - public static readonly DependencyProperty TabScrollDirectionProperty = - DependencyProperty.RegisterAttached("TabScrollDirection", typeof(TabScrollDirection), + public static readonly DependencyProperty ScrollDirectionProperty = + DependencyProperty.RegisterAttached("ScrollDirection", typeof(TabScrollDirection), typeof(TabControlHeaderScrollBehavior), new PropertyMetadata(TabScrollDirection.Unknown)); - public static TabScrollDirection GetTabScrollDirection(DependencyObject obj) => (TabScrollDirection)obj.GetValue(TabScrollDirectionProperty); - public static void SetTabScrollDirection(DependencyObject obj, TabScrollDirection value) => obj.SetValue(TabScrollDirectionProperty, value); + 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 { @@ -75,7 +75,7 @@ private void OnTabChanged(object sender, SelectionChangedEventArgs e) if (e.AddedItems.Count > 0) { _desiredScrollStart = AssociatedObject.ContentHorizontalOffset; - SetTabScrollDirection(tabControl, (IsMovingForward() ? TabScrollDirection.Forward : TabScrollDirection.Backward)); + SetScrollDirection(tabControl, (IsMovingForward() ? TabScrollDirection.Forward : TabScrollDirection.Backward)); } bool IsMovingForward() diff --git a/src/MaterialDesignThemes.Wpf/Internal/PaddedBringIntoViewStackPanel.cs b/src/MaterialDesignThemes.Wpf/Internal/PaddedBringIntoViewStackPanel.cs index 5071380538..f4116108ca 100644 --- a/src/MaterialDesignThemes.Wpf/Internal/PaddedBringIntoViewStackPanel.cs +++ b/src/MaterialDesignThemes.Wpf/Internal/PaddedBringIntoViewStackPanel.cs @@ -5,14 +5,14 @@ namespace MaterialDesignThemes.Wpf.Internal; public class PaddedBringIntoViewStackPanel : StackPanel { - public TabScrollDirection TabScrollDirection + public TabScrollDirection ScrollDirection { - get => (TabScrollDirection)GetValue(TabScrollDirectionProperty); - set => SetValue(TabScrollDirectionProperty, value); + get => (TabScrollDirection)GetValue(ScrollDirectionProperty); + set => SetValue(ScrollDirectionProperty, value); } - public static readonly DependencyProperty TabScrollDirectionProperty = - DependencyProperty.Register(nameof(TabScrollDirection), typeof(TabScrollDirection), + public static readonly DependencyProperty ScrollDirectionProperty = + DependencyProperty.Register(nameof(ScrollDirection), typeof(TabScrollDirection), typeof(PaddedBringIntoViewStackPanel), new PropertyMetadata(TabScrollDirection.Unknown)); public double TabScrollOffset @@ -34,8 +34,8 @@ private void OnRequestBringIntoView(object sender, RoutedEventArgs e) { e.Handled = true; - // TODO: Consider making the "TabScrollDirection" 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 = TabScrollDirection switch { + // 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 => -TabScrollOffset, TabScrollDirection.Forward => TabScrollOffset, _ => 0 diff --git a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml index 404fe0134a..c44508ea50 100644 --- a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml +++ b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml @@ -43,7 +43,7 @@ Date: Sun, 14 Dec 2025 20:49:10 +0100 Subject: [PATCH 13/16] Rename TabScrollOffset --- .../Internal/TabControlHeaderScrollBehavior.cs | 6 +++--- .../Internal/PaddedBringIntoViewStackPanel.cs | 14 +++++++------- src/MaterialDesignThemes.Wpf/TabAssist.cs | 12 ++++++------ .../Themes/MaterialDesignTheme.TabControl.xaml | 4 ++-- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs index 860df81390..ea7d208940 100644 --- a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs +++ b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Windows.Media.Animation; using Microsoft.Xaml.Behaviors; @@ -70,7 +70,7 @@ private static void OnScrollableContentChanged(DependencyObject d, DependencyPro private void OnTabChanged(object sender, SelectionChangedEventArgs e) { - TabControl tabControl = (TabControl)sender; + var tabControl = (TabControl)sender; if (e.AddedItems.Count > 0) { @@ -99,7 +99,7 @@ private void AddPaddingToScrollableContentIfWiderThanViewPort() if (ScrollableContent.ActualWidth > TabControl.ActualWidth) { - double offset = TabAssist.GetTabScrollOffset(TabControl); + double offset = TabAssist.GetHeaderPadding(TabControl); ScrollableContent.Margin = new(offset, 0, offset, 0); } else diff --git a/src/MaterialDesignThemes.Wpf/Internal/PaddedBringIntoViewStackPanel.cs b/src/MaterialDesignThemes.Wpf/Internal/PaddedBringIntoViewStackPanel.cs index f4116108ca..93d3164c27 100644 --- a/src/MaterialDesignThemes.Wpf/Internal/PaddedBringIntoViewStackPanel.cs +++ b/src/MaterialDesignThemes.Wpf/Internal/PaddedBringIntoViewStackPanel.cs @@ -15,14 +15,14 @@ public TabScrollDirection ScrollDirection DependencyProperty.Register(nameof(ScrollDirection), typeof(TabScrollDirection), typeof(PaddedBringIntoViewStackPanel), new PropertyMetadata(TabScrollDirection.Unknown)); - public double TabScrollOffset + public double HeaderPadding { - get => (double)GetValue(TabScrollOffsetProperty); - set => SetValue(TabScrollOffsetProperty, value); + get => (double)GetValue(HeaderPaddingProperty); + set => SetValue(HeaderPaddingProperty, value); } - public static readonly DependencyProperty TabScrollOffsetProperty = - DependencyProperty.Register(nameof(TabScrollOffset), + public static readonly DependencyProperty HeaderPaddingProperty = + DependencyProperty.Register(nameof(HeaderPadding), typeof(double), typeof(PaddedBringIntoViewStackPanel), new PropertyMetadata(0d)); public PaddedBringIntoViewStackPanel() @@ -36,8 +36,8 @@ private void OnRequestBringIntoView(object sender, RoutedEventArgs e) // 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 => -TabScrollOffset, - TabScrollDirection.Forward => TabScrollOffset, + TabScrollDirection.Backward => -HeaderPadding, + TabScrollDirection.Forward => HeaderPadding, _ => 0 }; var point = child.TranslatePoint(new Point(), this); diff --git a/src/MaterialDesignThemes.Wpf/TabAssist.cs b/src/MaterialDesignThemes.Wpf/TabAssist.cs index 7b7e7deebb..315d00dd5b 100644 --- a/src/MaterialDesignThemes.Wpf/TabAssist.cs +++ b/src/MaterialDesignThemes.Wpf/TabAssist.cs @@ -70,14 +70,14 @@ public static void SetHeaderBehavior(DependencyObject obj, TabControlHeaderBehav DependencyProperty.RegisterAttached("HeaderBehavior", typeof(TabControlHeaderBehavior), typeof(TabAssist), new PropertyMetadata(TabControlHeaderBehavior.Scrolling)); - public static double GetTabScrollOffset(DependencyObject obj) - => (double)obj.GetValue(TabScrollOffsetProperty); + public static double GetHeaderPadding(DependencyObject obj) + => (double)obj.GetValue(HeaderPaddingProperty); - public static void SetTabScrollOffset(DependencyObject obj, double value) - => obj.SetValue(TabScrollOffsetProperty, value); + public static void SetHeaderPadding(DependencyObject obj, double value) + => obj.SetValue(HeaderPaddingProperty, value); - public static readonly DependencyProperty TabScrollOffsetProperty = - DependencyProperty.RegisterAttached("TabScrollOffset", typeof(double), + public static readonly DependencyProperty HeaderPaddingProperty = + DependencyProperty.RegisterAttached("HeaderPadding", typeof(double), typeof(TabAssist), new PropertyMetadata(0d)); public static TimeSpan GetTabScrollDuration(DependencyObject obj) diff --git a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml index c44508ea50..40d74cd585 100644 --- a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml +++ b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml @@ -44,7 +44,7 @@ + HeaderPadding="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=(wpf:TabAssist.HeaderPadding)}"> - + From eb5d42ddca564f930b6bcf9e60dcbb71780ee9a7 Mon Sep 17 00:00:00 2001 From: Nicolai Henriksen Date: Sun, 14 Dec 2025 20:51:44 +0100 Subject: [PATCH 14/16] Rename TabScrollDuration --- .../Internal/TabControlHeaderScrollBehavior.cs | 2 +- src/MaterialDesignThemes.Wpf/TabAssist.cs | 12 ++++++------ .../Themes/MaterialDesignTheme.TabControl.xaml | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs index ea7d208940..dfd69ca687 100644 --- a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs +++ b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs @@ -129,7 +129,7 @@ protected override void OnDetaching() private void AssociatedObject_ScrollChanged(object sender, ScrollChangedEventArgs e) { Debug.WriteLine($"ContentHorizontalOffset: {AssociatedObject.ContentHorizontalOffset}, HorizontalOffset: {e.HorizontalOffset}, HorizontalChange: {e.HorizontalChange}, ViewportWidth: {e.ViewportWidth}, ExtentWidth: {e.ExtentWidth}"); - TimeSpan duration = TabAssist.GetTabScrollDuration(TabControl); + TimeSpan duration = TabAssist.GetScrollDuration(TabControl); if (duration == TimeSpan.Zero) return; if ( _isAnimatingScroll || _desiredScrollStart is not { } desiredOffsetStart) diff --git a/src/MaterialDesignThemes.Wpf/TabAssist.cs b/src/MaterialDesignThemes.Wpf/TabAssist.cs index 315d00dd5b..5663e3685d 100644 --- a/src/MaterialDesignThemes.Wpf/TabAssist.cs +++ b/src/MaterialDesignThemes.Wpf/TabAssist.cs @@ -80,13 +80,13 @@ public static void SetHeaderPadding(DependencyObject obj, double value) DependencyProperty.RegisterAttached("HeaderPadding", typeof(double), typeof(TabAssist), new PropertyMetadata(0d)); - public static TimeSpan GetTabScrollDuration(DependencyObject obj) - => (TimeSpan)obj.GetValue(TabScrollDurationProperty); + public static TimeSpan GetScrollDuration(DependencyObject obj) + => (TimeSpan)obj.GetValue(ScrollDurationProperty); - public static void SetTabScrollDuration(DependencyObject obj, TimeSpan value) - => obj.SetValue(TabScrollDurationProperty, value); + public static void SetScrollDuration(DependencyObject obj, TimeSpan value) + => obj.SetValue(ScrollDurationProperty, value); - public static readonly DependencyProperty TabScrollDurationProperty = - DependencyProperty.RegisterAttached("TabScrollDuration", typeof(TimeSpan), + 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 40d74cd585..176c63a45f 100644 --- a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml +++ b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml @@ -237,7 +237,7 @@ - + From 966c1e87cc1d5c2c19fe8ffb357d39039d4d93ed Mon Sep 17 00:00:00 2001 From: Nicolai Henriksen Date: Sun, 14 Dec 2025 20:53:04 +0100 Subject: [PATCH 15/16] Remove debug output --- .../Behaviors/Internal/TabControlHeaderScrollBehavior.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs index dfd69ca687..4fd4b098fb 100644 --- a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs +++ b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs @@ -128,7 +128,6 @@ protected override void OnDetaching() private void AssociatedObject_ScrollChanged(object sender, ScrollChangedEventArgs e) { - Debug.WriteLine($"ContentHorizontalOffset: {AssociatedObject.ContentHorizontalOffset}, HorizontalOffset: {e.HorizontalOffset}, HorizontalChange: {e.HorizontalChange}, ViewportWidth: {e.ViewportWidth}, ExtentWidth: {e.ExtentWidth}"); TimeSpan duration = TabAssist.GetScrollDuration(TabControl); if (duration == TimeSpan.Zero) return; @@ -137,7 +136,6 @@ private void AssociatedObject_ScrollChanged(object sender, ScrollChangedEventArg 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. @@ -145,11 +143,9 @@ private void AssociatedObject_ScrollChanged(object sender, ScrollChangedEventArg TabControl.SetCurrentValue(FrameworkElement.IsHitTestVisibleProperty, false); AssociatedObject.ScrollToHorizontalOffset(originalValue); - Debug.WriteLine($"Initiating animated scroll from {originalValue} to {newValue}. Change is: {e.HorizontalChange}"); DoubleAnimation scrollAnimation = new(originalValue, newValue, new Duration(duration)); scrollAnimation.Completed += (_, _) => { - Debug.WriteLine("Animation completed"); _desiredScrollStart = null; _isAnimatingScroll = false; From a604ccff641edd84cfe8b8ef0cda0b6f3ac6ed13 Mon Sep 17 00:00:00 2001 From: Nicolai Henriksen Date: Sun, 14 Dec 2025 21:21:26 +0100 Subject: [PATCH 16/16] Add TabAssist.UseHeaderPadding to enable/disable new behavior The feature is on by default --- .../Internal/TabControlHeaderScrollBehavior.cs | 4 ++++ .../Internal/PaddedBringIntoViewStackPanel.cs | 12 ++++++++++++ src/MaterialDesignThemes.Wpf/TabAssist.cs | 9 +++++++++ .../Themes/MaterialDesignTheme.TabControl.xaml | 4 +++- 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs index 4fd4b098fb..4b2aa6c3f7 100644 --- a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs +++ b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs @@ -94,6 +94,8 @@ bool IsMovingForward() private void AddPaddingToScrollableContentIfWiderThanViewPort() { + if (TabAssist.GetUseHeaderPadding(TabControl) == false) + return; if (ScrollableContent is null) return; @@ -128,6 +130,8 @@ protected override void OnDetaching() private void AssociatedObject_ScrollChanged(object sender, ScrollChangedEventArgs e) { + if (TabAssist.GetUseHeaderPadding(TabControl) == false) + return; TimeSpan duration = TabAssist.GetScrollDuration(TabControl); if (duration == TimeSpan.Zero) return; diff --git a/src/MaterialDesignThemes.Wpf/Internal/PaddedBringIntoViewStackPanel.cs b/src/MaterialDesignThemes.Wpf/Internal/PaddedBringIntoViewStackPanel.cs index 93d3164c27..62d3b34104 100644 --- a/src/MaterialDesignThemes.Wpf/Internal/PaddedBringIntoViewStackPanel.cs +++ b/src/MaterialDesignThemes.Wpf/Internal/PaddedBringIntoViewStackPanel.cs @@ -25,11 +25,23 @@ public double HeaderPadding 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; diff --git a/src/MaterialDesignThemes.Wpf/TabAssist.cs b/src/MaterialDesignThemes.Wpf/TabAssist.cs index 5663e3685d..b5ad4374a0 100644 --- a/src/MaterialDesignThemes.Wpf/TabAssist.cs +++ b/src/MaterialDesignThemes.Wpf/TabAssist.cs @@ -73,6 +73,15 @@ public static void SetHeaderBehavior(DependencyObject obj, TabControlHeaderBehav 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); diff --git a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml index 176c63a45f..0819b32a48 100644 --- a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml +++ b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml @@ -44,7 +44,8 @@ + HeaderPadding="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=(wpf:TabAssist.HeaderPadding)}" + UseHeaderPadding="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=(wpf:TabAssist.UseHeaderPadding)}"> +