查看文章前请参考:Java 中 Emoji 的正则表达式
功能介绍
- 过滤换行符,回车符,空白符
- 过滤Emoji
项目有个需求,所有与“标题”有关的输入,都不允许有换行。
第一次拿到这个需求的时候觉得很简单,直接设置一个InputFilter
public class NewlineFilter implements InputFilter {
/**
* @param source 输入的文字
* @param start 输入-0,删除-0
* @param end 输入-文字的长度,删除-0
* @param dest 原先显示的内容
* @param dstart 输入-原光标位置,删除-光标删除结束位置
* @param dend 输入-原光标位置,删除-光标删除开始位置
* @return null表示原始输入,""表示不接受输入,其他字符串表示变化值
*/
@Override
public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
if (source.toString().contains("\n")) {
return source.toString().replace("\n", "");
}
return null;
}
}
然后拿起手机测试,发现没毛病,开开心心的提测。。。
第二天打开jira一看,有个bug:
“魅蓝Note5输入字符的时候字符成倍出现,删除的时候还会输入字符”
我拿来测试机试了一下:
很明显,魅族的输入法会把当前“待输入字符”放入到EditText输入框里,而华为的讯飞输入法不会。
再回看上面的代码就会发现一个问题:
return source.toString().replace("\n", “”);会把当前魅族Note5输入框中的“待输入字符”转化为输入字符,但是,推荐词区域的字符并没有丢失,所以下次输入字符的时候会把推荐词内容一并倒入到输入框里,这就是测试同学说的现象。完美复现!
好,现在问题明了了,说白就是适配问题。
解决适配问题有个准则:
- 1.尽量少些特有平台代码
- 2.覆盖测试
所以我的思考方向是:看看官方怎么实现的
TransformationMethod有个子类:
/**
* This transformation method causes the characters in the {@link #getOriginal}
* array to be replaced by the corresponding characters in the
* {@link #getReplacement} array.
*/
public abstract class ReplacementTransformationMethod implements TransformationMethod {
/**
* Returns the list of characters that are to be replaced by other
* characters when displayed.
*/
protected abstract char[] getOriginal();
/**
* Returns a parallel array of replacement characters for the ones
* that are to be replaced.
*/
protected abstract char[] getReplacement();
...
}
它有个子类:SingleLineTransformationMethod,TextView的singleLine就是靠这个东西实现的。
所以我使用了一下,发现效果不错,没有适配问题。不过有个小问题,其实我的需求里是想要把换行直接pass的,看了一下这几个类,没法实现我的需求。
ReplacementTransformationMethod有另外一个子类:HideReturnsTransformationMethod
/**
* This transformation method causes any carriage return characters (\r)
* to be hidden by displaying them as zero-width non-breaking space
* characters (\uFEFF).
*/
public class HideReturnsTransformationMethod
extends ReplacementTransformationMethod {
private static char[] ORIGINAL = new char[] { '\r' };
private static char[] REPLACEMENT = new char[] { '\uFEFF' };
/**
* The character to be replaced is \r.
*/
protected char[] getOriginal() {
return ORIGINAL;
}
/**
* The character that \r is replaced with is \uFEFF.
*/
protected char[] getReplacement() {
return REPLACEMENT;
}
}
他把回车符(回车符是\r,换行符是\n)换成了’\uFEFF’,我测了一下这个字符是一个不可见字符,我立马把\n也替换成这个字符,高兴之余,发现这个字符虽然不可见,但是还是占用一个字符位。
所以我只能找别的方案。
再回去查看EditText的源码,对于输入内容Editable,它的实现类是SpannableStringBuilder,所以在仔细回想魅族输入法的时候,发现输入的过程中有个小细节:“待输入字符”有下划线,经测试,这些字符是一个span,它标识着自己是“待输入字符”。回想起之前最早的实现,实际上是破坏了这个span,通过查看系统里的InputFilter实现,发现这些实现都是new了一个新的SpannableStringBuilder,同时没有破坏原先的字符串。我照葫芦画瓢,写了一个InputFilter:
public class CharFilter implements InputFilter {
private final char[] filterChars;
public static CharFilter newlineCharFilter() {
return new CharFilter(new char[]{'\n'});
}
public static CharFilter whitespaceCharFilter() {
return new CharFilter(new char[]{' '});
}
public static CharFilter returnCharFilter() {
return new CharFilter(new char[]{'\r'});
}
public static CharFilter wnrCharFilter() {
return new CharFilter(new char[]{' ', '\n', '\r'});
}
private CharFilter(char[] filterChars) {
this.filterChars = filterChars == null ? new char[0] : filterChars;
}
/**
* @param source 输入的文字
* @param start 输入-0,删除-0
* @param end 输入-文字的长度,删除-0
* @param dest 原先显示的内容
* @param dstart 输入-原光标位置,删除-光标删除结束位置
* @param dend 输入-原光标位置,删除-光标删除开始位置
* @return null表示原始输入,""表示不接受输入,其他字符串表示变化值
*/
@Override
public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
if (needFilter(source)) {
SpannableStringBuilder builder = new SpannableStringBuilder();
int abStart = start;
for (int i = start; i < end; i++) {
if (isFilterChar(source.charAt(i))) {
if (i != abStart) {
builder.append(source.subSequence(abStart, i));
}
abStart = i + 1;
}
}
if (abStart < end) {
builder.append(source.subSequence(abStart, end));
}
return builder;
}
return null;
}
private boolean needFilter(CharSequence source) {
String s = source.toString();
for (char filterChar : filterChars) {
if (s.indexOf(filterChar) >= 0) {
return true;
}
}
return false;
}
private boolean isFilterChar(char c) {
for (char filterChar : filterChars) {
if (filterChar == c) {
return true;
}
}
return false;
}
}
实现非常简单,把之前原字符串里的\n \r 和空格都过滤掉了,剩下的子串按顺序组成新的SpannableStringBuilder。
我覆盖测试后,这个完美的解决了问题。
这个类有些局限,假如我想过滤所有中文,在魅族Note5上还是会有同样的问题。这个问题有别的解决方案,不在这里阐述。
这个问题暴露的原因主要还是早期覆盖测试不够,但是好在测试同学发现,不然这将是一个线上事故了。
虽然是一个小小的字符问题,但是不管是从技术角度考虑还是客户角度考虑,都要引起足够的重视。
解决过程还是学习到不少东西,比如TransformationMethod可以用来提前做字符变换。我相信这个问题也能用它解决。
出于代码效率和设计考虑,并没有使用TextWatcher和自定义EditText。优先考虑解耦的实现方式。
5. 持续更新
有一天产品出了一个需求:部分标题类输入不能有特殊字符,比如 各种显示特殊字符和Emoji。
我立马想到用上述的方法实现:
package com.icourt.alpha.widget.filter;
import android.text.InputFilter;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import java.util.regex.Pattern;
/**
* Description emoji过滤器
* Company Beijing iCourt.cc
*/
public class EmojiFilter implements InputFilter {
public static final Pattern EMOJI_PATTERN = Pattern.compile("(?:[\uD83C\uDF00-\uD83D\uDDFF]|[\uD83E\uDD00-\uD83E\uDDFF]|[\uD83D\uDE00-\uD83D\uDE4F]|[\uD83D\uDE80-\uD83D\uDEFF]|[\u2600-\u26FF]\uFE0F?|[\u2700-\u27BF]\uFE0F?|\u24C2\uFE0F?|[\uD83C\uDDE6-\uD83C\uDDFF]{1,2}|[\uD83C\uDD70\uD83C\uDD71\uD83C\uDD7E\uD83C\uDD7F\uD83C\uDD8E\uD83C\uDD91-\uD83C\uDD9A]\uFE0F?|[\u0023\u002A\u0030-\u0039]\uFE0F?\u20E3|[\u2194-\u2199\u21A9-\u21AA]\uFE0F?|[\u2B05-\u2B07\u2B1B\u2B1C\u2B50\u2B55]\uFE0F?|[\u2934\u2935]\uFE0F?|[\u3030\u303D]\uFE0F?|[\u3297\u3299]\uFE0F?|[\uD83C\uDE01\uD83C\uDE02\uD83C\uDE1A\uD83C\uDE2F\uD83C\uDE32-\uD83C\uDE3A\uD83C\uDE50\uD83C\uDE51]\uFE0F?|[\u203C\u2049]\uFE0F?|[\u25AA\u25AB\u25B6\u25C0\u25FB-\u25FE]\uFE0F?|[\u00A9\u00AE]\uFE0F?|[\u2122\u2139]\uFE0F?|\uD83C\uDC04\uFE0F?|\uD83C\uDCCF\uFE0F?|[\u231A\u231B\u2328\u23CF\u23E9-\u23F3\u23F8-\u23FA]\uFE0F?)",
Pattern.UNICODE_CASE | Pattern.CASE_INSENSITIVE);
@Override
public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
if (needFilter(source)) {
SpannableStringBuilder builder = new SpannableStringBuilder();
int abStart = start;
for (int i = start; i < end; i++) {
if (isEmoji(String.valueOf(source.charAt(i)))) {
if (i != abStart) {
builder.append(source.subSequence(abStart, i));
}
abStart = i + 1;
} else {
// 所有的emoji不是一个字符就是两个字符,所以单独处理
if (i + 1 <= end && isEmoji(source.subSequence(i, i + 2))) {
if (i != abStart) {
builder.append(source.subSequence(abStart, i));
}
abStart = i + 2;
i += 1; // 纠正角标
}
}
}
if (abStart < end) {
builder.append(source.subSequence(abStart, end));
}
return builder;
}
return source;
}
private boolean needFilter(CharSequence source) {
return EMOJI_PATTERN.matcher(source).find();
}
private boolean isEmoji(CharSequence str) {
return EMOJI_PATTERN.matcher(str).match();
}
}
过滤的实质是使用正则表达式完成的,而完整的正则来自于【Java 中 Emoji 的正则表达式】。
绝大部分emoji都是占用两个字节符,所以对比之前的换行符做了特殊处理。
所以对于过滤emoji和空白符,换行符以及回车符就非常好办了:
- 方案1: 使用上面的两个filter(上面实现)
- 方案2: 继承于EmojiFilter(下面的实现)
public class NameFilter extends EmojiFilter {
private static final String PATTERN_STR = "[\n|\t]";
private static final Pattern PATTERN = Pattern.compile(PATTERN_STR, Pattern.CASE_INSENSITIVE);
public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
// 将/n/t替换掉,这里不会出现奇怪的连带效果,亲测有效
return PATTERN.matcher(super.filter(source, start, end, dest, dstart, dend)).replaceAll("");
}
}
亲测有效。