📜 ⬆️ ⬇️

Marked text in android.widget.TextView

Recently, I needed to make a rather smart chat in an Android application. In addition to the information itself, it was required to transfer additional functionality to users in the context of a specific message: the name of the author of the message should be inserted into the response text field, and if this message is about a newly created game session, users should be able to join the game by click and so on . One of the main requirements was the ability to create a message containing several links, which set the direction of research.

WebView , possessing the necessary functionality, was rejected because of the severity of the decision: I did not even create 100 or some copies for testing purposes, one for each message, because it was immediately clear that this waste would not work properly.

Fortunately, the most common TextView has unexpectedly amazing text markup functionality and can be used both as a separate element and as a whole page, being incomparably lighter than WebView .

I implemented all the functionality I needed and found out some more pretty interesting things, faced with a number of pitfalls (though not very sharp). It can be said that everything described below is a guide to creating a fairly powerful help system in your application, almost for nothing.
')

Tasks


In this example, we will create an application with two Activities , one of which contains a TextView , which plays the role of a browser, from which, in particular, you can call a second Activity , which demonstrates working with call parameters. We will find out how to create text pages with markup and images and link them to links.

The content of the pages is taken from the lines in the application resources, and the images are drawable resources. Small code changes will allow other locations to be used.



Create application


In any convenient way we create a regular application:

AndroidMainfest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.markup.tutorial" android:versionCode="1" android:versionName="1.0"> <uses-sdk android:minSdkVersion="7" android:targetSdkVersion="15" /> <application android:icon="@drawable/ic_launcher" android:label="@string/app_name" android:theme="@style/AppTheme"> <activity android:name=".MainActivity" android:label="@string/title_activity_main"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <activity android:name=".AnotherActivity" android:exported="false"> <intent-filter> <data android:scheme="activity-run" android:host="AnotherActivityHost" /> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> </intent-filter> </activity> </application> </manifest> 

A few explanations to the manifesto. If everything is clear with the first Activity , the second ( AnotherActivity ) contains some additional descriptors.

android: exported = "false" is necessary so that the compiler does not issue a warning that we forgot to write something in the exported component. In my opinion, a purely decorative moment, but the less yellow triangles - the calmer.

The intent-filter section contains descriptors of how and under what circumstances the launch of the Activity will occur.

<data android: scheme = "activity-run" android: host = "AnotherActivityHost" /> means that you can start an Activity by referring to the activity-run type: // AnotherActivity? params ...

The action and category values ​​are necessary for the system to detect and launch an Activity .

MainActivity.java
 package com.example.markup.tutorial; import org.xml.sax.XMLReader; import android.os.Bundle; import android.app.Activity; import android.graphics.drawable.Drawable; import android.text.Editable; import android.text.Html; import android.text.Spannable; import android.text.Spanned; import android.text.method.LinkMovementMethod; import android.widget.TextView; public class MainActivity extends Activity { TextView tvContent; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); tvContent = (TextView)findViewById(R.id.tvContent); tvContent.setLinksClickable(true); tvContent.setMovementMethod(new LinkMovementMethod()); setArticle("article_main"); } void setArticle(String strArticleResId) { int articleResId = getResources().getIdentifier(strArticleResId, "string", getPackageName()); String text = getString(articleResId); if (text == null) text = "Article not found"; Spanned spannedText = Html.fromHtml(text, htmlImageGetter, htmlTagHandler); Spannable reversedText = revertSpanned(spannedText); tvContent.setText(reversedText); } final Spannable revertSpanned(Spanned stext) { Object[] spans = stext.getSpans(0, stext.length(), Object.class); Spannable ret = Spannable.Factory.getInstance().newSpannable(stext.toString()); if (spans != null && spans.length > 0) { for(int i = spans.length - 1; i >= 0; --i) { ret.setSpan(spans[i], stext.getSpanStart(spans[i]), stext.getSpanEnd(spans[i]), stext.getSpanFlags(spans[i])); } } return ret; } Html.ImageGetter htmlImageGetter = new Html.ImageGetter() { public Drawable getDrawable(String source) { int resId = getResources().getIdentifier(source, "drawable", getPackageName()); Drawable ret = MainActivity.this.getResources().getDrawable(resId); ret.setBounds(0, 0, ret.getIntrinsicWidth(), ret.getIntrinsicHeight()); return ret; } }; Html.TagHandler htmlTagHandler = new Html.TagHandler() { public void handleTag(boolean opening, String tag, Editable output, XMLReader xmlReader) { Object span = null; if (tag.startsWith("article_")) span = new ArticleSpan(MainActivity.this, tag); else if ("title".equalsIgnoreCase(tag)) span = new AppearanceSpan(0xffff2020, AppearanceSpan.NONE, 20, true, true, false, false); else if (tag.startsWith("color_")) span = new ParameterizedSpan(tag.substring(6)); if (span != null) processSpan(opening, output, span); } }; void processSpan(boolean opening, Editable output, Object span) { int len = output.length(); if (opening) { output.setSpan(span, len, len, Spannable.SPAN_MARK_MARK); } else { Object[] objs = output.getSpans(0, len, span.getClass()); int where = len; if (objs.length > 0) { for(int i = objs.length - 1; i >= 0; --i) { if (output.getSpanFlags(objs[i]) == Spannable.SPAN_MARK_MARK) { where = output.getSpanStart(objs[i]); output.removeSpan(objs[i]); break; } } } if (where != len) { output.setSpan(span, where, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } } } } 

AnotherActivity.java
 package com.example.markup.tutorial; import android.app.Activity; import android.app.AlertDialog; import android.content.DialogInterface; import android.net.Uri; import android.os.Bundle; public class AnotherActivity extends Activity { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Uri uri = getIntent().getData(); String caption = uri.getQueryParameter("caption"); String text = uri.getQueryParameter("text"); new AlertDialog.Builder(this) .setTitle(caption) .setMessage(text) .setPositiveButton("OK", dioclOK) .setCancelable(false) .create().show(); } DialogInterface.OnClickListener dioclOK = new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int which) { dialog.dismiss(); finish(); } }; } 

AppearanceSpan.java
 package com.example.markup.tutorial; import android.text.TextPaint; import android.text.style.CharacterStyle; public class AppearanceSpan extends CharacterStyle { public static final int NONE = -1; final int color, bgColor, textSize; final boolean boldText, italicText, strikeThruText, underlineText; public AppearanceSpan(int color, int bgColor, int textSize, boolean boldText, boolean italicText, boolean strikeThruText, boolean underlineText) { this.color = color; this.bgColor = bgColor; this.textSize = textSize; this.boldText = boldText; this.italicText = italicText; this.strikeThruText = strikeThruText; this.underlineText = underlineText; } @Override public void updateDrawState(TextPaint tp) { if (color != NONE) tp.setColor(color); if (bgColor != NONE) tp.bgColor = bgColor; tp.setFakeBoldText(boldText); tp.setStrikeThruText(strikeThruText); if (textSize != NONE) tp.setTextSize(textSize); tp.setUnderlineText(underlineText); tp.setTextSkewX(italicText ? -0.25f : 0); } } 

ArticleSpan.java
 package com.example.markup.tutorial; import android.text.style.ClickableSpan; import android.view.View; public class ArticleSpan extends ClickableSpan { final MainActivity activity; final String articleId; public ArticleSpan(MainActivity activity, String articleId) { super(); this.activity = activity; this.articleId = articleId; } @Override public void onClick(View arg0) { activity.setArticle(articleId); } } 

ParameterizedSpan.java
 package com.example.markup.tutorial; import android.graphics.Color; import android.text.TextPaint; import android.text.style.CharacterStyle; public class ParameterizedSpan extends CharacterStyle { int color = 0; public ParameterizedSpan(String param) { try { color = Color.parseColor("#" + param); } catch(Exception ex) { } } @Override public void updateDrawState(TextPaint tp) { tp.setColor(color); } } 


Resource Preparation


layout / activity_main.xml
 <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="fill_parent" android:layout_height="fill_parent" > <ScrollView android:id="@+id/sv" android:layout_width="fill_parent" android:layout_height="fill_parent" > <TextView android:id="@+id/tvContent" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Content" /> </ScrollView> </RelativeLayout> 

values ​​/ strings.xml
 <resources> <string name="app_name">MarkupTutor</string> <string name="hello_world">Hello world!</string> <string name="menu_settings">Settings</string> <string name="title_activity_main">MainActivity</string> <string name="article_main" formatted="false"><![CDATA[ <title> </title><br/> <br/> <img src="res_pushkin_little"> <article_pushkin_stih>..  "   "</article_pushkin_stih><br/> <img src="res_activity_little"> <a href="activity-run://AnotherActivityHost?caption=Another%20Activity&text=Hello%20from%20markup!">  Activity</a><br/> <br/> <color_ff00ff00>   <color_ffff00ff>  </color_ffff00ff>.</color_ff00ff00><br/>   GIF-:<br/> <img src="res_alien_anim"> ]]></string> <string name="article_pushkin_stih" formatted="false"><![CDATA[ <br/><article_main> </article_main><br/><br/> <img src="res_pushkin" /><br/><br/>  ,    ,<br/>   <br/>  ,   ,<br/>   .<br/>     ,<br/>    -    -<br/>   ,<br/><br/>    .<br/> ,  ,<br/> ,  ,<br/> ,  ,  .<br/> -, , .<br/>  , , <br/>     ,<br/>   <br/>   ,<br/>    :<br/>     -<br/>   <br/>    .<br/>    ,<br/> , ,<br/> , <br/>  !..<br/> ,   .<br/>       ,<br/>     .<br/>     ?<br/>  -<br/> (,    )<br/>    -<br/>    ...<br/> , ,  ,<br/>    .<br/> ]]></string> </resources> 


Strings containing markup must have the formatted attribute set to false , and the content must be passed in a CDATA block so that the compiler has no claim to markup and special characters. In this example, the sign of the article will be the prefix article_ in the string name.

Also noticed a strange glitch, manifested in the fact that if the text starts with a tag, then it ends with the same tag. If you have a link at the beginning of the article, I advise you to put before it either a space or <br/> .

Images can be in jpg, png or gif format without animation. An animated gif is displayed with a static image. The location is standard for resources; for displays of different density, you can prepare your own version of the picture. In this example, all the images are in drawable-nodpi



How it all works


Consider some parts of the code in detail.

 public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); tvContent = (TextView)findViewById(R.id.tvContent); tvContent.setLinksClickable(true); tvContent.setMovementMethod(new LinkMovementMethod()); setArticle("article_main"); } 


TextView used by us as a browser requires special initialization:

tvContent.setLinksClickable (true); indicates that the links in this item are clickable.

tvContent.setMovementMethod (new LinkMovementMethod ()); assigns a way to navigate an item. The LinkMovementMethod we used is interesting in its own right and perhaps deserves a separate article. I can only say that if you need more control, you can create a successor, whose redefined methods will allow you to track all actions with links in the element.

 void setArticle(String strArticleResId) { int articleResId = getResources().getIdentifier(strArticleResId, "string", getPackageName()); String text = getString(articleResId); if (text == null) text = "Article not found"; Spanned spannedText = Html.fromHtml(text, htmlImageGetter, htmlTagHandler); Spannable reversedText = revertSpanned(spannedText); tvContent.setText(reversedText); } 

In this method, a string is obtained by identifier from string resources, its conversion from HTML to a special Spanned object, then another conversion to Spannable and set to TextView as content. All this seems rather cumbersome, but for good reason.

In TextView , in my opinion, a strange order of processing spans - from the end of the list. With the natural arrangement of spans after converting a string from HTML, changes in the appearance of nested spans are overlapped by the properties of spans containing them. For a normal display, you have to literally turn the marking inside out using the revertSpanned method:

 final Spannable revertSpanned(Spanned stext) { Object[] spans = stext.getSpans(0, stext.length(), Object.class); Spannable ret = Spannable.Factory.getInstance().newSpannable(stext.toString()); if (spans != null && spans.length > 0) { for(int i = spans.length - 1; i >= 0; --i) { ret.setSpan(spans[i], stext.getSpanStart(spans[i]), stext.getSpanEnd(spans[i]), stext.getSpanFlags(spans[i])); } } return ret; } 

The definition of the image linker is minimalistic and is intended to load only pictures from resources. Since we are considering a version of the help system, I thought that would be enough. With your permission, I will not quote it. If you want more, you can refer, for example, to this article .

Html.TagHadler will be more interesting to us:

 Html.TagHandler htmlTagHandler = new Html.TagHandler() { public void handleTag(boolean opening, String tag, Editable output, XMLReader xmlReader) { Object span = null; if (tag.startsWith("article_")) span = new ArticleSpan(MainActivity.this, tag); else if ("title".equalsIgnoreCase(tag)) span = new AppearanceSpan(0xffff2020, AppearanceSpan.NONE, 20, true, true, false, false); else if (tag.startsWith("color_")) span = new ParameterizedSpan(tag.substring(6)); if (span != null) processSpan(opening, output, span); } }; 


Here we have some interesting things going on.

When converting from HTML to Spanned using the Html.fromHtml method, the tags br , p , div , em , b , strong , cite , dfn , i , big , small , font , blockquote , tt , a , u , sup , sub , h1...h6 and img . In case the tag is not identified, Html.TagHandler is called (if, of course, it is transferred to the call).

We check if the passed tag is “our” and if so, create the corresponding Span - markup element, and then apply it to the text. I created several of my own Span- s, they will be discussed further. As a rule, Spans are inherited from android.text.style.CharacterStyle .

Unfortunately, I did not manage to achieve the centering of individual lines or paragraphs with a little blood, and there is no built-in possibility for this. Also, it is impossible to read the tag attributes from the xmlReader , because it is not fully implemented. For this reason, I had to invent my own way of passing parameters: the value is part of the tag. In our example, the color value is transmitted in the color tag, which is converted to ParameterizedSpan . It turns out something like <color_ffff0000></color_ffff0000> . This is a rather limited and not very convenient way, but sometimes it’s better than this.

  void processSpan(boolean opening, Editable output, Object span) { int len = output.length(); if (opening) { output.setSpan(span, len, len, Spannable.SPAN_MARK_MARK); } else { Object[] objs = output.getSpans(0, len, span.getClass()); int where = len; if (objs.length > 0) { for(int i = objs.length - 1; i >= 0; --i) { if (output.getSpanFlags(objs[i]) == Spannable.SPAN_MARK_MARK) { where = output.getSpanStart(objs[i]); output.removeSpan(objs[i]); break; } } } if (where != len) { output.setSpan(span, where, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } } } 

This code does the following: In case the opening Span is transmitted, it is added to the end of the line in its current form. In case Span is closing, we find its opening analogue in the line, remember its position, then delete and add a new one, but with information about the initial position and length.

We have completed our consideration of the Activity class, which is the main module of our application. Now consider the helper classes.

 package com.example.markup.tutorial; import android.text.TextPaint; import android.text.style.CharacterStyle; public class AppearanceSpan extends CharacterStyle { public static final int NONE = -1; final int color, bgColor, textSize; final boolean boldText, italicText, strikeThruText, underlineText; public AppearanceSpan(int color, int bgColor, int textSize, boolean boldText, boolean italicText, boolean strikeThruText, boolean underlineText) { this.color = color; this.bgColor = bgColor; this.textSize = textSize; this.boldText = boldText; this.italicText = italicText; this.strikeThruText = strikeThruText; this.underlineText = underlineText; } @Override public void updateDrawState(TextPaint tp) { if (color != NONE) tp.setColor(color); if (bgColor != NONE) tp.bgColor = bgColor; tp.setFakeBoldText(boldText); tp.setStrikeThruText(strikeThruText); if (textSize != NONE) tp.setTextSize(textSize); tp.setUnderlineText(underlineText); tp.setTextSkewX(italicText ? -0.25f : 0); } } 

This is a general purpose Span and you can use it to set most of the text style options. It can be used as a base for creating text styles from your own tags.

 package com.example.markup.tutorial; import android.text.style.ClickableSpan; import android.view.View; public class ArticleSpan extends ClickableSpan { final MainActivity activity; final String articleId; public ArticleSpan(MainActivity activity, String articleId) { super(); this.activity = activity; this.articleId = articleId; } @Override public void onClick(View arg0) { activity.setArticle(articleId); } } 

This class describes an element that, by clicking on it, provides a transition to an article whose identifier is its parameter. Here I applied a derivative of the method I described earlier: the tag itself is its own parameter, and its class is determined by the prefix article_ . Let's go higher to the description of Html.TagHandler :

 if (tag.startsWith("article_")) span = new ArticleSpan(MainActivity.this, tag); 

The tag handler, seeing the tag that starts with article_ , creates a ArticleSpan , giving it the name of the tag as a parameter. The element, when clicked, calls the MainActivity.setArticle method, after which a new text is set in the TextView.

 package com.example.markup.tutorial; import android.graphics.Color; import android.text.TextPaint; import android.text.style.CharacterStyle; public class ParameterizedSpan extends CharacterStyle { int color = 0; public ParameterizedSpan(String param) { try { color = Color.parseColor("#" + param); } catch(Exception ex) { } } @Override public void updateDrawState(TextPaint tp) { tp.setColor(color); } } 

Here the element is implemented that receives the parameter explicitly and separately from its name. Claim for a kind of tag naming standard, since attributes cannot be passed.

Of course, everything described is a variation of one principle; everyone will choose what is more convenient for him.

Call Activity


Everything is very simple here. The call is made by using the usual <a href...> tag with the task of the scheme and the host, which are described in AndroidManifest.xml for the called Activity .

In HTML we see the following:
 <a href="activity-run://AnotherActivityHost?caption=Another%20Activity&text=Hello%20from%20markup!">  Activity</a> 

When you click on the link, AnotherActivity is called with the transfer of parameters to Intent. These parameters can be obtained and used:
 Uri uri = getIntent().getData(); String caption = uri.getQueryParameter("caption"); String text = uri.getQueryParameter("text"); 


Used materials


The following materials greatly accelerated the creation of this article, and, indeed, there it was made possible:

www.sherif.mobi/2011/09/html-and-activity-links-in-textview.html
stackoverflow.com/questions/3874999/alignment-in-html-fromhtml
stackoverflow.com/questions/11865334/how-to-use-xmlreader-in-taghandler-handletag
stackoverflow.com/questions/4044509/android-how-to-use-the-html-taghandler
stackoverflow.com/questions/1792604/html-imagegetter

I am very glad that StackOverflow.com exists in the world.

Archive with project sources



The archive with source texts and project resources can be found here .

Source: https://habr.com/ru/post/166351/


All Articles