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(); + } }