Android 利用ReplacementSpan打造纯文本填空题(附源码)

很久没有更新,今天为大家带来我在项目中利用ReplacementSpan来打造纯文本的填空题分享。效果如图

1.认识ReplacementSpan

关于ReplacementSpan这个类,开发者文档和源码里没明确说明。不过通过名字我们大概可以猜出来,它是替换Span的,而Span是替换文本里的文字,进行自定义属性的,既然是替换,所以ReplacementSpan也有这个功能。那ReplacementSpan是通过什么来让我们自定义属性的呢?我们新建工程,创建一个新的类继承ReplacementSpan,会发现有两个方法需要我们Override:

/**
 * Returns the width of the span
 */
public abstract int getSize(Paint paint, CharSequence   text, int start, int end, Paint.FontMetricsInt fm);

/**
 * Draws the span into the canvas.
 */
public abstract void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint);
 ———————————————— 

第一个方法  getSize,顾名思义,它是计算我们替换文字所需要的宽度

第二个方法  和自定义视图onDraw方法一样,是在TextView绘制时调用这个方法,因此我们自定义属性就是在这个方法里完成的

2.自定义ReplacementSpan

新建类,继承ReplacementSpan,实现getSize和draw方法,然后定义

/**
     * 
     * @param isSelect 空是否选中
     * @param context  上下文
     * @param userFillString 用户填入的字段
     * @param lineSpacing tv的行间距
     */
    public FillReplaceSpan(boolean isSelect, Context context,String userFillString,float lineSpacing) {
        this.isSelect = isSelect;
        this.context = context;
        this.mText=userFillString;
        this.lineSpacing=lineSpacing;
    }

然后重写getSize

@Override
    public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
        
        String mSWidth=mText;
        mWidth = (int) paint.measureText(mSWidth, 0, mSWidth.length());
        int defaultWidth = 120;//默认下划线长度
        if (mWidth< defaultWidth){
            mWidth= defaultWidth;
        }
        return mWidth;
    }

最后在draw中绘制下划线以及文本

@Override
    public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) {
        //填入对应单词
        int width = (int) paint.measureText(mText, 0, mText.length());
        width = (mWidth - width) / 2;

        if (isSelect) {
            paint.setStyle(Paint.Style.FILL);
            paint.setColor(ContextCompat.getColor(context, R.color.colorAccent));
        } else {
            paint.setColor(ContextCompat.getColor(context, R.color.colorPrimary));
        }
        paint.setStrokeWidth(5);
        //绘制下划线
        if (count - 1 == id) {
            canvas.drawLine(x, bottom - lineSpacing / 2, x + mWidth, bottom - lineSpacing / 2, paint);
        } else {
            canvas.drawLine(x, bottom - lineSpacing, x + mWidth, bottom - lineSpacing, paint);
        }
        if (!TextUtils.isEmpty(mText)) {
            paint.setColor(ContextCompat.getColor(context, R.color.colorPrimary));
            canvas.drawText(mText, 0, mText.length(), x + width, (float) y, paint);
        }
    }

完整ReplacementSpan代码

/**
 * 自定义的Span,用来绘制填空题
 */
public class FillReplaceSpan extends ReplacementSpan {
    private boolean isSelect;
    private Context context;
    private int index;
    private int count=10;//总共的空数
    private float lineSpacing;//文本的行间距

    /**
     *
     * @param isSelect 空是否选中
     * @param context  上下文
     * @param userFillString 用户填入的字段
     * @param lineSpacing tv的行间距
     */
    public FillReplaceSpan(boolean isSelect, Context context,String userFillString,float lineSpacing) {
        this.isSelect = isSelect;
        this.context = context;
        this.mText=userFillString;
        this.lineSpacing=lineSpacing;
    }

    public void setmOnSelect(OnSelect mOnSelect) {
        this.mOnSelect = mOnSelect;
    }


    public  interface OnClick {
        void onClick(TextView v, int id, FillReplaceSpan span);
    }

    public  interface OnSelect {
        void onSelect(TextView v, Spannable buffer, int id, FillReplaceSpan span);
    }

    public int id = 0;//回调中的对应Span的ID
    private int mWidth = 0;//最长单词的宽度
//    public String mWidthStr;//对应句子最长的单词
    public String mText;//保存的String
    public Object mObject;//回调中的任意对象
    public OnClick mOnClick;
    private OnSelect mOnSelect;

//    public void setWidth(String widthStr) {
        mWidthStr = widthStr;
//        mWidth = 0;
//    }

    public void onClick(TextView v, Spannable buffer, boolean isDown, int x, int y, int line, int off) {
        if (mOnClick != null) {
            mOnClick.onClick(v, id, this);
        }

        if (mOnSelect != null) {
            mOnSelect.onSelect(v, buffer, id, this);
        }
    }

    @Override
    public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
        //将返回相对于Paint画笔的文本 50== 左右两边增加的空余长度
        String mSWidth=mText;
        mWidth = (int) paint.measureText(mSWidth, 0, mSWidth.length());
        int defaultWidth = 120;//默认下划线长度
        if (mWidth< defaultWidth){
            mWidth= defaultWidth;
        }
        return mWidth;
    }

    @Override
    public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) {
        //填入对应单词
        int width = (int) paint.measureText(mText, 0, mText.length());
        width = (mWidth - width) / 2;

        if (isSelect) {
            paint.setStyle(Paint.Style.FILL);
            paint.setColor(ContextCompat.getColor(context, R.color.colorAccent));
        } else {
            paint.setColor(ContextCompat.getColor(context, R.color.colorPrimary));
        }
        paint.setStrokeWidth(5);
        //绘制下划线
        if (count - 1 == id) {
            canvas.drawLine(x, bottom - lineSpacing / 2, x + mWidth, bottom - lineSpacing / 2, paint);
        } else {
            canvas.drawLine(x, bottom - lineSpacing, x + mWidth, bottom - lineSpacing, paint);
        }
        if (!TextUtils.isEmpty(mText)) {
            paint.setColor(ContextCompat.getColor(context, R.color.colorPrimary));
            canvas.drawText(mText, 0, mText.length(), x + width, (float) y, paint);
        }
    }

  //TextView触摸事件-->Span点击事件
    public  static LinkMovementMethod Method = new LinkMovementMethod() {

        public boolean onTouchEvent(TextView widget, Spannable buffer,
                                    MotionEvent event) {
            int action = event.getAction();

            if (action == MotionEvent.ACTION_UP ||
                    action == MotionEvent.ACTION_DOWN) {
                int x = (int) event.getX();
                int y = (int) event.getY();

                x -= widget.getTotalPaddingLeft();
                y -= widget.getTotalPaddingTop();

                x += widget.getScrollX();
                y += widget.getScrollY();

                Layout layout = widget.getLayout();
                int line = layout.getLineForVertical(y);
                int off = layout.getOffsetForHorizontal(line, x);

                FillReplaceSpan[] link = buffer.getSpans(off, off, FillReplaceSpan.class);

                if (link.length != 0) {
                    //Span的点击事件
                    if (action == MotionEvent.ACTION_UP) {
                        link[0].onClick(widget, buffer, false, x, y, line, off);
                    } else if (action == MotionEvent.ACTION_DOWN) {
                        link[0].onClick(widget, buffer, true, x, y, line, off);
//                        Selection.setSelection(buffer,
//                                buffer.getSpanStart(link[0]),
//                                buffer.getSpanEnd(link[0]));
                    }
                    return true;
                }  //                    Selection.removeSelection(buffer);

            }
            return false;
        }
    };

    public void setSelect(boolean select) {
        this.isSelect = select;
    }

这样我们ReplacementSpan我们就完成了,但这只是一个视图,所以我们需要一个管理类来管理这个ReplacementSpan

3.创建FillSpanController管理类

这个类是用来处理数据以及和ReplacementSpan交互

创建makeData方法,用来处理数据以及插入ReplacementSpan

//造对应的sentence
    public void makeData(TextView tv, String str, FillReplaceSpan.OnClick onClick){
        if (tv == null || TextUtils.isEmpty(str))return;
        try{
            tv.setMovementMethod(FillReplaceSpan.Method);
            mTv = tv;
            char[] chars = str.toCharArray();
            for (int i = 0; i < chars.length; i++) {
                //获取需要绘制空的坐标位置
                if ('['==chars[i]){
                    mListIndex.add(i-mListIndex.size());
                }else if(']'==chars[i]){
                    mListIndex.add(i-mListIndex.size());
                }
            }
            mStr = str.replace("[","").replace("]","");
            mSpanString = new SpannableString(mStr);
            int index = 0;
            for (int i = 0; i < mListIndex.size(); i+=2) {
                FillReplaceSpan span = new FillReplaceSpan(i == 0,context,"",tv.getLineSpacingExtra());
                span.mOnClick = onClick;
                span.id = index++;
                mSpans.add(span);
                mSpanString.setSpan(span, mListIndex.get(i), mListIndex.get(i+1), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
            }
        }catch (Exception e){
            e.printStackTrace();
        }
        tv.setText(mSpanString);
    }

数据处理完成了,空也画出来了,但是还无法输入,要想要弹出软键盘以及接收到键盘的输入,那么我们就需要一个EditText,但是EditText我们放在哪里呢,当然是用户所选空的位置,因此我们在FillSpanController写一个获取ReplacementSpan位置

以及设置EditText位置的方法

//获取出对应Span的RectF数据
    public RectF drawSpanRect(TextView v, FillReplaceSpan s) {
        Layout layout = v.getLayout();
        Spannable buffer = (Spannable) v.getText();
        int l = buffer.getSpanStart(s);
        int r = buffer.getSpanEnd(s);
        int line = layout.getLineForOffset(l);
        int l2 = layout.getLineForOffset(r);
        if (mRf == null){
            mRf = new RectF();
            Rect rt = new Rect();
            v.getPaint().getTextBounds("TgQyYjJ",0,7,rt);
            mFontT = rt.top;
            mFontB  = rt.bottom;
        }
        mRf.left = layout.getPrimaryHorizontal(l);
        mRf.right = layout.getSecondaryHorizontal(r);
        // 通过基线去校准
        line = layout.getLineBaseline(line);
        mRf.top = line + mFontT;
        mRf.bottom = line + mFontB;
        return mRf;
    }

    //设置EditText填空题中的相对位置
    public void setEtXY(TextView tv, EditText et, RectF rf) {
        //设置et w,h的值
        RelativeLayout.LayoutParams lp = (RelativeLayout.LayoutParams) et.getLayoutParams();
        lp.width = (int)(rf.right - rf.left);
        lp.height = (int)(rf.bottom - rf.top);
        //设置et 相对于tv x,y的相对位置
        lp.leftMargin = (int) (tv.getLeft()+rf.left);
        lp.topMargin  = (int) (tv.getTop()+rf.top);
        et.setLayoutParams(lp);
//        获取焦点,弹出软键盘
        et.setFocusable(true);
        et.requestFocus();
        showImm(true,et);
    }

这样我们打造完成了,FillSpanController完整代码

/**
 * 填空题的控制器
 */
public class FillSpanController {

    private TextView mTv;
    private SpannableString mSpanString;

    private int mFontT; // 字体top
    private int mFontB;// 字体bottom
    public int mOldSpan = -1;
    private String mStr;
    public String mWidthStr;
    private ArrayList<Integer> mListIndex = new ArrayList<Integer>();
    private ArrayList<FillReplaceSpan> mSpans = new ArrayList<>();
    protected ImmFocus mFocus = new ImmFocus();

    private RectF mRf;
    private Context context;

    public FillSpanController(Context context) {
        this.context=context;
    }

    //造对应的sentence
    public void makeData(TextView tv, String str, FillReplaceSpan.OnClick onClick){
        if (tv == null || TextUtils.isEmpty(str))return;
        try{
            tv.setMovementMethod(FillReplaceSpan.Method);
            mTv = tv;
            char[] chars = str.toCharArray();
            for (int i = 0; i < chars.length; i++) {
                //正常情况下要去掉'[',']',减掉mListIndex.size()
                if ('['==chars[i]){
                    mListIndex.add(i-mListIndex.size());
                }else if(']'==chars[i]){
                    mListIndex.add(i-mListIndex.size());
                }
            }
            mStr = str.replace("[","").replace("]","");
            mSpanString = new SpannableString(mStr);
            int index = 0;
            for (int i = 0; i < mListIndex.size(); i+=2) {
                FillReplaceSpan span = new FillReplaceSpan(i == 0,context,"",tv.getLineSpacingExtra());
                span.mOnClick = onClick;
                span.id = index++;
                mSpans.add(span);
                mSpanString.setSpan(span, mListIndex.get(i), mListIndex.get(i+1), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
            }
        }catch (Exception e){
            e.printStackTrace();
        }
        tv.setText(mSpanString);
    }

    //填充缓存的数据
    public void setData(String str, int i){
        if (mTv == null || mSpans ==null ||mSpans.size() ==0 || i<0 ||i>mSpans.size()-1)return;
        FillReplaceSpan span = mSpans.get(i);
        span.mText = str;
        mTv.setText(mSpanString);
    }

    public void setData(int i){
        for (int z=0;z<mSpans.size();z++){
            if (mSpans.get(z).id==i){
                mSpans.get(z).setSelect(true);
            }else{
                mSpans.get(z).setSelect(false);
            }
        }
        mTv.invalidate();
    }


    public int setData(String str, Object o){
        if (mTv == null)return -2;
        for (int i = 0; i < mSpans.size(); i++) {
            FillReplaceSpan span = mSpans.get(i);
            if (TextUtils.isEmpty(span.mText)){
                span.mText = str;
                span.id = i;
                span.mObject = o;
                mTv.invalidate();
                return i;
            }
        }
        //-1说明填空题已经填满
        return -1;
    }

    public int isFill(){
        for (int i = 0; i < mSpans.size(); i++) {
            FillReplaceSpan span = mSpans.get(i);
            if (TextUtils.isEmpty(span.mText)){
                return i;
            }
        }
        //-1说明填空题已经填满
        return -1;
    }

    //获取出对应Span的RectF数据
    public RectF drawSpanRect(TextView v, FillReplaceSpan s) {
        Layout layout = v.getLayout();
        Spannable buffer = (Spannable) v.getText();
        int l = buffer.getSpanStart(s);
        int r = buffer.getSpanEnd(s);
        int line = layout.getLineForOffset(l);
        int l2 = layout.getLineForOffset(r);
        if (mRf == null){
            mRf = new RectF();
            Rect rt = new Rect();
            v.getPaint().getTextBounds("TgQyYjJ",0,7,rt);
            mFontT = rt.top;
            mFontB  = rt.bottom;
        }
        mRf.left = layout.getPrimaryHorizontal(l);
        mRf.right = layout.getSecondaryHorizontal(r);
        // 通过基线去校准
        line = layout.getLineBaseline(line);
        mRf.top = line + mFontT;
        mRf.bottom = line + mFontB;
        return mRf;
    }

    //设置EditText填空题中的相对位置
    public void setEtXY(TextView tv, EditText et, RectF rf) {
        //设置et w,h的值
        RelativeLayout.LayoutParams lp = (RelativeLayout.LayoutParams) et.getLayoutParams();
        lp.width = (int)(rf.right - rf.left);
        lp.height = (int)(rf.bottom - rf.top);
        //设置et 相对于tv x,y的相对位置
        lp.leftMargin = (int) (tv.getLeft()+rf.left);
        lp.topMargin  = (int) (tv.getTop()+rf.top);
        et.setLayoutParams(lp);
//        获取焦点,弹出软键盘
        et.setFocusable(true);
        et.requestFocus();
        showImm(true,et);
    }

    public void clearData(){
        for (FillReplaceSpan replaceSpan:mSpans){
            replaceSpan.mText="";
        }
        mTv.setText(mSpanString);
    }

    public String getAllAnswer(){
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < mSpans.size(); i++) {
            FillReplaceSpan span = mSpans.get(i);
           if(i == mSpans.size() -1){
                sb.append(span.mText);
            }else{
                sb.append(span.mText).append(",");
            }
        }
        return sb.toString();
    }

    public ArrayList<FillReplaceSpan> getSpanAll(){
        return mSpans;
    }

    public void showImm(boolean bOn, View focus) {
        try {
            if (bOn) {
                if (focus!=null) {
                    ImmFocus.show(true, focus);
                } else {
                    mFocus.setFocus(focus);
                }
            } else {
                ImmFocus.show(false, null);
            }
        }catch (Exception e){
            e.printStackTrace();
        }
    }


}

4.使用FillSpanController

public class MainActivity extends AppCompatActivity implements FillReplaceSpan.OnClick {

    private EditText editText;//提供弹出键盘的输入框
    private TextView textView;
    private int spanPosition=-1;//填空题空的位置
    private FillSpanController fillSpanController;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initView();
        initData();
    }

    private void initView(){
        editText = findViewById(R.id.fill_span_edit);
        textView = findViewById(R.id.fill_span_text);

        editText.addTextChangedListener(new TextWatcher() {
            @Override
            public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {

            }

            @Override
            public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
                fillSpanController.setData(charSequence.toString(),spanPosition);
            }

            @Override
            public void afterTextChanged(Editable editable) {

            }
        });
    }

    private void initData(){
        String fillString="Two people died.\n" +
                "Roads and highways are closed.\n" +
                "Homes are without power.\n" +
                "And travelers are (1)_______ in Great Britain because of record rainfall.\n" +
                "The rain set records and affected the northern part of England and Scotland.\n" +
                "The national weather service (2)_______ a \"red\" alert for rain in the area.\n" +
                "In some areas, water reached above the doors of parked cars.\n" +
                "Rescue workers removed (3)______________ by boat from a flooded residential street in Carlisle, Britain December six.\n" +
                "The Reuters news agency says two people died because of the flooding.\n" +
                "The head of Britain's Environment Agency called the weather unprecedented.\"\n" +
                "Most of the (4)________ received between 200 to 300 millimeters of rain over the weekend,(5)______________ the U.K.'s National Weather Service.\n" +
                "The weather office says (6)_________ weather may continue into the week.(7)___________ rainy weather is not only affecting the northern hemisphere.\n" +
                "The city of Chennai in southern India also received over 300 millimeters of rain in 24 hours last week.\n" +
                "The rain (8)_____________ came at the same time world leaders are meeting in Franceto (9)__________ climate change at the COP21 convention.\n" +
                "One climate change expert from the United Kingdom's office of the World Wildlife Fund said \"storm Desmond is the sort of storm that we will see more of if we fail to (10)________ climate change.";
        while (fillString.contains("___")){
            fillString=fillString.replace("___","__");//将数据所要画空的地方统一
        }
        fillString=fillString.replace("__","[A]");//将画空数据替换成我们需要替换的某块 [A]只是一个替换判断值,可修改FillSpanController.makeData里的判断元素自己进行替换
        fillSpanController = new FillSpanController(getApplicationContext());
        fillSpanController.makeData(textView,fillString,this);
    }
    //Span点击回调
    @Override
    public void onClick(TextView v, int id, FillReplaceSpan span) {
        if (id!=spanPosition){
            fillSpanController.setData(id);
            fillSpanController.setEtXY(textView,editText,fillSpanController.drawSpanRect(textView,span));
            spanPosition=id;
            editText.setText("");
        }
    }
}

到此就大功告成了。当然目前填入功能还不完善,我将在下一篇中完善:保存输入的答案以及判断对错。

补充:ImmFocus类

// 处理焦点
public class ImmFocus {
	// 处理编辑框焦点及输入法
	protected View mLastFocus;
	// 保存焦点
	public void save(View focus) {
		mLastFocus = focus;
		if (mLastFocus!=null&&(!show(false,mLastFocus)||!(mLastFocus instanceof TextView))) mLastFocus = null;
	}
	// 恢复焦点
	public void restore() {
		if (mLastFocus!=null) {
			show(true,mLastFocus);
			mLastFocus = null;
		}
	}
	// 预约焦点
	public void setFocus(View focus) {
		mLastFocus = focus;
	}
	// 显示/隐藏
	public static boolean show(boolean bOn,View focus) {
		InputMethodManager imm = (InputMethodManager)focus.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
		if (bOn) {
			focus.requestFocus();
			return imm.showSoftInput(focus, 0);
		} else {
			return imm.hideSoftInputFromWindow(focus.getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS);
		}
	}
}

 5.源码下载

源码下载https://download.csdn.net/download/pengguichu/11620663icon-default.png?t=L9C2https://download.csdn.net/download/pengguichu/11620663

 

  • 2
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值