Skip to content

WPF Smooth Scroll Viewer

In this article I will demonstrate how to implement a WPF Scroll Viewer that scrolls smoothly.

By default, WPF scrolling is not smooth/animated. I.e. it jumps between the scroll steps. There are some solutions around to add animation, but I didn’t find a properly solution for me so I decided to implement an own one. I tried to find an easy implementation which just adds animation without changing the default scrolling behavior and without adding a new control template. I found out that there is an ideal place to hook in: between the ScrollViewer and the IScrollInfo (i.e. the panel) counterpart. So I implemented an adapter class which is hooked in on loading time of the ScrollViewer. This also allows us to add the smooth scrolling functionality using a behavior. However, a drawback when using a behavior is that the ScrollInfo property is protected and needs to be accessed using reflection. If you don’t want to do that you can create a derivation from ScrollViewer, add an event handler for the Loaded event and add the adapter.

Hint: the animated scrolling is only working when scrolling is performed by physical units (i.e. CanContentScroll is set to false). When CanContentScroll = true the animation is automatically switched off!

The Scroll Info adapter is quite easy:

using System; using System.Windows; using System.Windows.Controls.Primitives; using System.Windows.Media; using System.Windows.Media.Animation; namespace ScrollViewer { public class ScrollInfoAdapter : UIElement, IScrollInfo { private IScrollInfo _child; private double _computedVerticalOffset = 0; private double _computedHorizontalOffset = 0; internal const double _scrollLineDelta = 16.0; internal const double _mouseWheelDelta = 48.0; public ScrollInfoAdapter(IScrollInfo child) { _child = child; } public bool CanVerticallyScroll { get => _child.CanVerticallyScroll; set => _child.CanVerticallyScroll = value; } public bool CanHorizontallyScroll { get => _child.CanHorizontallyScroll; set => _child.CanHorizontallyScroll = value; } public double ExtentWidth => _child.ExtentWidth; public double ExtentHeight => _child.ExtentHeight; public double ViewportWidth => _child.ViewportWidth; public double ViewportHeight => _child.ViewportHeight; public double HorizontalOffset => _child.HorizontalOffset; public double VerticalOffset => _child.VerticalOffset; public System.Windows.Controls.ScrollViewer ScrollOwner { get => _child.ScrollOwner; set => _child.ScrollOwner = value; } public Rect MakeVisible(Visual visual, Rect rectangle) { return _child.MakeVisible(visual, rectangle); } public void LineUp() { if (_child.ScrollOwner.CanContentScroll == true) _child.LineUp(); else VerticalScroll(_computedVerticalOffset - _scrollLineDelta); } public void LineDown() { if (_child.ScrollOwner.CanContentScroll == true) _child.LineDown(); else VerticalScroll(_computedVerticalOffset + _scrollLineDelta); } public void LineLeft() { if (_child.ScrollOwner.CanContentScroll == true) _child.LineLeft(); else HorizontalScroll(_computedHorizontalOffset - _scrollLineDelta); } public void LineRight() { if (_child.ScrollOwner.CanContentScroll == true) _child.LineRight(); else HorizontalScroll(_computedHorizontalOffset + _scrollLineDelta); } public void MouseWheelUp() { if (_child.ScrollOwner.CanContentScroll == true) _child.MouseWheelUp(); else VerticalScroll(_computedVerticalOffset - _mouseWheelDelta); } public void MouseWheelDown() { if (_child.ScrollOwner.CanContentScroll == true) _child.MouseWheelDown(); else VerticalScroll(_computedVerticalOffset + _mouseWheelDelta); } public void MouseWheelLeft() { if (_child.ScrollOwner.CanContentScroll == true) _child.MouseWheelLeft(); else HorizontalScroll(_computedHorizontalOffset - _mouseWheelDelta); } public void MouseWheelRight() { if (_child.ScrollOwner.CanContentScroll == true) _child.MouseWheelRight(); else HorizontalScroll(_computedHorizontalOffset + _mouseWheelDelta); } public void PageUp() { if (_child.ScrollOwner.CanContentScroll == true) _child.PageUp(); else VerticalScroll(_computedVerticalOffset - ViewportHeight); } public void PageDown() { if (_child.ScrollOwner.CanContentScroll == true) _child.PageDown(); else VerticalScroll(_computedVerticalOffset + ViewportHeight); } public void PageLeft() { if (_child.ScrollOwner.CanContentScroll == true) _child.PageLeft(); else HorizontalScroll(_computedHorizontalOffset - ViewportWidth); } public void PageRight() { if (_child.ScrollOwner.CanContentScroll == true) _child.PageRight(); else HorizontalScroll(_computedHorizontalOffset + ViewportWidth); } public void SetHorizontalOffset(double offset) { if (_child.ScrollOwner.CanContentScroll == true) _child.SetHorizontalOffset(offset); else { _computedHorizontalOffset = offset; Animate(HorizontalScrollOffsetProperty, offset, 0); } } public void SetVerticalOffset(double offset) { if (_child.ScrollOwner.CanContentScroll == true) _child.SetVerticalOffset(offset); else { _computedVerticalOffset = offset; Animate(VerticalScrollOffsetProperty, offset, 0); } } #region not exposed methods private void Animate(DependencyProperty property, double targetValue, int duration = 300) { //make a smooth animation that starts and ends slowly var keyFramesAnimation = new DoubleAnimationUsingKeyFrames(); keyFramesAnimation.Duration = TimeSpan.FromMilliseconds(duration); keyFramesAnimation.KeyFrames.Add( new SplineDoubleKeyFrame( targetValue, KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(duration)), new KeySpline(0.5, 0.0, 0.5, 1.0) ) ); BeginAnimation(property, keyFramesAnimation); } private void VerticalScroll(double val) { if (Math.Abs(_computedVerticalOffset - ValidateVerticalOffset(val)) > 0.1)//prevent restart of animation in case of frequent event fire { _computedVerticalOffset = ValidateVerticalOffset(val); Animate(VerticalScrollOffsetProperty, _computedVerticalOffset); } } private void HorizontalScroll(double val) { if (Math.Abs(_computedHorizontalOffset - ValidateHorizontalOffset(val)) > 0.1)//prevent restart of animation in case of frequent event fire { _computedHorizontalOffset = ValidateHorizontalOffset(val); Animate(HorizontalScrollOffsetProperty, _computedHorizontalOffset); } } private double ValidateVerticalOffset(double verticalOffset) { if (verticalOffset < 0) return 0; if (verticalOffset > _child.ScrollOwner.ScrollableHeight) return _child.ScrollOwner.ScrollableHeight; return verticalOffset; } private double ValidateHorizontalOffset(double horizontalOffset) { if (horizontalOffset < 0) return 0; if (horizontalOffset > _child.ScrollOwner.ScrollableWidth) return _child.ScrollOwner.ScrollableWidth; return horizontalOffset; } #endregion #region helper dependency properties as scrollbars are not animatable by default internal double VerticalScrollOffset { get { return (double)GetValue(VerticalScrollOffsetProperty); } set { SetValue(VerticalScrollOffsetProperty, value); } } internal static readonly DependencyProperty VerticalScrollOffsetProperty = DependencyProperty.Register("VerticalScrollOffset", typeof(double), typeof(ScrollInfoAdapter), new PropertyMetadata(0.0, new PropertyChangedCallback(OnVerticalScrollOffsetChanged))); private static void OnVerticalScrollOffsetChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var smoothScrollViewer = (ScrollInfoAdapter)d; smoothScrollViewer._child.SetVerticalOffset((double)e.NewValue); } internal double HorizontalScrollOffset { get { return (double)GetValue(HorizontalScrollOffsetProperty); } set { SetValue(HorizontalScrollOffsetProperty, value); } } internal static readonly DependencyProperty HorizontalScrollOffsetProperty = DependencyProperty.Register("HorizontalScrollOffset", typeof(double), typeof(ScrollInfoAdapter), new PropertyMetadata(0.0, new PropertyChangedCallback(OnHorizontalScrollOffsetChanged))); private static void OnHorizontalScrollOffsetChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var smoothScrollViewer = (ScrollInfoAdapter)d; smoothScrollViewer._child.SetHorizontalOffset((double)e.NewValue); } #endregion } }
Code language: C# (cs)

This adapter can be added be derivating from ScrollViewer or be means of an Behavior.

using System.Windows; namespace ScrollViewer { public class SmoothScrollViewer : System.Windows.Controls.ScrollViewer { public SmoothScrollViewer() { Loaded += ScrollViewer_Loaded; } private void ScrollViewer_Loaded(object sender, RoutedEventArgs e) { ScrollInfo = new ScrollInfoAdapter(ScrollInfo); } } }
Code language: C# (cs)
using Microsoft.Xaml.Behaviors; using System.Reflection; using System.Windows.Controls.Primitives; namespace ScrollViewer { public class SmoothScrollViewerBehavior : Behavior<System.Windows.Controls.ScrollViewer> { protected override void OnAttached() { base.OnAttached(); AssociatedObject.Loaded += ScrollViewerLoaded; } private void ScrollViewerLoaded(object sender, System.Windows.RoutedEventArgs e) { var property = AssociatedObject.GetType().GetProperty("ScrollInfo", BindingFlags.NonPublic | BindingFlags.Instance); property.SetValue(AssociatedObject, new ScrollInfoAdapter((IScrollInfo)property.GetValue(AssociatedObject))); } } }
Code language: C# (cs)

3 Comments

  1. NO matter what I do i have error Severity
    Error XDG0008 The name “SmoothScrollViewerBehavior” does not exist in the namespace “clr-namespace:ScrollViewer;assembly=ScrollViewer”.

    PLease can helpme?

    • Hi!

      have you copied the SmoothScrollViewerBehavior definition to your project?

      Best regards,
      Thomas

Leave a Reply

Your email address will not be published. Required fields are marked *

Copyright (c) by Thomas Kemp, 2021