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] 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