想做一个功能,把纯搜索框做成支持编辑加添加标签,并可以横向滑动,当有标签后点击输入框整体展示成文字,返回再变成文字加标签样式。
这里提供几种方案,由EditText变成支持横向滑动可以有以下几种方案:
1.添加父容器HorizontalScrollView,内部嵌套EditText和LinearLayout,LinearLayout用来添加标签;
2.输入框采用RecyclerView方案,有2中type,第一种展示EditText,第二种展示标签;
这里我用了第一种方案实现,布局如下图
给HorizontalScrollView配置属性,去除装饰
android:fadeScrollbars="false"
android:overScrollMode="never"
android:scrollbars="none"
EditText宽度设置为wrap_content,LinearLayout有个右边距,因为每添加一个标签就要自动滑动到最后一项,预留margin可以展示出更好的效果,可以参考京东添加标签的效果。
功能1:添加标签后自动滑到最后一个元素
起初我是计算的宽度,求EditText的宽度和LinearLayout的宽度和,然后平滑滑动到指定的位置;
HorizontalScrollView.smoothScrollTo(EditText.getMeasureWidth()+ LinearLayout.getMeasureWidth(),0);
这里注意用的是smoothScrollTo,不是smoothScrollBy,前者是滑到指定位置,这样如果标签没超出视野,也不会滑出去。smoothScrollBy则是滑动一定的距离,ScrollTo则没有平滑的动画效果。如果要平滑的滑到起点则是HorizontalScrollView.smoothScrollTo(0,0);
后来我看到HorizontalScrollView提供了其他更好的API,如fullScroll(View.FOCUS_RIGHT),表示滑动最右边,View.FOCUS_LEFT则是滑到最左边,代码实现如下:
LinearLayout.post(new Runnable() {
@Override
public void run() {
HorizontalScrollView.fullScroll(View.FOCUS_RIGHT);
}
});
这里注意下是给LinearLayout加标签也就是addView,所以需要在LinearLayout绘制完成后执行HorizontalScrollView滑动操作,放到post里面。我们知道View的绘制不是立刻完成的,想要获取测量宽度,一般有这些方法:
A. 通过ViewTreeObserver监听View的全局变化事件(addOnGlobalLayoutListener或addOnPreDrawListener),但用完后要移除监听,避免后续每一次发生全局 View 变化均触发该事件,影响性能。
view.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
if (Build.VERSION.SDK_INT >= 16) {
view.getViewTreeObserver().removeOnGlobalLayoutListener(this);
}else {
view.getViewTreeObserver().removeGlobalOnLayoutListener(this);
}
int width = view.getWidth();
}
});
B. 利用Handler通信机制,添加一个Runnable到MessageQueue 中,当View layout处理完成时,自动发送消息,通知 UI 线程,巧妙获取View 的宽高属性。
view.post(new Runnable() {
@Override
public void run() {
int width = view.getWidth();
}
});
C. 在Activity的onWindowFocusChanged中获取宽高。
问题1:
EditText正常测量是没有问题,但是如果hint很长,那么EditText.getMeasureWidth()就会返回初始hint的值,即便后边输入了文字且内容很短,宽度还是那么长,这就会导致添加的标签距离文字很远。
我们期望的是这样的效果:
但hint很长的效果:
解决方法是手动去设置EditText的宽度,给EditText添加addTextChangedListener,在afterTextChanged里面动态设置宽度。
if (TextUtils.isEmpty(text)) {
ViewGroup.LayoutParams layoutParams = EditText.getLayoutParams();
layoutParams.width = LayoutParams.MATCH_PARENT;
EditText.setLayoutParams(layoutParams);
} else {
ViewGroup.LayoutParams layoutParams = EditText.getLayoutParams();
layoutParams.width = (int) EditText.getPaint().measureText(EditText.getText().toString());
EditText.setLayoutParams(layoutParams);
}
这里判断了如果没有hint就是充满父容器,有文字则按文字的宽度来设置,用了getPaint().measureText()来获得结果。起初我是自己根据EditText的长度乘以12来计算宽度,12是设置的sp值,文字缩放值设置成为1保证长度固定。
// 在Activity中设置,取消系统设置文字大小时对sp的影响
configContextConfiguration(this);
configContextConfiguration(getApplicationContext());
private void configContextConfiguration(Context context) {
try {
Resources resources = context.getResources();
Configuration configuration = resources.getConfiguration();
configuration.fontScale = 1f;
resources.updateConfiguration(configuration, resources.getDisplayMetrics());
} catch (Throwable ignored) {
}
}
但是这样很不准确,比如空格,数字占的宽度就和汉字不一样,中文符号和英文符号也不一样,由于自己计算的误差导致滑动的位置也不准确,后来看API用measureText就没有这些问题了。
问题2:
点击整个输入框都需要弹出键盘,但是现在EditText变小了,那么需要让LinearLayout来承接点击事件,来唤起键盘,模拟出EditText被点击的效果,把光标移到最后一位。
InputMethodManager mInputMgr = (InputMethodManager) mContext.getSystemService(Context.INPUT_METHOD_SERVICE);
LinearLayout.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
EditText.setFocusable(true);
EditText.setFocusableInTouchMode(true);
EditText.setCursorVisible(true);
EditText.requestFocus();
mInputMgr.showSoftInput(EditText, 0);
EditText.setSelection(mSearchEdit.getText().toString().length());
}
});
起初给HorizontalScrollView设置点击事件,但不生效,这里讲解的很详细。
HorizontalScrollView#onTouchEvent方法总结起来有两点:
1、代码中没有调用OnClickListener的地方,也没有调用super.onTouchEvent方法,所以说我们给HorizontalScrollView设置的OnClickListener都是无效的,不会被调用。
2、在看返回值,如果HorizontalScrollView没有子View,那么onTouchEvent就返回false,表示不消耗事件;其余情况都会返回true,表示消耗事件。
网友提供了一种解决方案,给HorizontalScrollView设置touch监听,放开手就弹出键盘,这个适合搭配一个全屏的EditText使用,我们希望技能编辑又能滑动,很显然这样改完之后如果滑动标签后也会弹出键盘,不是想要的效果。
scrollView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View view, MotionEvent motionEvent) {
//通知父控件请勿拦截本控件touch事件
view.getParent().requestDisallowInterceptTouchEvent(true);
switch (motionEvent.getAction()){
case MotionEvent.ACTION_UP:
//点击整个页面都会让内容框获得焦点,且弹出软键盘
content.setFocusable(true);
content.setFocusableInTouchMode(true);
content.requestFocus();
AddFlagActivity.this.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE);
break;
}
return false;
}
});
由于HorizontalScrollView有子View,并且HorizontalScrollView自身是match_parent,所以它的父容器无法接受到点击事件,只能让子LinearLayout来接受点击事件了。起初没有用到HorizontalScrollView的fillViewport属性,使得LinearLayout设置match_parent不生效,于是设置了一个最小宽度,保证可以接受到点击事件,但是这存在适配性问题,而且搜索框有变大变小的动画,当变小后,内容只显示了一行,但是存在右边的边距导致可以滑动,体验也不好,于是考虑在动画完成监听里手动给LinearLayout设置宽度,宽度为HorizontalScrollView的父容器的大小,但是设置不生效,其实和ScrollView下的LinearLayout的高度只能内容自适应是一样的。
我写了个demo,在setLayoutParams之后重新回调了onMeasure和onLayout,这里打印出来的高度都是内容的大小,而不是设置的值。
Log.e("sss", "1:" + mLinearLayout.getHeight());
ViewGroup.LayoutParams p = mLinearLayout.getLayoutParams();
p.height = 1000;
mLinearLayout.setLayoutParams(p);
mLinearLayout.post(new Runnable() {
@Override
public void run() {
Log.e("sss", "2:" + mLinearLayout.getHeight());
}
});
最后解决办法采用android:fillViewport = "true",直接看HorizontalScrollView的源码,当设置mFillViewport为true后会把子View填满。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (!mFillViewport) {
return;
}
final int widthMode = MeasureSpec.getMode(widthMeasureSpec);
if (widthMode == MeasureSpec.UNSPECIFIED) {
return;
}
if (getChildCount() > 0) {
final View child = getChildAt(0);
final int widthPadding;
final int heightPadding;
final FrameLayout.LayoutParams lp = (LayoutParams) child.getLayoutParams();
final int targetSdkVersion = getContext().getApplicationInfo().targetSdkVersion;
if (targetSdkVersion >= Build.VERSION_CODES.M) {
widthPadding = mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin;
heightPadding = mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin;
} else {
widthPadding = mPaddingLeft + mPaddingRight;
heightPadding = mPaddingTop + mPaddingBottom;
}
int desiredWidth = getMeasuredWidth() - widthPadding;
if (child.getMeasuredWidth() < desiredWidth) {
final int childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(
desiredWidth, MeasureSpec.EXACTLY);
final int childHeightMeasureSpec = getChildMeasureSpec(
heightMeasureSpec, heightPadding, lp.height);
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
}
}
这里有篇文章分析ScrollView的源码,包括布局和事件。到这里这个功能就算是实现了,右边的标签容器直接addView就可以了。TagView通过tag和数据绑定,这样取数据的时候遍历view,通过tag拿到数据。由于有margin,可以设置gone和visiable,删除标签的时候判断是否是最后一个,是的话隐藏容器。
if (mFilterContainer.getChildCount() < 1) {
mFilterContainer.setVisibility(View.GONE);
}
由于布局比较多,为了查看方便,可以给每个容器设置一个背景,或打开布局边界,来查看容器的大小,算是开发调试小技巧把。
上面列举了采用HorizontalScrollView方案面临的问题:
1.hint太长导致EditText变大,导致添加标签时无法紧跟文字;
2.EditText变小了,所以需要让父容器填满宽度并捕获事件;
如果采用RecyclerView来实现呢,也不复杂,首先更换布局为RecyclerView,并编写两个Item的xml,一个包含EditText,一个包含标签样式的TextView。
(1)多type样式:
通过getItemViewType根据位置返回不同的type,然后在onBindViewHolder的时候通过getItemViewType(position)获取到type,再返回对应的ViewHolder;
@Override
public int getItemViewType(int position) {
if (position == 0) {
return TYPE_EDIT;
} else {
return TYPE_TAG;
}
}
(2)添加一个标签:
这里操纵数据集List,add数据后只刷新单条数据即可,同时滑动到最后一条;
notifyItemInserted(List.size()-1);
recyclerView.smoothScrollToPosition(list.size() - 1);
(3)删除一个标签:
在ViewHolder里面执行;
mTextView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
int i = getAdapterPosition();
genData.remove(i);
notifyItemRemoved(i);
}
});
(4)点击其他空白处可以唤醒键盘,这里可以给RecyclerView添加一个父容器来接受事件,在Adapter中存下EditText和文字,然后唤醒键盘,把标签变成内容,同时移除标签。
public void onclick() {
mTextView.setFocusable(true);
mTextView.setFocusableInTouchMode(true);
mTextView.setCursorVisible(true);
mTextView.requestFocus();
mInputMgr.showSoftInput(mTextView, 0);
StringBuilder stringBuilder = new StringBuilder();
for (int i = genData.size() - 1; i >= 1; i--) {
stringBuilder.append(" ");
stringBuilder.append(((MultiData1) genData.get(i)).getStr1());
genData.remove(i);
}
mTextView.setText(mString + stringBuilder.toString());
mTextView.setSelection(mTextView.getText().length());
notifyDataSetChanged();
}