用Span简单实现文本编辑器

96
安卓大叔
0.1 2017.06.22 16:48* 字数 1213

使用

文本编辑器在APP中太常见了,但如何实现的呢?不知大家有没有跟我一个疑问?下面我将用Span来实现一个简单的文本编辑器。国际惯例,先上效果图。

文本编辑器演示.gif

怎样,效果是不是还行,使用也很简单,只要一行代码就能改变文本的样式!

1.添加依赖
compile 'com.leo.extendedittext:library:0.1.1'
2.布局中配置
<com.leo.extendedittext.ExtendEditText
    android:id="@+id/extend_edit_text"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:textSize="@dimen/normal_text_size"
    android:scrollbars="none"
    android:background="@android:color/transparent"
    app:bulletColor="@color/colorPrimary" // 着重号颜色
    app:bulletRadius="@dimen/bullet_radius" // 着重号半径
    app:bulletGapWidth="@dimen/bullet_gap_width" // 着重号与文本的宽度
    app:quoteColor="@color/colorPrimary" // 引用颜色
    app:quoteStripeWidth="@dimen/quote_stripe_width" // 引用宽度
    app:quoteGapWidth="@dimen/quote_gap_width" // 引用与文本的宽度
    app:linkColor="@color/colorPrimaryDark" // 链接颜色
    app:drawUnderLine="true" // 链接是否画下划线
    app:enableHistory="true" // 是否开启历史记录
    app:historyCapacity="50" // 历史记录容量
    app:rule="EXCLUSIVE_EXCLUSIVE"> // 规则,后面说
</com.leo.extendedittext.ExtendEditText>

当然,配置项也可以用代码设置,如:

mExtendEdt.enableHistory(true); // 开启历史记录
3.设置样式

配置好了就非常简单了,只要选中文本,调用相应的接口,所选文本就会更换样式。

- mExtendEdt.bold(); // 粗体
- mExtendEdt.italic(); // 斜体
- mExtendEdt.underline(); // 下划线
- mExtendEdt.strikethrough(); // 删除线
- mExtendEdt.link(); // 链接
- mExtendEdt.bullet(); // 着重号
- mExtendEdt.quote(); // 引用

细心的同学应该看到我上面的配置有个app:rule的配置项,这是设置更换样式的规则,也可以代码设置。

mExtendEdt.setRule(Rule.EXCLUSIVE_INCLUSIVE);

有下面四个规则作用分别如下:

- Rule.EXCLUSIVE_EXCLUSIVE  // 设置样式只对选中文本有影响
- Rule.EXCLUSIVE_INCLUSIVE  // 设置样式对选中的文本有影响, 并在其后输入的文本也会有该样式
- Rule.INCLUSIVE_EXCLUSIVE  // 设置样式对选中的文本有影响, 并在其前输入的文本也会有该样式
- Rule.INCLUSIVE_INCLUSIVE  // 设置样式对选中的文本有影响, 并在其前后输入的文本都会有该样式

是不是还是不太懂什么意思,我举个例子。例如我设置了EXCLUSIVE_INCLUSIVE的规则,当我给选中文本设置为粗体时,在选中文本后继续输入文本,新增的文本也会为粗体;而在刚选中的文本前输入文本呢,就是普通的文本样式。但经我测试,除了EXCLUSIVE_EXCLUSIVE 规则以外的三种规则都不好控制...

使用就这么简单了!其实我还实现了链式调用。但发现链式调用的场景不多,一般设置字体都是点击一个样式图标设置一种样式,所以链式调用就没多大用处了,看看就好。

mExtendEdt.cover()
          .bold()
          .italic()
          .underline()
          .strikethrough()
          .link()
          .bullet()
          .quote()
          .action();

原理

在讲解原理之前,各位同学需要对Span有一定的了解,可以看这篇文章:【译】Spans,一个强大的概念/#使用自定义的span

每种样式对应一个Span,例如粗体样式对应StyleSpan(Typeface.BOLD)、斜体对应StyleSpan(Typeface.ITALIC)、下划线对应UnderlineSpan,只要获取到相应的样式,再调用Spannable.setSpan接口来设置样式即可。

// what参数传Span对象
// start文本开始索引
// end文本结束索引
// flags有四个值,分别为
// Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
// Spanned.SPAN_EXCLUSIVE_INCLUSIVE
// Spanned.SPAN_INCLUSIVE_EXCLUSIVE
// Spanned.SPAN_INCLUSIVE_INCLUSIVE

public void setSpan(Object what, int start, int end, int flags);

聪明的你应该猜到了,flags对应的就是我上面所说的Rule,我只是封装一层而已。

这里可能大家有疑问,我怎么获取到Spannable呢?放心,Editable是继承于Spannable的!

public interface Editable extends CharSequence, GetChars, Spannable, Appendable {
    ...
}

是的,我们只要继承EditView来实现文本编辑器,就能获取到Editable,也就能对文本进行样式修改了。说到这里捋一捋实现文本编辑器的思路:

  1. 创建继承于EditText的View
  • 获取EditText的Editable对象
  • 调用Editable的setSpan来设置样式

怎样?思路是不是非常简单明了!但只要设置样式就够了吗?APP往往点击一个按钮设置样式,再点击一次就清除样式。考虑到这里,设置样式和清除样式应该是同一个接口比较合理。好,基于此再来捋一捋思路:

  1. 创建继承于EditText的View
  • 获取EditText的Editable对象
  • 判断选中文本是否具有将要设置样式的样式
  • 若已设置,清除样式
  • 若没设置,设置样式

基于上面的思路,我定义了一个Style抽象类,下面是核心代码:

public abstract class Style {

    /**
     * 改变选中文本样式
     * @param text 选中的可编辑文本
     * @param start 开始索引
     * @param end 结束索引
     * @param rule 规则
     * @return 若设置样式返回true, 清除样式返回false
     */
    public boolean format(Editable text, int start, int end, Rule rule) {
        ...
        boolean result = false;
        if (!isSetting(text, start, end)) {
            set(text, start, end);
            result = true;
        } else {
            remove(text, start, end);
        }

        return result;
    }

    /**
     * 设置样式
     * @param text 可编辑文本
     * @param start 开始索引
     * @param end 结束索引
     */
    public abstract void set(Editable text, int start, int end);

    /**
     * 移除样式
     * @param text 可编辑文本
     * @param start 开始索引
     * @param end 结束索引
     */
    public abstract void remove(Editable text,int start, int end);

    /**
     * 选中文本是否已设置样式
     * @param text 可编辑文本
     * @param start 开始索引
     * @param end 结束索引
     * @return 若选中的全部文本已设置该样式, 返回true; 反之, 返回false.
     */
    public abstract boolean isSetting(Editable text, int start, int end);

    ...

然后各种样式继承Style抽象类,并实现isSetting、set和remove方法即可。下面用粗体Bold类的实现代码:

public class Bold extends Style {

    @Override
    public void set(Editable text, int start, int end) {
        if (start >= end) {
            return;
        }

        text.setSpan(new StyleSpan(Typeface.BOLD), start, end, mRule);
    }

    @Override
    public void remove(Editable text, int start, int end) {
        if (start >= end) {
            return;
        }

        StyleSpan[] spans = text.getSpans(start, end, StyleSpan.class);
        List<TypeBean> list = new ArrayList<>(spans.length);
        for (StyleSpan span : spans) {
            if (span.getStyle() == Typeface.BOLD) {
                list.add(new TypeBean(text.getSpanStart(span), text.getSpanEnd(span)));
                text.removeSpan(span); // remove
            }
        }

        // 恢复未选上但与移除文本具有相同样式的文本
        for (TypeBean bean : list) {
            if (bean.isValid()) {
                if (bean.getStart() < start) {
                    set(text, bean.getStart(), start);
                }

                if (bean.getEnd() > end) {
                    set(text, end, bean.getEnd());
                }
            }
        }
    }

    @Override
    public boolean isSetting(Editable text, int start, int end) {
        if (start >= end) {
            return false;
        }

        // 思路: 遍历可编辑文本, 若选中文本存在未设置该样式的, 返回false; 反之, 返回true
        StringBuilder builder = new StringBuilder();
        for (int i = start; i < end; i++) {
            // 获取每个字符的样式, 可能有重复, 只需获取判断一次
            StyleSpan[] spans = text.getSpans(i, i + 1, StyleSpan.class);
            for (StyleSpan span : spans) {
                if (span.getStyle() == Typeface.BOLD) {
                    builder.append(text.subSequence(i, i + 1).toString());
                    break;
                }
            }
        }

        return text.subSequence(start, end).toString().equals(builder.toString());
    }
}

代码注释说得很清楚了,这里不多说,但有一点需要提醒下,清除样式的接口是:

public void removeSpan(Object what);

可以看到, 没有指定开始和结束索引的,它会清除具有该样式的所有相邻的文本的样式。即如果HelloWorld整个单词是粗体,如果你选中“ello”,调用removeSpan来清除粗体样式,会把HelloWorld整个单词的粗体样式都清除掉。所以要想只清除选中的“ello”,就要先把整个单词的粗体样式清除,再对非选中的文本进行样式恢复。

另外一点需要注意的是,对于“着重号”、“链接”等样式,Android自带的不能满足我们的需求,所以需要自己改下,具体不说了,看源码吧!

结论

目前支持样式:

  • 粗体
  • 斜体
  • 下划线
  • 删除线
  • 链接
  • 着重号
  • 引用

未来更新支持样式:

  • 图片
  • 背景色

参考:

写篇文章不容易~ 记得帮我点个喜欢或者Star哈


源码下载

程序园
Web note ad 1