C# WPF MVVM method to scroll to a specific location of a text that is custom formatted
Asked Answered
K

1

0

To avoid this being an X-Y problem I will explain what I want to achieve first, and then what I tried to achieve it and failed.

Problem: I have a bunch of texts which I am searching through. The search produces a number of hits from different texts, when I click a hit, I want the viewer to open the text, bolden the match of the search pattern, and scroll to the location of the that text automatically. All while abiding by MVVM pattern and not breaking it with using UI elements in the ViewModel, as much as possible. I am using Caliburn Micro as the MVVM framework. Finally, the texts are in Arabic.

What I tried:

  1. ListBox: while it supports scrolling and formatting, it breaks the text into list items which does not allow for selection of multiple lines. If I combine the text into one item, I lose the ability to format and scroll. So I quickly discarded it.

  2. TextBox: it lacks the ability to format a specific part of the text while leaving the rest unformatted, and it does not support scrolling to a specific location.

  3. RichTextBox (native, and Extended WPF): It can be tweaked to allow for scrolling with binding. But, it is horrendous for Arabic language, the available parsers I found were incapable of producing RTF text for Arabic.

  4. ScrollView & TextBlock: I found code that extends it to allow selection and copy of text, as well as the ability to bind to the formatting text. I can get it to highlight the part I need with ease, and I can select and copy from it as well. The issue is, I cannot dynamically determine its height and bind to it so I can scroll to the proper location.

The 4th option is the one I am currently employing, and here is the XAML:

  <Border Grid.Row="1" Grid.Column="4" BorderThickness="0.5" BorderBrush="Gray" Margin="5">
    <ScrollViewer local:Attached.VerticalOffset="{Binding ViewerScrollOffset, Mode=TwoWay}">
      <local:SelectableTextBlock  Margin="5" local:Attached.FormattedText="{Binding DisplayText}" 
               FlowDirection="RightToLeft" TextWrapping="Wrap" />
    </ScrollViewer>
  </Border>

As shown above, I am using 2 attached properties: VerticalOffset for ScrollViewer and FormattedText for SelectableTextBlock. (sources linked in the keywords).

I can scroll to locations in the ScrollViewer, but given the height varies by the size of the text in the TextBlock, it is not possible to tell where to go without knowing the full height. I am aware of the ScrollableHeight property which can be accessed from the code behind, but it will break the MVVM pattern and I was wishing to have a solution that can achieve this with binding properly. I tried binding to Height of both ScrollViewer and TextBlock in several ways (changing mode, getting ancestor height, using triggers, etc.) but it does not work and I don't think it is even the correct property to retrieve.

How can I bind to ScrollableHeight so I can calculate exactly where my VerticalOffset needs to be? And are there better methods that I am oblivious to which can achieve the problem I stated in the start of the question?

Kapp answered 10/9, 2024 at 3:49 Comment(0)
K
0

Okay so it took a bit of tinkering around with stuff but I eventually got it to work, and I think it is a necessary addition to any ScrollViewer that has the attached property VirtualOffset.

I was able to create a new attached property that reports the ScrollableHeight when it changes, which is based on the ScrollChanged event where any change in either ExtentHeightChange or ViewportHeightChange MAY result in a change of ScrollabeHeight. However, it is possible for ViewportHeight or ExtentHeight to have changed without the ScrollableHeight to change, and that happens when the view is resized, but the text is not big enough to enable scrolling.

This is the attached property code (Attached is the class I am using for my attached properties):

public static readonly DependencyProperty VerticalOffsetLimitProperty =
DependencyProperty.RegisterAttached("VerticalOffsetLimit", typeof(double),
typeof(Attached), new FrameworkPropertyMetadata(double.NaN,
    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
    OnVerticalOffsetLimitPropertyChanged));

private static readonly DependencyProperty VerticalScrollHeightBindingProperty =
    DependencyProperty.RegisterAttached("VerticalScrollLimitBinding", typeof(bool?), typeof(Attached));

public static double GetVerticalOffsetLimit(DependencyObject depObj)
{
  return (double)depObj.GetValue(VerticalOffsetLimitProperty);
}

public static void SetVerticalOffsetLimit(DependencyObject depObj, double value)
{
  depObj.SetValue(VerticalOffsetLimitProperty, value);
}

private static void OnVerticalOffsetLimitPropertyChanged(DependencyObject d,
    DependencyPropertyChangedEventArgs e)
{
  ScrollViewer scrollViewer = d as ScrollViewer;
  if (scrollViewer == null)
    return;

  BindVerticalOffsetLimit(scrollViewer);
}

public static void BindVerticalOffsetLimit(ScrollViewer scrollViewer)
{
  if (scrollViewer.GetValue(VerticalScrollHeightBindingProperty) != null)
    return;
  
  scrollViewer.SetValue(VerticalScrollHeightBindingProperty, true);
  scrollViewer.ScrollChanged += (s, se) =>
  {
    if (se.ViewportHeightChange == 0 && se.ExtentHeightChange==0)
      return;
    SetVerticalOffsetLimit(scrollViewer, scrollViewer.ScrollableHeight);
  };
}

Then I bind to it in the View:

  <Border Grid.Row="1" Grid.Column="4" BorderThickness="0.5" BorderBrush="Gray" Margin="5">
    <ScrollViewer local:Attached.VerticalOffsetLimit="{Binding ViewerScrollLimit, NotifyOnTargetUpdated=True}"
      local:Attached.VerticalOffset="{Binding ViewerScrollOffset}">
      <i:Interaction.Triggers>
        <i:EventTrigger EventName="TargetUpdated">
          <i:InvokeCommandAction Command="{Binding ScrollToHighlightedWordCommand}"/>
        </i:EventTrigger>
      </i:Interaction.Triggers>
      <local:SelectableTextBlock  Margin="5" local:Attached.FormattedText="{Binding DisplayText}"
                                  FlowDirection="RightToLeft" TextWrapping="Wrap">
      </local:SelectableTextBlock>
    </ScrollViewer>
  </Border>

I let the property signal the TargetUpdated event, and using Microsoft.Xaml.Behaviors.Wpf I set up an event trigger binding to a command in the ViewModel. Finally, with Prism.Core I made the command to calculate the offset for the scroll.

Note: if window/view is resized, it will invoke the ScrollToHighlightedWordCommand and the scroll will reset to highlighted word unless the function in the ViewModel accounts for that.

Alternatively, the event trigger can be moved to the SelectableTextBlock within the ScrollViewer, but that will not guarantee the ViewerScrollLimit is going to be up-to-date when the scroll adjustment command is invoked. In fact, the UI Dispatcher will always invoke the scroll adjustment command before updating the ViewerScrollLimit property.

It is imperfect, but it works..

Kapp answered 10/9, 2024 at 7:23 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.