-
Notifications
You must be signed in to change notification settings - Fork 3.5k
Align (scrolling) TabControl with MD spec #3981
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
nicolaihenriksen
wants to merge
16
commits into
master
Choose a base branch
from
issue3976-nh
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+324
−2
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
df185e4
PoC for hijacking RequestBringIntoView event
nicolaihenriksen 8f02def
Improves tab header scrolling behavior
nicolaihenriksen 0511117
Adding smooth/animated scrolling to the behavior
nicolaihenriksen 721a25c
Ensure padding on tab headers is only applied when they overflow
nicolaihenriksen f631a2d
Adding UI tests without assertions to easily test behavior
nicolaihenriksen 9cace11
Minor hack! Prevent double-click on tab (control) while animating
nicolaihenriksen 7c0c9ee
Add TabAssist.AnimateTabScrolling to toggle tab switch animation feature
nicolaihenriksen c27cc43
Add TODO comment regarding destructive-read on TabScrollDirection AP
nicolaihenriksen d18d4d4
Add TabAssist.TabScrollOffset to give control of the offset
nicolaihenriksen 4a960c0
Replace TabAssist.AnimateTabScrolling with TabAssist.TabScrollDuration
nicolaihenriksen 9631e7a
Rename hijacking StackPanel
nicolaihenriksen f001a07
Rename TabScrollDirection
nicolaihenriksen 737223f
Rename TabScrollOffset
nicolaihenriksen eb5d42d
Rename TabScrollDuration
nicolaihenriksen 966c1e8
Remove debug output
nicolaihenriksen a604ccf
Add TabAssist.UseHeaderPadding to enable/disable new behavior
nicolaihenriksen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
168 changes: 168 additions & 0 deletions
168
src/MaterialDesignThemes.Wpf/Behaviors/Internal/TabControlHeaderScrollBehavior.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ScrollViewer> | ||
| { | ||
| 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 | ||
| } |
60 changes: 60 additions & 0 deletions
60
src/MaterialDesignThemes.Wpf/Internal/PaddedBringIntoViewStackPanel.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?