Skip to content

Commit dea4e27

Browse files
Align (scrolling) TabControl with MD spec (#3981)
* PoC for hijacking RequestBringIntoView event * 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. * Adding smooth/animated scrolling to the behavior * 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. * 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. * 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. * Add TabAssist.AnimateTabScrolling to toggle tab switch animation feature Defaulted to True in a style setter allowing for easy override at the call site. * Add TODO comment regarding destructive-read on TabScrollDirection AP * 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. * Replace TabAssist.AnimateTabScrolling with TabAssist.TabScrollDuration Setting a duration of TimeSpan.Zero, effectively disables the animated scrolling. * Rename hijacking StackPanel * Rename TabScrollDirection * Rename TabScrollOffset * Rename TabScrollDuration * Remove debug output * Add TabAssist.UseHeaderPadding to enable/disable new behavior The feature is on by default * Change bring into view event listener to class handler This is to avoid a potential handler leak * Implement "destructive read" of TabScrollDirection This a safety guard to avoid leaving an invalid value which may incorrectly "overscroll" when it should not. * Mitigate quick keypresses causing tab animation to stop If an animation is in progress, we simply ignore keypresses that would otherwise cause a new tab navigation. * Use tab animation even when tab has focusable content Reverts the "destructive read" commit because the event may fire twice now, and removing the scroll direction would lead the second event to scroll to a wrong (i.e. not offset) position. So we live with the extra event (in cases where there is no focusable content; probably not the common use-case), and live with the AP/DP value surviving. * Add Tab key to list of special keys handled in the ScrollBehavior Co-authored-by: Corvin <43533385+corvinsz@users.noreply.github.com> * Update UI tests to assert on header panel margin --------- Co-authored-by: Corvin <43533385+corvinsz@users.noreply.github.com>
1 parent 3971241 commit dea4e27

File tree

6 files changed

+361
-2
lines changed

6 files changed

+361
-2
lines changed
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
using System.Diagnostics;
2+
using System.Windows.Media.Animation;
3+
using Microsoft.Xaml.Behaviors;
4+
5+
namespace MaterialDesignThemes.Wpf.Behaviors.Internal;
6+
7+
public class TabControlHeaderScrollBehavior : Behavior<ScrollViewer>
8+
{
9+
public static readonly DependencyProperty CustomHorizontalOffsetProperty =
10+
DependencyProperty.RegisterAttached("CustomHorizontalOffset", typeof(double),
11+
typeof(TabControlHeaderScrollBehavior), new PropertyMetadata(0d, CustomHorizontalOffsetChanged));
12+
public static double GetCustomHorizontalOffset(DependencyObject obj) => (double)obj.GetValue(CustomHorizontalOffsetProperty);
13+
public static void SetCustomHorizontalOffset(DependencyObject obj, double value) => obj.SetValue(CustomHorizontalOffsetProperty, value);
14+
private static void CustomHorizontalOffsetChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
15+
{
16+
var scrollViewer = (ScrollViewer)d;
17+
scrollViewer.ScrollToHorizontalOffset((double)e.NewValue);
18+
}
19+
20+
public static readonly DependencyProperty ScrollDirectionProperty =
21+
DependencyProperty.RegisterAttached("ScrollDirection", typeof(TabScrollDirection),
22+
typeof(TabControlHeaderScrollBehavior), new PropertyMetadata(TabScrollDirection.Unknown));
23+
public static TabScrollDirection GetScrollDirection(DependencyObject obj) => (TabScrollDirection)obj.GetValue(ScrollDirectionProperty);
24+
public static void SetScrollDirection(DependencyObject obj, TabScrollDirection value) => obj.SetValue(ScrollDirectionProperty, value);
25+
26+
public TabControl TabControl
27+
{
28+
get => (TabControl)GetValue(TabControlProperty);
29+
set => SetValue(TabControlProperty, value);
30+
}
31+
32+
public static readonly DependencyProperty TabControlProperty =
33+
DependencyProperty.Register(nameof(TabControl), typeof(TabControl),
34+
typeof(TabControlHeaderScrollBehavior), new PropertyMetadata(null, OnTabControlChanged));
35+
36+
37+
private static void OnTabControlChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
38+
{
39+
var behavior = (TabControlHeaderScrollBehavior)d;
40+
if (e.OldValue is TabControl oldTabControl)
41+
{
42+
oldTabControl.SelectionChanged -= behavior.OnTabChanged;
43+
oldTabControl.SizeChanged -= behavior.OnTabControlSizeChanged;
44+
oldTabControl.PreviewKeyDown -= behavior.OnTabControlPreviewKeyDown;
45+
}
46+
if (e.NewValue is TabControl newTabControl)
47+
{
48+
newTabControl.SelectionChanged += behavior.OnTabChanged;
49+
newTabControl.SizeChanged += behavior.OnTabControlSizeChanged;
50+
newTabControl.PreviewKeyDown += behavior.OnTabControlPreviewKeyDown;
51+
}
52+
}
53+
54+
public FrameworkElement ScrollableContent
55+
{
56+
get => (FrameworkElement)GetValue(ScrollableContentProperty);
57+
set => SetValue(ScrollableContentProperty, value);
58+
}
59+
60+
public static readonly DependencyProperty ScrollableContentProperty =
61+
DependencyProperty.Register(nameof(ScrollableContent), typeof(FrameworkElement),
62+
typeof(TabControlHeaderScrollBehavior), new PropertyMetadata(null, OnScrollableContentChanged));
63+
64+
private static void OnScrollableContentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
65+
{
66+
var behavior = (TabControlHeaderScrollBehavior)d;
67+
behavior.AddPaddingToScrollableContentIfWiderThanViewPort();
68+
}
69+
70+
private double? _desiredScrollStart;
71+
private bool _isAnimatingScroll;
72+
73+
private void OnTabChanged(object sender, SelectionChangedEventArgs e)
74+
{
75+
var tabControl = (TabControl)sender;
76+
77+
if (e.AddedItems.Count > 0)
78+
{
79+
_desiredScrollStart = AssociatedObject.ContentHorizontalOffset;
80+
SetScrollDirection(tabControl, (IsMovingForward() ? TabScrollDirection.Forward : TabScrollDirection.Backward));
81+
82+
// In case the TabItem has focusable content, the FrameworkElement.RequestBringIntoView won't fire. The lines below ensures that it fires.
83+
var tab = tabControl.ItemContainerGenerator.ContainerFromItem(e.AddedItems[0]) as TabItem;
84+
tab?.BringIntoView();
85+
}
86+
87+
bool IsMovingForward()
88+
{
89+
if (e.RemovedItems.Count == 0) return true;
90+
int previousIndex = GetItemIndex(e.RemovedItems[0]);
91+
int nextIndex = GetItemIndex(e.AddedItems[^1]);
92+
return nextIndex > previousIndex;
93+
}
94+
95+
int GetItemIndex(object? item) => tabControl.Items.IndexOf(item);
96+
}
97+
98+
private void OnTabControlSizeChanged(object sender, SizeChangedEventArgs _) => AddPaddingToScrollableContentIfWiderThanViewPort();
99+
private void AssociatedObject_SizeChanged(object sender, SizeChangedEventArgs _) => AddPaddingToScrollableContentIfWiderThanViewPort();
100+
101+
private void AddPaddingToScrollableContentIfWiderThanViewPort()
102+
{
103+
if (TabAssist.GetUseHeaderPadding(TabControl) == false)
104+
return;
105+
if (ScrollableContent is null)
106+
return;
107+
108+
if (ScrollableContent.ActualWidth > TabControl.ActualWidth)
109+
{
110+
double offset = TabAssist.GetHeaderPadding(TabControl);
111+
ScrollableContent.Margin = new(offset, 0, offset, 0);
112+
}
113+
else
114+
{
115+
ScrollableContent.Margin = new();
116+
}
117+
}
118+
119+
private void OnTabControlPreviewKeyDown(object sender, KeyEventArgs e)
120+
{
121+
if (!_isAnimatingScroll)
122+
return;
123+
124+
if (e.Key is Key.Left or Key.Right or Key.Home or Key.End or Key.PageUp or Key.PageDown or Key.Tab)
125+
e.Handled = true;
126+
}
127+
128+
protected override void OnAttached()
129+
{
130+
base.OnAttached();
131+
AssociatedObject.ScrollChanged += AssociatedObject_ScrollChanged;
132+
AssociatedObject.SizeChanged += AssociatedObject_SizeChanged;
133+
Dispatcher.BeginInvoke(() => AddPaddingToScrollableContentIfWiderThanViewPort());
134+
}
135+
136+
protected override void OnDetaching()
137+
{
138+
base.OnDetaching();
139+
if (AssociatedObject is { } ao)
140+
{
141+
ao.ScrollChanged -= AssociatedObject_ScrollChanged;
142+
ao.SizeChanged -= AssociatedObject_SizeChanged;
143+
}
144+
}
145+
146+
private void AssociatedObject_ScrollChanged(object sender, ScrollChangedEventArgs e)
147+
{
148+
if (TabAssist.GetUseHeaderPadding(TabControl) == false)
149+
return;
150+
TimeSpan duration = TabAssist.GetScrollDuration(TabControl);
151+
if (duration == TimeSpan.Zero)
152+
return;
153+
if ( _isAnimatingScroll || _desiredScrollStart is not { } desiredOffsetStart)
154+
return;
155+
156+
double originalValue = desiredOffsetStart;
157+
double newValue = e.HorizontalOffset;
158+
_isAnimatingScroll = true;
159+
160+
// HACK: Temporarily disable user interaction while the animated scroll is ongoing. This prevents the double-click of a tab stopping the animation prematurely.
161+
bool originalIsHitTestVisibleValue = TabControl.IsHitTestVisible;
162+
TabControl.SetCurrentValue(FrameworkElement.IsHitTestVisibleProperty, false);
163+
164+
AssociatedObject.ScrollToHorizontalOffset(originalValue);
165+
DoubleAnimation scrollAnimation = new(originalValue, newValue, new Duration(duration));
166+
scrollAnimation.Completed += (_, _) =>
167+
{
168+
_desiredScrollStart = null;
169+
_isAnimatingScroll = false;
170+
171+
// HACK: Set the hit test visibility back to its original value
172+
TabControl.SetCurrentValue(FrameworkElement.IsHitTestVisibleProperty, originalIsHitTestVisibleValue);
173+
};
174+
AssociatedObject.BeginAnimation(TabControlHeaderScrollBehavior.CustomHorizontalOffsetProperty, scrollAnimation);
175+
}
176+
}
177+
178+
public enum TabScrollDirection
179+
{
180+
Unknown,
181+
Backward,
182+
Forward
183+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+

2+
using MaterialDesignThemes.Wpf.Behaviors.Internal;
3+
4+
namespace MaterialDesignThemes.Wpf.Internal;
5+
6+
public class PaddedBringIntoViewStackPanel : StackPanel
7+
{
8+
public TabScrollDirection ScrollDirection
9+
{
10+
get => (TabScrollDirection)GetValue(ScrollDirectionProperty);
11+
set => SetValue(ScrollDirectionProperty, value);
12+
}
13+
14+
public static readonly DependencyProperty ScrollDirectionProperty =
15+
DependencyProperty.Register(nameof(ScrollDirection), typeof(TabScrollDirection),
16+
typeof(PaddedBringIntoViewStackPanel), new PropertyMetadata(TabScrollDirection.Unknown));
17+
18+
public double HeaderPadding
19+
{
20+
get => (double)GetValue(HeaderPaddingProperty);
21+
set => SetValue(HeaderPaddingProperty, value);
22+
}
23+
24+
public static readonly DependencyProperty HeaderPaddingProperty =
25+
DependencyProperty.Register(nameof(HeaderPadding),
26+
typeof(double), typeof(PaddedBringIntoViewStackPanel), new PropertyMetadata(0d));
27+
28+
public bool UseHeaderPadding
29+
{
30+
get => (bool)GetValue(UseHeaderPaddingProperty);
31+
set => SetValue(UseHeaderPaddingProperty, value);
32+
}
33+
34+
public static readonly DependencyProperty UseHeaderPaddingProperty =
35+
DependencyProperty.Register(nameof(UseHeaderPadding), typeof(bool), typeof(PaddedBringIntoViewStackPanel), new PropertyMetadata(false));
36+
37+
static PaddedBringIntoViewStackPanel()
38+
=> EventManager.RegisterClassHandler(typeof(PaddedBringIntoViewStackPanel),
39+
FrameworkElement.RequestBringIntoViewEvent,
40+
new RequestBringIntoViewEventHandler(OnRequestBringIntoView));
41+
42+
private static void OnRequestBringIntoView(object sender, RoutedEventArgs e)
43+
{
44+
var panel = (PaddedBringIntoViewStackPanel)sender;
45+
if (!panel.UseHeaderPadding)
46+
return;
47+
48+
if (e.OriginalSource is FrameworkElement child && child != panel)
49+
{
50+
e.Handled = true;
51+
52+
double offset = panel.ScrollDirection switch
53+
{
54+
TabScrollDirection.Backward => -panel.HeaderPadding,
55+
TabScrollDirection.Forward => panel.HeaderPadding,
56+
_ => 0
57+
};
58+
var point = child.TranslatePoint(new Point(), panel);
59+
var newTargetRect = new Rect(new Point(point.X + offset, point.Y), child.RenderSize);
60+
panel.BringIntoView(newTargetRect);
61+
}
62+
}
63+
}

src/MaterialDesignThemes.Wpf/TabAssist.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,33 @@ public static void SetHeaderBehavior(DependencyObject obj, TabControlHeaderBehav
6969
public static readonly DependencyProperty HeaderBehaviorProperty =
7070
DependencyProperty.RegisterAttached("HeaderBehavior", typeof(TabControlHeaderBehavior), typeof(TabAssist),
7171
new PropertyMetadata(TabControlHeaderBehavior.Scrolling));
72+
73+
public static double GetHeaderPadding(DependencyObject obj)
74+
=> (double)obj.GetValue(HeaderPaddingProperty);
75+
76+
public static bool GetUseHeaderPadding(DependencyObject obj)
77+
=> (bool)obj.GetValue(UseHeaderPaddingProperty);
78+
79+
public static void SetUseHeaderPadding(DependencyObject obj, bool value)
80+
=> obj.SetValue(UseHeaderPaddingProperty, value);
81+
82+
public static readonly DependencyProperty UseHeaderPaddingProperty =
83+
DependencyProperty.RegisterAttached("UseHeaderPadding", typeof(bool), typeof(TabAssist), new PropertyMetadata(false));
84+
85+
public static void SetHeaderPadding(DependencyObject obj, double value)
86+
=> obj.SetValue(HeaderPaddingProperty, value);
87+
88+
public static readonly DependencyProperty HeaderPaddingProperty =
89+
DependencyProperty.RegisterAttached("HeaderPadding", typeof(double),
90+
typeof(TabAssist), new PropertyMetadata(0d));
91+
92+
public static TimeSpan GetScrollDuration(DependencyObject obj)
93+
=> (TimeSpan)obj.GetValue(ScrollDurationProperty);
94+
95+
public static void SetScrollDuration(DependencyObject obj, TimeSpan value)
96+
=> obj.SetValue(ScrollDurationProperty, value);
97+
98+
public static readonly DependencyProperty ScrollDurationProperty =
99+
DependencyProperty.RegisterAttached("ScrollDuration", typeof(TimeSpan),
100+
typeof(TabAssist), new PropertyMetadata(TimeSpan.Zero));
72101
}

src/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.TabControl.xaml

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
22
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
33
xmlns:converters="clr-namespace:MaterialDesignThemes.Wpf.Converters"
4+
xmlns:internal="clr-namespace:MaterialDesignThemes.Wpf.Internal"
5+
xmlns:behaviorsInternal="clr-namespace:MaterialDesignThemes.Wpf.Behaviors.Internal"
6+
xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
47
xmlns:wpf="clr-namespace:MaterialDesignThemes.Wpf">
58

69
<ResourceDictionary.MergedDictionaries>
@@ -36,7 +39,13 @@
3639
wpf:ScrollViewerAssist.PaddingMode="{Binding Path=(wpf:ScrollViewerAssist.PaddingMode), RelativeSource={RelativeSource TemplatedParent}}"
3740
HorizontalScrollBarVisibility="Hidden"
3841
VerticalScrollBarVisibility="Hidden">
39-
<StackPanel>
42+
<b:Interaction.Behaviors>
43+
<behaviorsInternal:TabControlHeaderScrollBehavior TabControl="{Binding RelativeSource={RelativeSource TemplatedParent}}" ScrollableContent="{Binding ElementName=ScrollableContent}" />
44+
</b:Interaction.Behaviors>
45+
<internal:PaddedBringIntoViewStackPanel x:Name="ScrollableContent"
46+
ScrollDirection="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=(behaviorsInternal:TabControlHeaderScrollBehavior.ScrollDirection)}"
47+
HeaderPadding="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=(wpf:TabAssist.HeaderPadding)}"
48+
UseHeaderPadding="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=(wpf:TabAssist.UseHeaderPadding)}">
4049
<UniformGrid x:Name="CenteredHeaderPanel"
4150
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
4251
Margin="{Binding Path=(wpf:TabAssist.HeaderPanelMargin), RelativeSource={RelativeSource TemplatedParent}}"
@@ -53,7 +62,7 @@
5362
Focusable="False"
5463
KeyboardNavigation.TabIndex="1"
5564
Orientation="Horizontal" />
56-
</StackPanel>
65+
</internal:PaddedBringIntoViewStackPanel>
5766
</ScrollViewer>
5867
</wpf:ColorZone>
5968

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

231244
<Style.Triggers>
232245
<Trigger Property="wpf:TabAssist.HeaderBehavior" Value="Wrapping">

tests/MaterialDesignThemes.UITests/TestBase.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Diagnostics.CodeAnalysis;
22
using System.Windows.Media;
33
using MaterialDesignThemes.UITests;
4+
using MaterialDesignThemes.Wpf.Internal;
45
using TUnit.Core.Interfaces;
56

67
[assembly: ParallelLimiter<SingleParallelLimit>]
@@ -16,6 +17,7 @@
1617
[assembly: GenerateHelpers(typeof(TimePicker))]
1718
[assembly: GenerateHelpers(typeof(TreeListView))]
1819
[assembly: GenerateHelpers(typeof(TreeListViewItem))]
20+
[assembly: GenerateHelpers(typeof(PaddedBringIntoViewStackPanel))]
1921

2022
namespace MaterialDesignThemes.UITests;
2123

0 commit comments

Comments
 (0)