一、回顾
写在前面:
距离上次更新已经有将近两个月了,一是因为工作有些忙,二是本以为类似这种感觉像是追求 UI 的方向大家可能不太感兴趣...
但是经过一段时间之后发现上篇文章已获得四十多个小心心,啊这是催我更新呐。这次文章出来以后我会根据受关注情况来决定后面的坑是填或不填...
顺带一提,上文说要跟领导汇报那些优化点,没想到还没等到汇报需求就改了:把登录页直接砍掉全部用短信登录。-_-|| 但我还是选择按照自己的理解把登录页进行优化并分享出来,毕竟别的项目也有登录页啊。-
以上基本都是废话与技术无关,下面开始正式填坑,本文主要内容有:
- 登录页面优化,更加人性化;
- Material Design 相关控件,ToolBar 、TextInputLayout 、AutoCompleteTextView 的原理简析以及使用。
二、开始填坑
先看一下最终的效果:
那么我们对着上篇文章写的需求来填坑吧。
上篇文章传送门:
Android 项目优化笔记(一):概览
问题 1、2:输入框 获取焦点时更新颜色。登录按钮高度太低。
解决问题1: 针对第一条,只要使用 Theme.AppCompat
类型的主题并指定相应的颜色即可。
比如这里的项目主色调是橙色的,那么就指定 colorAccent 的颜色为橙色,登录页使用该主题就ok了:
解决问题2: 第二条的话只需要注意把布局写得紧凑一点,最好不要超过屏幕的一半就好。
问题 3. 用户输入账号或密码后添加小×图标,一键删除输入内容。
解决问题3: 针对这个问题,我采用了一种比较普遍的方式来处理:自定义 EditText 并在代码中设置 rightDrawable,然后根据用户点击的位置来确认是否点击了删除图标。
那么看核心代码实现:
ClearEditText # init()
3.1 首先在实例化的时候指定资源图片并添加监听,当文字变化后调用 setDrawable()
方法来设置右侧图标。
// 指定资源图片
mImgClear = context.getResources().getDrawable(R.drawable.ic_text_clear);
private void init() {
addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
//在内容改变完之后
@Override
public void afterTextChanged(Editable editable) {
setDrawable();
}
});
}
3.2 动态设置 rightDrawable 的方法,此方法可以设置上下左右的图标,并且适用于 TextView等。
//绘制删除图片
private void setDrawable(){
if (length() < 1){
// 分别设置左、上、右、下的图片
setCompoundDrawablesWithIntrinsicBounds(null, null, null, null);
} else {
setCompoundDrawablesWithIntrinsicBounds(null, null, mImgClear, null);
}
}
3.3 最后,重写 onTouchEvent()
并根据用户手指点击抬起位置来判断是否点击了删除图标。
//当触摸范围在右侧时,触发删除方法,隐藏叉叉
@Override
public boolean onTouchEvent(MotionEvent event) {
if(mImgClear != null && event.getAction() == MotionEvent.ACTION_UP){
int eventX = (int) event.getRawX();
int eventY = (int) event.getRawY();
Rect rect = new Rect();
// 获取相对屏幕的可视区域
getGlobalVisibleRect(rect);
rect.left = rect.right - 100;
// 如果触摸的点在可视区域右侧 100 以内,视为点击了清除图标
if (rect.contains(eventX, eventY)){
getText().clear();
}
}
return super.onTouchEvent(event);
}
问题 4. 所有可操作按钮 添加响应。
这个是我一直吐槽我们项目的一个点:没有交互响应。讲道理都什么年代了,整个app连个点击效果都没有。况且 Google 都给咱们设计好了好看的水波反馈效果,求求你们用一下吧 Orz。
那么对照之前的登录页,我们能看到的需要交互响应的无非就是输入框和按钮,输入框已经搞定接下来就处理 Button 吧。
解决问题4:那么直接上代码
4.1 创建按钮背景 selector,分别设置两个状态:当用户没有输入合法数据时按钮为不可用状态,颜色要有区分。
4.2 不可用状态的背景颜色应该是较浅的,以提升用户该按钮不可点击。下面是 android:state_enabled="false" 的背景代码:
bg_shape_orange_200
只是简单设置了一个填充色 solid 和圆角 corners。
4.3 接下来看按钮可用状态的背景,它的颜色应该比较深。为了适配低版本,分别往 drawable 和 drawable-v21 包下放置了背景。
- 注意这个是 drawable 包下的,适用于 API 21 5.0 以下系统:
drawable/selector_ripple_orange
上面的代码在按钮被按下只变成更深的 orange_700 色。
- 下面是适用于 API 21 5.0 以上的水波效果背景
drawable-v21/selector_ripple_orange
-
drawable-v21 文件夹用来适配 API21、5.0 以上系统,系统会根据设备版本加载相应包下的资源。那么以上代码有一些标签需要解释下:
ripple:水波效果的标签,支持 5.0 以上;
android:color="@color/bg_orange_700":设置被点击以后的颜色,较深;
item 中的 shape :设置了 solid 表示未点击状态的颜色,corners 圆角弧度。
那么上面代码的结果就是可用且未点击的颜色是 bg_orange_300,点击后颜色为 bg_orange_700,松手后展示水波纹扩展效果。
以上用到的色值,在调色板里也有,这里贴出来:
#ffcc80
#ffb74d
#f57c00
问题 5. 打开登录页 自动弹出键盘,并锁定账号输入框。
解决问题5:
- 关于这个问题,只需要在
中将登陆页面的输入法模式设置为 android:windowSoftInputMode="adjustResize"
即可。windowSoftInputMode 还有很多属性,在此不作赘述。当软键盘弹出后,可以使用 View 的requestFocus()
方法来获取焦点。
- 除了上面那种方法,还有一种通过代码来设置键盘的显示。其实这两种方式都是通过设置 Activity 的 SoftInputMode 来实现的,使用代码设置相对灵活一点罢了。
/**
* EditText获取焦点并显示软键盘
*/
public static void showSoftInputFromWindow(Activity activity, EditText editText) {
editText.setFocusable(true);
editText.setFocusableInTouchMode(true);
editText.requestFocus();
activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE);
}
问题 6. 可用 MD 控件。
说明问题6:
6.1 顶部控件 Toolbar
先说顶部的 bar,这里推荐使用 Toolbar,毕竟是 Google 推荐替代 ActionBar 的 View。那么这里提供一种把 Toolbar 简单封装到 BaseActivity 的一种写法,仅供参考。如果你有更好的写法可以分享出来。
- step1:创建基础布局,里面添加 Toolbar 和 容器 FrameLayout。
Tips:Toolbar 里面可以放很多东西,可以自己去定义。比如套上布局放 EditText + 搜索按钮 就是一个搜索功能。
- step2:加载基础布局,首先通过子 Activity 必须重写的
setContentView()
方法拿到其布局 id,然后通过 LayoutInflater 把子 Activity 的布局添加进容器。
setContentView(R.layout.activity_base);
// 加载内容布局到容器布局
FrameLayout fl_container = findViewById(R.id.fl_container);
LayoutInflater.from(this).inflate(setContentView(), fl_container);
protected abstract int setContentView();
- step3:接下来就可以拿到 Toolbar 了,有了它就可以进行设置标题、返回按钮、设置菜单等骚操作了。
private void initToolbar(){
mToolbar = findViewById(R.id.toolbar);
mToolbarTitle = findViewById(R.id.tv_title);
if (null != mToolbar) {
// 如果没用到 ActionBar 的话,不需要这句代码
// 设置会导致 Toolbar 的部分功能失效,比如菜单栏
// setSupportActionBar(mToolbar);
setBackIcon();
}
if (null != mToolbarTitle) {
//getTitle()的值是activity的android:lable属性值
mToolbarTitle.setText(getTitle());
if(null != getSupportActionBar()){
//设置默认的标题不显示
getSupportActionBar().setDisplayShowTitleEnabled(false);
}
}
}
那么更多的具体功能以及实现可以到源码链接查看。
6.2 输入控件父布局:TextInputLayout
TextInputLayout 是一个自定义的 LinerLayout,大致原理是内部可嵌套 EditText,当 EditText 文字变化时再进行动画把提示文字滑上去或滑下来。
- TextInputLayout 原理简析
TextInputLayout # setEditText
该方法是给 TextInputLayout 设置 EditText 并添加文字变化监听,当文字变化时进行 Hint 变化动画。以下是源码片段,很好理解。
private void setEditText(EditText editText) {
// 不能重复添加 EditText
if (mEditText != null) {
throw new IllegalArgumentException("We already have an EditText, can only have one");
}
if (!(editText instanceof TextInputEditText)) {
Log.i(LOG_TAG, "EditText added is not a TextInputEditText. Please switch to using that"
+ " class instead.");
}
mEditText = editText;
...
// 添加监听,当文字改变时进行动画
mEditText.addTextChangedListener(new TextWatcher() {
@Override
public void afterTextChanged(Editable s) {
updateLabelState(!mRestoringSavedState);
if (mCounterEnabled) {
updateCounter(s.length());
}
}
...
});
...
}
TextInputLayout # updateLabelState
下面的方法是根据状态进行动画,当获取焦点时折叠 Hint 文字,反之展开。
void updateLabelState(boolean animate) {
updateLabelState(animate, false);
}
void updateLabelState(final boolean animate, final boolean force) {
...
if (hasText || (isEnabled() && (isFocused || isErrorShowing))) {
// We should be showing the label so do so if it isn't already
if (force || mHintExpanded) {
// 动画折叠 Hint
collapseHint(animate);
}
} else {
// We should not be showing the label so hide it
if (force || !mHintExpanded) {
// 展开 Hint
expandHint(animate);
}
}
}
TextInputLayout # collapseHint expandHint
展开或折叠的动画,动画执行的方法分别是 animateToExpansionFraction
和 animateToExpansionFraction
,感兴趣的可以去源码查看。
private void collapseHint(boolean animate) {
if (mAnimator != null && mAnimator.isRunning()) {
mAnimator.cancel();
}
if (animate && mHintAnimationEnabled) {
// 执行动画
animateToExpansionFraction(1f);
} else {
mCollapsingTextHelper.setExpansionFraction(1f);
}
mHintExpanded = false;
}
private void expandHint(boolean animate) {
if (mAnimator != null && mAnimator.isRunning()) {
mAnimator.cancel();
}
if (animate && mHintAnimationEnabled) {
// 执行动画
animateToExpansionFraction(0f);
} else {
mCollapsingTextHelper.setExpansionFraction(0f);
}
mHintExpanded = true;
}
TextInputLayout 除了 Hint 动画外还有很多功能:比如 弹出错误提示、展示文本框输入文字数量,同时也可以设置当 EditText 输入文字超过限制后改变字体颜色以更好的提示用户等功能。
- TextInputLayout 使用
TextInputLayout 作为一个 ViewGroup,需要嵌套 EditText 及其子类使用。且 Google 推荐嵌套 TextInputEditText,本文因为要实现自动补全所以嵌套 AutoCompleteTextView。
说明一下上面有的几个属性:
app:counterEnabled:是否展示输入超标提示
app:counterMaxLength:设置超过多少个字符展示提示
app:counterOverflowTextAppearance:设置字符超标的提示主题,字体大小颜色等
6.3 自动补全控件 AutoCompleteTextView
AutoCompleteTextView 顾名思义是一个自动补全的 View,内部维护了一个 ListPopupWindow。当文字变化时通过 Filter 从数据源循环匹配与之相关的字段并返回。
- AutoCompleteTextView 原理简析
AutoCompleteTextView # addTextChangedListener
// 添加监听
addTextChangedListener(new MyWatcher());
// 内部维护 TextWatcher
private class MyWatcher implements TextWatcher {
public void afterTextChanged(Editable s) {
doAfterTextChanged();
}
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
doBeforeTextChanged();
}
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
}
void doAfterTextChanged() {
...
// 判断能够触发Filter条件
if (enoughToFilter()) {
if (mFilter != null) {
mPopupCanBeUpdated = true;
// 执行 Filter 功能
performFiltering(getText(), mLastKeyCode);
}
}
...
}
protected void performFiltering(CharSequence text, int keyCode) {
mFilter.filter(text, this);
}
那么这个 mFilter 是哪里来的呢?mFilter.filter(text, this) 又做了什么呢?那么一起来看一下:
AutoCompleteTextView # setAdapter
public void setAdapter(T adapter) {
...
if (mAdapter != null) {
//noinspection unchecked
mFilter = ((Filterable) mAdapter).getFilter();
adapter.registerDataSetObserver(mObserver);
} else {
mFilter = null;
}
mPopup.setAdapter(mAdapter);
}
原来是在设置 adapter 的时候实例化的 Filter,且这个 Filter 是 adapter 里面的 Filter。那么就去 adapter 里面找找看。
LoginActivity # addEmailsToAutoComplete
AutoCompleteTextView 里面的 ListPopupWindow 需要设置数据源以及 adapter,看下面的方法,暂时不要管数据从哪里来。
/**
* 用来给 AutoCompleteTextView 设置数据源
* @param emailAddressCollection 数据列表
*/
private void addDatasToAutoComplete(List emailAddressCollection) {
ArrayAdapter adapter =
new ArrayAdapter<>(LoginActivity.this,
android.R.layout.simple_dropdown_item_1line, emailAddressCollection);
mPhone.setAdapter(adapter);
}
那么回到刚才的 Filter 问题,可以看到上面 set 的 adapter 是一个 ArrayAdapter,那么就去看看它的 Filter 是个什么实现:
ArrayAdapter # ArrayFilter
ArrayAdapter implements Filterable 重写 getFilter()
方法,创建 ArrayFilter:
@Override
public @NonNull Filter getFilter() {
if (mFilter == null) {
mFilter = new ArrayFilter();
}
return mFilter;
}
private class ArrayFilter extends Filter {
@Override
protected FilterResults performFiltering(CharSequence prefix) {
final FilterResults results = new FilterResults();
...
final String prefixString = prefix.toString().toLowerCase();
final ArrayList values;
synchronized (mLock) {
values = new ArrayList<>(mOriginalValues);
}
final int count = values.size();
final ArrayList newValues = new ArrayList<>();
// 遍历数据源的数据,也就是 adapter 维护的数据
for (int i = 0; i < count; i++) {
final T value = values.get(i);
final String valueText = value.toString().toLowerCase();
// 如果数据源的数据 startsWith 传来的数据,那么就把改数据放到一个新的集合里返回
if (valueText.startsWith(prefixString)) {
newValues.add(value);
} else {
final String[] words = valueText.split(" ");
for (String word : words) {
if (word.startsWith(prefixString)) {
newValues.add(value);
break;
}
}
}
}
results.values = newValues;
results.count = newValues.size();
}
return results;
}
额,这个注释怎么显示是斜体啊,根本看不清啊...算了,看我解释吧:
- step1: 首先传过来的参数 CharSequence prefix,就是用户输入的字符。转换成小写为 prefixString ,用它来跟 adapter 里面的数据进行匹配。
- step2: values 里面放的是 mOriginalValues(adapter 里面的数据),接着进行遍历。
- step3: 如果 values 里面是数据转换成小写以后,startsWith 输入的 prefixString,就把这个数据放到一个新的容器 newValues 里面,最后进行返回。
用新的数据再去给 adapter 设置,这样就实现了 Filter 过滤匹配字段的功能。
总结:其实 AutoCompleteTextView 的 setAdapter()
方法是给其内部的 ListPopupWindow 设置 adapter,这样数据有了就可以展示列表了。当用户输入字符后,调用 adapter 的 Filter 对象进行循环对比,如果存在起始字符相同的数据就作为一个新的列表返回并展示。这样就完成了整个过程。
- AutoCompleteTextView 使用
了解 AutoCompleteTextView 的原理后再来看是怎么用的:
- 画布局,布局文件使用 TextInputLayout + AutoCompleteTextView:
- 准备数据源,也就是给 AutoCompleteTextView 设置数据,好让它展示 popList。看下面的例子:
/**
* CursorLoader加载完成回调
* @param cursorLoader
* @param cursor
*/
@Override
public void onLoadFinished(Loader cursorLoader, Cursor cursor) {
List emails = new ArrayList<>();
cursor.moveToFirst();
while (!cursor.isAfterLast()) {
// 获取备注名
String name = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME));
// 获取电话号码
String phone = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER));
emails.add(phone);
cursor.moveToNext();
}
addDatasToAutoComplete(emails);
}
onLoadFinished()
方法是使用 CursorLoader 加载数据的回调,CursorLoader 相关的东西这里不聊了。本例中是使用 CursorLoader 加载联系人的手机号作为 AutoCompleteTextView 的数据源,你可以在初始化的时候加载其它任何地方的数据作为数据源,比如加载存在本地的登录过的账号信息列表。
- 创建 adapter。上面最后的
addDatasToAutoComplete()
方法用的是自带的 ArrayAdapter。你可以自己创建 adapter 以便维护,然后调用你的 AutoCompleteTextView 的setAdapter()
方法完成数据绑定。
/**
* 用来给 AutoCompleteTextView 设置数据源
* @param emailAddressCollection 数据列表
*/
private void addDatasToAutoComplete(List emailAddressCollection) {
ArrayAdapter adapter =
new ArrayAdapter<>(LoginActivity.this,
android.R.layout.simple_dropdown_item_1line, emailAddressCollection);
// AutoCompleteTextView 对象,手机号输入框
mPhone.setAdapter(adapter);
}
- 这样在输入的时候就可以用啦,AutoCompleteTextView 默认是输入两个字符再去查询,通过
setThreshold(int threshold)
方法来设置输入几个字符进行查询提示。比如我想要输入 1 个字符就弹出提示:
mPhone.setThreshold(1);
-
setError("错误提示")
方法可以设置弹出的错误提示,因为提示感叹号图标的位置在最右侧与一键情况图标冲突,所以我用的时候把相关代码注掉了。可以通过相关方法去更改感叹号的位置,具体是什么方法可以自己去找。
三、结语
本文就此结束,写博客的过程中也一直在思考:应该偏向原理多一点还是使用多一点?看到这里的同学可以留言或者发简信与我联系进行反馈,如果喜欢了解原理就多分析一些源码,注重实用就多分享些用法,感谢。