Visually Located

XAML and GIS

Displaying HTML content in a TextBlock

So many apps are using third party services to display data. Some of these services may give detailed information in HTML format. Why would they give information in HTML? Simple it’s universal. Everyone can display HTML. All of the platforms have some form of a webview control to display HTML. I recently came across such a service that actually gave information in both plain text and HTML. The plain text did not offer the detail that the HTML content did. So I set out to create a way to display the HTML inside a TextBlock. You may ask why I did not use a Webview control and I’ll say with a smile “Because I didn’t want to”. I’ll be 100% honest here, I took some pointers from the HtmlAgilityPack. I should note that this is not intended to display an entire website. You can adjust it to work, but just don’t.

To tackle this task I created a new Behavior that would adjust the text of a TextBlock when it was loaded. The Runtime Interactivity SDK does not include a base Behavior class like the other Interactivity SDKs. Instead of implementing the interface every time I want to create a behavior, I like to use a base Behavior class.

public abstract class Behavior<T> : Behavior where T : DependencyObject
{
    protected T AssociatedObject
    {
        get { return base.AssociatedObject as T; }
    }
 
    protected override void OnAttached()
    {
        base.OnAttached();
        if (this.AssociatedObject == null) throw new InvalidOperationException("AssociatedObject is not of the right type");
    }
}
 
public abstract class Behavior : DependencyObject, IBehavior
{
    public void Attach(DependencyObject associatedObject)
    {
        AssociatedObject = associatedObject;
        OnAttached();
    }
 
    public void Detach()
    {
        OnDetaching();
    }
 
    protected virtual void OnAttached() { }
 
    protected virtual void OnDetaching() { }
 
    protected DependencyObject AssociatedObject { get; set; }
 
    DependencyObject IBehavior.AssociatedObject
    {
        get { return this.AssociatedObject; }
    }
}

You can also get the base class here.

We’ll first start by listening to a few events of our TextBlock. The behavior will listen to the Loaded and the LayoutUpdated event. We need to listen to these events because the TextBlock does not have a TextChanged event. The TextBlock will first load but if you are getting data from a service, the text will not be populated yet. The LayoutUpdated event will let us know when the text is populated.

public class HtmlTextBehavior : Behavior<TextBlock>
{
    protected override void OnAttached()
    {
        base.OnAttached();
        AssociatedObject.Loaded += OnAssociatedObjectLoaded;
        AssociatedObject.LayoutUpdated += OnAssociatedObjectLayoutUpdated;
    }
 
    protected override void OnDetaching()
    {
        base.OnDetaching();
        AssociatedObject.Loaded -= OnAssociatedObjectLoaded;
        AssociatedObject.LayoutUpdated -= OnAssociatedObjectLayoutUpdated;
    }
 
    private void OnAssociatedObjectLayoutUpdated(object sender, object o)
    {
        UpdateText();
    }
 
    private void OnAssociatedObjectLoaded(object sender, RoutedEventArgs routedEventArgs)
    {
        UpdateText();
        AssociatedObject.Loaded -= OnAssociatedObjectLoaded;
    }
 
    private void UpdateText()
    {
        // TODO
    }
}

The UpdateText method will convert the text to XML via the XElement class, traverse the nodes to add text elements and then unsubscribe from all events. We’ll assume the text of the TextBlock will not change again.

private void UpdateText()
{
    if (AssociatedObject == null) return;
    if (string.IsNullOrEmpty(AssociatedObject.Text)) return;
 
    string text = AssociatedObject.Text;
 
    // Just incase we are not given text with elements.
    string modifiedText = string.Format("<div>{0}</div>", text);
 
    // reset the text because we will add to it.
    AssociatedObject.Inlines.Clear();
    try
    {
        var element = XElement.Parse(modifiedText);
        ParseText(element, AssociatedObject.Inlines);
    }
    catch (Exception)
    {
        // if anything goes wrong just show the html
        AssociatedObject.Text = text;
    }
    AssociatedObject.LayoutUpdated -= OnAssociatedObjectLayoutUpdated;
    AssociatedObject.Loaded -= OnAssociatedObjectLoaded;
}

 

The ParseText method is the meat to this whole meal. We’ll check the type of each element to determine what to do. If we find a <u> element we’ll start adding underline text. If we find a <b> element we’ll add bold text and so on.

/// <summary>
/// Traverses the XElement and adds text to the InlineCollection.
/// </summary>
/// <param name="element"></param>
/// <param name="inlines"></param>
private static void ParseText(XElement element, InlineCollection inlines)
{
    if (element == null) return;
 
    InlineCollection currentInlines = inlines;
    var elementName = element.Name.ToString().ToUpper();
    switch (elementName)
    {
        case ElementA:
            var link = new Hyperlink();
            var href = element.Attribute("href");
            if(href != null)
            {
                try
                {
                    link.NavigateUri = new Uri(href.Value);
                }
                catch (System.FormatException) { /* href is not valid */ }
            }
            inlines.Add(link);
            currentInlines = link.Inlines;
            break;
        case ElementB:
        case ElementStrong:
            var bold = new Bold();
            inlines.Add(bold);
            currentInlines = bold.Inlines;
            break;
        case ElementI:
        case ElementEm:
            var italic = new Italic();
            inlines.Add(italic);
            currentInlines = italic.Inlines;
            break;
        case ElementU:
            var underline = new Underline();
            inlines.Add(underline);
            currentInlines = underline.Inlines;
            break;
        case ElementBr:
            inlines.Add(new LineBreak());
            break;
        case ElementP:
            // Add two line breaks, one for the current text and the second for the gap.
            if (AddLineBreakIfNeeded(inlines))
            {
                inlines.Add(new LineBreak());
            }
 
            Span paragraphSpan = new Span();
            inlines.Add(paragraphSpan);
            currentInlines = paragraphSpan.Inlines;
            break;
        case ElementLi:
            inlines.Add(new LineBreak());
            inlines.Add(new Run { Text = " • " });
            break;
        case ElementUl:
        case ElementDiv:
            AddLineBreakIfNeeded(inlines);
            Span divSpan = new Span();
            inlines.Add(divSpan);
            currentInlines = divSpan.Inlines;
            break;
    }
    foreach (var node in element.Nodes())
    {
        XText textElement = node as XText;
        if (textElement != null)
        {
            currentInlines.Add(new Run { Text = textElement.Value });
        }
        else
        {
            ParseText(node as XElement, currentInlines);
        }
    }
    // Add newlines for paragraph tags
    if (elementName == ElementP)
    {
        currentInlines.Add(new LineBreak());
    }
}

Most of the checks are pretty straight forward. We do see two cases with a unique call to AddLineBreakIfNeeded. We see this in the div, ul, and p tags. The point of this is to avoid adding line breaks when we see html like

<div>
    <div>
         <p>
    </div>
</div>

We wouldn’t want to add line breaks for the start of each div and paragraph. In fact we wouldn’t want to add any. We do also add a followup line break for any paragraph tags. This does have a side effect of adding a line when one is not needed like the following

<p>Hello</p>

This does put a line break in when we really don’t need one. For the services I’ve used I haven’t seen this too often.

To check if we do need to add a line break at the start of the elements, we need to check the previous InlineCollection to see if the last item in there was a LineBreak.

/// <summary>
/// Check if the InlineCollection contains a LineBreak as the last item.
/// </summary>
/// <param name="inlines"></param>
/// <returns></returns>
private static bool AddLineBreakIfNeeded(InlineCollection inlines)
{
    if (inlines.Count > 0)
    {
        var lastInline = inlines[inlines.Count - 1];
        while ((lastInline is Span))
        {
            var span = (Span)lastInline;
            if (span.Inlines.Count > 0)
            {
                lastInline = span.Inlines[span.Inlines.Count - 1];
            }
        }
        if (!(lastInline is LineBreak))
        {
            inlines.Add(new LineBreak());
            return true;
        }
    }
    return false;
}

Now that we have the behavior ready, we need to test it out. First let’s create some sample data. I’ll use the following:

"<p>This is a test of using <u>underline</u> text</p>",
"<p>This is a test of using <b>bold</b> text</p>",
"<p>This is a test of using <i>italics</i> text</p>",
"<div>This is a test of using<p>Nested elements with </p><ul><li>one</li><li>two</li></ul><p>items</p></div>",
"This is a test with an <p>element inside</p>the text",
"<div>This is a test of using<div>multiple nested</div><div>divs within<div>each other</div></div></div>",
"<span>This is a test of using elements</span><span> that we are not testing</span>",
"This is test without any elements"

Put that into a collection of a class and bind an ItemsCollection to it

<Grid.Resources>
    <local:SampleData x:Key="Data"/>
</Grid.Resources>
<ItemsControl ItemsSource="{Binding HtmlItems, Source={StaticResource Data}}">
    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <StackPanel>
                <TextBlock Text="{Binding}" FontSize="20" TextWrapping="Wrap">
                    <Interactivity:Interaction.Behaviors>
                        <local:HtmlTextBehavior />
                    </Interactivity:Interaction.Behaviors>
                </TextBlock>
                <Line X1="0" X2="400" Stroke="Red" StrokeThickness="3"></Line>
            </StackPanel>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>

For this I put a line at the bottom of each element so you can see where it stops.

RenderedResults

You can see the extra line breaks for the paragraph elements. This won’t render prefect html and once again while this will work for a webpage, it shouldn’t be used to render an entire webpage!

You can download a complete working Universal sample.

blog comments powered by Disqus