GitHub传送门
1.写在前面
在上一篇文章《Android 使用代码实现一个填空题》中,我们学习了如何实现一个填空题,今天继续接着上一篇文章的节奏,学习一下如何实现一个选词填空题,由于本文中用到了一些上篇文章中的知识点,还没有看过上篇文章的同学可以>戳这里<了解一下。
首先看下效果图:
2.学习一些基础知识
选词填空题有一个很重要的功能就是拖拽,我们先来学习一下如何对View进行拖拽操作,写个简单的Demo来学习下:
public class DragActivity extends BaseActivity implements View.OnDragListener {
@Bind(R.id.tv_tip)
TextView tvTip;
@Bind(R.id.rl_container)
RelativeLayout rlContainer;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_drag);
ButterKnife.bind(this);
// 目标区域设置拖拽事件监听
rlContainer.setOnDragListener(this);
}
@OnTouch(R.id.iv_icon)
public boolean onTouch(View v) {
ClipData.Item item = new ClipData.Item("我来了");
ClipData data = new ClipData(null, new String[]{ClipDescription.MIMETYPE_TEXT_PLAIN}, item);
v.startDrag(data, new View.DragShadowBuilder(v), null, 0);
return true;
}
@Override
public boolean onDrag(View v, DragEvent event) {
final int action = event.getAction();
switch (action) {
case DragEvent.ACTION_DRAG_STARTED: // 拖拽开始
Log.i("拖拽事件", "拖拽开始");
return event.getClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN);
case DragEvent.ACTION_DRAG_ENTERED: // 被拖拽View进入目标区域
Log.i("拖拽事件", "被拖拽View进入目标区域");
return true;
case DragEvent.ACTION_DRAG_LOCATION: // 被拖拽View在目标区域移动
Log.i("拖拽事件", "被拖拽View在目标区域移动___X:" + event.getX() + "___Y:" + event.getY());
tvTip.setText("X:" + event.getX() + " Y:" + event.getY());
return true;
case DragEvent.ACTION_DRAG_EXITED: // 被拖拽View离开目标区域
Log.i("拖拽事件", "被拖拽View离开目标区域");
return true;
case DragEvent.ACTION_DROP: // 放开被拖拽View
Log.i("拖拽事件", "放开被拖拽View");
// 释放拖放阴影,并获取移动数据
ClipData.Item item = event.getClipData().getItemAt(0);
String content = item.getText().toString();
Toast.makeText(this, content, Toast.LENGTH_SHORT).show();
return true;
case DragEvent.ACTION_DRAG_ENDED: // 拖拽完成
Log.i("拖拽事件", "拖拽完成");
return true;
default:
break;
}
return false;
}
}
看下效果:
看下打印信息:
首先给被拖拽View设置一个触摸事件,在onTouch方法中定义一个ClipData对象,传入文本类型的数据“我来了”,当触摸被拖拽View时调用View的startDrag方法开始移动View,此时移动的是被拖拽View的“影子”。
View可以移动了,还需要为它设置一个目标区域,调用目标区域View的setOnDragListener方法设置拖拽事件的监听,实现onDrag方法,在ACTION_DRAG_STARTED(拖拽开始)时判断当前接收的是不是文件类型的数据,如果不是则返回false,不再响应拖拽事件,在ACTION_DROP(放开被拖拽View)时,释放拖拽阴影,并获取传递过来的数据,通过Toast显示出来。
3.实现
首先初始化一些数据
public class DragFillBlankView extends RelativeLayout implements View.OnDragListener,
View.OnLongClickListener {
private TextView tvContent;
private LinearLayout llOption;
// 初始数据
private String originContent;
// 初始答案范围集合
private List originAnswerRangeList;
// 填空题内容
private SpannableStringBuilder content;
// 选项列表
private List optionList;
// 答案范围集合
private List answerRangeList;
// 答案集合
private List answerList;
// 选项位置
private int optionPosition;
// 一次拖拽填空是否完成
private boolean isFillBlank;
public DragFillBlankView(Context context) {
this(context, null);
}
public DragFillBlankView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public DragFillBlankView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initView();
}
private void initView() {
LayoutInflater inflater = LayoutInflater.from(getContext());
inflater.inflate(R.layout.layout_drag_fill_blank, this);
tvContent = (TextView) findViewById(R.id.tv_content);
llOption = (LinearLayout) findViewById(R.id.ll_option);
}
...
}
定义一个设置数据的方法,供外部调用
/**
* 设置数据
*
* @param originContent 源数据
* @param optionList 选项列表
* @param answerRangeList 答案范围集合
*/
public void setData(String originContent, List optionList, List answerRangeList) {
if (TextUtils.isEmpty(originContent) || optionList == null || optionList.isEmpty()
|| answerRangeList == null || answerRangeList.isEmpty()) {
return;
}
// 初始数据
this.originContent = originContent;
// 初始答案范围集合
this.originAnswerRangeList = new ArrayList<>();
this.originAnswerRangeList.addAll(answerRangeList);
// 获取课文内容
this.content = new SpannableStringBuilder(originContent);
// 选项列表
this.optionList = optionList;
// 答案范围集合
this.answerRangeList = answerRangeList;
// 避免重复创建拖拽选项
if (llOption.getChildCount() < 1) {
// 拖拽选项列表
List
首先初始化一些全局数据,这个稍后会用到,然后创建拖拽选项,为了避免重复创建选项,先判断选项是否已经创建过了,如果已经创建过了,则把已经填空的选项隐藏,然后接下来的逻辑就和普通填空题一样了,代码里已经写了注释,不再多说。
重点看下填空处设置触摸事件这里,由于拖拽是触摸事件而不是点击事件,在这里就需要定义一个TouchLinkMovementMethod来响应触摸事件,最后对填空题区域进行拖拽监听,看下TouchLinkMovementMethod类:
public class TouchLinkMovementMethod extends LinkMovementMethod {
@Override
public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) {
int action = event.getAction();
if (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);
ClickableSpan[] link = buffer.getSpans(off, off, ClickableSpan.class);
if (link.length != 0) {
link[0].onClick(widget);
return true;
} else {
Selection.removeSelection(buffer);
}
}
return super.onTouchEvent(widget, buffer, event);
}
}
当手指按下的时候,回调ClickableSpan的onClick方法,并且不再响应点击事件。
拖拽开始
@Override
public boolean onLongClick(View v) {
startDrag(v);
return true;
}
/**
* 开始拖拽
*
* @param v 当前对象
*/
private void startDrag(View v) {
// 选项内容
String optionContent = ((Button) v).getText().toString();
// 记录当前答案选项的位置
optionPosition = getOptionPosition(optionContent);
// 开始拖拽后在列表中隐藏答案选项
v.setVisibility(INVISIBLE);
ClipData.Item item = new ClipData.Item(optionContent);
ClipData data = new ClipData(null, new String[]{ClipDescription.MIMETYPE_TEXT_PLAIN}, item);
v.startDrag(data, new DragShadowBuilder(v), null, 0);
}
/**
* 获取选项位置
*
* @param option 选项内容
* @return 选项位置
*/
private int getOptionPosition(String option) {
for (int i = 0; i < llOption.getChildCount(); i++) {
Button btnOption = (Button) llOption.getChildAt(i);
if (btnOption.getText().toString().equals(option)) {
return i;
}
}
return 0;
}
在初始化拖拽选项时,我们为每个Button都设置了一个长按监听事件,下面来看看它是如何工作的,首先获取到当前拖拽选项上的答案,作为参数进行传递,然后记录选项的位置,这个是为了当拖拽未完成时,重新显示选项用的,最后在列表中隐藏当前拖拽的选项。现在选项已经可以移动了,还记得在设置数据的时候我们为填空题区域设置了拖拽监听,看下它是如何进行响应的:
@Override
public boolean onDrag(View v, DragEvent event) {
final int action = event.getAction();
switch (action) {
case DragEvent.ACTION_DRAG_STARTED: // 拖拽开始
return event.getClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN);
case DragEvent.ACTION_DRAG_ENTERED: // 被拖拽View进入目标区域
return true;
case DragEvent.ACTION_DRAG_LOCATION: // 被拖拽View在目标区域移动
return true;
case DragEvent.ACTION_DRAG_EXITED: // 被拖拽View离开目标区域
return true;
case DragEvent.ACTION_DROP: // 放开被拖拽View
int position = 0;
// 获取TextView的Layout对象
Layout layout = tvContent.getLayout();
// 当前x、y坐标
float currentX = event.getX();
float currentY = event.getY();
// 如果拖拽答案没有进行填空则return
boolean isContinue = false;
for (int i = 0; i < answerRangeList.size(); i++) {
AnswerRange range = answerRangeList.get(i);
// 获取TextView中字符坐标
Rect bound = new Rect();
int line = layout.getLineForOffset(range.start);
layout.getLineBounds(line, bound);
// 字符顶部y坐标
int yAxisTop = bound.top - dp2px(10);
// 字符底部y坐标
int yAxisBottom = bound.bottom + dp2px(5);
// 字符左边x坐标
float xAxisLeft = layout.getPrimaryHorizontal(range.start) - dp2px(10);
// 字符右边x坐标
float xAxisRight = layout.getSecondaryHorizontal(range.end) + dp2px(10);
if (xAxisRight > xAxisLeft) { // 填空在一行
if (currentX > xAxisLeft && currentX < xAxisRight &&
currentY < yAxisBottom && currentY > yAxisTop) {
position = i;
isContinue = true;
break;
}
} else { // 跨行填空
if ((currentX > xAxisLeft || currentX < xAxisRight) &&
currentY < yAxisBottom && currentY > yAxisTop) {
position = i;
isContinue = true;
break;
}
}
}
if (!isContinue) {
return true;
}
// 释放拖放阴影,并获取移动数据
ClipData.Item item = event.getClipData().getItemAt(0);
String answer = item.getText().toString();
// 重复拖拽,在答案列表中显示原答案
String oldAnswer = answerList.get(position);
if (!TextUtils.isEmpty(oldAnswer)) {
llOption.getChildAt(getOptionPosition(oldAnswer)).setVisibility(VISIBLE);
}
// 填写答案
fillAnswer(answer, position);
isFillBlank = true;
return true;
case DragEvent.ACTION_DRAG_ENDED: // 拖拽完成
if (!isFillBlank) {
llOption.getChildAt(optionPosition).setVisibility(VISIBLE);
} else {
isFillBlank = false;
}
return true;
default:
break;
}
return false;
}
如何才能判断拖拽选项是否到达了某一个填空处呢?别担心,在TextView中我们可以获取到每一个字符的坐标,当放开拖拽选项的时候,判断一下是不是处于某一个填空区域就大功告成了,别忘了还有填空处跨行的问题需要特殊处理一下。
到达指定位置后,我们就要把选项中的答案填到题目中了,接下来该轮到fillAnswer方法大显身手了:
/**
* 填写答案
*
* @param answer 当前填空处答案
* @param position 填空位置
*/
private void fillAnswer(String answer, int position) {
answer = " " + answer + " ";
// 替换答案
AnswerRange range = answerRangeList.get(position);
content.replace(range.start, range.end, answer);
// 更新当前的答案范围
AnswerRange currentRange = new AnswerRange(range.start, range.start + answer.length());
answerRangeList.set(position, currentRange);
// 答案设置下划线
content.setSpan(new UnderlineSpan(),
currentRange.start, currentRange.end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
// 将答案添加到集合中
answerList.set(position, answer.replace(" ", ""));
// 更新内容
tvContent.setText(content);
for (int i = 0; i < answerRangeList.size(); i++) {
if (i > position) {
// 获取下一个答案原来的范围
AnswerRange oldNextRange = answerRangeList.get(i);
int oldNextAmount = oldNextRange.end - oldNextRange.start;
// 计算新旧答案字数的差值
int difference = currentRange.end - range.end;
// 更新下一个答案的范围
AnswerRange nextRange = new AnswerRange(oldNextRange.start + difference,
oldNextRange.start + difference + oldNextAmount);
answerRangeList.set(i, nextRange);
}
}
}
首先把填空处的下划线或旧答案替换成新答案,然后更新一下当前的答案范围,由于下划线已经被答案替换了,所以需要为答案设置一条下划线,最后把答案更新到集合中,这样一个填空就完成了。
But,当一个填空处的答案范围改变后,后面所有的填空处答案范围都要跟着改变,所以还需要再更新一下后面填空处的答案范围。首先获取下一个答案原来的范围,计算一下需要向前或向后移动的距离,然后更新一下答案范围就可以了。
在效果图中我们可以看到,当填空完成后,触摸填空处还可以继续拖拽的,继续往下看:
/**
* 触摸事件
*/
class BlankClickableSpan extends ClickableSpan {
private int position;
public BlankClickableSpan(int position) {
this.position = position;
}
@Override
public void onClick(final View widget) {
// 显示原有答案
String oldAnswer = answerList.get(position);
if (!TextUtils.isEmpty(oldAnswer)) {
answerList.set(position, "");
updateAnswer(answerList);
startDrag(llOption.getChildAt(getOptionPosition(oldAnswer)));
}
}
@Override
public void updateDrawState(TextPaint ds) {
// 不显示下划线
ds.setUnderlineText(false);
}
}
/**
* 更新答案
*
* @param answerList 答案列表
*/
public void updateAnswer(List answerList) {
// 重新初始化数据
setData(originContent, optionList, originAnswerRangeList);
// 重新填写已经存在的答案
if (answerList != null && !answerList.isEmpty()) {
for (int i = 0; i < answerList.size(); i++) {
String answer = answerList.get(i);
if (!TextUtils.isEmpty(answer)) {
fillAnswer(answer, i);
}
}
}
}
当触摸填空处时,如果此填空处已经填写了答案,则调用updateAnswer方法把当前填空处的答案清除,然后调用startDrag方法开始进行拖拽。
最后看下如何设置数据
public class MainActivity extends AppCompatActivity {
@BindView(R.id.dfbv_content)
DragFillBlankView dfbvContent;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
ButterKnife.bind(this);
initData();
}
private void initData() {
String content = "纷纷扬扬的________下了半尺多厚。天地间________的一片。我顺着________工地走了四十多公里," +
"只听见各种机器的吼声,可是看不见人影,也看不见工点。一进灵官峡,我就心里发慌。";
// 选项集合
List optionList = new ArrayList<>();
optionList.add("白茫茫");
optionList.add("雾蒙蒙");
optionList.add("铁路");
optionList.add("公路");
optionList.add("大雪");
// 答案范围集合
List rangeList = new ArrayList<>();
rangeList.add(new AnswerRange(5, 13));
rangeList.add(new AnswerRange(23, 31));
rangeList.add(new AnswerRange(38, 46));
dfbvContent.setData(content, optionList, rangeList);
}
}
4.写在最后
源码已托管到GitHub上,欢迎Fork,觉得还不错就Start一下吧!
GitHub传送门
欢迎同学们吐槽评论,如果你觉得本篇博客对你有用,那么就留个言或者点下喜欢吧(^-^)