Xamarin.Controls – Creating Your Own Android Markdown TextView

In a previous post, I talked about a Xamarin.Forms control I put on GitHub to allow you to display Markdown strings properly. It worked by parsing the markdown into html, then using the custom renderer to display the html string in a TextView for Android and a UILabel for iOS.

However, we are not always using Xamarin.Forms, so let’s take a look at achieving the same functionality with just Xamarin.Android.

We’ll break it down into a few parts:

  1. Parse a markdown string into an html string
  2. Parse the html string into an ICharSequence
  3. Create an extra tag handler to help show html elements that are not traditionally supported in a TextView
  4. Set the TextFormatted of the TextView

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, "<br/>");

    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("<br/>");
    html = regex.Replace(html, "\n");
    return html;
}

public static string ReplaceBreaksWithSpace(this string html)
{
    var regex = new Regex("<br/>");
    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>"

Parsing Html to ICharSequence

The next step is to take our processed html string and turn it into something that an Android TextView can use.

How about another extension method?


public static ICharSequence ToFormattedHtml(this string htmlText)
{
    try
    {
        ICharSequence html;
        if(Android.OS.Build.VERSION.SdkInt >= Android.OS.BuildVersionCodes.N)
        {
            html = Html.FromHtml(htmlText.ParseCodeTags(), FromHtmlOptions.ModeLegacy, null, new HtmlTagHandler()) as ICharSequence;
        }
        else
        {
            // handle legacy builds
            html = Html.FromHtml(htmlText.ParseCodeTags(), null, new HtmlTagHandler()) as ICharSequence;
        }
        // this is required to get rid of the end two "\n" that android adds with Html.FromHtml
        // see: http://stackoverflow.com/questions/16585557/extra-padding-on-textview-with-html-contents for example
        while (html.CharAt(html.Length() - 1) == '\n')
        {
            html = html.SubSequenceFormatted(0, html.Length() - 1);
        }
        return html;
    }
    catch
    {
        return null;
    }
}

To breakdown what this is doing: We check against the sdk version we are running against to ensure we don’t use an obsolete api, then call to get the Html as an ICharSequence. Then, we clean up the output and return it.

We’ll add one more extension method just to make the full conversion from markdown to html to formatted html:

public static ICharSequence MarkdownToHtml(this string markdown)
{
    return markdown.ToHtml().ToFormattedHtml();
}

There’s one piece here that is unique, and that is the HtmlTagHandler() used in the call to Html.FromHtml.

Extending the Tag Handler

Android, unfortunately, doesn’t support many html elements in its TextFormatted. Daniel Lew has a great post about the supported types.

In order to get around this, we need to use tags that are supported and combine those to simulate the missing tags; especially for things like ul and ol.

I’ve translated a popular processor from Java to C# so we can use it in Xamarin: HtmlTagHandler.cs

/// <summary>
/// Custom tag handler for parsing more html tags for android textviews
/// This is a port/translation of https://github.com/sufficientlysecure/html-textview/blob/master/HtmlTextView/src/main/java/org/sufficientlysecure/htmltextview/HtmlTagHandler.java
/// Which is considered the most robust handler
/// </summary>
public class HtmlTagHandler : Java.Lang.Object, Android.Text.Html.ITagHandler
{
    /**
     * Keeps track of lists (ol, ul). On bottom of Stack is the outermost list
     * and on top of Stack is the most nested list
     */
    Stack<String> lists = new Stack<String>();
    /**
     * Tracks indexes of ordered lists so that after a nested list ends
     * we can continue with correct index of outer list
     */
    Stack<Int32> olNextIndex = new Stack<Int32>();
    /**
     * List indentation in pixels. Nested lists use multiple of this.
     */
    private static int indent = 10;
    private static int listItemIndent = indent * 2;
    private static BulletSpan bullet = new BulletSpan(indent);
    private class Ul : Java.Lang.Object
    {
    }
    private class Ol : Java.Lang.Object
    {
    }

    private class Code : Java.Lang.Object
    {
    }
    private class Center : Java.Lang.Object
    {
    }

    private class Strike : Java.Lang.Object
    {
    }


    public void HandleTag(Boolean opening, String tag, Android.Text.IEditable output, IXMLReader xmlReader)
    {
        if (opening)
        {
            if (tag.ToLower() == "ul")
            {
                lists.Push(tag);
            }
            else if (tag.EqualsIgnoreCase("ol"))
            {
                lists.Push(tag);
                olNextIndex.Push(1);
            }
            else if (tag.EqualsIgnoreCase("li"))
            {
                if (output.Length() > 0 && output.CharAt(output.Length() - 1) != '\n')
                {
                    output.Append("\n");
                }
                String parentList = lists.Peek();
                if (parentList.EqualsIgnoreCase("ol"))
                {
                    Start(output, new Ol());
                     output.Append(olNextIndex.Peek().ToString()).Append('.').Append(' ');
                    olNextIndex.Push(olNextIndex.Pop() + 1);
                }
                else if (parentList.EqualsIgnoreCase("ul"))
                {
                    Start(output, new Ul());
                }
            }
            else if (tag.EqualsIgnoreCase("code"))
            {
                Start(output, new Code());
            }
            else if (tag.EqualsIgnoreCase("center"))
            {
                Start(output, new Center());
            }
            else if (tag.EqualsIgnoreCase("s") || tag.EqualsIgnoreCase("strike"))
            {
                Start(output, new Strike());
            }
        }
        else
        {
            if (tag.EqualsIgnoreCase("ul"))
            {
                lists.Pop();
            }
            else if (tag.EqualsIgnoreCase("ol"))
            {
                lists.Pop();
                olNextIndex.Pop();
            }
            else if (tag.EqualsIgnoreCase("li"))
            {
                if (lists.Peek().EqualsIgnoreCase("ul"))
                {
                    if (output.Length() > 0 && output.CharAt(output.Length() - 1) != '\n')
                    {
                        output.Append("\n");
                    }
                    // Nested BulletSpans increases distance between bullet and Text, so we must prevent it.
                    int bulletMargin = indent;
                    if (lists.Count > 1)
                    {
                        bulletMargin = indent - bullet.GetLeadingMargin(true);
                        if (lists.Count > 2)
                        {
                            // This get's more complicated when we add a LeadingMarginSpan into the same line:
                            // we have also counter it's effect to BulletSpan
                            bulletMargin -= (lists.Count - 2) * listItemIndent;
                        }
                    }
                    BulletSpan newBullet = new BulletSpan(bulletMargin);
                    End(output, typeof(Ul), false,
                            new LeadingMarginSpanStandard(listItemIndent * (lists.Count - 1)),
                            newBullet);
                }
                else if (lists.Peek().EqualsIgnoreCase("ol"))
                {
                    if (output.Length() > 0 && output.CharAt(output.Length() - 1) != '\n')
                    {
                        output.Append("\n");
                    }
                    int numberMargin = listItemIndent * (lists.Count - 1);
                    if (lists.Count > 2)
                    {
                        // Same as in ordered lists: counter the effect of nested Spans
                        numberMargin -= (lists.Count - 2) * listItemIndent;
                    }
                    End(output, typeof(Ol), false, new LeadingMarginSpanStandard(numberMargin));
               }
            }
            else if (tag.EqualsIgnoreCase("code"))
            {
                End(output, typeof(Code), false, new TypefaceSpan("monospace"));
            }
            else if (tag.EqualsIgnoreCase("center"))
            {
                End(output, typeof(Center), true, new AlignmentSpanStandard(Layout.Alignment.AlignCenter));
            }
            else if (tag.EqualsIgnoreCase("s") || tag.EqualsIgnoreCase("strike"))
            {
                End(output, typeof(Strike), false, new StrikethroughSpan());
            }
        }
    }

    /**
     * Mark the opening tag by using private classes
     */
    private void Start(IEditable output, Java.Lang.Object mark)
    {
        int len = output.Length();
        output.SetSpan(mark, len, len, SpanTypes.MarkMark);
    }

    /**
     * Modified from {@link Android.Text.Html}
     */
    private void End(IEditable output, Type kind, Boolean paragraphStyle, params Java.Lang.Object[] replaces)
    {
        Java.Lang.Object obj = GetLast(output, kind);
        // start of the tag
        int where = output.GetSpanStart(obj);
        // end of the tag
        int len = output.Length();
        output.RemoveSpan(obj);

       if (where != len)
       {
            int thisLen = len;
            // paragraph styles like AlignmentSpan need to end with a new line!
            if (paragraphStyle)
            {
                output.Append("\n");
                thisLen++;
            }
            foreach (Java.Lang.Object replace in replaces)
            {
                output.SetSpan(replace, where, thisLen, SpanTypes.ExclusiveExclusive);
            }


       }
   }

    /**
     * Get last marked position of a specific tag kind (private class)
     */
    private static Java.Lang.Object GetLast(IEditable Text, Type kind)
    {
        Java.Lang.Object[] objs = Text.GetSpans(0, Text.Length(), Java.Lang.Class.FromType(kind)); // TODO: LOl will this work?
        if (objs.Length == 0)
        {
            return null;
        }
        else
        {
            for (int i = objs.Length; i > 0; i--)
            {
                if (Text.GetSpanFlags(objs[i - 1]) == SpanTypes.MarkMark)
                {
                    return objs[i - 1];
                }
            }
           return null;
        }
    }

}

So now we can properly display list elements as well as code elements!

Now for the very last bit: Show it!

Assiging To the TextView

Pretty simple now that we have our useful extension methods!

var markdown = "# Hello *World*";
var myTextView = FindViewById<TextView>(Resource.Id.MyTextView);
myTextView.TextFormatted = markdown.MarkdownToHtml();

That’s it! Now you can see some cool stylized text in your labels.

Advertisements

3 thoughts on “Xamarin.Controls – Creating Your Own Android Markdown TextView”

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