FlowDocumentScrollViewers, BringIntoView and why we can never have nice things
Context
So, I’m working on rebuilding the UI of one of our products in WPF for down-tools week at work. It’s as much an exercise in doing a big chunk of work in WPF as anything else. Particularly, I’m trying to observe reasonably strict MVVM - no codebehinds, everything in the UI that interacts with code does so through bindings and nothing else.
Part of the UI is a code view with parts of the code highlighted according to
a search term. This is implemented as a FlowDocument
, contained in a
FlowDocumentScrollViewer
.
Dynamic content in a FlowDocument
The content is dynamically generated, and bound using an attached property on
the FlowDocument
. We have a ViewModel for the UserControl
hosting the
FlowDocumentScrollViewer
with a Document
property on it of type Block
(I
guess I could have made it an IEnumerable<Block>
, but for my purposes, one
was sufficient). The code for this is below as it took a while to get right.
in the xaml:
<FlowDocument wpfControls:FlowDocumentBindingAssistant.BoundDocument="{Binding Document}" />
and the attached property:
class FlowDocumentBindingAssistant : DependencyObject
{
public static readonly DependencyProperty BoundDocument =
DependencyProperty.RegisterAttached("BoundDocument",
typeof(Block),
typeof(FlowDocumentBindingAssistant),
new FrameworkPropertyMetadata(DocumentChangedCallback));
private static void DocumentChangedCallback(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs dependencyPropertyChangedEventArgs)
{
var doc = dependencyObject as FlowDocument;
if (doc == null)
return;
var section = GetBoundDocument(doc);
doc.Blocks.Clear();
if (section != null)
box.Blocks.Add(section);
}
public static Block GetBoundDocument(DependencyObject d)
{
return d.GetValue(BoundDocument) as Block;
}
public static void SetBoundDocument(DependencyObject d, Block value)
{
d.SetValue(BoundDocument, value);
}
}
So, bound to this we have a Block
(actually a Paragraph
) with a bunch of
Run
s in of either plain or highlighted text, something like:
<Paragraph>
<Run xml:preserve="space">This is some </Run>
<Run xml:preserve="space" Background="Yellow">highlighted</Run>
<Run xml:preserve="space"> text.</Run>
</Paragraph>
(the xml:preserve="space"
is there because at the moment I generate the
XAML for the document then parse it using a XamlReader
and I care about
preserving the spaces)
Jumping in
So, anyway. This works nicely and I have a nicely highlighted document whose
contents I can change at run-time by updating my ViewModel. The problem comes
because I also want to scroll to the position of the first highlighted Run
.
Theoretically, this should be straightforward:
In the code that sets the Block
on the ViewModel, I could have:
Paragraph paragraph = BuildHighlightedParagraphFromText(myText);
ViewModel.Document = paragraph;
var firstHighlightedRun = paragraph.Inlines.First(run => run.Background != null); // A little crude, but it works
if (firstHighlightedRun != null)
{
firstHighlightedRun.BringIntoView();
}
Unfortunately, this doesn’t work. I’m yet to find out exactly why, but googling
a bit around the problem seemed to suggest a bunch of other people were having
the same problem - for some people, BringIntoView()
worked, but for others
(like me), it just plain did nothing. I’ve spent the last day beating my head
against this, and I think I’ve got an acceptable solution.
Doing things the hard(er way than should be strictly necessary)
For more-or-less anything in a FlowDocument
, we can access a ContentStart
property, which hands us a TextPointer
object describing the start position
of the text in that object. From that TextPointer
, we can call
GetCharacterRect(LogicalDirection)
to get a Rect
that describes the bounding
box of the content in a specified direction from that pointer - and from this,
we can get the Top
of the content. OK, so, all we need to do is call
ScrollToVerticalPosition()
on our ScrollViewer
and we’re done, right?
Er, no. What we have is a FlowDocumentScrollViewer
, not a ScrollViewer
. And
that’s different because reasons. Particularly, it’s different because
there’s no ScrollToVerticalPosition()
method on it. However, a
FlowDocumentScrollViewer
does have a ScrollViewer
as a child element - so
if we can get hold of that child, we can call ScrollToVerticalOffset()
on
that instead. Of course, we’re being good MVVM citizens, so we don’t call
it directly from the code manipulating the ViewModel - in fact, we don’t even
have access to the control from there.
So, we’re going to need another attached property - this time, on the
FlowDocumentScrollViewer
itself - and we can bind to a suitable value in our
ViewModel:
<FlowDocumentScrollViewer wpfControls:FlowDocumentBindingAssistant.ScrollPosition="{Binding ScrollPosition}">
....
</FlowDocument>
The C# code itself is fairly straightforward, we’ll want to an attached property
as above, and on the ScrollPositionChanged
callback, something like:
private static void ScrollPositionChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var fdScrollViewer = d as FlowDocumentScrollViewer;
var top = GetScrollPosition(fdScrollViewer);
var scrollViewer = GetChild<ScrollViewer>(fdScrollViewer);
scrollViewer.ScrollToVerticalOffset(top);
}
The GetChild<T>()
method just uses the VisualTreeHelper
to recursively walk
through a DependencyObject
’s children and find the first one that is of
the relevant type.
And so, in our calling code, we can now do:
var run = paragraph.Inlines.First(run => run.Background != null);
if (run != null)
{
var top = run.ContentStart.GetCharacterRect(LogicalDirection.Forward).Top;
ViewModel.ScrollPosition = top;
}
This will position the highlighted Run
directly at the top of the view; it
might be nicer to push it down a little way to give the user some context - but
now we have the ability to do this, we can use this as a start point.
It might be even nicer to have the ViewModel take the element to scroll to, rather than just an offset, but that is left as an exercise for the reader :)