Xamarin.Controls – Creating your own Markdown TextBlock in UWP

After talking about using the MarkdownTextView I created and how to accomplish rendering Markdown in iOS and Android without a WebView, I received a few requests to do it for UWP as well.

Check these previous posts out:

  1. Xamarin.Controls – MarkdownTextView
  2. Xamarin.Controls – Creating Your Own Android Markdown TextView
  3. Xamarin.Controls – Creating Your Own iOS Markdown UILabel

Let’s dive in to rendering markdown into a TextBlock in UWP. We’ll break it down into a few steps:

  1. Parse a markdown string into an html string
  2. Create a Behavior for the TextBlock
  3. Parse the html into relevant Span tags in the behavior
  4. Use the new Behavior in our XAML or C#

 

Parsing Markdown

This is traditionally the most difficult part. However, our community is awesome and open sourced a Markdown processor with an MIT license (so use it freely!).

I won’t put the actual code in here because it is overwhelmingly long, but here is a link to it:

https://github.com/SuavePirate/MarkdownTextView/blob/master/src/Forms/SPControls.MarkdownTextView/SPControls.MarkdownTextView/Markdown.cs

Note that this is portable, so you can use it in a PCL without a problem and share it between your platforms.

Now that we have our means of processing the Markdown, let’s create some extension methods to make it easier to parse and do some extra processing like cleaning up our tags, line breaks, etc.

#region MARKDOWN STYLES
private const string ORIGINAL_PATTERN_BEGIN = "<code>";
private const string ORIGINAL_PATTERN_END = "</code>";
private const string PARSED_PATTERN_BEGIN = "<font color=\"#888888\" face=\"monospace\"><tt>";
private const string PARSED_PATTERN_END = "</tt></font>";

#endregion

public static string ToHtml(this string markdownText)
{
    var markdownOptions = new MarkdownOptions
    {
        AutoHyperlink = true,
        AutoNewlines = false,
        EncodeProblemUrlCharacters = false,
        LinkEmails = true,
        StrictBoldItalic = true
    };
    var markdown = new Markdown(markdownOptions);
    var htmlContent = markdown.Transform(markdownText);
    var regex = new Regex("\n");
    htmlContent = regex.Replace(htmlContent, "
");

    var html = htmlContent.HtmlWrapped();
    var regex2 = new Regex("\r");
    html = regex.Replace(html, string.Empty);
    html = regex2.Replace(html, string.Empty);
    return html;
}

///
<summary>
/// Wrap html with a full html tag
/// </summary>
/// <param name="html"></param>
/// <returns></returns>
public static string HtmlWrapped(this string html)
{
    if (!html.StartsWith("<html>") || !html.EndsWith("</html>"))
    {
        html = $"<html><body>{html}</body></html>";
    }
    return html;
}

///<summary>
/// Parses html with code or pre tags and gives them proper
/// styled spans so that Android can parse it properly
/// </summary>
/// <param name="htmlText">The html string</param>
/// <returns>The html string with parsed code tags</returns>
public static string ParseCodeTags(this string htmlText)
{
    if (htmlText.IndexOf(ORIGINAL_PATTERN_BEGIN) < 0) return htmlText;
    var regex = new Regex(ORIGINAL_PATTERN_BEGIN);
    var regex2 = new Regex(ORIGINAL_PATTERN_END);

    htmlText = regex.Replace(htmlText, PARSED_PATTERN_BEGIN);
    htmlText = regex2.Replace(htmlText, PARSED_PATTERN_END);
    htmlText = htmlText.TrimLines();
    return htmlText;
}

public static bool EqualsIgnoreCase(this string text, string text2)
{
    return text.Equals(text2, StringComparison.CurrentCultureIgnoreCase);
}

public static string ReplaceBreaks(this string html)
{
    var regex = new Regex("
");
    html = regex.Replace(html, "\n");
    return html;
}

public static string ReplaceBreaksWithSpace(this string html)
{
    var regex = new Regex("
");
    html = regex.Replace(html, " ");
    return html;
}

public static string TrimLines(this string originalString)
{
    originalString = originalString.Trim('\n');
    return originalString;
}

Now we can properly parse markdown to html:

var markdown = "# Hello *World*";
var html = markdown.ToHtml();
// html = "<h1>Hello <strong>World</strong></h1>"

Create a Behavior

I’m going to take some inspiration from Shawn Kendrot and his post here.

The first thing Shawn does is implement a base Behavior class, so let’s go ahead and use that here:

    // WinRT Implementation of the base Behavior classes
    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; }
        }
    }

Now we can create our actual Behavior implementation to handle the html parsing. If you want the entire file, you can find it in a gist here. Or keep reading so we can break it down.

HtmlTextBehavior.cs

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()
    {
        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;
    }
    
    /// <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;
            // TODO: Add ElementH1 - ElementH6 handlers here. They should behave the same way as ElementP with increased font size.
            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());
        }
    }
    /// <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;
    }
}

That’s a lot of stuff to look at. Let’s break it down. The core of the processing is in the UpdateText and ParseText methods.

Essentially what we are doing here is some layout management, and then parsing the html (parsed into an XElement). It’s different from the approaches we took in Android and iOS where there are native APIs that can parse HTML automatically. But if you looked at the Android post I made before, you’ll remember the extra TagHandler we created that took unsupported html element types and parsed them into Spans for proper formatting. What we did in that situation is a simplified version of what we accomplish here. We take relevant html tags, and create Spans, Bolds, HyperLinks and other relevant types in order to be able to render it within our final TextBlock.

There are a few element types in the parsing that are not included, but can be pretty easily added. For example, code, pre, h1h6, etc. However, these can be easily added to the switch statement! Most of these are going to be handled the same way that ElementP does. So you can add them, and adjust the FontSize or FontFamily of your Span!

Applying the Behavior

Now that we have our HtmlTextBehavior built out, we can apply it to our actual TextBlock!

In XAML:

<TextBlock Text="{Binding MyHtmlText}" FontSize="20" TextWrapping="Wrap">
    <Interactivity:Interaction.Behaviors>
        <local:HtmlTextBehavior />
    </Interactivity:Interaction.Behaviors>
</TextBlock>

Now we can render out MyHtmlText property from our ViewModel directly in our TextBlock!

Advertisements

One thought on “Xamarin.Controls – Creating your own Markdown TextBlock in UWP”

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s