Words, by Chris

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 Runs 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 :)