解决EditText中InputFilter与联想词输入法冲突

这一天,产品提出了一个神奇的需求:用户姓名设置框限制,允许最多8个汉字或16个英文字母或数字。
简单来说就是长度限制最大为16个字符,一个汉字认为是两个字符。虽然其实跟用户解释一个汉字等于两个字符这件事情是很奇怪的,但是毕竟是需求,我们来实现一下吧。

首先简单说一下要求:

  • 只允许输入数字汉字或英文字母
  • 输入限制为最大16字符
  • 汉字计为2字符,数字和英文计为1字符
  • 当输入超过16字符时(如拼音输入法一次性输入多个文字),输入内容截取到最大部分

首先由于汉字需要记为2字符,那么最简单的android:maxLength="16"这种方法就不可以使用了。并且由于需要限制输入的内容,那么我们使用InputFilter来控制实现。

创建一个自定义Filter类,继承自InputFilter,并复写filter方法。简单介绍下filter的几个参数:

  • source : 新输入的字符串
  • start : 新输入的字符串起始下标
  • end : 新输入的字符串结尾下标
  • dest : 之前文本框内容
  • dstart : 原内容起始下标
  • dend : 原内容结尾下标

而filter的返回值,则是经过自定义规则处理之后,需要输入进去的内容。
判断输入内容是否合法,我使用了正则表达式进行判断,并且对输入的内容进行限制和截取。

核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Override
public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
String sourceText = source.toString();
String destText = dest.toString();

//检查输入的是否为中文,英文,数字
Matcher matcher = mPattern.matcher(source);
if (!matcher.matches()) {
return "";
}

//验证删除等按键
if (TextUtils.isEmpty(sourceText)) {
return "";
}

int destLength = getLength(destText);
int sourceLength = getLength(sourceText);
//如果原始字符已经有16个字符了,那么不需要截取
if (destLength >= MAX_LENGTH) {
return "";
} else if (destLength + sourceLength > MAX_LENGTH) {
return splitStr(sourceText, MAX_LENGTH - destLength);
}

return sourceText;
}

经过测试:当输入"你好你好你好1234"之后,就不允许输入新的内容了。大功告成,提交代码下班。
可是当第二天测试的报告,却发现了另一个奇怪的现象。当用户使用带联想词功能的输入法时(英文输入法,或者miui的输入法等),由于用户预输入的拼音和单词,是会被输入法拦截并设置到EditText中,使得filter被反复触发,会导致用户无法输入完整拼音或单词,甚至输入内容重复累加等问题。

打印日志后发现,英文联想词输入法情况下,在sourceText中,竟然包括了dest文本中的内容,那么这个时候如果我们再去判断sourceText的文本,就会触发BUG。
那么有没有什么办法,可以判断用户是否处于联想词输入模式下呢。答案是:有的。

在网络上查询了许久之后,发现了一个很有趣的实现方法,预输入模式下,输入的内容是带有一个下划线的,那么判断输入的这部分内容是否带下划线,就可以判断出输入的内容是在什么模式下的了。

1
2
3
4
5
6
7
8
9
SpannableString ss = new SpannableString(source);
Object[] spans = ss.getSpans(0, ss.length(), Object.class);
if (spans != null) {
for (Object span : spans) {
if (span instanceof UnderlineSpan) {
//该文本为预输入内容
}
}
}

添加之后,确实预输入时不会出现输入错乱等问题了,但是最大输入的长度却无法控制了,因为预输入结束后,输入法是直接把内容设置到EditText里了,没有经过Filter。
这时就可以使用一个经常用到的方法addTextChangedListener对输入的内容进行控制,因为任何字符的改变都会经过这里,只要在这里,对超出长度的内容进行截取就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
editText.addTextChangedListener(new TextWatcher() {
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
String str = s.toString();
//当处于输入法预输入模式下,对输入长度进行校验
if (getLength(str) > MAX_LENGTH) {
int splitIndex = getSplitIndex(str, MAX_LENGTH);
editText.setText(str.substring(0, splitIndex));
editText.setSelection(editText.length());
}
}
});

经过测试,这回可以满足要求了,即使在预输入模式下,也可以正确的限制输入长度,内容和规则了。优化一下代码,将功能整合到bindEditText,方便以后需求的使用。

最后,完整代码如下,使用bindEditText方法,就可以将此规则绑定到目标EditText上了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
public class NameInputFilter implements InputFilter {

private static final int MAX_LENGTH = 16;

private static final String regCN = "[\u4E00-\u9FA5]";
private static final String regEnNum = "[a-zA-Z0-9]";
private static final String regAll = "^[\u4E00-\u9FA5a-zA-Z0-9]+$";
private static final String regExceptText = "((?![\u4E00-\u9FA5aa-zA-Z0-9]).)";

private Pattern mCNPattern;
private Pattern mEnNumPattern;
private Pattern mPattern;

public NameInputFilter() {
mPattern = Pattern.compile(regAll);
mCNPattern = Pattern.compile(regCN);
mEnNumPattern = Pattern.compile(regEnNum);
}

/**
* @param source 新输入的字符串
* @param start 新输入的字符串起始下标
* @param end 新输入的字符串结尾下标
* @param dest 之前文本框内容
* @param dstart 原内容起始下标
* @param dend 原内容结尾下标
* @return 输入内容
*/
@Override
public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
String sourceText = source.toString();
String destText = dest.toString();

//当用户处于输入法联想模式下(MIUI的中文输入法,Gboard输入法英文输入联想模式等)
//由于输入法会进行文本预设,所以此时不对输入内容进行校验,但是需要对输入字符的情况进行剔除
//因为仍然不允许输入字符。联想词模式下,输入位数的限制由TextChangedListener控制
SpannableString ss = new SpannableString(source);
Object[] spans = ss.getSpans(0, ss.length(), Object.class);
if (spans != null) {
for (Object span : spans) {
if (span instanceof UnderlineSpan) {
//检查输入的是否为中文,英文,数字
//不符合的替换为空
return sourceText.replaceAll(regExceptText, "");
}
}
}

//检查输入的是否为中文,英文,数字
Matcher matcher = mPattern.matcher(source);
if (!matcher.matches()) {
return "";
}

//验证删除等按键
if (TextUtils.isEmpty(sourceText)) {
return "";
}

int destLength = getLength(destText);
int sourceLength = getLength(sourceText);
//如果原始字符已经有16个字符了,那么不需要截取
if (destLength >= MAX_LENGTH) {
return "";
} else if (destLength + sourceLength > MAX_LENGTH) {
return splitStr(sourceText, MAX_LENGTH - destLength);
}

return sourceText;
}

/**
* 当中文为2字符,英文数字为1字符情况下,文本的长度计算
*
* @param str 文本
* @return 文本长度
*/
public int getLength(String str) {
int length = 0;

char[] chars = str.toCharArray();

for (char aChar : chars) {
//检查输入的是否为中文
Matcher cnMatcher = mCNPattern.matcher(String.valueOf(aChar));
if (cnMatcher.matches()) {
length = length + 2;
continue;
}

//除了中文之外暂只判断为英文或1字节
Matcher matcher = mEnNumPattern.matcher(String.valueOf(aChar));
if (matcher.matches()) {
length = length + 1;
}
}

return length;
}

/**
* 获取到需要截取的位置
*
* @param str 原str
* @param maxLength 最大长度
* @return 从0开始截取的末尾位置 index
*/
public int getSplitIndex(String str, int maxLength) {
int length = 0;
int splitIndex = 0;

char[] chars = str.toCharArray();

for (char aChar : chars) {
//检查输入的数量是否超规格
if (length >= maxLength) {
return splitIndex;
}

//检查输入的是否为中文
Matcher cnMatcher = mCNPattern.matcher(String.valueOf(aChar));
if (cnMatcher.matches()) {
length = length + 2;
} else {
//除了中文之外暂只判断为英文或1字节
length = length + 1;
}
splitIndex++;
}

//不需要截取,没有超过最大值
return str.length();
}

/**
* 截取至符合要求的位置字符串
*
* @param str 文本
* @param size 要求长度
* @return
*/
private String splitStr(String str, int size) {
int length = 0;

char[] chars = str.toCharArray();
StringBuilder result = new StringBuilder();

for (char aChar : chars) {
//检查输入的是否为中文
Matcher cnMatcher = mCNPattern.matcher(String.valueOf(aChar));
if (cnMatcher.matches()) {
length = length + 2;

//如果添加这个字符不会超过size限额,那么这个字符是可以添加的
if (length <= size) {
result.append(aChar);
} else {
break;
}
continue;
}

Matcher matcher = mEnNumPattern.matcher(String.valueOf(aChar));
if (matcher.matches()) {
length = length + 1;

if (length <= size) {
result.append(aChar);
} else {
break;
}
}
}

return result.toString();
}

/**
* 绑定目标EditText
*
* @param editText
*/
public void bindEditText(EditText editText) {
editText.setFilters(new InputFilter[]{this});
editText.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {

}

@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
String str = s.toString();
//当处于输入法预输入模式下,对输入长度进行校验
if (getLength(str) > MAX_LENGTH) {
int splitIndex = getSplitIndex(str, MAX_LENGTH);
editText.setText(str.substring(0, splitIndex));
editText.setSelection(editText.length());
}
}

@Override
public void afterTextChanged(Editable s) {

}
});
}
}

我本以为事情就这样结束了,第二天测试跟我反馈,在华为的手机上,问题依旧存在。
经过对sourcedest的比对,我发现,当检测出下划线部分之后,返回处理后的String其实在这里是有问题的。
因为:华为的输入法会判断返回CharSequence是否有下划线,决定是否替换!!
也就是说,我必须传入原来的,或者说重建一个带下划线的SpannableString才会被处理到。
那么我们在发现符号后,重建一个带下划线的SpannableString。
并且在此时发现,文本长度的过滤和截取,实际上完全由TextChangedListener处理比较好,因为有些输入法在source里面夹带了一些东西,原样传回去才不容易出BUG。

修改后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Override
public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {

String sourceText = source.toString();
String destText = dest.toString();

//当用户处于输入法联想模式下(MIUI的中文输入法,Gboard输入法英文输入联想模式等)
//由于输入法会进行文本预设,所以此时不对输入内容进行校验,但是需要对输入字符的情况进行剔除
//因为仍然不允许输入字符。联想词模式下,输入位数的限制由TextChangedListener控制
SpannableString ss = new SpannableString(source);
Object[] spans = ss.getSpans(0, ss.length(), Object.class);
if (spans != null) {
for (Object span : spans) {
if (span instanceof UnderlineSpan) {
//检查输入的是否为中文,英文,数字
//不符合的替换为空
String s = sourceText.replaceAll(regExceptText, "");
//华为输入法会判断返回CharSequence是否有下划线,决定是否替换
if (s.equals(sourceText)){
return source;
}else {
SpannableString str = new SpannableString(s);
str.setSpan(new UnderlineSpan(),0,s.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
return str;
}
}
}
}

//检查输入的是否为中文,英文,数字
Matcher matcher = mPattern.matcher(source);
if (!matcher.matches() && !TextUtils.isEmpty(sourceText)) {
return "";
}
return source;
}

当然,如果没有这种奇怪的需求就完事皆无了,不是么。

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×