From ce4c6740991c1e91314f46d563fd208067c51481 Mon Sep 17 00:00:00 2001
From: maihcx <59072697+maihcx@users.noreply.github.com>
Date: Thu, 13 Nov 2025 08:40:29 +0700
Subject: [PATCH 1/3] feat(controls): Add Smooth Scrolling for Page
---
.../DynamicScrollViewer.xaml | 3 +
src/Wpf.Ui/Controls/SmoothScrollBehavior.cs | 330 ++++++++++++++++++
2 files changed, 333 insertions(+)
create mode 100644 src/Wpf.Ui/Controls/SmoothScrollBehavior.cs
diff --git a/src/Wpf.Ui/Controls/DynamicScrollViewer/DynamicScrollViewer.xaml b/src/Wpf.Ui/Controls/DynamicScrollViewer/DynamicScrollViewer.xaml
index 201647a79..ea3445e69 100644
--- a/src/Wpf.Ui/Controls/DynamicScrollViewer/DynamicScrollViewer.xaml
+++ b/src/Wpf.Ui/Controls/DynamicScrollViewer/DynamicScrollViewer.xaml
@@ -18,6 +18,9 @@
+
+
+
diff --git a/src/Wpf.Ui/Controls/SmoothScrollBehavior.cs b/src/Wpf.Ui/Controls/SmoothScrollBehavior.cs
new file mode 100644
index 000000000..8fb4c01a5
--- /dev/null
+++ b/src/Wpf.Ui/Controls/SmoothScrollBehavior.cs
@@ -0,0 +1,330 @@
+// This Source Code Form is subject to the terms of the MIT License.
+// If a copy of the MIT was not distributed with this file, You can obtain one at https://opensource.org/licenses/MIT.
+// Copyright (C) Leszek Pomianowski and WPF UI Contributors.
+// All Rights Reserved.
+
+using System.Runtime.CompilerServices;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Animation;
+
+namespace Wpf.Ui.Controls;
+
+///
+/// Attached behavior to add smooth scrolling to any ScrollViewer
+///
+public static class SmoothScrollBehavior
+{
+ private class ScrollData
+ {
+ public double LastVerticalOffset { get; set; }
+
+ public double LastHorizontalOffset { get; set; }
+
+ public bool IsAnimating { get; set; }
+ }
+
+ private static readonly ConditionalWeakTable _scrollDataTable = new();
+
+ public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
+ "IsEnabled",
+ typeof(bool),
+ typeof(SmoothScrollBehavior),
+ new PropertyMetadata(false, OnIsEnabledChanged)
+ );
+
+ public static readonly DependencyProperty DurationProperty = DependencyProperty.RegisterAttached(
+ "Duration",
+ typeof(double),
+ typeof(SmoothScrollBehavior),
+ new PropertyMetadata(250.0)
+ );
+
+ public static readonly DependencyProperty MultiplierProperty = DependencyProperty.RegisterAttached(
+ "Multiplier",
+ typeof(double),
+ typeof(SmoothScrollBehavior),
+ new PropertyMetadata(1.0)
+ );
+
+ public static readonly DependencyProperty AnimatedVerticalOffsetProperty = DependencyProperty.RegisterAttached(
+ "AnimatedVerticalOffset",
+ typeof(double),
+ typeof(SmoothScrollBehavior),
+ new PropertyMetadata(0.0, OnAnimatedVerticalOffsetChanged)
+ );
+
+ public static readonly DependencyProperty AnimatedHorizontalOffsetProperty = DependencyProperty.RegisterAttached(
+ "AnimatedHorizontalOffset",
+ typeof(double),
+ typeof(SmoothScrollBehavior),
+ new PropertyMetadata(0.0, OnAnimatedHorizontalOffsetChanged)
+ );
+
+ public static readonly DependencyProperty IsAnimatingProperty = DependencyProperty.RegisterAttached(
+ "IsAnimating",
+ typeof(bool),
+ typeof(SmoothScrollBehavior),
+ new PropertyMetadata(false)
+ );
+
+ public static bool GetIsEnabled(DependencyObject obj) => (bool)obj.GetValue(IsEnabledProperty);
+
+ public static void SetIsEnabled(DependencyObject obj, bool value) => obj.SetValue(IsEnabledProperty, value);
+
+ public static double GetDuration(DependencyObject obj) => (double)obj.GetValue(DurationProperty);
+
+ public static void SetDuration(DependencyObject obj, double value) => obj.SetValue(DurationProperty, value);
+
+ public static double GetMultiplier(DependencyObject obj) => (double)obj.GetValue(MultiplierProperty);
+
+ public static void SetMultiplier(DependencyObject obj, double value) => obj.SetValue(MultiplierProperty, value);
+
+ private static double GetAnimatedVerticalOffset(DependencyObject obj) => (double)obj.GetValue(AnimatedVerticalOffsetProperty);
+
+ private static void SetAnimatedVerticalOffset(DependencyObject obj, double value) => obj.SetValue(AnimatedVerticalOffsetProperty, value);
+
+ private static double GetAnimatedHorizontalOffset(DependencyObject obj) => (double)obj.GetValue(AnimatedHorizontalOffsetProperty);
+
+ private static void SetAnimatedHorizontalOffset(DependencyObject obj, double value) => obj.SetValue(AnimatedHorizontalOffsetProperty, value);
+
+ private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is ScrollViewer scrollViewer)
+ {
+ if ((bool)e.NewValue)
+ {
+ AttachScrollViewer(scrollViewer);
+ }
+ else
+ {
+ DetachScrollViewer(scrollViewer);
+ }
+ }
+ else if (d is FrameworkElement element)
+ {
+ if ((bool)e.NewValue)
+ {
+ element.Loaded += OnElementLoaded;
+ }
+ else
+ {
+ element.Loaded -= OnElementLoaded;
+ }
+ }
+ }
+
+ private static void OnElementLoaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is FrameworkElement element)
+ {
+ ScrollViewer? scrollViewer = FindScrollViewer(element);
+
+ if (scrollViewer != null)
+ {
+ AttachScrollViewer(scrollViewer);
+ }
+ }
+ }
+
+ private static void AttachScrollViewer(ScrollViewer scrollViewer)
+ {
+ ScrollData data = _scrollDataTable.GetOrCreateValue(scrollViewer);
+
+ data.LastVerticalOffset = scrollViewer.VerticalOffset;
+ data.LastHorizontalOffset = scrollViewer.HorizontalOffset;
+
+ scrollViewer.PreviewMouseWheel += ScrollViewer_PreviewMouseWheel;
+ scrollViewer.ScrollChanged += ScrollViewer_ScrollChanged;
+ }
+
+ private static void DetachScrollViewer(ScrollViewer scrollViewer)
+ {
+ scrollViewer.PreviewMouseWheel -= ScrollViewer_PreviewMouseWheel;
+ scrollViewer.ScrollChanged -= ScrollViewer_ScrollChanged;
+
+ _ = _scrollDataTable.Remove(scrollViewer);
+ }
+
+ private static void ScrollViewer_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
+ {
+ if (sender is not ScrollViewer scrollViewer)
+ {
+ return;
+ }
+
+ if (!_scrollDataTable.TryGetValue(scrollViewer, out ScrollData? data))
+ {
+ return;
+ }
+
+ // Check if scrolling inside nested scrollviewer
+ if (IsNestedScrollViewer(e.OriginalSource as DependencyObject, scrollViewer))
+ {
+ return;
+ }
+
+ bool isHorizontal = Keyboard.Modifiers == ModifierKeys.Shift;
+ double multiplier = GetMultiplier(scrollViewer);
+
+ if (isHorizontal)
+ {
+ if (scrollViewer.ScrollableWidth <= 0)
+ {
+ return;
+ }
+
+ e.Handled = true;
+
+ double wheelChange = e.Delta * multiplier;
+ double newOffset = data.LastHorizontalOffset - wheelChange;
+ newOffset = Math.Max(0, Math.Min(scrollViewer.ScrollableWidth, newOffset));
+
+ if (Math.Abs(newOffset - data.LastHorizontalOffset) < 0.1)
+ {
+ return;
+ }
+
+ scrollViewer.ScrollToHorizontalOffset(data.LastHorizontalOffset);
+ AnimateScroll(scrollViewer, newOffset, false);
+ data.LastHorizontalOffset = newOffset;
+ }
+ else
+ {
+ if (scrollViewer.ScrollableHeight <= 0)
+ {
+ return;
+ }
+
+ double wheelChange = e.Delta * multiplier;
+ double newOffset = data.LastVerticalOffset - wheelChange;
+
+ // Check boundary for parent scrolling
+ if ((newOffset < 0 && wheelChange < 0) || (newOffset > scrollViewer.ScrollableHeight && wheelChange > 0))
+ {
+ return;
+ }
+
+ e.Handled = true;
+
+ newOffset = Math.Max(0, Math.Min(scrollViewer.ScrollableHeight, newOffset));
+
+ if (Math.Abs(newOffset - data.LastVerticalOffset) < 0.1)
+ {
+ return;
+ }
+
+ scrollViewer.ScrollToVerticalOffset(data.LastVerticalOffset);
+ AnimateScroll(scrollViewer, newOffset, true);
+ data.LastVerticalOffset = newOffset;
+ }
+ }
+
+ private static void ScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e)
+ {
+ if (sender is not ScrollViewer scrollViewer)
+ {
+ return;
+ }
+
+ if (!_scrollDataTable.TryGetValue(scrollViewer, out ScrollData? data))
+ {
+ return;
+ }
+
+ // Update last offsets only when not animating
+ if (!data.IsAnimating)
+ {
+ data.LastVerticalOffset = scrollViewer.VerticalOffset;
+ data.LastHorizontalOffset = scrollViewer.HorizontalOffset;
+ }
+ }
+
+ private static void AnimateScroll(ScrollViewer scrollViewer, double toValue, bool isVertical)
+ {
+ if (!_scrollDataTable.TryGetValue(scrollViewer, out ScrollData? data))
+ {
+ return;
+ }
+
+ data.IsAnimating = true;
+
+ double duration = GetDuration(scrollViewer);
+
+ DependencyProperty property = isVertical ? AnimatedVerticalOffsetProperty : AnimatedHorizontalOffsetProperty;
+
+ double fromValue = isVertical ? scrollViewer.VerticalOffset : scrollViewer.HorizontalOffset;
+
+ scrollViewer.BeginAnimation(property, null);
+
+ var animation = new DoubleAnimation
+ {
+ From = fromValue,
+ To = toValue,
+ Duration = TimeSpan.FromMilliseconds(duration),
+ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
+ };
+
+ animation.Completed += (s, e) => { data.IsAnimating = false; };
+
+ scrollViewer.BeginAnimation(property, animation);
+ }
+
+ private static void OnAnimatedVerticalOffsetChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is ScrollViewer scrollViewer)
+ {
+ scrollViewer.ScrollToVerticalOffset((double)e.NewValue);
+ }
+ }
+
+ private static void OnAnimatedHorizontalOffsetChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is ScrollViewer scrollViewer)
+ {
+ scrollViewer.ScrollToHorizontalOffset((double)e.NewValue);
+ }
+ }
+
+ private static bool IsNestedScrollViewer(DependencyObject? element, ScrollViewer parentScrollViewer)
+ {
+ if (element == null)
+ {
+ return false;
+ }
+
+ while (element != null && element != parentScrollViewer)
+ {
+ if (element is ScrollViewer sv && sv != parentScrollViewer)
+ {
+ return sv.ScrollableHeight > 0 || sv.ScrollableWidth > 0;
+ }
+
+ element = VisualTreeHelper.GetParent(element);
+ }
+
+ return false;
+ }
+
+ private static ScrollViewer? FindScrollViewer(DependencyObject element)
+ {
+ if (element is ScrollViewer sv)
+ {
+ return sv;
+ }
+
+ for (int i = 0; i < VisualTreeHelper.GetChildrenCount(element); i++)
+ {
+ DependencyObject child = VisualTreeHelper.GetChild(element, i);
+ ScrollViewer? result = FindScrollViewer(child);
+ if (result != null)
+ {
+ return result;
+ }
+ }
+
+ return null;
+ }
+}
\ No newline at end of file
From 65a0908ba8809a59439388cc784b4a52003717d4 Mon Sep 17 00:00:00 2001
From: Huynh Cong Xuan Mai <59072697+maihcx@users.noreply.github.com>
Date: Tue, 16 Jun 2026 14:15:49 +0700
Subject: [PATCH 2/3] Refactor SmoothScrollBehavior for improved scrolling
Updated the SmoothScrollBehavior to enhance scrolling functionality and adjust default duration.
---
src/Wpf.Ui/Controls/SmoothScrollBehavior.cs | 105 +++++++++++++++++---
1 file changed, 89 insertions(+), 16 deletions(-)
diff --git a/src/Wpf.Ui/Controls/SmoothScrollBehavior.cs b/src/Wpf.Ui/Controls/SmoothScrollBehavior.cs
index 8fb4c01a5..ad8cc7b14 100644
--- a/src/Wpf.Ui/Controls/SmoothScrollBehavior.cs
+++ b/src/Wpf.Ui/Controls/SmoothScrollBehavior.cs
@@ -5,9 +5,7 @@
using System.Runtime.CompilerServices;
using System.Windows.Controls;
-using System.Windows.Controls.Primitives;
using System.Windows.Input;
-using System.Windows.Media;
using System.Windows.Media.Animation;
namespace Wpf.Ui.Controls;
@@ -24,6 +22,8 @@ private class ScrollData
public double LastHorizontalOffset { get; set; }
public bool IsAnimating { get; set; }
+
+ public FrameworkElement? SourceElement { get; set; }
}
private static readonly ConditionalWeakTable _scrollDataTable = new();
@@ -39,7 +39,7 @@ private class ScrollData
"Duration",
typeof(double),
typeof(SmoothScrollBehavior),
- new PropertyMetadata(250.0)
+ new PropertyMetadata(300.0)
);
public static readonly DependencyProperty MultiplierProperty = DependencyProperty.RegisterAttached(
@@ -112,21 +112,46 @@ private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyCha
else
{
element.Loaded -= OnElementLoaded;
+
+ ScrollViewer? sv = FindScrollViewer(element);
+ if (sv != null)
+ {
+ DetachScrollViewer(sv);
+ }
}
}
}
private static void OnElementLoaded(object sender, RoutedEventArgs e)
{
- if (sender is FrameworkElement element)
+ if (sender is not FrameworkElement element)
+ {
+ return;
+ }
+
+ VirtualizingPanel.SetScrollUnit(element, ScrollUnit.Pixel);
+
+ element.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, () =>
{
ScrollViewer? scrollViewer = FindScrollViewer(element);
+ if (scrollViewer == null)
+ {
+ return;
+ }
+
+ VirtualizingPanel.SetScrollUnit(scrollViewer, ScrollUnit.Pixel);
- if (scrollViewer != null)
+ VirtualizingPanel? panel = FindVisualChild(scrollViewer);
+ if (panel != null)
{
- AttachScrollViewer(scrollViewer);
+ VirtualizingPanel.SetScrollUnit(panel, ScrollUnit.Pixel);
}
- }
+
+ ScrollData data = _scrollDataTable.GetOrCreateValue(scrollViewer);
+ data.SourceElement = element;
+
+ AttachScrollViewer(scrollViewer);
+ });
}
private static void AttachScrollViewer(ScrollViewer scrollViewer)
@@ -145,6 +170,22 @@ private static void DetachScrollViewer(ScrollViewer scrollViewer)
scrollViewer.PreviewMouseWheel -= ScrollViewer_PreviewMouseWheel;
scrollViewer.ScrollChanged -= ScrollViewer_ScrollChanged;
+ if (_scrollDataTable.TryGetValue(scrollViewer, out ScrollData? data))
+ {
+ if (data.SourceElement != null)
+ {
+ VirtualizingPanel.SetScrollUnit(data.SourceElement, ScrollUnit.Item);
+ }
+
+ VirtualizingPanel.SetScrollUnit(scrollViewer, ScrollUnit.Item);
+
+ VirtualizingPanel? panel = FindVisualChild(scrollViewer);
+ if (panel != null)
+ {
+ VirtualizingPanel.SetScrollUnit(panel, ScrollUnit.Item);
+ }
+ }
+
_ = _scrollDataTable.Remove(scrollViewer);
}
@@ -160,7 +201,6 @@ private static void ScrollViewer_PreviewMouseWheel(object sender, MouseWheelEven
return;
}
- // Check if scrolling inside nested scrollviewer
if (IsNestedScrollViewer(e.OriginalSource as DependencyObject, scrollViewer))
{
return;
@@ -187,7 +227,6 @@ private static void ScrollViewer_PreviewMouseWheel(object sender, MouseWheelEven
return;
}
- scrollViewer.ScrollToHorizontalOffset(data.LastHorizontalOffset);
AnimateScroll(scrollViewer, newOffset, false);
data.LastHorizontalOffset = newOffset;
}
@@ -201,7 +240,6 @@ private static void ScrollViewer_PreviewMouseWheel(object sender, MouseWheelEven
double wheelChange = e.Delta * multiplier;
double newOffset = data.LastVerticalOffset - wheelChange;
- // Check boundary for parent scrolling
if ((newOffset < 0 && wheelChange < 0) || (newOffset > scrollViewer.ScrollableHeight && wheelChange > 0))
{
return;
@@ -216,7 +254,6 @@ private static void ScrollViewer_PreviewMouseWheel(object sender, MouseWheelEven
return;
}
- scrollViewer.ScrollToVerticalOffset(data.LastVerticalOffset);
AnimateScroll(scrollViewer, newOffset, true);
data.LastVerticalOffset = newOffset;
}
@@ -251,11 +288,17 @@ private static void AnimateScroll(ScrollViewer scrollViewer, double toValue, boo
data.IsAnimating = true;
- double duration = GetDuration(scrollViewer);
+ double duration = GetDuration(scrollViewer); // giữ default 250ms, hoặc set XAML Duration="220"
DependencyProperty property = isVertical ? AnimatedVerticalOffsetProperty : AnimatedHorizontalOffsetProperty;
- double fromValue = isVertical ? scrollViewer.VerticalOffset : scrollViewer.HorizontalOffset;
+ double currentAnimatedValue = isVertical
+ ? GetAnimatedVerticalOffset(scrollViewer)
+ : GetAnimatedHorizontalOffset(scrollViewer);
+
+ double fromValue = data.IsAnimating && currentAnimatedValue > 0
+ ? currentAnimatedValue
+ : (isVertical ? scrollViewer.VerticalOffset : scrollViewer.HorizontalOffset);
scrollViewer.BeginAnimation(property, null);
@@ -264,7 +307,7 @@ private static void AnimateScroll(ScrollViewer scrollViewer, double toValue, boo
From = fromValue,
To = toValue,
Duration = TimeSpan.FromMilliseconds(duration),
- EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
+ EasingFunction = new ExponentialEase { EasingMode = EasingMode.EaseOut, Exponent = 2.5 }
};
animation.Completed += (s, e) => { data.IsAnimating = false; };
@@ -302,7 +345,15 @@ private static bool IsNestedScrollViewer(DependencyObject? element, ScrollViewer
return sv.ScrollableHeight > 0 || sv.ScrollableWidth > 0;
}
- element = VisualTreeHelper.GetParent(element);
+ DependencyObject? parent = null;
+ if (element is Visual or System.Windows.Media.Media3D.Visual3D)
+ {
+ parent = VisualTreeHelper.GetParent(element);
+ }
+
+ parent ??= LogicalTreeHelper.GetParent(element);
+
+ element = parent;
}
return false;
@@ -327,4 +378,26 @@ private static bool IsNestedScrollViewer(DependencyObject? element, ScrollViewer
return null;
}
-}
\ No newline at end of file
+
+ private static T? FindVisualChild(DependencyObject parent)
+ where T : DependencyObject
+ {
+ for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
+ {
+ DependencyObject child = VisualTreeHelper.GetChild(parent, i);
+
+ if (child is T t)
+ {
+ return t;
+ }
+
+ T? result = FindVisualChild(child);
+ if (result != null)
+ {
+ return result;
+ }
+ }
+
+ return null;
+ }
+}
From dee611282dcc6a9e50389f7924e865bc604ba583 Mon Sep 17 00:00:00 2001
From: Huynh Cong Xuan Mai <59072697+maihcx@users.noreply.github.com>
Date: Tue, 16 Jun 2026 14:31:09 +0700
Subject: [PATCH 3/3] Remove SmoothScrollBehavior settings from
DynamicScrollViewer
Removed SmoothScrollBehavior properties for duration and multiplier.
---
.../Controls/DynamicScrollViewer/DynamicScrollViewer.xaml | 2 --
1 file changed, 2 deletions(-)
diff --git a/src/Wpf.Ui/Controls/DynamicScrollViewer/DynamicScrollViewer.xaml b/src/Wpf.Ui/Controls/DynamicScrollViewer/DynamicScrollViewer.xaml
index ea3445e69..043e34de5 100644
--- a/src/Wpf.Ui/Controls/DynamicScrollViewer/DynamicScrollViewer.xaml
+++ b/src/Wpf.Ui/Controls/DynamicScrollViewer/DynamicScrollViewer.xaml
@@ -19,8 +19,6 @@
-
-