From d492b0c065ee7e6ec8dfaf0ccf5c0bf5af6ec482 Mon Sep 17 00:00:00 2001 From: Nicolai Henriksen Date: Thu, 26 Mar 2026 11:53:02 +0100 Subject: [PATCH 01/13] NavigationPanel and CustomContent elements added Navigation buttons still need to take "disabled" tabs into account --- .../TabControlHeaderScrollBehavior.cs | 84 ++++++++++++- ...TabControlNavButtonPanelMarginConverter.cs | 21 ++++ ...ontrolNavButtonPanelVisibilityConverter.cs | 19 +++ src/MaterialDesignThemes.Wpf/TabAssist.cs | 38 ++++++ .../MaterialDesignTheme.TabControl.xaml | 113 +++++++++++++----- 5 files changed, 241 insertions(+), 34 deletions(-) create mode 100644 src/MaterialDesignThemes.Wpf/Converters/Internal/TabControlNavButtonPanelMarginConverter.cs create mode 100644 src/MaterialDesignThemes.Wpf/Converters/Internal/TabControlNavButtonPanelVisibilityConverter.cs diff --git a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs index 6605714ac4..504e047cc2 100644 --- a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs +++ b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Windows.Media.Animation; using Microsoft.Xaml.Behaviors; @@ -42,15 +41,33 @@ private static void OnTabControlChanged(DependencyObject d, DependencyPropertyCh oldTabControl.SelectionChanged -= behavior.OnTabChanged; oldTabControl.SizeChanged -= behavior.OnTabControlSizeChanged; oldTabControl.PreviewKeyDown -= behavior.OnTabControlPreviewKeyDown; + oldTabControl.ItemContainerGenerator.ItemsChanged -= behavior.OnTabsChanged; } if (e.NewValue is TabControl newTabControl) { newTabControl.SelectionChanged += behavior.OnTabChanged; newTabControl.SizeChanged += behavior.OnTabControlSizeChanged; newTabControl.PreviewKeyDown += behavior.OnTabControlPreviewKeyDown; + newTabControl.ItemContainerGenerator.ItemsChanged += behavior.OnTabsChanged; } } + public double AdditionalHeaderPanelContentWidth + { + get => (double)GetValue(AdditionalHeaderPanelContentWidthProperty); + set => SetValue(AdditionalHeaderPanelContentWidthProperty, value); + } + + public static readonly DependencyProperty AdditionalHeaderPanelContentWidthProperty = + DependencyProperty.Register(nameof(AdditionalHeaderPanelContentWidth), typeof(double), + typeof(TabControlHeaderScrollBehavior), new PropertyMetadata(0d, AdditionalHeaderPanelContentWidthChanged)); + + private static void AdditionalHeaderPanelContentWidthChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var behavior = (TabControlHeaderScrollBehavior)d; + behavior.AddPaddingToScrollableContentIfWiderThanViewPort(); + } + public FrameworkElement ScrollableContent { get => (FrameworkElement)GetValue(ScrollableContentProperty); @@ -67,9 +84,36 @@ private static void OnScrollableContentChanged(DependencyObject d, DependencyPro behavior.AddPaddingToScrollableContentIfWiderThanViewPort(); } + public ICommand NextTabCommand { get; } + public ICommand PreviousTabCommand { get; } + private double? _desiredScrollStart; private bool _isAnimatingScroll; + public TabControlHeaderScrollBehavior() + { + NextTabCommand = new SimpleICommandImplementation(_ => + { + // TODO: How to deal with disabled tabs? + if (TabControl is { } tabControl && tabControl.SelectedIndex < tabControl.Items.Count - 1) + { + tabControl.SelectedIndex++; + ((SimpleICommandImplementation)PreviousTabCommand!).Refresh(); + ((SimpleICommandImplementation)NextTabCommand!).Refresh(); + } + }, _ => (TabControl is { } tabControl && tabControl.SelectedIndex < tabControl.Items.Count - 1)); + PreviousTabCommand = new SimpleICommandImplementation(_ => + { + // TODO: How to deal with disabled tabs? + if (TabControl is { } tabControl && tabControl.SelectedIndex > 0) + { + tabControl.SelectedIndex--; + ((SimpleICommandImplementation)PreviousTabCommand!).Refresh(); + ((SimpleICommandImplementation)NextTabCommand!).Refresh(); + } + }, _ => (TabControl is { } tabControl && tabControl.SelectedIndex > 0)); + } + private void OnTabChanged(object sender, SelectionChangedEventArgs e) { var tabControl = (TabControl)sender; @@ -95,6 +139,8 @@ bool IsMovingForward() int GetItemIndex(object? item) => tabControl.Items.IndexOf(item); } + private void OnTabsChanged(object sender, ItemsChangedEventArgs e) + => AssociatedObject.Dispatcher.BeginInvoke(() => AddPaddingToScrollableContentIfWiderThanViewPort(), System.Windows.Threading.DispatcherPriority.Loaded); // Defer execution until collection change is rendered private void OnTabControlSizeChanged(object sender, SizeChangedEventArgs _) => AddPaddingToScrollableContentIfWiderThanViewPort(); private void AssociatedObject_SizeChanged(object sender, SizeChangedEventArgs _) => AddPaddingToScrollableContentIfWiderThanViewPort(); @@ -105,15 +151,19 @@ private void AddPaddingToScrollableContentIfWiderThanViewPort() if (ScrollableContent is null) return; - if (ScrollableContent.ActualWidth > TabControl.ActualWidth) + if (ScrollableContent.ActualWidth > TabControl.ActualWidth - AdditionalHeaderPanelContentWidth) { double offset = TabAssist.GetHeaderPadding(TabControl); - ScrollableContent.Margin = new(offset, 0, offset, 0); + ScrollableContent.Margin = new(offset, 0, offset + AdditionalHeaderPanelContentWidth, 0); + TabAssist.SetIsOverflowing(TabControl, true); } else { ScrollableContent.Margin = new(); + AssociatedObject.SetCurrentValue(TabControlHeaderScrollBehavior.CustomHorizontalOffsetProperty, 0d); + TabAssist.SetIsOverflowing(TabControl, false); } + AssociatedObject.Margin = new(0, 0, AdditionalHeaderPanelContentWidth, 0); } private void OnTabControlPreviewKeyDown(object sender, KeyEventArgs e) @@ -173,6 +223,34 @@ private void AssociatedObject_ScrollChanged(object sender, ScrollChangedEventArg }; AssociatedObject.BeginAnimation(TabControlHeaderScrollBehavior.CustomHorizontalOffsetProperty, scrollAnimation); } + + private class SimpleICommandImplementation : ICommand + { + private readonly Action _execute; + private readonly Func _canExecute; + + public SimpleICommandImplementation(Action execute) + : this(execute, null) + { } + + public SimpleICommandImplementation(Action execute, Func? canExecute) + { + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _canExecute = canExecute ?? (x => true); + } + + public bool CanExecute(object? parameter) => _canExecute(parameter); + + public void Execute(object? parameter) => _execute(parameter); + + public event EventHandler? CanExecuteChanged + { + add => CommandManager.RequerySuggested += value; + remove => CommandManager.RequerySuggested -= value; + } + + public void Refresh() => CommandManager.InvalidateRequerySuggested(); + } } public enum TabScrollDirection diff --git a/src/MaterialDesignThemes.Wpf/Converters/Internal/TabControlNavButtonPanelMarginConverter.cs b/src/MaterialDesignThemes.Wpf/Converters/Internal/TabControlNavButtonPanelMarginConverter.cs new file mode 100644 index 0000000000..f18a1baecd --- /dev/null +++ b/src/MaterialDesignThemes.Wpf/Converters/Internal/TabControlNavButtonPanelMarginConverter.cs @@ -0,0 +1,21 @@ +using System.Globalization; +using System.Windows.Data; + +namespace MaterialDesignThemes.Wpf.Converters.Internal; + +public class TabControlNavButtonPanelMarginConverter : IMultiValueConverter +{ + public object? Convert(object?[]? values, Type targetType, object? parameter, CultureInfo culture) + { + if (values is [double scrollableContentWidthDefault, double scrollableContentWidthNonUniform, double controlWidth, StackPanel navPanel, ..]) + { + double scrollableContentWidth = Math.Max(scrollableContentWidthDefault, scrollableContentWidthNonUniform); + double xOffset = Math.Min(controlWidth, scrollableContentWidth + navPanel.ActualWidth) - navPanel.ActualWidth; + return new Thickness(xOffset, 0, 0, 0); + } + return new Thickness(0); + } + + public object?[]? ConvertBack(object? value, Type[] targetTypes, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} diff --git a/src/MaterialDesignThemes.Wpf/Converters/Internal/TabControlNavButtonPanelVisibilityConverter.cs b/src/MaterialDesignThemes.Wpf/Converters/Internal/TabControlNavButtonPanelVisibilityConverter.cs new file mode 100644 index 0000000000..e3cfc9243c --- /dev/null +++ b/src/MaterialDesignThemes.Wpf/Converters/Internal/TabControlNavButtonPanelVisibilityConverter.cs @@ -0,0 +1,19 @@ +using System.Globalization; +using System.Windows.Data; + +namespace MaterialDesignThemes.Wpf.Converters.Internal; + +public class TabControlNavButtonPanelVisibilityConverter : IMultiValueConverter +{ + public object? Convert(object?[]? values, Type targetType, object? parameter, CultureInfo culture) + { + if (values is [bool useNavigationPanel, bool isOverflowing]) + { + return useNavigationPanel && isOverflowing ? Visibility.Visible : Visibility.Collapsed; + } + return Visibility.Collapsed; + } + + public object?[]? ConvertBack(object? value, Type[] targetTypes, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} diff --git a/src/MaterialDesignThemes.Wpf/TabAssist.cs b/src/MaterialDesignThemes.Wpf/TabAssist.cs index b5ad4374a0..cb537d33f2 100644 --- a/src/MaterialDesignThemes.Wpf/TabAssist.cs +++ b/src/MaterialDesignThemes.Wpf/TabAssist.cs @@ -34,6 +34,44 @@ public static void SetHeaderPanelMargin(DependencyObject element, Thickness valu public static Thickness GetHeaderPanelMargin(DependencyObject element) => (Thickness)element.GetValue(HeaderPanelMarginProperty); + public static object? GetHeaderPanelCustomContent(DependencyObject obj) + => (object?)obj.GetValue(HeaderPanelCustomContentProperty); + + public static void SetHeaderPanelCustomContent(DependencyObject obj, object? value) + => obj.SetValue(HeaderPanelCustomContentProperty, value); + + public static readonly DependencyProperty HeaderPanelCustomContentProperty = DependencyProperty.RegisterAttached( + "HeaderPanelCustomContent", typeof(object), typeof(TabAssist), new PropertyMetadata(default)); + + internal static readonly DependencyPropertyKey IsOverflowingPropertyKey = DependencyProperty.RegisterAttachedReadOnly( + "IsOverflowing", typeof(bool), typeof(TabAssist), new PropertyMetadata(false)); + + public static readonly DependencyProperty IsOverflowingProperty = IsOverflowingPropertyKey.DependencyProperty; + + public static bool GetIsOverflowing(DependencyObject obj) + => (bool)obj.GetValue(IsOverflowingProperty); + + public static bool GetUseNavigationPanel(DependencyObject obj) + => (bool)obj.GetValue(UseNavigationPanelProperty); + + public static void SetUseNavigationPanel(DependencyObject obj, bool value) + => obj.SetValue(UseNavigationPanelProperty, value); + + public static readonly DependencyProperty UseNavigationPanelProperty = + DependencyProperty.RegisterAttached("UseNavigationPanel", typeof(bool), typeof(TabAssist), new PropertyMetadata(false)); + + public static Thickness GetNavigationPanelMargin(DependencyObject obj) + => (Thickness)obj.GetValue(NavigationPanelMarginProperty); + + public static void SetNavigationPanelMargin(DependencyObject obj, Thickness value) + => obj.SetValue(NavigationPanelMarginProperty, value); + + public static readonly DependencyProperty NavigationPanelMarginProperty = + DependencyProperty.RegisterAttached("NavigationPanelMargin", typeof(Thickness), typeof(TabAssist), new PropertyMetadata(default(Thickness))); + + internal static void SetIsOverflowing(DependencyObject obj, bool value) + => obj.SetValue(IsOverflowingPropertyKey, value); + public static Visibility GetBindableIsItemsHost(DependencyObject obj) => (Visibility)obj.GetValue(BindableIsItemsHostProperty); diff --git a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml index 0819b32a48..3c5b352922 100644 --- a/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml +++ b/src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml @@ -1,6 +1,7 @@  + + + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 69a878d2bd38b3b17cb784b6b73db607438c41c7 Mon Sep 17 00:00:00 2001 From: Nicolai Henriksen Date: Thu, 26 Mar 2026 12:48:14 +0100 Subject: [PATCH 02/13] NavigationPanel now takes disabled tabs into account --- .../TabControlHeaderScrollBehavior.cs | 46 +++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs index 504e047cc2..66890c363f 100644 --- a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs +++ b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs @@ -94,24 +94,54 @@ public TabControlHeaderScrollBehavior() { NextTabCommand = new SimpleICommandImplementation(_ => { - // TODO: How to deal with disabled tabs? - if (TabControl is { } tabControl && tabControl.SelectedIndex < tabControl.Items.Count - 1) + if (TabControl is { } tabControl && TryGetNextTabIndex(tabControl, out int nextIndex)) { - tabControl.SelectedIndex++; + tabControl.SelectedIndex = nextIndex; ((SimpleICommandImplementation)PreviousTabCommand!).Refresh(); ((SimpleICommandImplementation)NextTabCommand!).Refresh(); } - }, _ => (TabControl is { } tabControl && tabControl.SelectedIndex < tabControl.Items.Count - 1)); + }, _ => (TabControl is { } tabControl && TryGetNextTabIndex(tabControl, out int _))); PreviousTabCommand = new SimpleICommandImplementation(_ => { - // TODO: How to deal with disabled tabs? - if (TabControl is { } tabControl && tabControl.SelectedIndex > 0) + if (TabControl is { } tabControl && TryGetPreviousTabIndex(tabControl, out int previousIndex)) { - tabControl.SelectedIndex--; + tabControl.SelectedIndex = previousIndex; ((SimpleICommandImplementation)PreviousTabCommand!).Refresh(); ((SimpleICommandImplementation)NextTabCommand!).Refresh(); } - }, _ => (TabControl is { } tabControl && tabControl.SelectedIndex > 0)); + }, _ => (TabControl is { } tabControl && TryGetPreviousTabIndex(tabControl, out int _))); + + static bool TryGetNextTabIndex(TabControl tabControl, out int nextTabIndex) + { + nextTabIndex = -1; + var nextTabs = GetEnabledTabItemIndices(tabControl, index => index > tabControl.SelectedIndex); + if (nextTabs.Count > 0) + { + nextTabIndex = nextTabs.First(); + return true; + } + return false; + } + + static bool TryGetPreviousTabIndex(TabControl tabControl, out int nextTabIndex) + { + nextTabIndex = -1; + var previousTabs = GetEnabledTabItemIndices(tabControl, index => index < tabControl.SelectedIndex); + if (previousTabs.Count > 0) + { + nextTabIndex = previousTabs.Last(); + return true; + } + return false; + } + + static List GetEnabledTabItemIndices(TabControl tabControl, Predicate predicate) => [.. tabControl + .Items + .Cast() + .Select(item => (TabItem)tabControl.ItemContainerGenerator.ContainerFromItem(item)) + .Where(tab => tab != null && tab.IsEnabled) + .Select(tab => tabControl.ItemContainerGenerator.IndexFromContainer(tab)) + .Where(tabIndex => predicate(tabIndex))]; } private void OnTabChanged(object sender, SelectionChangedEventArgs e) From eeb77e0f74a8e3886beac88226f0341d06eb53fb Mon Sep 17 00:00:00 2001 From: Nicolai Henriksen Date: Thu, 26 Mar 2026 13:01:12 +0100 Subject: [PATCH 03/13] Adding samples to demo app --- src/MainDemo.Wpf/Tabs.xaml | 91 +++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/src/MainDemo.Wpf/Tabs.xaml b/src/MainDemo.Wpf/Tabs.xaml index b575c717ae..05ed526ac6 100644 --- a/src/MainDemo.Wpf/Tabs.xaml +++ b/src/MainDemo.Wpf/Tabs.xaml @@ -756,8 +756,9 @@ + Margin="0,0,0,16" + BorderBrush="{DynamicResource MaterialDesign.Brush.Primary}" + BorderThickness="1"> @@ -837,5 +838,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/MaterialDesignThemes.UITests/WPF/TabControls/TabControlTests.cs b/tests/MaterialDesignThemes.UITests/WPF/TabControls/TabControlTests.cs index e273cc41dd..8984afe870 100644 --- a/tests/MaterialDesignThemes.UITests/WPF/TabControls/TabControlTests.cs +++ b/tests/MaterialDesignThemes.UITests/WPF/TabControls/TabControlTests.cs @@ -227,7 +227,7 @@ public async Task ScrollingTabs_WithLessTabsThanScreenRealEstate_ShouldNotAddLef [Arguments("", 20, true)] // UniformGrid style [Arguments("HorizontalContentAlignment=\"Left\"", 5, false)] // VirtualizingStackPanel style [Arguments("HorizontalContentAlignment=\"Left\"", 20, true)] // VirtualizingStackPanel style - public async Task ScrollingTabs_WithNavigationPanel_ShouldAddLeftAndRightMarginToHeaderPanel(string additionalProperties, int numberOfTabs, bool expectedToOverflow) + public async Task ScrollingTabs_WithNavigationPanels_ShouldCorrectlySetIsOverflowingAndNavigationPanelVisibility(string additionalProperties, int numberOfTabs, bool expectedToOverflow) { await using var recorder = new TestRecorder(App); @@ -246,17 +246,96 @@ public async Task ScrollingTabs_WithNavigationPanel_ShouldAddLeftAndRightMarginT } xaml.Append(""); IVisualElement tabControl = await LoadXaml(xaml.ToString()); - IVisualElement navigationPanel = await tabControl.GetElement("NavigationPanel"); + IVisualElement navigationPanelLeft = await (await tabControl.GetElement("NavigationPanelLeft")).GetElement(); + IVisualElement navigationPanelRight = await tabControl.GetElement("NavigationPanelRight"); static bool GetIsOverflowing(TabControl tc) => TabAssist.GetIsOverflowing(tc); //Act bool isOverflowing = await tabControl.RemoteExecute(GetIsOverflowing); - Visibility headerPanelVisibility = await navigationPanel.GetVisibility(); + Visibility navigationPanelLeftVisibility = await navigationPanelLeft.GetVisibility(); + Visibility navigationPanelRightVisibility = await navigationPanelRight.GetVisibility(); // Assert await Assert.That(isOverflowing).IsEqualTo(expectedToOverflow); - await Assert.That(headerPanelVisibility).IsEqualTo(expectedToOverflow ? Visibility.Visible : Visibility.Collapsed); + await Assert.That(navigationPanelLeftVisibility).IsEqualTo(expectedToOverflow ? Visibility.Visible : Visibility.Collapsed); + await Assert.That(navigationPanelRightVisibility).IsEqualTo(expectedToOverflow ? Visibility.Visible : Visibility.Collapsed); + + recorder.Success(); + } + + [Test] + [Arguments("")] // UniformGrid style + [Arguments("HorizontalContentAlignment=\"Left\"")] // VirtualizingStackPanel style + public async Task ScrollingTabs_WithNavigationPanelLeft_ShouldCorrectlySetIsOverflowingAndNavigationPanelLeftVisibility(string additionalProperties) + { + await using var recorder = new TestRecorder(App); + + //Arrange + StringBuilder xaml = new($""" + + """); + + const int numTabs = 20; + for (int i = 1; i <= numTabs; i++) + { + xaml.Append($""" + + + + """); + } + xaml.Append(""); + IVisualElement tabControl = await LoadXaml(xaml.ToString()); + IVisualElement navigationPanel = await (await tabControl.GetElement("NavigationPanelLeft")).GetElement(); + + static bool GetIsOverflowing(TabControl tc) => TabAssist.GetIsOverflowing(tc); + + //Act + bool isOverflowing = await tabControl.RemoteExecute(GetIsOverflowing); + Visibility navigationPanelVisibility = await navigationPanel.GetVisibility(); + + // Assert + await Assert.That(isOverflowing).IsEqualTo(true); + await Assert.That(navigationPanelVisibility).IsEqualTo(Visibility.Visible); + + recorder.Success(); + } + + [Test] + [Arguments("")] // UniformGrid style + [Arguments("HorizontalContentAlignment=\"Left\"")] // VirtualizingStackPanel style + public async Task ScrollingTabs_WithNavigationPanelRight_ShouldCorrectlySetIsOverflowingAndNavigationPanelLeftVisibility(string additionalProperties) + { + await using var recorder = new TestRecorder(App); + + //Arrange + StringBuilder xaml = new($""" + + """); + + const int numTabs = 20; + for (int i = 1; i <= numTabs; i++) + { + xaml.Append($""" + + + + """); + } + xaml.Append(""); + IVisualElement tabControl = await LoadXaml(xaml.ToString()); + IVisualElement navigationPanel = await tabControl.GetElement("NavigationPanelRight"); + + static bool GetIsOverflowing(TabControl tc) => TabAssist.GetIsOverflowing(tc); + + //Act + bool isOverflowing = await tabControl.RemoteExecute(GetIsOverflowing); + Visibility navigationPanelVisibility = await navigationPanel.GetVisibility(); + + // Assert + await Assert.That(isOverflowing).IsEqualTo(true); + await Assert.That(navigationPanelVisibility).IsEqualTo(Visibility.Visible); recorder.Success(); } From 2762025bc45fd7c29f010b49604521bcbee2d4f8 Mon Sep 17 00:00:00 2001 From: Nicolai Henriksen Date: Sun, 29 Mar 2026 10:35:05 +0200 Subject: [PATCH 06/13] Add TabAssist.NavigationPanelBehavior to control button functionality --- .../TabControlHeaderScrollBehavior.cs | 74 +++++++++++++++---- src/MaterialDesignThemes.Wpf/TabAssist.cs | 17 ++++- 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs index 86bc4def9e..11822f0f05 100644 --- a/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs +++ b/src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs @@ -110,22 +110,64 @@ public TabControlHeaderScrollBehavior() { NextTabCommand = new SimpleICommandImplementation(_ => { - if (TabControl is { } tabControl && TryGetNextTabIndex(tabControl, out int nextIndex)) + if (TabControl is { } tabControl) { - tabControl.SelectedIndex = nextIndex; - ((SimpleICommandImplementation)PreviousTabCommand!).Refresh(); - ((SimpleICommandImplementation)NextTabCommand!).Refresh(); + NavigationPanelBehavior behavior = TabAssist.GetNavigationPanelBehavior(tabControl); + if (behavior == NavigationPanelBehavior.Scroll) + { + _desiredScrollStart = AssociatedObject.ContentHorizontalOffset; + AssociatedObject.ScrollToHorizontalOffset(AssociatedObject.ContentHorizontalOffset + AssociatedObject.ActualWidth); + } + else if (behavior == NavigationPanelBehavior.Select && TryGetNextTabIndex(tabControl, out int nextIndex)) + { + tabControl.SelectedIndex = nextIndex; + } } - }, _ => (TabControl is { } tabControl && TryGetNextTabIndex(tabControl, out int _))); + }, CanNextTabCommandExecute); PreviousTabCommand = new SimpleICommandImplementation(_ => { - if (TabControl is { } tabControl && TryGetPreviousTabIndex(tabControl, out int previousIndex)) + if (TabControl is { } tabControl) { - tabControl.SelectedIndex = previousIndex; - ((SimpleICommandImplementation)PreviousTabCommand!).Refresh(); - ((SimpleICommandImplementation)NextTabCommand!).Refresh(); + NavigationPanelBehavior behavior = TabAssist.GetNavigationPanelBehavior(tabControl); + if (behavior == NavigationPanelBehavior.Scroll) + { + _desiredScrollStart = AssociatedObject.ContentHorizontalOffset; + AssociatedObject.ScrollToHorizontalOffset(AssociatedObject.ContentHorizontalOffset - AssociatedObject.ActualWidth); + } + else if (behavior == NavigationPanelBehavior.Select && TryGetPreviousTabIndex(tabControl, out int previousIndex)) + { + tabControl.SelectedIndex = previousIndex; + } } - }, _ => (TabControl is { } tabControl && TryGetPreviousTabIndex(tabControl, out int _))); + }, CanPreviousTabCommandExecute); + + bool CanNextTabCommandExecute(object? _) + { + if (TabControl is not { } tabControl) + return false; + + NavigationPanelBehavior behavior = TabAssist.GetNavigationPanelBehavior(tabControl); + return behavior switch + { + NavigationPanelBehavior.Scroll => AssociatedObject.ContentHorizontalOffset < AssociatedObject.ExtentWidth - AssociatedObject.ActualWidth, + NavigationPanelBehavior.Select => TryGetNextTabIndex(tabControl, out int _), + _ => false + }; + } + + bool CanPreviousTabCommandExecute(object? _) + { + if (TabControl is not { } tabControl) + return false; + + NavigationPanelBehavior behavior = TabAssist.GetNavigationPanelBehavior(tabControl); + return behavior switch + { + NavigationPanelBehavior.Scroll => AssociatedObject.ContentHorizontalOffset > 0, + NavigationPanelBehavior.Select => TryGetPreviousTabIndex(tabControl, out int _), + _ => false + }; + } static bool TryGetNextTabIndex(TabControl tabControl, out int nextTabIndex) { @@ -139,13 +181,13 @@ static bool TryGetNextTabIndex(TabControl tabControl, out int nextTabIndex) return false; } - static bool TryGetPreviousTabIndex(TabControl tabControl, out int nextTabIndex) + static bool TryGetPreviousTabIndex(TabControl tabControl, out int previousTabIndex) { - nextTabIndex = -1; + previousTabIndex = -1; var previousTabs = GetEnabledTabItemIndices(tabControl, index => index < tabControl.SelectedIndex); if (previousTabs.Count > 0) { - nextTabIndex = previousTabs.Last(); + previousTabIndex = previousTabs.Last(); return true; } return false; @@ -241,6 +283,9 @@ protected override void OnDetaching() private void AssociatedObject_ScrollChanged(object sender, ScrollChangedEventArgs e) { + ((SimpleICommandImplementation)PreviousTabCommand!).Refresh(); + ((SimpleICommandImplementation)NextTabCommand!).Refresh(); + if (TabAssist.GetUseHeaderPadding(TabControl) == false) return; TimeSpan duration = TabAssist.GetScrollDuration(TabControl); @@ -261,6 +306,9 @@ private void AssociatedObject_ScrollChanged(object sender, ScrollChangedEventArg DoubleAnimation scrollAnimation = new(originalValue, newValue, new Duration(duration)); scrollAnimation.Completed += (_, _) => { + ((SimpleICommandImplementation)PreviousTabCommand!).Refresh(); + ((SimpleICommandImplementation)NextTabCommand!).Refresh(); + _desiredScrollStart = null; _isAnimatingScroll = false; diff --git a/src/MaterialDesignThemes.Wpf/TabAssist.cs b/src/MaterialDesignThemes.Wpf/TabAssist.cs index 401bb61d66..4c771378c2 100644 --- a/src/MaterialDesignThemes.Wpf/TabAssist.cs +++ b/src/MaterialDesignThemes.Wpf/TabAssist.cs @@ -76,7 +76,16 @@ public static void SetNavigationPanelPlacement(DependencyObject obj, NavigationP => obj.SetValue(NavigationPanelPlacementProperty, value); public static readonly DependencyProperty NavigationPanelPlacementProperty = DependencyProperty.RegisterAttached( - "NavigationPanelPlacement", typeof(NavigationPanelPlacement), typeof(TabAssist), new PropertyMetadata(default(NavigationPanelPlacement))); + "NavigationPanelPlacement", typeof(NavigationPanelPlacement), typeof(TabAssist), new PropertyMetadata(default(NavigationPanelPlacement))); + + public static NavigationPanelBehavior GetNavigationPanelBehavior(DependencyObject obj) + => (NavigationPanelBehavior)obj.GetValue(NavigationPanelBehaviorProperty); + + public static void SetNavigationPanelBehavior(DependencyObject obj, NavigationPanelBehavior value) + => obj.SetValue(NavigationPanelBehaviorProperty, value); + + public static readonly DependencyProperty NavigationPanelBehaviorProperty = DependencyProperty.RegisterAttached( + "NavigationPanelBehavior", typeof(NavigationPanelBehavior), typeof(TabAssist), new PropertyMetadata(default(NavigationPanelBehavior))); internal static void SetIsOverflowing(DependencyObject obj, bool value) => obj.SetValue(IsOverflowingPropertyKey, value); @@ -153,3 +162,9 @@ public enum NavigationPanelPlacement Left, Right } + +public enum NavigationPanelBehavior +{ + Scroll, + Select +} From e6695f3f2c2049edddfc226ef84379a2f43fa2f7 Mon Sep 17 00:00:00 2001 From: Nicolai Henriksen Date: Sun, 29 Mar 2026 11:17:52 +0200 Subject: [PATCH 07/13] Add UI tests for TabAssist.NavigationPanelBehavior --- .../WPF/TabControls/TabControlTests.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/tests/MaterialDesignThemes.UITests/WPF/TabControls/TabControlTests.cs b/tests/MaterialDesignThemes.UITests/WPF/TabControls/TabControlTests.cs index 8984afe870..5ee70c654f 100644 --- a/tests/MaterialDesignThemes.UITests/WPF/TabControls/TabControlTests.cs +++ b/tests/MaterialDesignThemes.UITests/WPF/TabControls/TabControlTests.cs @@ -339,4 +339,82 @@ public async Task ScrollingTabs_WithNavigationPanelRight_ShouldCorrectlySetIsOve recorder.Success(); } + + [Test] + [Arguments("")] // UniformGrid style + [Arguments("HorizontalContentAlignment=\"Left\"")] // VirtualizingStackPanel style + public async Task ScrollingTabs_WithNavigationPanelAndSelectBehavior_ShouldChangeSelectedTabWhenClicked(string additionalProperties) + { + await using var recorder = new TestRecorder(App); + + //Arrange + StringBuilder xaml = new($""" + + """); + + const int numTabs = 20; + for (int i = 1; i <= numTabs; i++) + { + xaml.Append($""" + + + + """); + } + xaml.Append(""); + IVisualElement tabControl = await LoadXaml(xaml.ToString()); + IVisualElement scrollViewer = await tabControl.GetElement(); + IVisualElement navigationPanel = await tabControl.GetElement("NavigationPanelRight"); + IVisualElement