用TextView實(shí)現(xiàn)富文本展示,點(diǎn)擊斷句和語音播報

最近有一個需求:移動端需要展示用戶在PC端做的筆記,而筆記內(nèi)容是富文本形式——有圖片,有文字,文字可以設(shè)置顏色、加粗、傾斜等等。同時,用戶點(diǎn)擊的時候能夠語音朗讀所點(diǎn)擊的當(dāng)前整句的內(nèi)容。

第一反應(yīng)就是富文本!PC端生成的就是html文件,創(chuàng)給我,直接用WebView展示不就ok了嘛!

但是,還有一需求:點(diǎn)擊斷句——我們需要判斷用戶的點(diǎn)擊,定位到所點(diǎn)擊的整句話,然后再將整句內(nèi)容實(shí)現(xiàn)語音播報。

這樣的話WebView似乎就不滿足要求了,所以最終決定使用TextView來實(shí)現(xiàn)。

github地址 歡迎star

一、先看下富文本展示效果:

靜態(tài)展示:

這里寫圖片描述

點(diǎn)擊斷句

這里寫圖片描述

語音合成播報
這個就不展示了,大家可以下載實(shí)例代碼運(yùn)行體驗(yàn)。

特別地:我還實(shí)現(xiàn)了斷點(diǎn)語音播報和循環(huán)播報。

二、技術(shù)點(diǎn)

在實(shí)現(xiàn)上述需要求,我們需要以下技術(shù)點(diǎn)為基礎(chǔ):


這里寫圖片描述

三、Html.fromHtml( )

fromHtml重載兩個方法,分別是:

1、Spanned android.text.Html.fromHtml(String source) //輸入的參數(shù)為(html格式的文本)

目前android不支持全部的html的標(biāo)簽,目前只支持與文本顯示和段落等標(biāo)簽,對于圖片和其他的多媒體,還有一些自定義標(biāo)簽不能識別

例子:

TextView t3 = (TextView) findViewById(R.id.text3);   
t3.setText(Html.fromHtml( "<b>text3:</b>  Text with a " + "<a href=\"http://www.google.com\">link</a> " +"created in the Java source code using HTML."));

2 、Spanned android.text.Html.fromHtml(String source, ImageGetter imageGetter, TagHandler tagHandler)

  • source: 需處理的html文本

  • imageGetter :對圖片處理(處理html中的圖片標(biāo)簽)

  • tagHandler :對標(biāo)簽進(jìn)行處理(相當(dāng)于自定義的標(biāo)簽處理,在這里面可以處理自定義的標(biāo)簽)

也就是說,我們完全可以使用Html.fromHtml方法,傳入html代碼,最后返回Spanned 對象,在使用setText方法既可實(shí)現(xiàn)用TextView展示html類型的富文本。

四、圖片處理

上一部分也說了,使用Html.fromHtml( )方法展示富文本的時候,某些自定義的標(biāo)簽和圖片識別不了,也就是加載不出來。而我們的項(xiàng)目中沒有自定義的特殊標(biāo)簽,最關(guān)鍵的就是圖片的加載!

翻過頭我們再看下fromHtml的三個參數(shù)的方法:

  • source: 需處理的html文本

  • imageGetter :對圖片處理(處理html中的圖片標(biāo)簽)

  • tagHandler :對標(biāo)簽進(jìn)行處理(相當(dāng)于自定義的標(biāo)簽處理,在這里面可以處理自定義的標(biāo)簽)

source是html文本這個不用說了,第二個參數(shù)imageGetter 負(fù)責(zé)圖片的加載,tagHandler 是在加載時獲取各標(biāo)簽。

想到這里,圖片加載使用自定義ImageGetter就可以了啊,于是乎:

1、 創(chuàng)建圖片請求工具方法:

html標(biāo)簽中的圖片全是在img標(biāo)簽中,而且都是圖片鏈接,所以簡單寫一方法來實(shí)現(xiàn)加載網(wǎng)絡(luò)圖片:

    /**
     * 根據(jù)一個網(wǎng)絡(luò)連接(String)獲取bitmap圖像
     *
     * @param imageUri
     * @return
     */
    public static Bitmap getbitmap(String imageUri) {

        // 顯示網(wǎng)絡(luò)上的圖片
        Bitmap bitmap = null;
        try {
            URL myFileUrl = new URL(imageUri);
            HttpURLConnection conn = (HttpURLConnection) myFileUrl
                    .openConnection();
            conn.setDoInput(true);
            conn.connect();
            InputStream is = conn.getInputStream();
            bitmap = BitmapFactory.decodeStream(is);
            is.close();

        } catch (OutOfMemoryError e) {
            e.printStackTrace();
            bitmap = null;
        } catch (IOException e) {
            e.printStackTrace();
            bitmap = null;
        }
        return bitmap;
    }

我這里簡單使用HttpUrlConnection來實(shí)現(xiàn)加載網(wǎng)絡(luò)圖片,大家可以根據(jù)自己項(xiàng)目換成Glide等框架。

2、自定義ImageLoader:

    class NetWorkImageGetter implements Html.ImageGetter {

        @Override
        public Drawable getDrawable(final String source) {

            Log.e(TAG, "getDrawable: ");

            Drawable drawable= new BitmapDrawable(getbitmap(source));

            return drawable;
        }

    }

getDrawable方法中的參數(shù)source通過打log看出就是在加載html文本時,需要加載的網(wǎng)絡(luò)圖片的地址url;

那似乎很簡單啊,加載網(wǎng)絡(luò)圖片返回(需要注意的是:加載到的是Bitmap對象,需要轉(zhuǎn)成Drawable對象再返回;再者就是需要考慮子線程去加載,我這里只是簡單展示原理,沒有開啟子線程加載圖片)。

然后創(chuàng)建NetWorkImageGetter 對象,在fromHtml時傳入既可。

但是!

3、存在的問題及優(yōu)化

這樣存在一個問題,我們使用fromHtml加載html文本時,圖片是同步加載,而加載網(wǎng)絡(luò)圖片和加載html是異步的,也就是說:在加載到圖片之前,其他文本已經(jīng)顯示到界面上,所以需要我們再次設(shè)置html文本。

那我們考慮下,是不是每加載完一張圖片就刷新一下呢?這樣會導(dǎo)致界面刷新好多次,用戶可能剛滑到底部查看內(nèi)容,這時加載到第一張圖片,界面就會立馬刷新到最上方,這樣的用戶體驗(yàn)會不會很不好~

所以,我的思路是當(dāng)所有圖片全部加載完成后,再刷新界面,也就是重新setText。

但我怎么會知道什么時候就全部加載完圖片了呢?或者說我怎么能夠知道一共需要加載多少張圖片呢?

此時就用到了第三個參數(shù):TagHandler

先了解下TagHandler

new Html.TagHandler() {
    @Override
    public void handleTag(boolean b, String s, Editable editable, XMLReader xmlReader) {
        Log.e(TAG, "handleTag: " + s);
    }
};

結(jié)果呢:


這里寫圖片描述

突然發(fā)現(xiàn),s變量就是html文本中的各個標(biāo)簽。同時我們也發(fā)現(xiàn),每次都是先加載圖片,然后才彈回img的tag。

這樣就好辦了,

在TagHandler中計(jì)算img標(biāo)簽的個數(shù),在ImageGetter中等加載圖片個數(shù)全部完成時,再次刷新界面(重新調(diào)用setText方法)。

setText(Html.fromHtml(text, mNetWorkImageGetter, new Html.TagHandler() {
    @Override
    public void handleTag(boolean b, String s, Editable editable, XMLReader xmlReader) {
        Log.e(TAG, "handleTag: " + s);
        if (s.equals("img")) {
            img_num++;
        }
    }
}));
    class NetWorkImageGetter implements Html.ImageGetter {

        @Override
        public Drawable getDrawable(final String source) {

            Log.e(TAG, "getDrawable: ");

            if (imgs.containsKey(source)) {
                imgs.get(source).setBounds(0, 0, imgs.get(source).getIntrinsicWidth() * 2,
                        imgs.get(source).getIntrinsicHeight() * 2);
            } else {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        imgs.put(source, new BitmapDrawable(getbitmap(source)));

                        if (imgs.size() == img_num) {
                            handler.post(new Runnable() {
                                @Override
                                public void run() {
                                    setText();
                                }
                            });
                        }
                    }
                }).start();
            }

            return imgs.get(source);
        }

    }

在全部圖片加載完成后在刷新textview內(nèi)容(這里的setText是稍后會講到的封裝的設(shè)置html代碼,大家可簡單的理解成setText(Html.fromHtml(... )))。

五、點(diǎn)擊斷句

這里就用到了SpannableStringBuilder!

我的思路是這樣的:

這里寫圖片描述
    private void setText() {
        Log.e(TAG, "setText: ");
        lines = getText().toString().split("。|?|!|@|···|;|;|!");

        if (lines != null && lines.length > 0) {

            span = new int[lines.length];
            for (int i = 0; i < lines.length; i++) {
                Log.e(TAG, "run: " + i + " " + lines[i]);
                if (i == 0) {
                    span[i] = 0;
                } else {
                    span[i] = span[i - 1] + lines[i - 1].length() + 1;
                }

            }

        }

        setText(Html.fromHtml(text, mNetWorkImageGetter, null));

        style = new SpannableStringBuilder(getText());
        for (int i = 0; i < span.length; i++) {
            if (i == span.length - 1) {
                style.setSpan(new TextViewURLSpan(i), span[i], getText().length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
            } else {
                style.setSpan(new TextViewURLSpan(i), span[i], span[i + 1] - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
            }

        }
        setText(style);
        setMovementMethod(LinkMovementMethod.getInstance());
    }
  1. 從TextView獲取展示的內(nèi)容。我們認(rèn)為! 。 ? @ ... ···等符號是一句話結(jié)束的標(biāo)志,所以通過它們將完整語句分割,存入數(shù)組;
  2. 創(chuàng)建一int類型數(shù)組,存放每句話在全文中開始的位置;
  3. 使用循環(huán)將每一句都設(shè)置對應(yīng)的點(diǎn)擊;
  4. 注意setMovementMethod(LinkMovementMethod.getInstance());必須設(shè)置,否則無效果。

看下TextViewURLSpan代碼:

    private class TextViewURLSpan extends ClickableSpan {
        int flag;

        public TextViewURLSpan(int flag) {
            this.flag = flag;
        }

        @Override
        public void updateDrawState(TextPaint ds) {
        }

        @Override
        public void onClick(View widget) {//點(diǎn)擊事件
            Log.e(TAG, "onClick: ");

            handler.removeMessages(205);

            startSpeaking(flag);
        }
    }

我們將每句對應(yīng)數(shù)組中的下標(biāo)傳入,方便語音合成時從數(shù)組中獲取文本內(nèi)容。

因?yàn)檠h(huán)播放是使用handler發(fā)消息進(jìn)行通知的,所以重新開始播放時,先移出之前的消息。

六、語音播放

    private void startSpeaking(final int flag) {
        for (int i = 0; i < span.length; i++) {
            if (i == flag) {
                if (i == span.length - 1) {
                    style.setSpan(new ForegroundColorSpan(Color.RED), span[i], getText().length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                } else {
                    style.setSpan(new ForegroundColorSpan(Color.RED), span[i], span[i + 1] - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                }
            } else {
                if (i == span.length - 1) {
                    style.setSpan(new ForegroundColorSpan(Color.GRAY), span[i], getText().length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                } else {
                    style.setSpan(new ForegroundColorSpan(Color.GRAY), span[i], span[i + 1] - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                }
            }
        }

        setText(style);

        // 語音合成
        mSpeechSynthesizer.setParameter(SpeechConstant.ENGINE_TYPE, mEngineType);
        mSpeechSynthesizer.setParameter(SpeechConstant.ENGINE_MODE, mEngineType);

        mSpeechSynthesizer.setParameter(SpeechConstant.VOICE_NAME, voicerCloud);

        mSpeechSynthesizer.startSpeaking(lines[flag], new SynthesizerListener() {
            @Override
            public void onSpeakBegin() {

            }

            @Override
            public void onBufferProgress(int i, int i1, int i2, String s) {

            }

            @Override
            public void onSpeakPaused() {

            }

            @Override
            public void onSpeakResumed() {

            }

            @Override
            public void onSpeakProgress(int i, int i1, int i2) {

            }

            @Override
            public void onCompleted(SpeechError speechError) {
                if (flag != lines.length - 1) {
                    Message msg = new Message();
                    msg.what = 205;
                    msg.obj = flag;
                    handler.sendMessage(msg);


                }
            }

            @Override
            public void onEvent(int i, int i1, int i2, Bundle bundle) {

            }
        });
    }

語音合成就不再啰嗦了,不清楚的查看訊飛開發(fā)文檔就ok了,挺簡單的。

因?yàn)樾枨笠笫屈c(diǎn)擊每句要變顏色,所以進(jìn)行了一次循環(huán),給每句話都設(shè)置了ForegroundColorSpan,給文字更改顏色。

播放一句完后發(fā)送消息播放下一句。

這樣就結(jié)束了哦!

可以關(guān)注我的微信公眾號——安卓干貨營,獲取更多精彩內(nèi)容!


這里寫圖片描述

最后附上完整代碼:

/**
 * Description: 富文本展示  訊飛語音閱讀
 * Created by jia on 2017/10/20.
 * 人之所以能,是相信能
 */
public class RichTextView extends TextView {

    private static final String TAG = "RichTextView";

    private HashMap<String, Drawable> imgs = new HashMap<>();

    private NetWorkImageGetter mNetWorkImageGetter = new NetWorkImageGetter();

    private int img_num = 0;

    private int[] span;

    private String[] lines;

    private String text;

    private SpannableStringBuilder style;

    //語音合成對象
    private SpeechSynthesizer mSpeechSynthesizer;

    // 默認(rèn)云端發(fā)音人
    public static String voicerCloud = "xiaoyan";
    // 引擎類型
    private String mEngineType = SpeechConstant.TYPE_CLOUD;


    private Handler handler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            if (msg.what == 205) {
                startSpeaking((int) msg.obj + 1);
            }
        }
    };

    public RichTextView(Context context) {
        super(context);
        init();
    }

    public RichTextView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public RichTextView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {

        mSpeechSynthesizer = SpeechSynthesizer.createSynthesizer(getContext(), new InitListener() {
            @Override
            public void onInit(int i) {
                Log.e(TAG, "onInit: " + i);
            }
        });
    }

    public void fromHtml(String text) {

        this.text = text;

        setText(Html.fromHtml(text, mNetWorkImageGetter, new Html.TagHandler() {
            @Override
            public void handleTag(boolean b, String s, Editable editable, XMLReader xmlReader) {
                Log.e(TAG, "handleTag: " + s);
                if (s.equals("img")) {
                    img_num++;
                }
            }
        }));

        // 沒有圖片直接加載
        if (img_num == 0) {
            setText();
        }
    }


    class NetWorkImageGetter implements Html.ImageGetter {

        @Override
        public Drawable getDrawable(final String source) {

            Log.e(TAG, "getDrawable: ");

            if (imgs.containsKey(source)) {
                imgs.get(source).setBounds(0, 0, imgs.get(source).getIntrinsicWidth() * 2,
                        imgs.get(source).getIntrinsicHeight() * 2);
            } else {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        imgs.put(source, new BitmapDrawable(getbitmap(source)));

                        if (imgs.size() == img_num) {
                            handler.post(new Runnable() {
                                @Override
                                public void run() {
                                    setText();
                                }
                            });
                        }
                    }
                }).start();
            }

            return imgs.get(source);
        }

    }

    private void setText() {
        Log.e(TAG, "setText: ");
        lines = getText().toString().split("。|?|!|@|···|;|;|!");

        if (lines != null && lines.length > 0) {

            span = new int[lines.length];
            for (int i = 0; i < lines.length; i++) {
                Log.e(TAG, "run: " + i + " " + lines[i]);
                if (i == 0) {
                    span[i] = 0;
                } else {
                    span[i] = span[i - 1] + lines[i - 1].length() + 1;
                }

            }

        }

        setText(Html.fromHtml(text, mNetWorkImageGetter, null));

        style = new SpannableStringBuilder(getText());
        for (int i = 0; i < span.length; i++) {
            if (i == span.length - 1) {
                style.setSpan(new TextViewURLSpan(i), span[i], getText().length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
            } else {
                style.setSpan(new TextViewURLSpan(i), span[i], span[i + 1] - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
            }

        }
        setText(style);
        setMovementMethod(LinkMovementMethod.getInstance());
    }

    private class TextViewURLSpan extends ClickableSpan {
        int flag;

        public TextViewURLSpan(int flag) {
            this.flag = flag;
        }

        @Override
        public void updateDrawState(TextPaint ds) {
        }

        @Override
        public void onClick(View widget) {//點(diǎn)擊事件
            Log.e(TAG, "onClick: ");

            handler.removeMessages(205);

            startSpeaking(flag);
        }
    }

    private void startSpeaking(final int flag) {
        for (int i = 0; i < span.length; i++) {
            if (i == flag) {
                if (i == span.length - 1) {
                    style.setSpan(new ForegroundColorSpan(Color.RED), span[i], getText().length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                } else {
                    style.setSpan(new ForegroundColorSpan(Color.RED), span[i], span[i + 1] - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                }
            } else {
                if (i == span.length - 1) {
                    style.setSpan(new ForegroundColorSpan(Color.GRAY), span[i], getText().length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                } else {
                    style.setSpan(new ForegroundColorSpan(Color.GRAY), span[i], span[i + 1] - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                }
            }
        }

        setText(style);

        // 語音合成
        mSpeechSynthesizer.setParameter(SpeechConstant.ENGINE_TYPE, mEngineType);
        mSpeechSynthesizer.setParameter(SpeechConstant.ENGINE_MODE, mEngineType);

        mSpeechSynthesizer.setParameter(SpeechConstant.VOICE_NAME, voicerCloud);

        mSpeechSynthesizer.startSpeaking(lines[flag], new SynthesizerListener() {
            @Override
            public void onSpeakBegin() {

            }

            @Override
            public void onBufferProgress(int i, int i1, int i2, String s) {

            }

            @Override
            public void onSpeakPaused() {

            }

            @Override
            public void onSpeakResumed() {

            }

            @Override
            public void onSpeakProgress(int i, int i1, int i2) {

            }

            @Override
            public void onCompleted(SpeechError speechError) {
                if (flag != lines.length - 1) {
                    Message msg = new Message();
                    msg.what = 205;
                    msg.obj = flag;
                    handler.sendMessage(msg);


                }
            }

            @Override
            public void onEvent(int i, int i1, int i2, Bundle bundle) {

            }
        });
    }

    /**
     * 根據(jù)一個網(wǎng)絡(luò)連接(String)獲取bitmap圖像
     *
     * @param imageUri
     * @return
     */
    public static Bitmap getbitmap(String imageUri) {

        // 顯示網(wǎng)絡(luò)上的圖片
        Bitmap bitmap = null;
        try {
            URL myFileUrl = new URL(imageUri);
            HttpURLConnection conn = (HttpURLConnection) myFileUrl
                    .openConnection();
            conn.setDoInput(true);
            conn.connect();
            InputStream is = conn.getInputStream();
            bitmap = BitmapFactory.decodeStream(is);
            is.close();

        } catch (OutOfMemoryError e) {
            e.printStackTrace();
            bitmap = null;
        } catch (IOException e) {
            e.printStackTrace();
            bitmap = null;
        }
        return bitmap;
    }

    @Override
    protected boolean getDefaultEditable() {//禁止EditText被編輯
        return false;
    }


    @Override
    protected MovementMethod getDefaultMovementMethod() {
        return super.getDefaultMovementMethod();
    }

    @Override
    public void setVisibility(int visibility) {
        super.setVisibility(visibility);
        mSpeechSynthesizer.stopSpeaking();
    }
}

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 178,917評論 25 709
  • Spring Cloud為開發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務(wù)發(fā)現(xiàn),斷路器,智...
    卡卡羅2017閱讀 136,534評論 19 139
  • 內(nèi)容抽屜菜單ListViewWebViewSwitchButton按鈕點(diǎn)贊按鈕進(jìn)度條TabLayout圖標(biāo)下拉刷新...
    皇小弟閱讀 47,144評論 22 665
  • 我想我已經(jīng)深深地愛上了你,我天天盼望你的消息,沒有你生命似乎少了樂趣,還有那么一點(diǎn)茶不想飯不思。一旦你有一點(diǎn)風(fēng)生水...
    mjhjht閱讀 544評論 4 11
  • 這又是一個默默被誤解的詞匯。相由心生,并不是說你的相貌由內(nèi)心決定,否則韓國那幫名醫(yī)的飯碗就沒了?!跋嘤尚纳边€有后...
    木作金剛閱讀 810評論 0 6

友情鏈接更多精彩內(nèi)容