The blog of dlaa.me

Scrolling so smooth like the butter on a muffin [How to: Animate the Horizontal/VerticalOffset properties of a ScrollViewer]

Recently, I got email from two different people with the same question: How do I animate the HorizontalOffset/VerticalOffset properties of a Silverlight/WPF ScrollViewer control? Just in case you've never tried this yourself, I'll tell you that it's not quite as easy as you'd think; those two properties are both read-only and therefore can't be animated by a DoubleAnimation. The official way to accomplish this task is to call the ScrollToHorizontalOffset or ScrollToVerticalOffset method - but of course that can't be done by a DoubleAnimation either...

Aside: Why are HorizontalOffset and VerticalOffset read-only in the first place? Good question; I don't know! I don't see why they couldn't be writable in today's world, but I bet there was a good reason - once upon a time. :)

While I didn't have the time to implement a solution myself, I did have an idea for what to do. It was inspired by a rather quirky business plan and went like this:

  1. Create an attached DP for ScrollViewer of type double called SettableHorizontalOffset
  2. In its change handler, call scrollViewerInstance.ScrollToHorizontalOffset(newValue)
  3. Animate the attached DP on the ScrollViewer of your choice
  4. ???
  5. Profit

I cautioned at the time that my idea might not work - and sure enough, when I tried it myself, it didn't! It turns out that neither Silverlight nor WPF much like the idea of pointing Storyboard.TargetProperty at an attached DependencyProperty. So much for my clever idea; I needed a different way to solve the problem... And before long I realized that I could use a Mediator just like I did for a similar situation with LayoutTransformer (see previous posts).

 

So I created a simple class, ScrollViewerOffsetMediator, which exposes a ScrollViewer property that you point at a ScrollViewer instance (hint: use a Binding with ElementName to make this easy) and a VerticalOffset property which is conveniently writable. :) Any changes to the VerticalOffset property are automatically applied to the relevant ScrollViewer via a call to its ScrollToVerticalOffset method - so you can animate the mediator and it'll be just like you were animating the ScrollViewer itself!

That solves the original problem and it's all well and good - but it doesn't quite address a pretty common scenario: wanting to animate a ScrollViewer from top to bottom. You see, VerticalOffset is expressed in pixels and it's not easy to pass the (unknown) height of an arbitrary ScrollViewer to an animation in static XAML. So I added another property called ScrollableHeightMultiplier which takes a value between 0.0 and 1.0, multiplies it by the ScrollViewer's current ScrollableHeight and sets the VerticalOffset to that value. In this manner, animations can be written in general, pixel-independent terms, and it's easy to get the animated scrolling effect everybody wants!

Aside: I've implemented support for only the vertical direction - doing the same thing for the horizontal direction is left as an exercise to the reader.

 

The sample application uses ScrollableHeightMultiplier and an EasingFunction (just to show off!) and looks like this:

AnimatingScrollViewerOffsets sample

 

[Click here to download the complete AnimatingScrollViewerOffsets sample.]

 

The XAML for the sample is quite straightforward:

<Grid Height="200" Width="150">

    <!-- Trigger to start the animation when loaded -->
    <Grid.Triggers>
        <EventTrigger RoutedEvent="Grid.Loaded">
            <BeginStoryboard>
                <BeginStoryboard.Storyboard>
                    <!-- Animate back and forth forever -->
                    <Storyboard
                        AutoReverse="True"
                        RepeatBehavior="Forever">
                        <!-- Animate from top to bottom -->
                        <DoubleAnimation
                            Storyboard.TargetName="Mediator"
                            Storyboard.TargetProperty="ScrollableHeightMultiplier"
                            From="0"
                            To="1"
                            Duration="0:0:1">
                            <DoubleAnimation.EasingFunction>
                                <!-- Ease in and out -->
                                <ExponentialEase EasingMode="EaseInOut"/>
                            </DoubleAnimation.EasingFunction>
                        </DoubleAnimation>
                    </Storyboard>
                </BeginStoryboard.Storyboard>
            </BeginStoryboard>
        </EventTrigger>
    </Grid.Triggers>

    <!-- ScrollViewer that will be animated -->
    <ScrollViewer
        x:Name="Scroller">
        <!-- Arbitrary content... -->
        <StackPanel>
            <ItemsControl
                ItemsSource="{Binding}"
                FontSize="32"/>
        </StackPanel>
    </ScrollViewer>

    <!-- Mediator that forwards the property changes -->
    <local:ScrollViewerOffsetMediator
        x:Name="Mediator"
        ScrollViewer="{Binding ElementName=Scroller}"/>

</Grid>

 

Here's the complete implementation of ScrollViewerOffsetMediator which naturally works perfectly well on WPF:

/// <summary>
/// Mediator that forwards Offset property changes on to a ScrollViewer
/// instance to enable the animation of Horizontal/VerticalOffset.
/// </summary>
public class ScrollViewerOffsetMediator : FrameworkElement
{
    /// <summary>
    /// ScrollViewer instance to forward Offset changes on to.
    /// </summary>
    public ScrollViewer ScrollViewer
    {
        get { return (ScrollViewer)GetValue(ScrollViewerProperty); }
        set { SetValue(ScrollViewerProperty, value); }
    }
    public static readonly DependencyProperty ScrollViewerProperty =
        DependencyProperty.Register(
            "ScrollViewer",
            typeof(ScrollViewer),
            typeof(ScrollViewerOffsetMediator),
            new PropertyMetadata(OnScrollViewerChanged));
    private static void OnScrollViewerChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
    {
        var mediator = (ScrollViewerOffsetMediator)o;
        var scrollViewer = (ScrollViewer)(e.NewValue);
        if (null != scrollViewer)
        {
            scrollViewer.ScrollToVerticalOffset(mediator.VerticalOffset);
        }
    }

    /// <summary>
    /// VerticalOffset property to forward to the ScrollViewer.
    /// </summary>
    public double VerticalOffset
    {
        get { return (double)GetValue(VerticalOffsetProperty); }
        set { SetValue(VerticalOffsetProperty, value); }
    }
    public static readonly DependencyProperty VerticalOffsetProperty =
        DependencyProperty.Register(
            "VerticalOffset",
            typeof(double),
            typeof(ScrollViewerOffsetMediator),
            new PropertyMetadata(OnVerticalOffsetChanged));
    public static void OnVerticalOffsetChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
    {
        var mediator = (ScrollViewerOffsetMediator)o;
        if (null != mediator.ScrollViewer)
        {
            mediator.ScrollViewer.ScrollToVerticalOffset((double)(e.NewValue));
        }
    }

    /// <summary>
    /// Multiplier for ScrollableHeight property to forward to the ScrollViewer.
    /// </summary>
    /// <remarks>
    /// 0.0 means "scrolled to top"; 1.0 means "scrolled to bottom".
    /// </remarks>
    public double ScrollableHeightMultiplier
    {
        get { return (double)GetValue(ScrollableHeightMultiplierProperty); }
        set { SetValue(ScrollableHeightMultiplierProperty, value); }
    }
    public static readonly DependencyProperty ScrollableHeightMultiplierProperty =
        DependencyProperty.Register(
            "ScrollableHeightMultiplier",
            typeof(double),
            typeof(ScrollViewerOffsetMediator),
            new PropertyMetadata(OnScrollableHeightMultiplierChanged));
    public static void OnScrollableHeightMultiplierChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
    {
        var mediator = (ScrollViewerOffsetMediator)o;
        var scrollViewer = mediator.ScrollViewer;
        if (null != scrollViewer)
        {
            scrollViewer.ScrollToVerticalOffset((double)(e.NewValue) * scrollViewer.ScrollableHeight);
        }
    }
}

 

PS - Thanks to Homestar Runner's Strong Bad for inspiring the title of this post.