最近有一个需求:移动端需要展示用户在PC端做的笔记,而笔记内容是富文本形式——有图片,有文字,文字可以设置颜色、加粗、倾斜等等。同时,用户点击的时候能够语音朗读所点击的当前整句的内容。
第一反应就是富文本!PC端生成的就是html文件,创给我,直接用WebView展示不就ok了嘛!
但是,还有一需求:点击断句——我们需要判断用户的点击,定位到所点击的整句话,然后再将整句内容实现语音播报。
这样的话WebView似乎就不满足要求了,所以最终决定使用TextView来实现。
github地址 欢迎star
一、先看下富文本展示效果:
静态展示:
点击断句
语音合成播报
这个就不展示了,大家可以下载实例代码运行体验。
特别地:我还实现了断点语音播报和循环播报。
二、技术点
在实现上述需要求,我们需要以下技术点为基础:
这里写图片描述
三、Html.fromHtml( )
fromHtml重载两个方法,分别是:
1、Spanned android.text.Html.fromHtml(String source) //输入的参数为(html格式的文本)
目前android不支持全部的html的标签,目前只支持与文本显示和段落等标签,对于图片和其他的多媒体,还有一些自定义标签不能识别。
例子:
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中的图片标签)
-
tagHandler :对标签进行处理(相当于自定义的标签处理,在这里面可以处理自定义的标签)
也就是说,我们完全可以使用Html.fromHtml方法,传入html代码,最后返回Spanned 对象,在使用setText方法既可实现用TextView展示html类型的富文本。
四、图片处理
上一部分也说了,使用Html.fromHtml( )方法展示富文本的时候,某些自定义的标签和图片识别不了,也就是加载不出来。而我们的项目中没有自定义的特殊标签,最关键的就是图片的加载!
翻过头我们再看下fromHtml的三个参数的方法:
-
source: 需处理的html文本
-
imageGetter :对图片处理(处理html中的图片标签)
-
tagHandler :对标签进行处理(相当于自定义的标签处理,在这里面可以处理自定义的标签)
source是html文本这个不用说了,第二个参数imageGetter 负责图片的加载,tagHandler 是在加载时获取各标签。
想到这里,图片加载使用自定义ImageGetter就可以了啊,于是乎:
1、 创建图片请求工具方法:
html标签中的图片全是在img标签中,而且都是图片链接,所以简单写一方法来实现加载网络图片:
/**
* 根据一个网络连接(String)获取bitmap图像
*
* @param imageUri
* @return
*/
public static Bitmap getbitmap(String imageUri) {
// 显示网络上的图片
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来实现加载网络图片,大家可以根据自己项目换成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方法中的参数source通过打log看出就是在加载html文本时,需要加载的网络图片的地址url;
那似乎很简单啊,加载网络图片返回(需要注意的是:加载到的是Bitmap对象,需要转成Drawable对象再返回;再者就是需要考虑子线程去加载,我这里只是简单展示原理,没有开启子线程加载图片)。
然后创建NetWorkImageGetter 对象,在fromHtml时传入既可。
但是!
3、存在的问题及优化
这样存在一个问题,我们使用fromHtml加载html文本时,图片是同步加载,而加载网络图片和加载html是异步的,也就是说:在加载到图片之前,其他文本已经显示到界面上,所以需要我们再次设置html文本。
那我们考虑下,是不是每加载完一张图片就刷新一下呢?这样会导致界面刷新好多次,用户可能刚滑到底部查看内容,这时加载到第一张图片,界面就会立马刷新到最上方,这样的用户体验会不会很不好~
所以,我的思路是当所有图片全部加载完成后,再刷新界面,也就是重新setText。
但我怎么会知道什么时候就全部加载完图片了呢?或者说我怎么能够知道一共需要加载多少张图片呢?
此时就用到了第三个参数:TagHandler
先了解下TagHandler
new Html.TagHandler() {
@Override
public void handleTag(boolean b, String s, Editable editable, XMLReader xmlReader) {
Log.e(TAG, "handleTag: " + s);
}
};
结果呢:
这里写图片描述
突然发现,s变量就是html文本中的各个标签。同时我们也发现,每次都是先加载图片,然后才弹回img的tag。
这样就好办了,
在TagHandler中计算img标签的个数,在ImageGetter中等加载图片个数全部完成时,再次刷新界面(重新调用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内容(这里的setText是稍后会讲到的封装的设置html代码,大家可简单的理解成setText(Html.fromHtml(... )))。
五、点击断句
这里就用到了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());
}
- 从TextView获取展示的内容。我们认为! 。 ? @ ... ···等符号是一句话结束的标志,所以通过它们将完整语句分割,存入数组;
- 创建一int类型数组,存放每句话在全文中开始的位置;
- 使用循环将每一句都设置对应的点击;
- 注意setMovementMethod(LinkMovementMethod.getInstance());必须设置,否则无效果。
看下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) {//点击事件
Log.e(TAG, "onClick: ");
handler.removeMessages(205);
startSpeaking(flag);
}
}
我们将每句对应数组中的下标传入,方便语音合成时从数组中获取文本内容。
因为循环播放是使用handler发消息进行通知的,所以重新开始播放时,先移出之前的消息。
六、语音播放
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) {
}
});
}
语音合成就不再啰嗦了,不清楚的查看讯飞开发文档就ok了,挺简单的。
因为需求要求是点击每句要变颜色,所以进行了一次循环,给每句话都设置了ForegroundColorSpan,给文字更改颜色。
播放一句完后发送消息播放下一句。
这样就结束了哦!
可以关注我的微信公众号——安卓干货营,获取更多精彩内容!
这里写图片描述
最后附上完整代码:
/**
* 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;
// 默认云端发音人
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) {//点击事件
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) {
}
});
}
/**
* 根据一个网络连接(String)获取bitmap图像
*
* @param imageUri
* @return
*/
public static Bitmap getbitmap(String imageUri) {
// 显示网络上的图片
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();
}
}
网友评论