Android 千變萬化 TextView:神奇的 SpannableString

之前寫過一篇SpannableString的文章,最近搬出來統一放在簡書上。

前言

TextView 可以說是 Android 中最簡單、最常見的文字控件了,幾乎每個頁面都有 TextView 的身影,絕大多數情況我們用 TextView 只是單純地顯示一個文本,但是 TextView 的功能遠遠不止如此哦,簡單的 TextView 也能千變萬化顯示出各種效果,這一切都要歸功於 SpannableString。

TextView 和 SpannableString 一起使用具體有哪些神奇的地方呢?本場 Chat 將全面地介紹 SpannableString 的用法,讓你的 TextView 不再簡單。

SpannableString

在 Android 中,常規的字符串類就是 String 或者 Charsequence,String 用的最多,有些人可能對 Charsequence 都有點陌生,EditText 的 getText() 返回的就是 Charsequence 對象。但是今天我們要介紹的 SpannableString 就是另一種更強大的字符串類。

Spannable 是什麼意思?英語詞典上還真不太好查,我自己的理解的意思是:可測量、可塑造的,所以 SpannableString 就是一種可測量可塑造的字符串。

1)默認 TextView 樣式

默認 TextView 樣式我們再熟悉不過了,看下截圖,沒啥好說的。

2)自定義字體

SpannableString 可以給 TextView 設置自定義字體樣式,並且可以指定某幾個字,其實 SpannableString 幾乎所有的屬性可可以指定到具體某幾個字。

SpannableString ss = new SpannableString(txCustomTypeface.getText());
ss.setSpan(new TypefaceSpan("sans-serif"), 2, 4, SPAN_EXCLUSIVE_EXCLUSIVE);
txCustomTypeface.setText(ss);

這裏用到了一個新的類:TypefaceSpan,它就是用來設置字體樣式的,參數有 5 個可選值:default、default-bold、monospace、serif、sans-serif。後面的 2 和 4 是需要生效的起始位置和結束位置。

在這個例子中,我們把 2 - 4 的文字設置成了 sans-serif 樣式,但是竟然看不出任何差別。不過也不必奇怪,這些字體樣式之間的差異確實非常小,根據一篇專業的字體研究報告稱,sans 字體適合正文內容文字,能長時間集中視覺注意力,而 sans-serif 適合標題文字,能快速抓住注意力,但不適宜長時間閱讀。總之,這之間的差別是比較專業的,在這個例子中確實看不出多大區別。

3)絕對字體和相對字體

SpannableString 可以動態地改變字體大小,並且支持絕對大小和相對大小兩種模式。

絕對大小
SpannableString ss = new SpannableString(txAbsoluteSize.getText());
ss.setSpan(new AbsoluteSizeSpan(12, true), 2, 4, SPAN_EXCLUSIVE_EXCLUSIVE);
txAbsoluteSize.setText(ss);

圖中可以看到中間兩個字變小了,AbsoluteSizeSpan 就是構建絕對大小的類,它有兩個參數,第一個表示字體大小,第二個表示是否使用 DIP,false 的話單位就是 px,true 的話單位就是 dp。

相對大小
SpannableString ss = new SpannableString(txRelativeSize.getText());
ss.setSpan(new RelativeSizeSpan(1.5f), 2, 4, SPAN_EXCLUSIVE_EXCLUSIVE);
txRelativeSize.setText(ss);

相對字體大小就簡單一些了,只需要傳入一個字體相對大小,比如我們傳入了 1.5,中間兩個字就變成了原始字體的 1.5 倍大。

4)前景色和背景色

其實對於 TextView 來說,前景色就是 textColor,背景色就是 background。你可能會覺得那爲什麼要用 SpannableString 來做呢,直接用 textColor 和 background 不就可以了嗎?但是 textColor 和 background 只能對 textView 整體生效,而 SpannableString 可以動態給不同位置的文字設置不同顏色。

前景色
SpannableString ss = new SpannableString(txForegroundColor.getText());
ss.setSpan(new ForegroundColorSpan(Color.BLUE), 0, txForegroundColor.getText().length(), SPAN_EXCLUSIVE_EXCLUSIVE);
txForegroundColor.setText(ss);

背景色
SpannableString ss = new SpannableString(txBackgroundColor.getText());
ss.setSpan(new BackgroundColorSpan(Color.LTGRAY), 0, 
    txBackgroundColor.getText().length(), SPAN_EXCLUSIVE_EXCLUSIVE);
txBackgroundColor.setText(ss);

5)字體的加粗和傾斜

這裏和大多數編輯器一樣,支持三種:粗體、斜體、粗斜體

對應的常量是:Typeface.BOLD、Typeface.ITALIC、Typeface.BOLD_ITALIC

SpannableString ss = new SpannableString(txBord.getText());
ss.setSpan(new StyleSpan(Typeface.BOLD), 0, txBord.getText().length(), 
    SPAN_EXCLUSIVE_EXCLUSIVE);
txBord.setText(ss);

6)刪除線和下劃線

刪除線和下劃線是兩種常用文本標記符號,SpannableString 當然也是支持的。設置刪除線和下劃線很簡單,只要指定起始位置和結束位置即可,下面直接看代碼和效果圖吧。

刪除線

刪除線用到的類是 StrikethroughSpan,沒有參數。

SpannableString ss = new SpannableString(txDeleteLine.getText());
ss.setSpan(new StrikethroughSpan(), 0, txDeleteLine.getText().length(), 
    SPAN_EXCLUSIVE_EXCLUSIVE);
txDeleteLine.setText(ss);

下劃線

下劃線用到的類是 UnderlineSpan,沒有參數。

SpannableString ss = new SpannableString(txUnderLine.getText());
ss.setSpan(new UnderlineSpan(), 0, txUnderLine.getText().length(), 
    SPAN_EXCLUSIVE_EXCLUSIVE);
txUnderLine.setText(ss);

7)文字的上標和下標

這個在實際開發中不常用,但是卻很重要,因爲萬一遇到這種需求要自己實現的話還挺麻煩的。SpannableString 實現起來就很簡單了。

SpannableString ss = new SpannableString(txSubSuperScript.getText());
ss.setSpan(new SuperscriptSpan(), 2, 3, SPAN_EXCLUSIVE_EXCLUSIVE);
ss.setSpan(new SubscriptSpan(), 5, 6, SPAN_EXCLUSIVE_EXCLUSIVE);
txSubSuperScript.setText(ss);

8)6 種超鏈接形式

我記得我實習那會遇到一個需求要實現一個 TextView 中超鏈接的功能,那時候我還不知道 SpannableString,想了各種辦法,頭都大了。

SpannableString 支持 6 中超鏈接形式,分別是: 電話超鏈接、郵件超鏈接、網址超鏈接、短信超鏈接、彩信超鏈接、地圖超鏈接。

a.電話超鏈接

這裏又涉及到了一個新的類:URLSpan,實際上6種超鏈接都是使用 URLSpan 構建的,只是構造函數傳入的鏈接格式不一樣, 電話超鏈接傳入的是 tel: 開頭,後面接要撥打的電話號碼,點擊後就會自動跳轉撥打電話。

SpannableString ss = new SpannableString(txTelUrl.getText());
ss.setSpan(new URLSpan("tel:02512345678"), 0, txTelUrl.getText().length(), 
    SPAN_EXCLUSIVE_EXCLUSIVE);
txTelUrl.setText(ss);
txTelUrl.setMovementMethod(LinkMovementMethod.getInstance());

b.郵件超鏈接

郵件超鏈接是以 mailto: 開頭,後面接郵箱地址。點擊後就會自動跳轉郵件 app。

SpannableString ss = new SpannableString(txMailUrl.getText());
ss.setSpan(new URLSpan("mailto:[email protected]"), 0, txMailUrl.getText().length(), 
    SPAN_EXCLUSIVE_EXCLUSIVE);
txMailUrl.setText(ss);
txMailUrl.setMovementMethod(LinkMovementMethod.getInstance());

如果你的手機裏存在多個郵件 app,需要選擇一個。

c.網址超鏈接

網址超鏈接是以 http:// 或 https:// 開頭,後面接網址,點擊後跳轉瀏覽器 app,同樣如果有多個瀏覽器,需要作出選擇。

SpannableString ss = new SpannableString(txWebUrl.getText());
ss.setSpan(new URLSpan("http://www.baidu.com"), 0, txWebUrl.getText().length(), 
    SPAN_EXCLUSIVE_EXCLUSIVE);
txWebUrl.setText(ss);
txWebUrl.setMovementMethod(LinkMovementMethod.getInstance());

d.短信超鏈接

短信超鏈接是以 sms: 開頭,後面接手機號碼,點擊後跳轉系統短信 app。

SpannableString ss = new SpannableString(txSmsUrl.getText());
ss.setSpan(new URLSpan("sms:02512345678"), 0, txSmsUrl.getText().length(), 
    SPAN_EXCLUSIVE_EXCLUSIVE);
txSmsUrl.setText(ss);
txSmsUrl.setMovementMethod(LinkMovementMethod.getInstance());

e.彩信超鏈接

彩信超鏈接是以 mms: 開頭,後面接手機號碼,點擊永陽跳轉系統短信 app。

SpannableString ss = new SpannableString(txMmsUrl.getText());
ss.setSpan(new URLSpan("mms:02512345678"), 0, txMmsUrl.getText().length(), 
    SPAN_EXCLUSIVE_EXCLUSIVE);
txMmsUrl.setText(ss);
txMmsUrl.setMovementMethod(LinkMovementMethod.getInstance());

f.地圖超鏈接

地圖超鏈接以 geo: 開頭,後面接經緯度,點擊後跳轉地圖 app。

SpannableString ss = new SpannableString(txGeoUrl.getText());
ss.setSpan(new URLSpan("geo:30.123456,-50.024456"), 0, 
    txGeoUrl.getText().length(), SPAN_EXCLUSIVE_EXCLUSIVE);
txGeoUrl.setText(ss);
txGeoUrl.setMovementMethod(LinkMovementMethod.getInstance());

如果你的手機有多個地圖 app,需要選擇一個默認 app。

9)添加項目符號

關於這一點,客觀地說用處不大,SpannableString 雖然支持設置項目符號,但是實際開發中基本不會用,如果是頁面中的欄位,我們肯定會用小 icon 實現項目符號,如果是 H5,那就是 HTML 的標籤實現。

BulletSpan 類用於構建項目符號,第一個參數是項目符號所佔的寬度,第二個參數是項目符號的顏色。

SpannableString ss = new SpannableString(txBullte.getText());
ss16.setSpan(new BulletSpan(20, Color.RED), 0, txBullte.getText().length(), 
    SPAN_EXCLUSIVE_EXCLUSIVE);
txBullte.setText(ss);

10)文字的橫向和縱向拉伸

一般我們要改變字體大小,都是設置 textSize 屬性,這個屬性是文字整體等比例放大縮小,那如果我只想文字橫向拉伸呢?這時候就要用到 SpannableString 了。

SpannableString ss = new SpannableString(txScaleX.getText());
ss.setSpan(new ScaleXSpan(2.5f), 0, txScaleX.getText().length(), 
    SPAN_EXCLUSIVE_EXCLUSIVE);
txScaleX.setText(ss);

ScaleXSpan 類用於指定橫向拉伸的比例,我們傳 2.5 表示橫向拉伸爲原來的 2.5 倍。

有了橫向拉伸,自然我們會想縱向拉伸,不好意思,不支持。因爲縱向的高度就得用 textSize 設置。

11)ColorStateList

這個東西我很少發現有人用,可能是因爲不知道有這個類,也可能是因爲這個用起來太麻煩。但不代表這個東西沒用。

大家有沒有遇到過這樣的場景,一個 Button,默認灰色背景,黑色文字,按下後,背景要變成黑色,這個需求很常見,但是你有可能遇到這樣的場景。

本來文字就是黑色,按下後背景變成黑色,文字就看不見了,背景顏色和文字顏色的對比度太低了甚至爲 0,導致文字不可見。

我們希望正常狀態下背景灰色,文字黑色,按下狀態背景變成黑色,文字變成白色。這時候就要用到 ColorStateList。

首先像以前一樣定義一個 drawable,button_text.xml

<?xml version="1.0" encoding="utf-8"?>
<selector 
    xmlns:android="http://schemas.android.com/apk/res/android" 
    android:enterFadeDuration="300" 
    android:exitFadeDuration="300">

    <item android:state_pressed="true" android:color="#ffffff"/>
    <item android:color="#000000"/>
</selector>

然後解析 xml,構建 ColorStateList 並設置給 textView,效果就實現了。

ColorStateList csl = null;
try {
    =XmlResourceParser xrp = getResources().getXml(R.drawable.button_text);
    csl = ColorStateList.createFromXml(getResources(), xrp);
} catch (XmlPullParserException e) {
    e.printStackTrace();
} catch (IOException e) {
    e.printStackTrace();
}
btn.setTextColor(csl);

實戰:表情文字

下面我們來做一個稍有難度的小項目:表情文字。 其效果就和常規的聊天軟件一樣,可以混合輸入表情和文字,並且可以顯示在聊天記錄中。

看上去效果還不錯,表情和文字稍微有點不對齊(偏下),還可以再優化下,後面代碼分析也會說到。文字和表情可以混排,輸入框中輸入的表情和聊天列表中顯示一致,基本功能都實現了。下面就來看下是怎麼實現的吧。

1)分析

整個過程可以分成兩步,第一步是讓輸入框 EditText 可以輸入表情,第二步是把輸入框輸入的表情顯示到 TextView 上。

2)準備表情資源

我在網上下載了一批常用的表情圖片,放在 drawable - xxhdpi 目錄下:

3)給表情編碼

我們在 assets 目錄下新建一個文件 emotion.xml,我們把每一個表情定義爲一個 emotion,有 code 和 name 兩個屬性,name 就是表情圖片的文件名。

<?xml version="1.0" encoding="utf-8"?>
<emotions>
    <emotion>
        <code><![CDATA[[em:1:]]]></code>
        <name>f001</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:2:]]]></code>
        <name>f002</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:3:]]]></code>
        <name>f003</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:4:]]]></code>
        <name>f004</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:5:]]]></code>
        <name>f005</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:6:]]]></code>
        <name>f006</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:7:]]]></code>
        <name>f007</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:8:]]]></code>
        <name>f008</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:9:]]]></code>
        <name>f009</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:10:]]]></code>
        <name>f010</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:11:]]]></code>
        <name>f011</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:12:]]]></code>
        <name>f012</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:13:]]]></code>
        <name>f013</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:14:]]]></code>
        <name>f014</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:15:]]]></code>
        <name>f015</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:16:]]]></code>
        <name>f016</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:17:]]]></code>
        <name>f017</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:18:]]]></code>
        <name>f018</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:19:]]]></code>
        <name>f019</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:20:]]]></code>
        <name>f020</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:21:]]]></code>
        <name>f021</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:22:]]]></code>
        <name>f022</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:23:]]]></code>
        <name>f023</name>
    </emotion>
    <emotion>
        <code><![CDATA[[em:24:]]]></code>
        <name>f024</name>
    </emotion>
</emotions>

4)解析 emotion.xml

xml 只是配置,最終肯定要解析成 java bean,下面是我的解析過程。

當然你也可以用 json 編碼 emotion,然後解析 json,可能會比解析 xml 要簡單些

public static List<Emotion> getEmotions(InputStream inputStream) {
    XmlPullParser parser = Xml.newPullParser();
    int eventType = 0;
    List<Emotion> emotions = null;
    Emotion emotion = null;
    try {
        parser.setInput(inputStream, "UTF-8");
        eventType = parser.getEventType();
        while (eventType != XmlPullParser.END_DOCUMENT) {

            switch (eventType) {
            case XmlPullParser.START_DOCUMENT:

                emotions = new ArrayList<Emotion>();
                break;
            case XmlPullParser.START_TAG:
                if ("emotion".equals(parser.getName())) {
                    emotion = new Emotion();

                } else if ("code".equals(parser.getName())) {
                    emotion.setCode(parser.nextText());
                } else if ("name".equals(parser.getName())) {
                    emotion.setName(parser.nextText());
                }
                break;
            case XmlPullParser.END_TAG:
                if ("emotion".equals(parser.getName())) {
                    emotions.add(emotion);
                    emotion = null;
                }
                break;
            default:
                break;
            }
            eventType = parser.next();
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    return emotions;
}

5)顯示錶情

拿到了表情列表,顯示出來就簡單了,我們隨便用 GridView 或者 RecyclerView 都可以,太基礎了,這部分代碼就不放出來了,直接看下效果圖吧。

6)輸入表情

哎,關鍵的地方來了,怎麼把表情輸入到 EditText 中呢?

我們這篇文章講的是 SpannableString,那當然是用 SpannableString 做。

SpannableString 除了可以像前面那樣把文字變大變小變長變色,還可以把一部分文字變成圖片,承載圖片的是 Drawable 對象,而實現這個效果的就是 ImageSpan。

看下基本使用方法

SpannableString ss = new SpannableString(str);
ImageSpan span = new ImageSpan(drawable, ImageSpan.ALIGN_BOTTOM);
ss.setSpan(span, 0, str.length(), SPAN_EXCLUSIVE_EXCLUSIVE);

ImageSpan 的構造函數要傳 2 個參數,drawable 對象和對齊方式,這裏的對齊方式就是表情和文字的對齊方式,只有兩個選項:

ALIGN_ BASELINE 和 ALIGN_ BOTTOM,我這裏選擇的是 ALIGN_BOTTOM,所以表情相對文字會偏下。

這樣設置後,字符串 str 就和 drawable 對象對應上了,在顯示時會顯示 drawable,但是調用 editText.getText() 得到的還是字符串。

弄懂了這個原理,再看下面代碼就簡單多了。

@Override
public void onItemClick(AdapterView<?> p, View v, int position, long id) {
    Emotion emotion = emotions.get(position);
    int cursor = etInput.getSelectionStart();
    Field f;
    try {
        f = (Field) R.drawable.class.getDeclaredField(emotion.getName());
        int j = f.getInt(R.drawable.class);
        Drawable d = getResources().getDrawable(j);
        int textSize = (int)etInput.getTextSize();
        d.setBounds(0, 0, textSize, textSize);

        String str = null;
        int pos = position + 1;
        if (pos < 10) {
            str = "f00" + pos;
        } else if (pos < 100) {
            str = "f0" + pos;
        } else {
            str = "f" + pos;
        }
        SpannableString ss = new SpannableString(str);
        ImageSpan span = new ImageSpan(d, ImageSpan.ALIGN_BOTTOM);
        ss.setSpan(span, 0, str.length(),
                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
        etInput.getText().insert(cursor, ss);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

上述代碼可簡單分析成以下步驟:
(1)根據點擊位置,獲取到該位置的 Emotion 對象。
(2)根據 emotion 的 name,通過反射的方式獲取到 Drawable 對象。
(3)根據 EditText 的 textSize 設置 drawable 的大小,爲了看上去表情和文字是協調的,我直接把 drawable 的寬高設置成了textSize。
(4)構建 ImageSpan 和 SpannableString,把 drawable 和字符串 str 對應起來。
(5)把 SpannableString 插入到 EditText 當前光標位置。

這樣解釋是不是太簡單了,可是代碼確實很簡單啊。至此,我們算是實現了第一步:在 EditText 中輸入表情,接下來就要實現第二步,把輸入的表情顯示在聊天記錄中。

7)把輸入的表情顯示在聊天列表

我們既然已經把表情輸入到 EditText 了,顯示到 TextView 還不簡單,直接把 SpannableString 設置給 TextView 不就行了嗎?

在 demo 中是可以,但是在實際項目中不行。實際項目中輸入的內容是要轉成 String 傳輸的,再發給客戶端,客戶端接收到消息後再解析顯示。所以這就需要再執行一次構建 SpannableString 的操作,具體代碼如下:

(1)首先獲取 EditText 輸入的內容,然後經過一個 getExpressionString 方法轉成 SpannableString,然後添加到 adapter 中刷新聊天列表,最後清空輸入框。

public void onSendClick() {
    String receiveStr = etInput.getText().toString();
    SpannableString ss= getExpressionString(this, receiveStr, textSize);
    messages.add(ss);
    adapter.notifyDataSetChanged();
    lvMsg.setSelection(messages.size() - 1);
    etInput.setText(null);
}

(2)那麼重點就是 getExpressionString 方法了,這個方法構建一個 SpannableString 和一個正則匹配模式,接着又調用了 dealExpression 方法。

public static final String PATTEN_STR = "f0[0-9]{2}|f10[0-7]";

public SpannableString getExpressionString(Context context, String str, 
        int textSize) {
    SpannableString ss = new SpannableString(str);
    Pattern sinaPatten = Pattern.compile(PATTEN_STR, Pattern.CASE_INSENSITIVE);
    try {
        dealExpression(context, ss, textSize, sinaPatten, 0);
    } catch (Exception e) {
        Log.e("dealExpression", e.getMessage());
    }
    return ss;
}

(3)真正的重點來了,這個方法中利用正則匹配模式,找到輸入內容中每一條符合正則的子字符串,也就是表情編碼的字符串,然後像之前那樣通過反射獲取 Drawable,構建 SpannableString 把 Drawable 和 String 對應起來。

(此部分代碼和之前是一樣的)

public void dealExpression(Context context, SpannableString ss, 
        int textSize, Pattern patten, int start) throws Exception {
    Matcher matcher = patten.matcher(ss);
    while (matcher.find()) {
        String key = matcher.group();
        if (matcher.start() < start) {
            continue;
        }
        Field field = R.drawable.class.getDeclaredField(key);
        int resId = field.getInt(R.drawable.class);
        if (resId != 0) {
            Drawable d = context.getResources().getDrawable(resId);
            d.setBounds(0, 0, textSize, textSize);
            ImageSpan imageSpan = new ImageSpan(d);
            int end = matcher.start() + key.length();
            ss.setSpan(imageSpan, matcher.start(), end,
                    Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
            if (end < ss.length()) {
                dealExpression(context, ss, textSize, patten, end);
            }
            break;
        }
    }
}

看到這你明白了嗎?整個過程就是操作 SpannableString 的過程,SpannableString 內部通過 ImageSpan 把字符串和 Drawable 對應起來,在顯示的時候表現爲 Drawable,在 getText 時表現爲普通 String。

就是這麼簡單,以前可能覺得表情文字是很神奇的存在,現在是不是覺得就是紙老虎。

大工告成!至此,整個實現的邏輯就講完了,但是我的工程中遠不止這些,還有很多邊緣性的功能,但核心的東西都講了。

最後,我把完整的工程代碼放出來,需要的朋友下載吧。
https://gitee.com/alexandor/EmotionText

好了,以上就是本期 Chat 的全部內容,感謝大家的支持,如有錯誤或不當之處還請指出。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章