7

I've got a Windows Store app with a RichEditBox (editor) and a Grid (MarginNotes).

I need the vertical scroll position of the two elements to be matched at all times. The purpose of this is to allow the user to add notes in the margin of the document.

I've already figured out Note positioning based on the cursor position - when a note is added, a text selection is made of everything up to the cursor. that selection is then added to a second, invisible RichEditBox, inside a StackPanel. I then get the ActualHeight of this control which gives me the position of the note in the grid.

My issue is that when I scroll the RichEditBox up and down, the Grid does not scroll accordingly.

First Technique

I tried putting them both inside a ScrollViewer, and disabling scrolling on the RichEditBox

<ScrollViewer x:Name="EditorScroller" 
    VerticalAlignment="Stretch" HorizontalAlignment="Stretch">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="150" />
            <ColumnDefinition Width="{Binding *" />
            <ColumnDefinition Width="150" />
        </Grid.ColumnDefinitions>
        <Grid x:Name="MarginNotes" Grid.Column="0" HorizontalAlignment="Right"                  
            Height="{Binding ActualHeight, ElementName=editor}">
        </Grid>
        <StackPanel Grid.Column="1">
            <RichEditBox x:Name="margin_helper" Opacity="0" Height="Auto"></RichEditBox>
        </StackPanel>
        <RichEditBox x:Name="editor" Grid.Column="1" Height="Auto"
            ScrollViewer.VerticalScrollBarVisibility="Hidden" />
    </Grid>
</ScrollViewer>

When I scroll to the bottom of the RichEditBox control, and hit enter a few times, the cursor drops out of sight. The ScrollViewer doesn't scroll automatically with the cursor.

I tried adding C# code which would check the position of the cursor, compare it to the VerticalOffset and height of the editor, and then adjust the scroll accordingly. This worked, but was incredibly slow. Initially I had it on the KeyUp event which brought the app to a standstill when I typed a sentence. Afterwards I put it on a 5 second timer, but this still slowed down the app performance and also meant that there could be a 5 second delay between the cursor dropping out of sight and the RichEditBox scrolling.

Second Technique

I also tried putting just MarginNotes in its own ScrollViewer, and programmatically setting the VerticalOffset based off my RichEditBoxs ViewChanged event.

<Grid>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="150" />
        <ColumnDefinition Width="{Binding *" />
        <ColumnDefinition Width="150" />
    </Grid.ColumnDefinitions>
    <ScrollViewer x:Name="MarginScroller" Grid.Column="0" 
         VerticalAlignment="Stretch" HorizontalAlignment="Stretch">
        <Grid x:Name="MarginNotes" HorizontalAlignment="Right"                  
            Height="{Binding ActualHeight, ElementName=editor}">
        </Grid>
    </ScrollViewer>
    <StackPanel Grid.Column="1">
        <RichEditBox x:Name="margin_helper" Opacity="0" Height="Auto"></RichEditBox>
    </StackPanel>
    <RichEditBox x:Name="editor" Grid.Column="1" Height="Auto" 
        Loaded="editor_loaded" SizeChanged="editor_SizeChanged" />
</Grid>

relevant event handlers

void editor_Loaded(object sender, RoutedEventArgs e)
{
    // setting this in the OnNavigatedTo causes a crash, has to be set here. 
    // this uses WinRTXAMLToolkit as suggested by Nate Diamond to find the 
    // ScrollViewer and add the event handler
    editor.GetFirstDescendantOfType<ScrollViewer>().ViewChanged += editor_ViewChanged;
}

private void editor_ViewChanged(object sender, ScrollViewerViewChangedEventArgs e)
{
    // when the RichEditBox scrolls, scroll the MarginScroller the same amount
    double editor_vertical_offset = ((ScrollViewer)sender).VerticalOffset;
    MarginScroller.ChangeView(0, editor_vertical_offset, 1);       
}

private void editor_SizeChanged(object sender, SizeChangedEventArgs e)
{
    // when the RichEditBox size changes, change the size of MarginNotes to match
    string text = "";
    editor.Document.GetText(TextGetOptions.None, out text);
    margin_helper.Document.SetText(TextSetOptions.None, text);
    MarginNotes.Height = margin_helper.ActualHeight;
}

This worked, but was quite laggy as scrolling is not applied until the ViewChanged event fires, after scrolling has stopped. I tried using the ViewChanging event, but it does not fire at all for some reason. Additionally, the Grid was sometimes mis-positioned after a fast scroll.

Jerry Nixon
  • 31,313
  • 14
  • 117
  • 233
roryok
  • 9,325
  • 17
  • 71
  • 138
  • First thing I'd try is to make sure that `VerticalScrollChaining` is enabled on the `RichEditBox`. You may also try disabling scrolling as a whole on the `RichEditBox` and seeing if that fixes it. You may need to update the offset on `SizeChanged` or something similar. – Nate Diamond Feb 10 '14 at 20:47
  • Thanks Nate. None of those things did it I'm afraid. Any other suggestions? – roryok Feb 11 '14 at 14:09
  • I'd probably disable scrolling completely on the `RichEditBox`, if you haven't done so already using `ScrollViewer.VerticalScrollMode="Disabled"`. – Nate Diamond Feb 11 '14 at 16:12
  • Tried that too. Scrolling is disabled now. The issue is that the ScrollViewer surrounding it doesn't seem to care where the cursor is in the RichEditBox, scrolling enabled or not. – roryok Feb 11 '14 at 16:16
  • I have a code based solution which calculates the cursor height. basically it selects the text all the way up to the cursor, puts it in a second RichEditBox and measures the height of _that_. If the cursor height is greater than the VerticalOffset + ActualHeight it programatically scrolls to the cursor height. Unfortunately this is dog slow to run and just isn't going to do the job – roryok Feb 11 '14 at 16:26
  • This may sound strange and it's going to take some research to do, but what I would do is find the Carat control (the blinking cursor) by parsing the RichEditBox's VisualTree and then find the Carat's position by doing a Transform between it and the parent ScrollViewer. At that point I would see if the Carat's current position is larger than the height of the ScrollViewer, at which point I would add to the VerticalOffset the difference between the bottom of the Carat (plus some buffer) and the bottom of the ScrollViewer. – Nate Diamond Feb 11 '14 at 20:09
  • To do this, I suggest using the extensions in the [WinRTXamlToolkit](http://WinRTXamlToolkit.codeplex.com). They will help you parse the visual tree. You may be able to find the Carat based on its name. I'd bet money that they have consistent naming conventions. – Nate Diamond Feb 11 '14 at 20:09
  • Hi Nate. I'm not sure the Caret is an actual UI Element so I don't think it can be selected in this way. – roryok Feb 11 '14 at 23:55
  • You could try to make your custom ScrollViewer adding a CustomVerticalOffset DependencyProperty and Bind the ScrollViewers one to another CustomVerticalOffset to the VerticalOffset of the other and viceversa. – csharpwinphonexaml Apr 21 '14 at 17:46

1 Answers1

1

So, what makes this difficult is that the size of the text or the placement of the text in different types of TextBoxes means that syncing the scrollbar doesn't guarantee you are syncing the text. Having said that, here's how you do it.

void MainPage_Loaded(object sender, RoutedEventArgs args)
{
    MyRichEditBox.Document.SetText(Windows.UI.Text.TextSetOptions.None, MyTextBox.Text);
    var textboxScroll = Children(MyTextBox).First(x => x is ScrollViewer) as ScrollViewer;
    textboxScroll.ViewChanged += (s, e) => Sync(MyTextBox, MyRichEditBox);
}

public void Sync(TextBox textbox, RichEditBox richbox)
{
    var textboxScroll = Children(textbox).First(x => x is ScrollViewer) as ScrollViewer;
    var richboxScroll = Children(richbox).First(x => x is ScrollViewer) as ScrollViewer;
    richboxScroll.ChangeView(null, textboxScroll.VerticalOffset, null);
}

public static IEnumerable<FrameworkElement> Children(FrameworkElement element)
{
    Func<DependencyObject, List<FrameworkElement>> recurseChildren = null;
    recurseChildren = (parent) =>
    {
        var list = new List<FrameworkElement>();
        for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
        {
            var child = VisualTreeHelper.GetChild(parent, i);
            if (child is FrameworkElement)
                list.Add(child as FrameworkElement);
            list.AddRange(recurseChildren(child));
        }
        return list;
    };
    var children = recurseChildren(element);
    return children;
}

Deciding when to invoke the sync is tricky. Maybe on PointerReleased, PointerExit, LostFocus, KeyUp - there are a lot of ways to scroll is the real issue there. You might need to handle all of those. But, it is what it is. At least you can.

Best of luck.

Jerry Nixon
  • 31,313
  • 14
  • 117
  • 233
  • Hi Gerry. Thanks for the reply. As I noted in my original post, the `ViewChanged` method is very laggy, with the scroll being applied only when scrolling stops. using `ViewChanging` doesn't help either. – roryok May 12 '14 at 13:19
  • You are incorrect. ViewChanged is not raised only when the scrolling is stopped. Look up the `ScrollViewerViewChangedEventArgs.IsIntermediate` property to learn more about the issue. The short of it is, there is no better method to attach to as other methods overlap resulting in redundant calls that can be victims of a race condition between them. – Jerry Nixon May 12 '14 at 20:16
  • Well, either way it's still too laggy for me. Thanks though. Also sorry for misspelling your name! – roryok May 13 '14 at 09:16