Android 项目优化笔记(二):登录页

一、回顾

写在前面:

  • 距离上次更新已经有将近两个月了,一是因为工作有些忙,二是本以为类似这种感觉像是追求 UI 的方向大家可能不太感兴趣…
    但是经过一段时间之后发现上篇文章已获得四十多个小心心,啊这是催我更新呐。这次文章出来以后我会根据受关注情况来决定后面的坑是填或不填…
    顺带一提,上文说要跟领导汇报那些优化点,没想到还没等到汇报需求就改了:把登录页直接砍掉全部用短信登录。-_-|| 但我还是选择按照自己的理解把登录页进行优化并分享出来,毕竟别的项目也有登录页啊。

  • 以上基本都是废话与技术无关,下面开始正式填坑,本文主要内容有:

    • 登录页面优化,更加人性化;
    • Material Design 相关控件,ToolBar 、TextInputLayout 、AutoCompleteTextView 的原理简析以及使用。

二、开始填坑

先看一下最终的效果:
效果总览.gif

那么我们对着上篇文章写的需求来填坑吧。

上篇文章传送门:

Android 项目优化笔记(一):概览

问题 1、2:输入框 **获取焦点时更新颜色。**登录按钮高度太低。

解决问题1: 针对第一条,只要使用 Theme.AppCompat 类型的主题并指定相应的颜色即可。

比如这里的项目主色调是橙色的,那么就指定 colorAccent 的颜色为橙色,登录页使用该主题就ok了:

    <style name="DarkActionBarTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <!-- 导航栏颜色 -->
        <item name="colorPrimary">@color/bg_orange</item>
        <!-- 通知栏颜色 5.0 以后有效-->
        <item name="colorPrimaryDark">@color/bg_orange</item>
        <!-- 控件选中后颜色 -->
        <item name="colorAccent">@color/bg_orange</item>
    </style>

解决问题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,分别设置两个状态:当用户没有输入合法数据时按钮为不可用状态,颜色要有区分。
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@drawable/bg_shape_orange_200" android:state_enabled="false"/>
    <item android:drawable="@drawable/selector_ripple_orange" android:state_enabled="true"/>
</selector>
4.2 不可用状态的背景颜色应该是较浅的,以提升用户该按钮不可点击。下面是 android:state_enabled=“false” 的背景代码:

bg_shape_orange_200

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="@color/bg_orange_200"/>
    <corners android:radius="3dp"/>
</shape>

只是简单设置了一个填充色 solid 和圆角 corners。

4.3 接下来看按钮可用状态的背景,它的颜色应该比较深。为了适配低版本,分别往 drawable 和 drawable-v21 包下放置了背景。
  • 注意这个是 drawable 包下的,适用于 API 21 5.0 以下系统:

drawable/selector_ripple_orange

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@drawable/bg_shape_orange_200" android:state_enabled="false"/>
    <item android:drawable="@drawable/bg_shape_orange_700" android:state_enabled="true"/>
</selector>

上面的代码在按钮被按下只变成更深的 orange_700 色。

  • 下面是适用于 API 21 5.0 以上的水波效果背景

drawable-v21/selector_ripple_orange

<ripple xmlns:android="http://schemas.android.com/apk/res/android"
     android:color="@color/bg_orange_700">  <!--被点击以后的颜色-->
    <item>
        <shape xmlns:android="http://schemas.android.com/apk/res/android">
            <!--默认填充色-->
            <solid android:color="@color/bg_orange_300" />
            <!--圆角弧度-->
            <corners android:radius="3dp" />
        </shape>
    </item>
</ripple>

drawable-v21 文件夹用来适配 API21、5.0 以上系统,系统会根据设备版本加载相应包下的资源。那么以上代码有一些标签需要解释下:

**ripple:**水波效果的标签,支持 5.0 以上;
**android:color="@color/bg_orange_700"😗*设置被点击以后的颜色,较深;
**item 中的 shape 😗*设置了 solid 表示未点击状态的颜色,corners 圆角弧度。

那么上面代码的结果就是可用且未点击的颜色是 bg_orange_300,点击后颜色为 bg_orange_700,松手后展示水波纹扩展效果。

以上用到的色值,在调色板里也有,这里贴出来:

     <!--颜色由浅到深-->
    <color name="bg_orange_200">#ffcc80</color>
    <color name="bg_orange_300">#ffb74d</color>
    <color name="bg_orange_700">#f57c00</color>
问题 5. 打开登录页 自动弹出键盘,并锁定账号输入框。

解决问题5:

  • 关于这个问题,只需要在 <manifest 中将登陆页面的输入法模式设置为 android:windowSoftInputMode="adjustResize"即可。windowSoftInputMode 还有很多属性,在此不作赘述。当软键盘弹出后,可以使用 View 的 requestFocus() 方法来获取焦点。
<activity
    android:name=".ui.LoginActivity"
    android:windowSoftInputMode="adjustResize"
    android:label="@string/title_activity_login"
    android:theme="@style/DarkActionBarTheme">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>
  • 除了上面那种方法,还有一种通过代码来设置键盘的显示。其实这两种方式都是通过设置 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。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical">

    <!--Toolbar-->
    <android.support.v7.widget.Toolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        android:background="?attr/colorPrimary"
        android:theme="@style/ThemeOverlay.AppCompat.Dark"
        app:popupTheme="@style/ThemeOverlay.AppCompat.Light">

        <TextView
            android:id="@+id/tv_title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            style="@style/TextAppearance.AppCompat.Widget.ActionBar.Title"
            android:textColor="#ffffff"
            android:layout_gravity="center"/>

    </android.support.v7.widget.Toolbar>

    <!-- Content -->
    <FrameLayout
        android:id="@+id/fl_container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</LinearLayout>

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

展开或折叠的动画,动画执行的方法分别是 animateToExpansionFractionanimateToExpansionFraction,感兴趣的可以去源码查看。

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。
<android.support.design.widget.TextInputLayout
    android:orientation="horizontal"
    app:counterEnabled="true"
    app:counterMaxLength="11"
    app:counterOverflowTextAppearance="@style/HintError"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <AutoCompleteTextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</android.support.design.widget.TextInputLayout>
<style name="HintError" parent="TextAppearance.AppCompat">
    <item name="android:textColor">@color/md_red_a700</item>
</style>

说明一下上面有的几个属性:

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 <T extends ListAdapter & Filterable> 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<String> emailAddressCollection) {
    ArrayAdapter<String> 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<T> values;
        synchronized (mLock) {
            values = new ArrayList<>(mOriginalValues);
        }

        final int count = values.size();
        final ArrayList<T> 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 的原理后再来看是怎么用的:

  1. 画布局,布局文件使用 TextInputLayout + AutoCompleteTextView:
<android.support.design.widget.TextInputLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <AutoCompleteTextView 
        android:id="@+id/atv_phone"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="@string/prompt_email"
        android:inputType="phone"
        android:maxLines="1"
        android:singleLine="true" />

</android.support.design.widget.TextInputLayout>
  1. 准备数据源,也就是给 AutoCompleteTextView 设置数据,好让它展示 popList。看下面的例子:
/**
 * CursorLoader加载完成回调
 * @param cursorLoader
 * @param cursor
 */
@Override
public void onLoadFinished(Loader<Cursor> cursorLoader, Cursor cursor) {
    List<String> 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 的数据源,你可以在初始化的时候加载其它任何地方的数据作为数据源,比如加载存在本地的登录过的账号信息列表。

  1. 创建 adapter。上面最后的 addDatasToAutoComplete() 方法用的是自带的 ArrayAdapter。你可以自己创建 adapter 以便维护,然后调用你的 AutoCompleteTextView 的 setAdapter() 方法完成数据绑定。
/**
 * 用来给 AutoCompleteTextView 设置数据源
 * @param emailAddressCollection 数据列表
 */
private void addDatasToAutoComplete(List<String> emailAddressCollection) {
    ArrayAdapter<String> adapter =
            new ArrayAdapter<>(LoginActivity.this,
                    android.R.layout.simple_dropdown_item_1line, emailAddressCollection);
    // AutoCompleteTextView 对象,手机号输入框
    mPhone.setAdapter(adapter);
}
  1. 这样在输入的时候就可以用啦,AutoCompleteTextView 默认是输入两个字符再去查询,通过 setThreshold(int threshold) 方法来设置输入几个字符进行查询提示。比如我想要输入 1 个字符就弹出提示:
mPhone.setThreshold(1);
  1. setError("错误提示") 方法可以设置弹出的错误提示,因为提示感叹号图标的位置在最右侧与一键情况图标冲突,所以我用的时候把相关代码注掉了。可以通过相关方法去更改感叹号的位置,具体是什么方法可以自己去找。

三、结语

本文就此结束,写博客的过程中也一直在思考:应该偏向原理多一点还是使用多一点?看到这里的同学可以留言或者发简信与我联系进行反馈,如果喜欢了解原理就多分析一些源码,注重实用就多分享些用法,感谢。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值