Reputation: 1835
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:
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.
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.
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.
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?
Upvotes: 0
Views: 130
Reputation: 1835
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..
Upvotes: 0