Skip to content

Conversation

@nicolaihenriksen
Copy link
Contributor

@nicolaihenriksen nicolaihenriksen commented Dec 10, 2025

Fixes #3976

This PR aligns the TabControl (scrolling) style with the MD spec.

It contains the following changes:

  • Introduces TabControlHeaderScrollBehavior in order to react the tab/scroll events.
  • Introduces custom StackPanel (PaddedBringIntoViewStackPanel) in order to hijack and re-raise the RequestBringIntoView event.
    • It sits in between the ScrollViewer and the contained TabItems. It "hijacks" the FrameworkElement.RequestBringIntoViewEvent by marking it as handled, and subsequently issue a new FrameworkElement.BringIntoView(Rect) call with a Rect matching the original event, but offset left/right based on scroll direction.
  • Adds a "padding" to the left of the first tab, and to the right of the last tab as per the MD spec.
    • This padding is only applied if the width of the tabs overflow the available width.
  • Adds smooth/animated scrolling behavior when a partially visible tab needs to be brought into view.
    • This is done by animating a custom AP which in turn calls ScrollViewer.ScrollToHorizontalOffset() - thanks @Keboo for that idea!
    • A slight hack is used to prevent users to click on anything while the (very short) animation is running.
  • Adds TabAssist.ScrollDuration AP to allow consumers to control the animation duration. TimeSpan.Zero effectively disables the animation.
    • Default value set in Style to allow easy override at the call site.
  • Adds TabAssist.HeaderPadding AP to allow consumers to modify the offset (i.e. "padding" from above).
    • Default value set in Style to allow easy override at the call site.
  • Adds TabAssist.UseHeaderPadding AP to allow consumers to modify enable/disable the new behavior. The default value is "on".
    • Default value set in Style to allow easy override at the call site.
  • 2 new UI tests which currently don't assert anything, but allow for easy testing of the feature.

Tested the following

  • Ensure "mouse-wheel tilt" feature behaves nicely with this feature. Thanks @Keboo for the help.
  • Ensure FlowDirection=RightToLeft works as expected.
  • Ensure TabControl.TabStripPlacement=Left|Top|Right|Bottom all work as expected.

Still missing the following (thanks @corvinsz for finding the issues):

  • Ensure (animated) scrolling works when navigating tabs with the keyboard, fast keyboard presses currently do not work (see comment-thread below for details).
  • Ensure (animated) scrolling works with focusable Tab-content (see comment-thread below for details).

I have left a couple of UI tests that don't currently assert anything in there. Feel free to have a stab at them if you wish. If not, I will get around to it eventually... X-mas is a busy time 😄

Preview of the feature

ScrollingTabsFix

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.
There is no need for the padding if all tabs can fit inside the visible region.
The intention is to eventually add some assertions, but we'll need to figure out what is relevant to assert on.
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.
Defaulted to True in a style setter allowing for easy override at the call site.
Default value (40, although spec says 52) is set in style to allow easy override at the call site.
Setting a duration of TimeSpan.Zero, effectively disables the animated scrolling.
@Keboo
Copy link
Member

Keboo commented Dec 13, 2025

I tested it tonight and my mouse does have a tilt scroll. It works as I would expect. Each tilt of the wheel applies the small scroll amount of the scroll viewer. Effectively the same as clicking the arrow buttons of the scroll view if they were visible. This does mean that you can scroll your tab view to a state where a tab is right on the horizon line. However, I think that is the expected behavior.

For the naming, how about something like PaddedBringIntoViewStackPanel and then rename the properties to something like TabScrollDirection => ScrollDirection and TabScrollOffset => Padding. Thoughts?

For the hack stuff, I sort of anticipated it would be something a little ugly like that. At least with my slow fingers I couldn't really break it in any meaningful way so perhaps it is fine at least for a V1.

One final thing, I think we will want this on by default, but I think we will probably want a simple AttachedProperty to allow people to turn it off to get more default tab control behavior.

@nicolaihenriksen
Copy link
Contributor Author

@Keboo Thanks for testing the tilt 👍 I'm glad that you didn't find any obvious conflicts between the 2 features.

TabScrollOffset => Padding. Thoughts?

Since this AP lives on TabAssist, won't that name be a little "too generic"? Perhaps TabHeaderPadding or just HeaderPadding (if TabAssist makes it obvious that we're dealing with tabs)?

I will rename the StackPanel to your suggestion, and also add in a new AP for enabling/disabling the behavior (on by default); we may need to iterate on the naming of that AP as well 🤣

@nicolaihenriksen nicolaihenriksen marked this pull request as ready for review December 14, 2025 20:27
DependencyProperty.Register(nameof(UseHeaderPadding), typeof(bool), typeof(PaddedBringIntoViewStackPanel), new PropertyMetadata(false));

public PaddedBringIntoViewStackPanel()
=> AddHandler(FrameworkElement.RequestBringIntoViewEvent, new RoutedEventHandler(OnRequestBringIntoView), false);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Keboo We may want to handle the AddHandler()/RemoveHandler() on loaded/unloaded instead, to avoid a potential handler leak. Thoughts? Could also consider if registering a class handler is the better approach?

@corvinsz
Copy link
Member

This is starting to look great.
One small nuance I noticed:

  • When double-tapping an arrow key to navigate through the tabs, the selected tab is not always brought into view correctly

I assume this has to do with the scrolling animation not having finished yet?

tabControlSpamArrowKey.mp4

@nicolaihenriksen
Copy link
Contributor Author

@corvinsz Thanks. That is exactly why. The same thing could happen with quick mouse clicks, but I "fixed" that with a hack of temporarily making it not hit test visible. Perhaps some similar short-circuit hack needs to be added for they keyboard case... Ideas are welcome.

@corvinsz
Copy link
Member

corvinsz commented Dec 16, 2025

@corvinsz Thanks. That is exactly why. The same thing could happen with quick mouse clicks, but I "fixed" that with a hack of temporarily making it not hit test visible. Perhaps some similar short-circuit hack needs to be added for they keyboard case... Ideas are welcome.

Maybe the following could be a possible solution for the above problem?
At least from my manual testing it worked like expected. I'm not sure of any side-effects though:
Listen for all Keyboard.PreviewKeyDownEvent on the TabControl and mark it as e.Handled = true if the pressed key is any of the ones which can change the selected tab?

protected override void OnAttached()
{
    base.OnAttached();
    AssociatedObject.ScrollChanged += AssociatedObject_ScrollChanged;
    AssociatedObject.SizeChanged += AssociatedObject_SizeChanged;
+   TabControl.AddHandler(Keyboard.PreviewKeyDownEvent, (KeyEventHandler)TabControl_PreviewKeyDown, handledEventsToo: true);
    Dispatcher.BeginInvoke(() => AddPaddingToScrollableContentIfWiderThanViewPort());
}

protected override void OnDetaching()
{
    base.OnDetaching();
    if (AssociatedObject is { } ao)
    {
        ao.ScrollChanged -= AssociatedObject_ScrollChanged;
        ao.SizeChanged -= AssociatedObject_SizeChanged;
    }
+   TabControl.RemoveHandler(Keyboard.PreviewKeyDownEvent, (KeyEventHandler)TabControl_PreviewKeyDown);
}

+private void TabControl_PreviewKeyDown(object sender, KeyEventArgs e)
+{
+    if (_isAnimatingScroll == false)
+    {
+        return;
+    }
+
+    if (e.Key is Key.Left or
+                 Key.Right or
+                 Key.Home or
+                 Key.End or
+                 Key.PageUp or
+                 Key.PageDown)
+    {
+        e.Handled = true;
+    }
+}

Problem with focusable content

@nicolaihenriksen I think there is also still a fundamental problem (which I haven't looked into myself). If a tab contains a focusable element (e.g. TextBox) the scrolling does not seem to work.
Maybe you can add this to your first post in this PR so that it is tracked as a "task"?

  • Ensure scrolling with focusable Tab-content
    527101653-26ad51c3-4828-40a9-a8fb-498a210e0cb6

@nicolaihenriksen
Copy link
Contributor Author

Maybe you can add this to your first post in this PR so that it is tracked as a "task"?

@corvinsz I have added the tasks to the PR description. Thanks for finding the issues!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TabControl does not conform to MD specs

4 participants