之前由于产品需求变更,需要实现带拼音的文本框的功能,下面将整个实现过程简单做一下总结:
我们先来看下效果图:
要实现这样的功能对于初学者来说,可能有一定的难度。甚至对于工作好几年的人来说,也可能没那么容易。下面我简单做一下梳理:
1.下载与引用:
这里主要使用到了一个汉语转拼音的jar包,当前版本为2.5.0,下载地址:http://download.csdn.net/download/lmj623565791/7161713,当完成拼音的下载时,在build.gradle文件中进行jar文件的引用:
compile files('libs/pinyin4j-2.5.0.jar')
- pinyin4j的使用:
pinyin4j.jar的使用过程也比较简单,当我们输入一个汉字时,会给我们输出一个拼音的字符串数组,而数组的长度代表该汉字有多少个多音字,会默认根据使用频率进行数组排序,实现如下:
public static String[] getPinyinString(String hanzi) {
if (hanzi != null && hanzi.length() > 0) {
String[] pinyin = new String[hanzi.length()];
HanyuPinyinOutputFormat format = new HanyuPinyinOutputFormat();
format.setCaseType(HanyuPinyinCaseType.LOWERCASE);
format.setToneType(HanyuPinyinToneType.WITH_TONE_NUMBER);
for (int index = 0; index < hanzi.length(); index++) {
char c = hanzi.charAt(index);
try {
String[] pinyinUnit = PinyinHelper.toHanyuPinyinStringArray(c, format);
if (pinyinUnit == null) {
pinyin[index] = "null"; // 非汉字字符,如标点符号
continue;
} else {
pinyin[index] = formatCenterUnit(pinyinUnit[0].substring(0, pinyinUnit[0].length() - 1)) +
pinyinUnit[0].charAt(pinyinUnit[0].length() - 1); // 带音调且长度固定为7个字符长度,,拼音居中,末尾优先
Log.e("pinyin", pinyin[index]);
}
} catch (BadHanyuPinyinOutputFormatCombination badHanyuPinyinOutputFormatCombination) {
badHanyuPinyinOutputFormatCombination.printStackTrace();
}
}
return pinyin;
} else {
return null;
}
}
其中:
format.setCaseType(HanyuPinyinCaseType.LOWERCASE);
format.setToneType(HanyuPinyinToneType.WITH_TONE_NUMBER);
分别表示返回的拼音字母为小写,并带有声调,声调用数字表示。
该段代码主要功能实现为将汉字字符串转化成拼音的功能,首先会遍历汉字中的每个字符,当字符不为汉字时(如标点符号),这个时候会返回null,当返回结果为null时,我们使用"null"字符串来标记它,表示一个不带拼音的字符;当字符为汉字时,我们使用它的第一个拼音单元来表示,这里会固定拼音的长度为7个字符长度(最大拼音长度 + 拼音与拼音之间的空格),最后一个字符表示它的音调。返回结果即为格式化后的拼音数组。
格式化拼音代码如下:
// 每个拼音单元长度以7个字符长度为标准,拼音居中,末尾优先
private static String formatCenterUnit(String unit) {
String result = unit;
switch(unit.length()) {
case 1:
result = " " + result + " ";
break;
case 2:
result = " " + result + " ";
break;
case 3:
result = " " + result + " ";
break;
case 4:
result = " " + result + " ";
break;
case 5:
result = " " + result + " ";
break;
case 6:
result = result + " ";
break;
}
return result;
}
另外,为了防止汉字为空以及与拼音对应,我们同时也对汉字做格式化处理如下:
public static String[] getFormatHanzi(String hanzi) {
if (hanzi != null && hanzi.length() > 0) {
char[] c = hanzi.toCharArray();
String[] result = new String[c.length];
for (int index = 0; index < c.length; index++) {
result[index] = c[index] + "";
}
return result;
} else {
return null;
}
}
而在使用时,我们只需要将格式化后的拼音与汉字传给我们自己定义的TextView即可:
pinyinTv.setPinyin(PinyinUtils.getPinyinString(pages.get(position - 1).getText()));
pinyinTv.setHanzi(PinyinUtils.getFormatHanzi(pages.get(position - 1).getText()));
这里传进去的参数即为文本信息。
3.我们接下来看自定义TextView中的实现:
public class PinyinTextView extends TextView {
private final int fontSize = 72;
private String[] pinyin;
private String[] hanzi;
private int color = Color.rgb(99, 99, 99);
private int[] colors = new int[]{Color.rgb(0x3d, 0xb1, 0x69), Color.rgb(99, 99, 99)};
private TextPaint textPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
private Paint.FontMetrics fontMetrics;
private final int paddingTop = 20;
private final int lestHeight = 141;
private int snot = 0;
private ScrollView scrollView;
private ArrayList<String> dots = new ArrayList<>(); // 统计标点长度
private ArrayList<Integer> indexList = new ArrayList<>(); // 存储每行首个String位置
int comlum = 1;
float density;
private TemplateItem item;
public PinyinTextView(Context context) {
this(context, null);
}
public PinyinTextView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public PinyinTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.PinyinTextView);
color = typedArray.getColor(R.styleable.PinyinTextView_textColor, Color.BLACK);
typedArray.recycle();
initTextPaint();
}
public void initTextPaint() {
textPaint.setColor(color);
float denity = getResources().getDisplayMetrics().density;
textPaint.setStrokeWidth(denity * 2);
if (item != null) {
textPaint.setTextSize(item.getPageTextFontSize());
}
fontMetrics = textPaint.getFontMetrics();
fontMetricsInt = textPaint.getFontMetricsInt();
density = getResources().getDisplayMetrics().density;
}
public void setTemplateItem(TemplateItem item) {
this.item = item;
if (item != null) {
initTextPaint();
}
}
public void setPinyin(String[] pinyin) {
this.pinyin = pinyin;
}
public void setHanzi(String[] hanzi) {
this.hanzi = hanzi;
}
public void setColor(int color) {
this.color = color;
snot = 0;
if (textPaint != null) {
textPaint.setColor(color);
}
}
public void setScrollEnable(boolean isScrollEnable) {
Log.e("jacky", "isScrollEnable == " + isScrollEnable);
this.isScrollEnable = isScrollEnable;
if (isScrollEnable) {
setMovementMethod(ScrollingMovementMethod.getInstance());
} else {
setMovementMethod(null);
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 需要根据文本测量高度
int widthMode, heightMode;
int width = 0, height = 0;
indexList.clear();
widthMode = MeasureSpec.getMode(widthMeasureSpec);
heightMode = MeasureSpec.getMode(heightMeasureSpec);
if (widthMode == MeasureSpec.EXACTLY) {
width = MeasureSpec.getSize(widthMeasureSpec);
}
if (heightMode == MeasureSpec.EXACTLY) {
height = MeasureSpec.getSize(heightMeasureSpec);
} else if (heightMode == MeasureSpec.AT_MOST) {
if (textPaint != null) {
if (pinyin != null && pinyin.length != 0) {
height = (int) ((pinyin.length / 10 + 1) * 2 * (fontMetrics.bottom - fontMetrics.top) + paddingTop);
} else if (hanzi != null) {
height = (int) ((fontMetrics.bottom - fontMetrics.top) + paddingTop);
}
}
} else if (height == MeasureSpec.UNSPECIFIED) {
if (textPaint != null) {
if (pinyin != null && pinyin.length != 0) {
float pinyinWidth = 0;
int comlumTotal = 1;
for (int index = 0; index < pinyin.length; index++) {
if (TextUtils.equals(pinyin[index], "null")) {
pinyinWidth = pinyinWidth + textPaint.measureText(hanzi[index]);
} else {
pinyinWidth = pinyinWidth + textPaint.measureText(pinyin[index].substring(0, pinyin[index].length() - 1));
}
if (pinyinWidth > width) {
indexList.add(index);
comlumTotal++;
pinyinWidth = (TextUtils.equals(pinyin[index], "null") ?
textPaint.measureText(pinyin[index]) : textPaint.measureText(pinyin[index].substring(0, pinyin[index].length() - 1)));
}
}
height = (int) Math.ceil((comlumTotal * 2) * (textPaint.getFontSpacing() + density * 1));
} else if (hanzi != null) {
height = (int) textPaint.getFontSpacing();
}
}
}
height = height < lestHeight ? lestHeight : height;
setMeasuredDimension(width, height);
}
private int snotMark = 0;
private void scrollByUser(int snot, boolean isByUser) {
if (snotMark != snot && !isByUser && scrollView != null) {
scrollView.smoothScrollBy(0, (int) ((fontMetrics.bottom - fontMetrics.top) * 2) + 10);
dots.clear();
}
this.snotMark = snot;
}
public void startScrolling(int snot) {
if (snotMark != snot && scrollView != null) {
scrollView.smoothScrollTo(0, 0);
snot = 0;
dots.clear();
}
this.snotMark = snot;
}
private int snotDrawMark = 0;
private float pinyinWidth = 0;
@Override
protected void onDraw(Canvas canvas) {
float widthMesure = 0f;
if (indexList.isEmpty()) {
// 单行数据处理
if (pinyin != null && pinyin.length > 0) {
widthMesure = (getWidth() - textPaint.measureText(combinePinEnd(0, pinyin.length))) / 2;
Log.e("jacky", "widthMesure1 === " + widthMesure);
} else if (hanzi != null && hanzi.length > 0) {
widthMesure = (getWidth() - textPaint.measureText(combineHanziEnd(0, hanzi.length))) / 2;
}
}
int count = 0;
pinyinWidth = 0;
comlum = 1;
if (pinyin != null && pinyin.length > 0) {
for (int index = 0; index < pinyin.length; index++) {
if (snot != 0 && snot >= index) {
textPaint.setColor(colors[0]);
if (indexList.contains(snot)) {
scrollByUser(snot, false);
}
} else {
textPaint.setColor(colors[1]);
}
if (!TextUtils.equals(pinyin[index], "null") && !TextUtils.equals(pinyin[index], " ")) {
pinyinWidth = widthMesure + textPaint.measureText(pinyin[index].substring(0, pinyin[index].length() - 1));
if (pinyinWidth > getWidth()) {
comlum++;
widthMesure = 0;
// 多行考虑最后一行居中问题
if (indexList.size() > 1 && indexList.get(indexList.size() - 1) == index) {
// 最后一行
widthMesure = (getWidth() - textPaint.measureText(combinePinEnd(index, pinyin.length))) / 2;
}
}
Log.e("jacky", "widthmeasure2 === " + widthMesure);
canvas.drawText(pinyin[index].substring(0, pinyin[index].length() - 1), widthMesure, (comlum * 2 - 1) * (textPaint.getFontSpacing()), textPaint);
String tone = " ";
switch (pinyin[index].charAt(pinyin[index].length() - 1)) {
case '1':
tone = "ˉ";
break;
case '2':
tone = "ˊ";
break;
case '3':
tone = "ˇ";
break;
case '4':
tone = "ˋ";
break;
}
int toneIndex = pinyin[index].length() - 3; // 去掉数字和空格符
int stateIndex = -1;
for (; toneIndex >= 0; toneIndex--) {
if (pinyin[index].charAt(toneIndex) == 'a' || pinyin[index].charAt(toneIndex) == 'e'
|| pinyin[index].charAt(toneIndex) == 'i' || pinyin[index].charAt(toneIndex) == 'o'
|| pinyin[index].charAt(toneIndex) == 'u' || pinyin[index].charAt(toneIndex) == 'v') {
if (stateIndex == -1 || pinyin[index].charAt(toneIndex) < pinyin[index].charAt(stateIndex)) {
stateIndex = toneIndex;
}
}
}
// iu同时存在规则
if (pinyin[index].contains("u") && pinyin[index].contains("i") && !pinyin[index].contains("a") && !pinyin[index].contains("o") && !pinyin[index].contains("e")) {
stateIndex = pinyin[index].indexOf("u") > pinyin[index].indexOf("i") ? pinyin[index].indexOf("u") : pinyin[index].indexOf("i");
}
Log.e("jacky", "stateIndex === " + stateIndex);
if (stateIndex != -1) {
// 没有声母存在时,stateIndex一直为-1 ('嗯' 转成拼音后变成 ng,导致没有声母存在,stateIndex一直为-1,数组越界crash)
canvas.drawText(tone, widthMesure + textPaint.measureText(pinyin[index].substring(0, stateIndex)) + (textPaint.measureText(pinyin[index].charAt(stateIndex) + "") - textPaint.measureText(tone + "")) / 2, (comlum * 2 - 1) * (textPaint.getFontSpacing()), textPaint);
}
canvas.drawText(hanzi[index], widthMesure + (textPaint.measureText(pinyin[index].substring(0, pinyin[index].length() - 1)) - textPaint.measureText(hanzi[index])) / 2 - moveHalfIfNeed(pinyin[index].substring(0, pinyin[index].length() - 1), textPaint), (comlum * 2) * (textPaint.getFontSpacing()), textPaint); // 由于拼音长度固定,采用居中显示策略,计算拼音实际长度不需要去掉拼音后面空格
if (index + 1 < pinyin.length && TextUtils.equals("null", pinyin[index + 1])) {
widthMesure = widthMesure + textPaint.measureText(pinyin[index].substring(0, pinyin[index].length() - 1));
} else {
widthMesure = widthMesure + textPaint.measureText(pinyin[index].substring(0, pinyin[index].length() - 1)); // 下个字符为拼音
}
if (index % 10 == 0 && index >= 10 && textPaint.getColor() == colors[1]) {
}
count = count + 1; // 有效拼音
} else if (TextUtils.equals(pinyin[index], "null")) { // (count / 10) * 100 + 80 之前高度
if (!dots.isEmpty()) {
float hanziWidth = widthMesure + textPaint.measureText(hanzi[index]);
if (hanziWidth > getWidth()) {
comlum++;
widthMesure = 0;
}
canvas.drawText(hanzi[index], widthMesure, (comlum * 2) * textPaint.getFontSpacing(), textPaint);
widthMesure = widthMesure + textPaint.measureText(hanzi[index]);
} else {
float hanziWidth = widthMesure + textPaint.measureText(hanzi[index]);
if (hanziWidth > getWidth()) {
comlum++;
widthMesure = 0;
}
canvas.drawText(hanzi[index], widthMesure, (comlum * 2) * textPaint.getFontSpacing(), textPaint);
widthMesure = widthMesure + textPaint.measureText(hanzi[index]);
}
count = count + 1;
}
}
} else {
}
snotDrawMark = snot;
super.onDraw(canvas);
}
private float moveHalfIfNeed(String pinyinUnit, TextPaint paint) {
if (pinyinUnit.trim().length() % 2 == 0) {
return paint.measureText(" ") / 2;
} else {
return 0;
}
}
private String combinePinEnd(int index, int length) {
StringBuilder sb = new StringBuilder();
for (int subIndex = index; subIndex < length; subIndex++) {
String pendString = pinyin[subIndex].substring(0, pinyin[subIndex].length() - 1);
sb.append(pendString);
}
return sb.toString();
}
private String combineHanziEnd(int index, int length) {
StringBuilder sb = new StringBuilder();
for (int subIndex = index; subIndex < length; subIndex++) {
sb.append(hanzi[subIndex]);
}
return sb.toString();
}
}
整个PinyinTextView使用起来很简单,但它的实现还是有点复杂的,因为不仅涉及到我们的拼音问题,还增加了根据朗读的速度实现字体变色与自动滚动的逻辑,这部分逻辑并不影响我们带拼音的文本显示,我并没有剔除掉这部分逻辑,因为在开发中你也许同样会遇到这种不按套路出牌的产品经理,这里我简单理一下主要逻辑处理。
首先我们会根据文本内容的高度完成对文本的宽高的测量,由于每个拼音的长度固定为6个字符(不包含拼音之间的间隔),所以拼音的长度一定是大于汉字的长度的,所以我们以拼音的宽度为基准进行测量,当当前拼音的总长度加上间隔在加上下一个拼音的长度大于PinyinTextView的width时(测量值,也是最终值),这个时候会换行,高度增加两行文本的高度再加上行间距,即高度增加固定高度,通过这种方式即可得到文本框的高度。
draw过程绘制为三部分,分别为音调的绘制,拼音的绘制与汉字的绘制(包含标点符号或无拼音文本的处理,即拼音为“null”时)。首先我们需要在循环中对拼音数组进行逐个绘制,考虑到汉字位于拼音中间的问题,绘制过程为以每个拼音单元为基准进行绘制,首先进行拼音的绘制,然后绘制音调,音调位于拼音的声母正上位置(这个时候要熟悉拼音的标法,幼儿园基础),最后绘制汉字,汉字位于拼音的正下位置,需要对拼音单元进行测量。当完成整个遍历时,即完成我们的整个绘制过程。如果当前行不能够充满宽度时,需要居中显示。
其中细节比较多,需要读者细细品味。