I have extensive product documentation in my Silverlight application. I have a product overview, ten tutorials, plus an "Easter egg" that gives background on how such a product ever came to be developed. Each document is initially presented as a list of
Expander
elements within a
ScrollViewer
. The average list has about 8
Expander
elements and each
Expander
expands and collapses on average 5 or 6 paragraphs each of which averages about 8 lines of wrapping text.
I discovered that when the
Expander
expanded to a vertical size larger than the
ScrollViewer
viewport, sometimes the
Expander
header flowed off the top of the viewport and then the user could no longer read the topic covered by the
Expander
. So I wrote code to center the
Expander
header within the viewport. But I found that the sudden movement of the header was disconcerting, and so I decided to animate the vertical scroll.
I quickly discovered that the
ScrollViewer.VerticalOffset
is for some inexplicable reason read-only. (If anyone knows why, please tell me. Googling for the answer just seems to produce sheepish acceptance of what Microsoft has done without questioning this strange decision.) Because
VerticalOffset
is read-only, the animation
Storyboard
cannot be used to animate the scroll. Silverlight 4 has also sealed the
ScrollViewer
class so you can't write extensions to define new Dependency Properties! Microsoft seems to be bending over backwards to thwart scrolling animation within a
ScrollViewer
! There must be something wrong with it that they don't know how to fix. The only way to programmatically change the vertical offset within a
ScrollViewer
is to use its
ScrollToVerticalOffset()
public member.
I really thought this animation was necessary on my Website, so I decided to write a class that would perform the animation for me using a
DispatcherTimer
, the old-fashioned way of developing animations! Based on the number of lines of code I found it necessary to do this, using this brute force technique is about 6X as hard as it would be if
VerticalOffset
were a read-write Dependency Property.
My class works fairly well. To make it scroll smoothly, I had to increment my scroll 120 times per second, rather than the 60 that WPF/Silverlight uses by default. Incrementing my scroll merely 60 times per second produced an inexplicable and unacceptable jerkiness. There is some anomalous behavior expanding
Expander
elements near the end of the
ScrollViewer
, but it is still easy to see what is going on.
I wanted to make the class reasonably general purpose and so I abstracted the
Expander
being expanded/collapsed as a "vertical offset to be centered" and then named the class,
CenterVerticalOffset
. It takes a
ScrollViewer
reference and an animation duration in seconds as parameters.
CenterVerticalOffset
is created at the end of the
Loaded
event in the class instance holding the
ScrollViewer
.
After the
CenterVerticalOffset
class instance is created, a class member called
Center()
is called when the size of the
Expander
changes (because you've clicked on it).
Center()
is thus called within the
Expander.SizeChanged
event.
Center()
takes the vertical offset that you want to center in the viewport. For my application, the vertical offset is the Y coordinate of the mouse pointer when the user clicks on the
Expander
. I obtain this value from a
MouseMove
event from the
ScrollViewer
.
You can watch the animation in action by bringing up my Website:
http://powerphototools.com. Bring up any tutorial or the product overview to watch it in action. Here is the source for my
CenterVerticalOffset
class:
public class CenterVerticalOffset
{
DispatcherTimer Timer { get
double NewVerticalOffset { get
double VerticalOffsetIncrement { get
double CurrentVerticalOffset { get
ScrollViewer ScrollViewer { get
double ViewportHeight { get
double VerticalOffset { get
double Intervals { get
public CenterVerticalOffset(ScrollViewer scrollViewer, double durationInSeconds)
{
ScrollViewer = scrollViewer
Intervals = durationInSeconds * 120
Timer = new DispatcherTimer()
Timer.Interval = TimeSpan.FromSeconds(1.0 / 120.0)
Timer.Tick += new EventHandler(Timer_Tick)
}
public void Center(double verticalOffsetToBeCentered)
{
ViewportHeight = ScrollViewer.ViewportHeight
VerticalOffset = ScrollViewer.VerticalOffset
NewVerticalOffset =
VerticalOffset - ViewportHeight / 2 + verticalOffsetToBeCentered
if (NewVerticalOffset < 0)
{
NewVerticalOffset = 0
}
VerticalOffsetIncrement = (NewVerticalOffset - VerticalOffset) / Intervals
if (VerticalOffsetIncrement == 0.0)
{
return
}
CurrentVerticalOffset = VerticalOffset
Timer.Start()
}
void Timer_Tick(object sender, EventArgs e)
{
double extentHeight = ScrollViewer.ExtentHeight
CurrentVerticalOffset += VerticalOffsetIncrement
if (VerticalOffsetIncrement > 0 && CurrentVerticalOffset > NewVerticalOffset ||
VerticalOffsetIncrement < 0 && NewVerticalOffset > CurrentVerticalOffset)
{
Timer.Stop()
}
else
{
ScrollViewer.ScrollToVerticalOffset(CurrentVerticalOffset)
}
}
}