Skip to main content

C# WPF Tutorial - Implementing IScrollInfo [Advanced]


The ScrollViewer in WPF is pretty handy (and quite flexible) - especially when compared to what you had to work with in WinForms (ScrollableControl). 98% of the time, I can make the ScrollViewer do what I need it to for the given situation. Those other 2 percent, though, can get kind of hairy. Fortunately, WPF provides the IScrollInfo interface - which is what we will be talking about today.

So what is IScrollInfo? Well, it is a way to take over the logic behind scrolling, while still maintaining the look and feel of the standard ScrollViewer. Now, first off, why in the world would we want to do that? To answer that question, I'm going to take a an example from a tutorial that is over a year old now - Creating a Custom Panel Control. In that tutorial, we created our own custom WPF panel (that animated!). One of the issues with that panel though (and the WPF WrapPanel in general) is that you have to disable the horizontal scrollbar if you put the panel in a ScrollViewer.
If you don't, you go from something that looks like this:

Wrap Panel with no horizontal scrollbar.

To something that looks like this:

Wrap Panel with horizontal scrollbar.

And that kind of really defeats the purpose of a wrap panel.
The problem with disabling the horizontal scroll bar altogether is a situation like this:

Wrap Panel with item bigger than width of panel.

In that case, you would really like a horizontal scroll bar to be there, but not change the wrapping behavior. And to get that behavior, you have to write your own custom scroll logic using IScrollInfo.

Ok, time to dive into the code. First, let's take a look at what methods IScrollInfo requires us to implement:
public class AnimatedWrapPanel : IScrollInfo
{
  public void LineDown(){ }

  public void LineLeft(){ }

  public void LineRight(){ }

  public void LineUp(){ }

  public void MouseWheelDown() { }

  public void MouseWheelLeft() { }

  public void MouseWheelRight() { }

  public void MouseWheelUp() { }

  public void PageDown() { }

  public void PageLeft() { }

  public void PageRight() { }

  public void PageUp() { }

  public ScrollViewer ScrollOwner { get; set; }

  public bool CanHorizontallyScroll { get; set; }

  public bool CanVerticallyScroll { get; set; }

  public double ExtentHeight { get; }

  public double ExtentWidth { get; }

  public double HorizontalOffset { get; }

  public double VerticalOffset { get; }

  public double ViewportHeight { get; }

  public double ViewportWidth { get; }

  public Rect MakeVisible(Visual visual, Rect rectangle)
  { }

  public void SetHorizontalOffset(double offset)
  { }

  public void SetVerticalOffset(double offset)
  { }
}
 
Wow! Thats quite a lot of stuff there. But don't worry - almost all of it is your basic simple fill in the blank. For instance, take all the 'Up', 'Down', 'Left', 'Right' methods. Those methods give you fine grained control over how much your panel will scroll when the user clicks the up/down buttons on the scroll bar, or scrolls their mouse wheel. But for our purposes, they can be filled in pretty easily:
public class AnimatedWrapPanel : IScrollInfo
{
  private const double LineSize = 16;
  private const double WheelSize = 3 * LineSize;

  public void LineDown()
  { SetVerticalOffset(VerticalOffset + LineSize); }

  public void LineUp()
  { SetVerticalOffset(VerticalOffset - LineSize); }

  public void LineLeft()
  { SetHorizontalOffset(HorizontalOffset - LineSize); }

  public void LineRight()
  { SetHorizontalOffset(HorizontalOffset + LineSize); }

  public void MouseWheelDown()
  { SetVerticalOffset(VerticalOffset + WheelSize); }

  public void MouseWheelUp()
  { SetVerticalOffset(VerticalOffset - WheelSize); }

  public void MouseWheelLeft()
  { SetHorizontalOffset(HorizontalOffset - WheelSize); }

  public void MouseWheelRight()
  { SetHorizontalOffset(HorizontalOffset + WheelSize); }

  public void PageDown()
  { SetVerticalOffset(VerticalOffset + ViewportHeight); }

  public void PageUp()
  { SetVerticalOffset(VerticalOffset - ViewportHeight); }

  public void PageLeft()
  { SetHorizontalOffset(HorizontalOffset - ViewportWidth); }

  public void PageRight()
  { SetHorizontalOffset(HorizontalOffset + ViewportWidth); }

  public ScrollViewer ScrollOwner { get; set; }

  public bool CanHorizontallyScroll { get; set; }

  public bool CanVerticallyScroll { get; set; }

  public double ExtentHeight { get; }

  public double ExtentWidth { get; }

  public double HorizontalOffset { get; }

  public double VerticalOffset { get; }

  public double ViewportHeight { get; }

  public double ViewportWidth { get; }

  public Rect MakeVisible(Visual visual, Rect rectangle)
  { }

  public void SetHorizontalOffset(double offset)
  { }

  public void SetVerticalOffset(double offset)
  { }
}
 
Just set up some constants for the amount to scroll per line and per wheel click, and away we go! Thats over half the methods down already. Now let's take care of some of those pesky properties:
public class AnimatedWrapPanel : IScrollInfo
{
  private const double LineSize = 16;
  private const double WheelSize = 3 * LineSize;

  private bool _CanHorizontallyScroll;
  private bool _CanVerticallyScroll;
  private ScrollViewer _ScrollOwner;
  private Vector _Offset;
  private Size _Extent;
  private Size _Viewport;

  public void LineDown()
  { SetVerticalOffset(VerticalOffset + LineSize); }

  public void LineUp()
  { SetVerticalOffset(VerticalOffset - LineSize); }

  public void LineLeft()
  { SetHorizontalOffset(HorizontalOffset - LineSize); }

  public void LineRight()
  { SetHorizontalOffset(HorizontalOffset + LineSize); }

  public void MouseWheelDown()
  { SetVerticalOffset(VerticalOffset + WheelSize); }

  public void MouseWheelUp()
  { SetVerticalOffset(VerticalOffset - WheelSize); }

  public void MouseWheelLeft()
  { SetHorizontalOffset(HorizontalOffset - WheelSize); }

  public void MouseWheelRight()
  { SetHorizontalOffset(HorizontalOffset + WheelSize); }

  public void PageDown()
  { SetVerticalOffset(VerticalOffset + ViewportHeight); }

  public void PageUp()
  { SetVerticalOffset(VerticalOffset - ViewportHeight); }

  public void PageLeft()
  { SetHorizontalOffset(HorizontalOffset - ViewportWidth); }

  public void PageRight()
  { SetHorizontalOffset(HorizontalOffset + ViewportWidth); }

  public ScrollViewer ScrollOwner
  {
    get { return _ScrollOwner; }
    set { _ScrollOwner = value; }
  }

  public bool CanHorizontallyScroll
  {
    get { return _CanHorizontallyScroll; }
    set { _CanHorizontallyScroll = value; }
  }

  public bool CanVerticallyScroll
  {
    get { return _CanVerticallyScroll; }
    set { _CanVerticallyScroll = value; }
  }

  public double ExtentHeight
  { get { return _Extent.Height; } }

  public double ExtentWidth
  { get { return _Extent.Width; } }

  public double HorizontalOffset
  { get { return _Offset.X; } }

  public double VerticalOffset
  { get { return _Offset.Y; } }

  public double ViewportHeight
  { get { return _Viewport.Height; } }

  public double ViewportWidth
  { get { return _Viewport.Width; } }

  public Rect MakeVisible(Visual visual, Rect rectangle)
  { }

  public void SetHorizontalOffset(double offset)
  { }

  public void SetVerticalOffset(double offset)
  { }
}
 
Pretty much we just needed to set up backing fields for all those properties. The property names are pretty self explanatory - "Extent" is the total size of the panel, while "Viewport" is the amount that is visible on screen. "Offset" is the amount that the viewport is offset from 0,0 - i.e., how far scrolled down/right we are.

What is left are the more complicated parts of the interface. First, let's fill out the SetHorizontalOffset and SetVerticalOffset calls:
public void SetHorizontalOffset(double offset)
{
  offset = Math.Max(0, Math.Min(offset, ExtentWidth - ViewportWidth));
  if (offset != _Offset.Y)
  {
    _Offset.X = offset;
    InvalidateArrange();
  }
}

public void SetVerticalOffset(double offset)
{
  offset = Math.Max(0, Math.Min(offset, ExtentHeight - ViewportHeight));
  if (offset != _Offset.Y)
  {
    _Offset.Y = offset;
    InvalidateArrange();
  }
}
 
In both cases, we force the offset into a valid range, and then if it is different than the current offset, we set it as the new offset and invalidate the arrange of the panel (so that the items on the panel will get moved appropriately).

What's left on the interface is MakeVisible, which is what gets called to scroll an item into view. The code in there is just a bunch of math to calculate new scroll offsets - I'm not going to walk through it, but you can check it out in the full code farther down the tutorial.

So the interface is fully implemented. But sadly, that isn't enough - we still have to do things like calculate the Viewport and the Extent, as well as modify the MeasureOverride and ArrangeOverride to deal with their own scroll behavior.

If you take a look at the code in Creating a Custom Panel Control, the following code might look very familiar. This is because I tried to modify the code for the original animated wrap panel as little as possible - see if you can spot the changes:
protected override Size MeasureOverride(Size availableSize)
{
  double curX = 0, curY = 0, curLineHeight = 0, maxLineWidth = 0;
  foreach (UIElement child in Children)
  {
    child.Measure(InfiniteSize);

    if (curX + child.DesiredSize.Width > availableSize.Width)
    { //Wrap to next line
      curY += curLineHeight;
      curX = 0;
      curLineHeight = 0;
    }

    curX += child.DesiredSize.Width;
    if (child.DesiredSize.Height > curLineHeight)
    { curLineHeight = child.DesiredSize.Height; }

    if (curX > maxLineWidth)
    { maxLineWidth = curX; }
  }

  curY += curLineHeight;

  VerifyScrollData(availableSize, new Size(maxLineWidth, curY));

  return _Viewport;
}

protected override Size ArrangeOverride(Size finalSize)
{
  if (this.Children == null || this.Children.Count == 0)
  { return finalSize; }

  TranslateTransform trans = null;
  double curX = 0, curY = 0, curLineHeight = 0, maxLineWidth = 0;

  foreach (UIElement child in Children)
  {
    trans = child.RenderTransform as TranslateTransform;
    if (trans == null)
    {
      child.RenderTransformOrigin = new Point(0, 0);
      trans = new TranslateTransform();
      child.RenderTransform = trans;
    }

    if (curX + child.DesiredSize.Width > finalSize.Width)
    { //Wrap to next line
      curY += curLineHeight;
      curX = 0;
      curLineHeight = 0;
    }

    child.Arrange(new Rect(0, 0, 
      child.DesiredSize.Width, child.DesiredSize.Height));

    trans.BeginAnimation(TranslateTransform.XProperty, 
      new DoubleAnimation(curX - HorizontalOffset, _AnimationLength), 
      HandoffBehavior.Compose);
    trans.BeginAnimation(TranslateTransform.YProperty, 
      new DoubleAnimation(curY - VerticalOffset, _AnimationLength), 
      HandoffBehavior.Compose);

    curX += child.DesiredSize.Width;
    if (child.DesiredSize.Height > curLineHeight)
    { curLineHeight = child.DesiredSize.Height; }

    if (curX > maxLineWidth)
    { maxLineWidth = curX; }
  }

  curY += curLineHeight;
  VerifyScrollData(finalSize, new Size(maxLineWidth, curY));

  return finalSize;
}
 
MeasureOverride is almost identical to the old code, except for two things. One, we keep track of the max row width, to correctly calculate the horizontal extent of the panel. Two, we have a call to VerifyScrollData at the end of the method - a method we have not seen yet (but will be taking a look at soon).

ArrangeOverride has a couple more changes. Again, we are keeping track of the max row width, and calling VerifyScrollData. But we are also modifying the positions at which the items are placed by the amount of the scroll offset. This is because since we are in charge of scrolling behavior, we are also in charge of making sure items are place correctly according to the scrolling behavior.

Ok, now for that VerifyScrollData method:
protected void VerifyScrollData(Size viewport, Size extent)
{
  if (double.IsInfinity(viewport.Width))
  { viewport.Width = extent.Width; }

  if (double.IsInfinity(viewport.Height))
  { viewport.Height = extent.Height; }

  _Extent = extent;
  _Viewport = viewport;

  _Offset.X = Math.Max(0, 
    Math.Min(_Offset.X, ExtentWidth - ViewportWidth));
  _Offset.Y = Math.Max(0, 
    Math.Min(_Offset.Y, ExtentHeight - ViewportHeight));

  if (ScrollOwner != null)
  { ScrollOwner.InvalidateScrollInfo(); }
}
 
It is this function that sets the viewport and extent fields. It also coerces the offsets to be within the correct ranges (changes to the extent/viewport can make a previously valid offset incorrect). Finally, if there is a scroll owner currently attached, we call InvalidateScrollInfo. This makes sure that the scrolviewer is displaying the right ranges and positions for the scrollbars.

And that is it for implementing your own implementation of IScrollInfo. Here is all the code together:
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Media;
using System.Windows.Media.Animation;

namespace AnimatedWrapPanel
{
  public class AnimatedWrapPanel : Panel, IScrollInfo
  {
    private static Size InfiniteSize =
      new Size(double.PositiveInfinity, double.PositiveInfinity);
    private const double LineSize = 16;
    private const double WheelSize = 3 * LineSize;

    private bool _CanHorizontallyScroll;
    private bool _CanVerticallyScroll;
    private ScrollViewer _ScrollOwner;
    private Vector _Offset;
    private Size _Extent;
    private Size _Viewport;

    private TimeSpan _AnimationLength = TimeSpan.FromMilliseconds(200);

    protected override Size MeasureOverride(Size availableSize)
    {
      double curX = 0, curY = 0, curLineHeight = 0, maxLineWidth = 0;
      foreach (UIElement child in Children)
      {
        child.Measure(InfiniteSize);

        if (curX + child.DesiredSize.Width > availableSize.Width)
        { //Wrap to next line
          curY += curLineHeight;
          curX = 0;
          curLineHeight = 0;
        }

        curX += child.DesiredSize.Width;
        if (child.DesiredSize.Height > curLineHeight)
        { curLineHeight = child.DesiredSize.Height; }

        if (curX > maxLineWidth)
        { maxLineWidth = curX; }
      }

      curY += curLineHeight;

      VerifyScrollData(availableSize, new Size(maxLineWidth, curY));

      return _Viewport;
    }

    protected override Size ArrangeOverride(Size finalSize)
    {
      if (this.Children == null || this.Children.Count == 0)
      { return finalSize; }

      TranslateTransform trans = null;
      double curX = 0, curY = 0, curLineHeight = 0, maxLineWidth = 0;

      foreach (UIElement child in Children)
      {
        trans = child.RenderTransform as TranslateTransform;
        if (trans == null)
        {
          child.RenderTransformOrigin = new Point(0, 0);
          trans = new TranslateTransform();
          child.RenderTransform = trans;
        }

        if (curX + child.DesiredSize.Width > finalSize.Width)
        { //Wrap to next line
          curY += curLineHeight;
          curX = 0;
          curLineHeight = 0;
        }

        child.Arrange(new Rect(0, 0, 
          child.DesiredSize.Width, child.DesiredSize.Height));

        trans.BeginAnimation(TranslateTransform.XProperty, 
          new DoubleAnimation(curX - HorizontalOffset, _AnimationLength), 
          HandoffBehavior.Compose);
        trans.BeginAnimation(TranslateTransform.YProperty, 
          new DoubleAnimation(curY - VerticalOffset, _AnimationLength), 
          HandoffBehavior.Compose);

        curX += child.DesiredSize.Width;
        if (child.DesiredSize.Height > curLineHeight)
        { curLineHeight = child.DesiredSize.Height; }

        if (curX > maxLineWidth)
        { maxLineWidth = curX; }
      }

      curY += curLineHeight;
      VerifyScrollData(finalSize, new Size(maxLineWidth, curY));

      return finalSize;
    }

    #region Movement Methods
    public void LineDown()
    { SetVerticalOffset(VerticalOffset + LineSize); }

    public void LineUp()
    { SetVerticalOffset(VerticalOffset - LineSize); }

    public void LineLeft()
    { SetHorizontalOffset(HorizontalOffset - LineSize); }

    public void LineRight()
    { SetHorizontalOffset(HorizontalOffset + LineSize); }

    public void MouseWheelDown()
    { SetVerticalOffset(VerticalOffset + WheelSize); }

    public void MouseWheelUp()
    { SetVerticalOffset(VerticalOffset - WheelSize); }

    public void MouseWheelLeft()
    { SetHorizontalOffset(HorizontalOffset - WheelSize); }

    public void MouseWheelRight()
    { SetHorizontalOffset(HorizontalOffset + WheelSize); }

    public void PageDown()
    { SetVerticalOffset(VerticalOffset + ViewportHeight); }

    public void PageUp()
    { SetVerticalOffset(VerticalOffset - ViewportHeight); }

    public void PageLeft()
    { SetHorizontalOffset(HorizontalOffset - ViewportWidth); }

    public void PageRight()
    { SetHorizontalOffset(HorizontalOffset + ViewportWidth); }
    #endregion

    public ScrollViewer ScrollOwner
    {
      get { return _ScrollOwner; }
      set { _ScrollOwner = value; }
    }

    public bool CanHorizontallyScroll
    {
      get { return _CanHorizontallyScroll; }
      set { _CanHorizontallyScroll = value; }
    }

    public bool CanVerticallyScroll
    {
      get { return _CanVerticallyScroll; }
      set { _CanVerticallyScroll = value; }
    }

    public double ExtentHeight
    { get { return _Extent.Height; } }

    public double ExtentWidth
    { get { return _Extent.Width; } }

    public double HorizontalOffset
    { get { return _Offset.X; } }

    public double VerticalOffset
    { get { return _Offset.Y; } }

    public double ViewportHeight
    { get { return _Viewport.Height; } }

    public double ViewportWidth
    { get { return _Viewport.Width; } }

    public Rect MakeVisible(Visual visual, Rect rectangle)
    {
      if (rectangle.IsEmpty || visual == null 
        || visual == this || !base.IsAncestorOf(visual))
      { return Rect.Empty; }

      rectangle = visual.TransformToAncestor(this).TransformBounds(rectangle);

      Rect viewRect = new Rect(HorizontalOffset, 
        VerticalOffset, ViewportWidth, ViewportHeight);
      rectangle.X += viewRect.X;
      rectangle.Y += viewRect.Y;
      viewRect.X = CalculateNewScrollOffset(viewRect.Left, 
        viewRect.Right, rectangle.Left, rectangle.Right);
      viewRect.Y = CalculateNewScrollOffset(viewRect.Top, 
        viewRect.Bottom, rectangle.Top, rectangle.Bottom);
      SetHorizontalOffset(viewRect.X);
      SetVerticalOffset(viewRect.Y);
      rectangle.Intersect(viewRect);
      rectangle.X -= viewRect.X;
      rectangle.Y -= viewRect.Y;

      return rectangle;
    }

    private static double CalculateNewScrollOffset(double topView, 
      double bottomView, double topChild, double bottomChild)
    {
      bool offBottom = topChild < topView && bottomChild < bottomView;
      bool offTop = bottomChild > bottomView && topChild > topView;
      bool tooLarge = (bottomChild - topChild) > (bottomView - topView);

      if (!offBottom && !offTop)
      { return topView; } //Don't do anything, already in view

      if ((offBottom && !tooLarge) || (offTop && tooLarge))
      { return topChild; }

      return (bottomChild - (bottomView - topView));
    }

    protected void VerifyScrollData(Size viewport, Size extent)
    {
      if (double.IsInfinity(viewport.Width))
      { viewport.Width = extent.Width; }

      if (double.IsInfinity(viewport.Height))
      { viewport.Height = extent.Height; }

      _Extent = extent;
      _Viewport = viewport;

      _Offset.X = Math.Max(0, 
        Math.Min(_Offset.X, ExtentWidth - ViewportWidth));
      _Offset.Y = Math.Max(0, 
        Math.Min(_Offset.Y, ExtentHeight - ViewportHeight));

      if (ScrollOwner != null)
      { ScrollOwner.InvalidateScrollInfo(); }
    }

    public void SetHorizontalOffset(double offset)
    {
      offset = Math.Max(0, 
        Math.Min(offset, ExtentWidth - ViewportWidth));
      if (offset != _Offset.Y)
      {
        _Offset.X = offset;
        InvalidateArrange();
      }
    }

    public void SetVerticalOffset(double offset)
    {
      offset = Math.Max(0, 
        Math.Min(offset, ExtentHeight - ViewportHeight));
      if (offset != _Offset.Y)
      {
        _Offset.Y = offset;
        InvalidateArrange();
      }
    }
  }
}
 
Now, in order to use a class that implements IScrollInfo, you do have to do one other thing - you have to remember to set the property CanContentScroll to true on the ScrollViewer surrounding the instance of your class. This signifies to the ScrollViewer that the content can control its own scroll behavior - letting the logic that you have written work its magic. So with that property set, your XAML might look something like this:
<Window x:Class="AnimatedWrapPanel.Window1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:ARP="clr-namespace:AnimatedWrapPanel"
    Title="Animated Wrap Panel Test" Height="300" Width="300">
  <ScrollViewer CanContentScroll="True" 
                HorizontalScrollBarVisibility="Auto"
                VerticalScrollBarVisibility="Auto">
    <ARP:AnimatedWrapPanel>
      <Image Source="Images\Aquarium.jpg" Stretch="Uniform" 
             Width="100" Margin="5"/>
      <Image Source="Images\Ascent.jpg" Stretch="Uniform" 
             Width="50" Margin="5" />
      <Image Source="Images\Autumn.jpg" Stretch="Uniform" 
             Width="200" Margin="5"/>
      <Image Source="Images\Crystal.jpg" Stretch="Uniform" 
             Width="75" Margin="5"/>
      <Image Source="Images\DaVinci.jpg" Stretch="Uniform" 
             Width="125" Margin="5"/>
      <Image Source="Images\Follow.jpg" Stretch="Uniform" 
             Width="100" Margin="5"/>
      <Image Source="Images\Friend.jpg" Stretch="Uniform" 
             Width="50" Margin="5"/>
      <Image Source="Images\Home.jpg" Stretch="Uniform" 
             Width="150" Margin="5"/>
      <Image Source="Images\Moon flower.jpg" Stretch="Uniform" 
             Width="100" Margin="5"/>
    </ARP:AnimatedWrapPanel>
  </ScrollViewer>
</Window>
 
And this leaves you a panel that can scroll horizontally, but only when it needs to:

Resulting panel screenshot

Well, that about wraps it up. As always, you can grab the source for the example below if you want to play with the code on your own.

Source Files:

Comments

Popular posts from this blog

C# Snippet - Shuffling a Dictionary [Beginner]

Randomizing something can be a daunting task, especially with all the algorithms out there. However, sometimes you just need to shuffle things up, in a simple, yet effective manner. Today we are going to take a quick look at an easy and simple way to randomize a dictionary, which is most likely something that you may be using in a complex application. The tricky thing about ordering dictionaries is that...well they are not ordered to begin with. Typically they are a chaotic collection of key/value pairs. There is no first element or last element, just elements. This is why it is a little tricky to randomize them. Before we get started, we need to build a quick dictionary. For this tutorial, we will be doing an extremely simple string/int dictionary, but rest assured the steps we take can be used for any kind of dictionary you can come up with, no matter what object types you use. Dictionary < String , int > origin = new Dictionary < string , int >(); ...

C# WPF Printing Part 2 - Pagination [Intermediate]

About two weeks ago, we had a tutorial here at SOTC on the basics of printing in WPF . It covered the standard stuff, like popping the print dialog, and what you needed to do to print visuals (both created in XAML and on the fly). But really, that's barely scratching the surface - any decent printing system in pretty much any application needs to be able to do a lot more than that. So today, we are going to take one more baby step forward into the world of printing - we are going to take a look at pagination. The main class that we will need to do pagination is the DocumentPaginator . I mentioned this class very briefly in the previous tutorial, but only in the context of the printing methods on PrintDialog , PrintVisual (which we focused on last time) and PrintDocument (which we will be focusing on today). This PrintDocument function takes a DocumentPaginator to print - and this is why we need to create one. Unfortunately, making a DocumentPaginator is not as easy as...