安卓开发内容编辑不同于H5,即便是编辑多行文本,文本块,也只能使用 EditText,这也是我们使用的最多的控件之一,今天就来分析一下,EditText该怎样使用。

在开始之前,先查看一下这个View的继承方式,通过继承方式我们可以看出很多内容来。

在此先进行一下说明,AppCompatTextView 是迎合 Meterial Design进行一些外观的处理,实际功能并没有任何变化。

一、基础部分

就平时而言,EditText 最多的是改一些文字,颜色,大小,通过 xml 几乎可以完成大部分的功能,常用的属性值在此列出:

<EditText
    xmlns:android="http://schemas.android/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:cursorVisible="true"
    android:ellipsize="marquee"
    android:hint="hint文字"
    android:imeOptions="actionNext"
    android:inputType="numberDecimal"
    android:marqueeRepeatLimit="marquee_forever"
    android:maxLength="16"
    android:singleLine="true"
    android:text="文字"
    android:includeFontPadding="false"
    android:digits="0123456789."
    android:textAllCaps="false"
    android:textAppearance="@style/TextAppearance.AppCompat"
    android:textColor="@color/main_color_white"
    android:textColorHint="@color/toolbar_hint_color_primary"
    android:textCursorDrawable="@drawable/shape_cursor_2dp_white"
    android:textIsSelectable="true"
    android:textSize="@dimen/text_size_normal_14sp"
    android:textStyle="bold">
</EditText>

基本的属性就这些,根据名称就可以看出属性代表的功能,这里就几个不常见的另做说明:

  • cursorVisible 光标显示控制
  • includeFontPadding是否显示上下标,关于文本上下标的问题,可以参考博客:Android开发&TextView设定精确间隔
  • digits显示文本框中只能输入指定的一些字符
  • textIsSelectable文本内容是否可以被选中

从上面的继承关系中,应该能想到,基本上TextView 有的功能,EditText 也拥有,因此对于一些 drawable***适用于 TextView 的属性,同样可用于 EditText

二、焦点控制

EditText 相比 TextView ,最大的改变是可人为输入内容,安卓系统自诞生到现在,几乎使用的都是虚拟键盘,这就引出了焦点的概念。

简单的理解,焦点就是当前界面中,跟用户做交互的控件,用户要输入内容,EditText 就需要先获取到焦点,然后弹出输入法,键盘中输入内容,EditText 可以动态显示。

焦点可以丢失,也可以获得,如果我们需要控制某个 EditText 获取焦点让用户输入信息,就需要动态使用代码来控制:

mEt.findFocus();
mEt.requestFocus()
mEt.clearFocus()

更多时候,我们还需要控制键盘主动弹出或者消失,这里推荐一个比较好的工具类:AndroidUtilCode

然后找到键盘相关的方法进行调用即可:

二、动态切换明文与密文

在很多场景(比如 显示银行卡余额 ,或者 输入密码 )下,我们需要对输入或者显示的文字动态密文或者明文,是否显示密文需要用户来控制,这时候,就需要使用如下操作来完成:

//显示
mEtPassword.setTransformationMethod(new HideReturnsTransformationMethod());

//隐藏
mEtPassword.setTransformationMethod( new PasswordTransformationMethod());

三、控制输入文本样式

在最前面,提到了digits属性,可以来规范输入框中输入的内容,如果输入范围较小,当然可以使用这种方式一一列出,如果限定的范围比较大 ,那么列出所有的可能就不太合理了,因此需要使用其他的方式。

翻阅前面的属性,还有一个我们没做解释:inputType,这个属性用来规范输入的内容类别,官方解释是这样的:

大致意思是说:

则用来帮助用户规范如何来输入内容;可以选择单个值,也可以选择多个值,中间以| 符号间隔;如果指定了非 none 外的其他值,则暗示这个文本框为:editable

inputType 可选下列的值:

ConstantValueDescription
date4For entering a date. Corresponds to InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE.
datetime4For entering a date and time. Corresponds to InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL.
none0There is no content type. The text is not editable.
number2A numeric only field. Corresponds to InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_NORMAL.
numberDecimal2Can be combined with number and its other options to allow a decimal (fractional) number. Corresponds to InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL.
numberPassword2A numeric password field. Corresponds to InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD.
numberSigned2Can be combined with number and its other options to allow a signed number. Corresponds to InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED.
phone3For entering a phone number. Corresponds to InputType.TYPE_CLASS_PHONE.
text1Just plain old text. Corresponds to InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_NORMAL.
textAutoComplete1Can be combined with text and its variations to specify that this field will be doing its own auto-completion and talking with the input method appropriately. Corresponds to InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE.
textAutoCorrect1Can be combined with text and its variations to request auto-correction of text being input. Corresponds to InputType.TYPE_TEXT_FLAG_AUTO_CORRECT.
textCapCharacters1Can be combined with text and its variations to request capitalization of all characters. Corresponds to InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS.
textCapSentences1Can be combined with text and its variations to request capitalization of the first character of every sentence. Corresponds to InputType.TYPE_TEXT_FLAG_CAP_SENTENCES.
textCapWords1Can be combined with text and its variations to request capitalization of the first character of every word. Corresponds to InputType.TYPE_TEXT_FLAG_CAP_WORDS.
textEmailAddress1Text that will be used as an e-mail address. Corresponds to InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS.
textEmailSubject1Text that is being supplied as the subject of an e-mail. Corresponds to InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_SUBJECT.
textFilter b1Text that is filtering some other data. Corresponds to InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_FILTER.
textImeMultiLine1Can be combined with text and its variations to indicate that though the regular text view should not be multiple lines, the IME should provide multiple lines if it can. Corresponds to InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE.
textLongMessage1Text that is the content of a long message. Corresponds to InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_LONG_MESSAGE.
textMultiLine1Can be combined with text and its variations to allow multiple lines of text in the field. If this flag is not set, the text field will be constrained to a single line. Corresponds to InputType.TYPE_TEXT_FLAG_MULTI_LINE.
textNoSuggestions1Can be combined with text and its variations to indicate that the IME should not show any dictionary-based word suggestions. Corresponds to InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS.
textPassword1Text that is a password. Corresponds to InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD.
textPersonName1Text that is the name of a person. Corresponds to InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PERSON_NAME.
textPhonetic c1Text that is for phonetic pronunciation, such as a phonetic name field in a contact entry. Corresponds to InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PHONETIC.
textPostalAddress1Text that is being supplied as a postal mailing address. Corresponds to InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_POSTAL_ADDRESS.
textShortMessage1Text that is the content of a short message. Corresponds to InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE.
textUri1Text that will be used as a URI. Corresponds to InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI.
textVisiblePassword1Text that is a password that should be visible. Corresponds to InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD.
textWebEditText a1Text that is being supplied as text in a web form. Corresponds to InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT.
textWebEmailAddress d1Text that will be used as an e-mail address on a web form. Corresponds to InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS.
textWebPassword e1Text that will be used as a password on a web form. Corresponds to InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD.
time4For entering a time. Corresponds to InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME.

根据 inputType 可以规范输入内容类型,实现预期的效果。

四、添加Drawable或使用Span

TextView 或者 说 EditText 拥有一个强大的功能:

  • drawableStart
  • drawableEnd
  • drawableTop
  • drawableBottom

根据设计图(PS效果图)进行开发时,经常会遇到这种情况:

很简单的,我们一般会把左侧的图标当做drawableStart的属性值,然后添加drawablePadding控制间距。自是不必多说,但如果是这种情况呢?

放置两个TextView当然可以,不过若是布局嵌套很深,或者是使用库中的UI进行布局的话,恐怕就不是很方便了。

通常情况下,可以使用Span来进行处理:

//设置高亮色为透明
this.highlightColor = dispatchGetColor(android.R.color.transparent)

//设置部分可点击
this.movementMethod = LinkMovementMethod()

SpannableString("重新获取").run {
  setSpan(object : ClickableSpan() {
        override fun onClick(widget: View) {
            route(ARouterConst.Activity_ServiceAgreementActivity).empty(comment = "跳转到协议界面")
        }

        override fun updateDrawState(ds: TextPaint) {
            ds.color = dispatchGetColor(R.color.red_ff414c)
        }
    }, 0, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
    SpannableStringBuilder("没有收到邮件?").append(this)
}

这样一来,在修改颜色的同时,还给颜色部分添加了点击事件。

通常我们使用的Span只有寥寥数种:详见:Android中各种Span的用法

BackgroundColorSpan:给部分文字设置背景颜色
ForegroundColorSpan:给部分文字设置前景色
ClickableSpan:设置点击事件
URLSpan:设置链接,相当于Html的标签
MaskFilterSpan:文字的装饰效果。分为两种:BlurMaskFilter(模糊效果) 和 EmbossMaskFilter (浮雕效果)
AbsoluteSizeSpan:设置字体大小
RelativeSizeSpan:设置字体的相对大小
ImageSpan:设置图片
ScaleXSpan:横向压缩
SubscriptSpan:设置脚注
SuperscriptSpan:上标,相当于数学中的平方样式
TextAppearanceSpan:使用style来定义文本样式
TypefaceSpan:设置字体
RasterizerSpan:设置光栅字样
StrikethroughSpan:删除线,相当于购物网站上的划掉的原价
UnderlineSpan:下划线。


再来考虑一种情况,我们使用的Toolbar控件左上角一般为返回图标:

在基类中,都会处理按钮的点击事件,但设计时不可避免的会碰到这种情况:

Toolbar返回键图标设置方式如下:

public void setNavigationIcon(@Nullable Drawable icon) {
   //...
}

或者

public void setNavigationIcon(@DrawableRes int resId) {
   //...
}

让UI重新切图,当然可以,不过只是为了这一个功能,就需要切多套图片,是在有些麻烦,这时候,就可以使用 TextDrawable 了,代码逻辑类似如下:

/**
 * 设置toolbar的navigation为text
 */
protected final void setToolbarNavText(@NotNull Toolbar t, @Nullable String text, @ColorRes int color) {
    t.post(() -> {
        ColorTextDrawable textDrawable = new ColorTextDrawable(getBaseContext())
                .setText(TextUtils.isEmpty(text) ? getString(R.string.function_close) : text)
                .setColor(dispatchGetColor(color))
                .setTextSize(getResources().getDimensionPixelSize(R.dimen.text_size_title_15sp))
                .setDefaultBounds();
        t.setNavigationIcon(textDrawable);
    });
}

详细代码可参考:携带 label 的 text 文本 => 使用 ColorTextDrawable 设定label

五、ClearText

文本内容较多时,需要添加删除图标,这样比较方便:

public class ClearEditText extends AppCompatEditText {
    /**
     * 记录 横坐标
     */
    private float touchX;

    /**
     * 左侧点击事件重载
     * 右侧点击事件重载
     */
    private BasePresenter.HttpCallback<Editable> clickLeftCallback;
    private BasePresenter.HttpCallback<Editable> clickRightCallback;

    {
        //设置默认的清除标识
        int dimen24 = getResources().getDimensionPixelSize(R.dimen.smallest_view_height_24dp);

        Drawable[] compoundDrawables = getCompoundDrawables();
        Drawable drawable = getContext().getDrawable(R.drawable.selector_clear_text);
        drawable.setBounds(0, 0, Math.min(drawable.getMinimumWidth(), dimen24), Math.min(drawable.getMinimumHeight(), dimen24));
        setCompoundDrawables(compoundDrawables[0], compoundDrawables[1], compoundDrawables[2] != null ? compoundDrawables[2] : drawable, compoundDrawables[3]);
    }

    public ClearEditText(Context context) {
        super(context);
    }

    public ClearEditText(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public ClearEditText(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @SuppressLint("ClickableViewAccessibility")
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                touchX = event.getX();
                break;
            case MotionEvent.ACTION_UP://如果点击的位置在右侧,并且手指抬起时仍在右侧,那么表示点击的clear标志
                //计算右侧距离
                Drawable drawableRight = getCompoundDrawables()[2];
                if (drawableRight != null) {
                    int judgeX = getWidth() - (drawableRight.getBounds().right - drawableRight.getBounds().left + getCompoundDrawablePadding() / 2 + getPaddingEnd());
                    //如果x位置大于判断基准,且down时位置也大于判断基准,则直接删除内容
                    if (event.getX() > judgeX && touchX > judgeX) {
                        if (clickRightCallback == null) {
                            post(() -> setText(null));
                        } else {
                            clickRightCallback.run(getText());
                        }
                    }
                }

                //计算左侧距离
                Drawable drawableLeft = getCompoundDrawables()[0];
                if (drawableLeft != null) {
                    int judgeX = drawableLeft.getBounds().right - drawableLeft.getBounds().left + getCompoundDrawablePadding() / 2 + getPaddingStart();
                    if (event.getX() < judgeX && touchX < judgeX) {
                        if (clickLeftCallback != null) {
                            clickLeftCallback.run(getText());
                        }
                    }
                }
                break;
        }
        return super.onTouchEvent(event);
    }

    /**
     * 点击左侧时,进行处理
     */
    public void setOnClickLeft(@Nullable BasePresenter.HttpCallback<Editable> callback) {
        this.clickLeftCallback = callback;
    }

    /**
     * 点击左侧时,进行处理
     */
    public void setOnClickRight(@Nullable BasePresenter.HttpCallback<Editable> callback) {
        this.clickRightCallback = callback;
    }
}

逻辑比较简单:

  • 判断是否存在DrawableEnd值,若不存在,则放置一个默认的删除图标
  • 添加onTouchEvent 监听,当点击右侧部分时,执行回调,回调对象不存在或者返回false时,自动删除文本内容。

六、重写键盘确认,自动完成功能

现在来这样一种情况:

存在一个界面,两个输入框,一个按钮,分别表示:手机号验证码登录按钮

  <LinearLayout
        android:id="@+id/linear_loginType1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginBottom="@dimen/view_padding_margin_40dp"
        android:orientation="vertical"
        android:paddingStart="@dimen/view_padding_margin_32dp"
        android:paddingEnd="@dimen/view_padding_margin_32dp">

        <EditText
            android:id="@+id/et_phone"
            style="@style/TextViewStandard"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@color/transparent"
            android:hint="@string/activity_login_please_input_phoneNumber"
            android:imeActionLabel="发送验证码"
            android:imeOptions="actionDone" 
            android:inputType="phone"
            android:paddingStart="0dp"
            android:drawablePadding="@dimen/view_padding_margin_16dp"
            android:drawableStart="@drawable/icon_login_phone"
            android:paddingEnd="0dp"
            android:singleLine="true"
            android:textColorHint="@color/black_text_663a4254"
            android:textSize="@dimen/text_size_title_15sp" />

        <com.linktech.hrt.view.DividerView
            android:layout_width="match_parent"
            android:layout_height="@dimen/divider_line_width_1dp" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal">

            <EditText
                android:id="@+id/et_random_code"
                style="@style/TextViewStandard"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:background="@color/transparent"
                android:hint="@string/activity_login_please_input_code"
                android:imeActionLabel="@string/activity_login_submit"
                android:imeOptions="actionGo"
                android:drawablePadding="@dimen/view_padding_margin_16dp"
                android:inputType="number"
                android:maxLength="6"
                android:drawableStart="@drawable/icon_login_password"
                android:paddingStart="0dp"
                android:paddingEnd="0dp"
                android:singleLine="true"
                android:textColorHint="@color/black_text_663a4254"
                android:textSize="@dimen/text_size_title_15sp" />

            <com.linktech.hrt.view.RandomCodeTextView
                android:id="@+id/rctv_random_code"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center_vertical"
                android:paddingStart="@dimen/view_padding_margin_8dp"
                android:paddingTop="@dimen/view_padding_margin_4dp"
                android:paddingEnd="@dimen/view_padding_margin_8dp"
                android:paddingBottom="@dimen/view_padding_margin_4dp" />

        </LinearLayout>

        <com.linktech.hrt.view.DividerView
            android:layout_width="match_parent"
            android:layout_height="@dimen/divider_line_width_1dp" />
    </LinearLayout>

注意观察输入控件中的这两行:

  • android:imeOptions="actionDone"
  • android:imeOptions="actionGo"

布局实现的效果大致是这样:

我们现在要实现的功能是:

  • 1、打开该界面(Activity)时,默认聚焦在 手机号 输入控件;
  • 2、当在虚拟键盘上完成手机号的输入后,只需要点击虚拟键盘右下角按键,即可自动触发验证码发送,同时虚拟键盘消息;
  • 3、发送手机号前如果需要,先动态申请权限,手机号发送成功后,自动捕获短信内容;
  • 4、短信验证码自动输入,并触发登录按钮的点击事件;
  • 5、登录完成后,界面可以退出。

如果以上功能实现,那我们登录时,所需的只是进入界面 -> 输入手机号 -> 点击回车键,像这类功能,可以很大程度减轻用户的负担。

接下来,我们就分析如何实现以上效果;

1、打开界面,自动聚焦

KeyboardUtils.showSoftInput(mEtPhone);
mEtPhone.setSelection(mEtPhone .getText().length());

KeyboardUtils所属库为:AndroidUtilCode

  • 第一行代码是将输入法弹出
  • 第二行代码将光标移动输入框中内容的最后

2、重写回车键逻辑,自动发送验证码/自动点击登录按钮

首先,为输入框添加监听事件:

et_phone.setOnEditorActionListener(this)
et_random_code.setOnEditorActionListener(this)

然后处理两个输入框的逻辑:

override fun onEditorAction(v: TextView, actionId: Int, event: KeyEvent?): Boolean {
    //当actionId不表示无操作时
    if (actionId != EditorInfo.IME_ACTION_UNSPECIFIED) {
        when (v.id) {
            R.id.et_phone -> rctv_random_code.performClick()  //输入手机号完成,则默认去触发一次获取验证码按钮
            R.id.et_random_code, R.id.et_password -> bt_common_function.performClick()   //输入完验证码或者密码后,直接调用一次登录操作
        }
    }
    return false
}

手机号输入框监听到返回键时,自动去触发一下获取验证码按钮。

这里就需要去监听广播了:

/**
 * 注入短信监听
 */
@NeedsPermission({Manifest.permission.RECEIVE_SMS, Manifest.permission.READ_SMS, Manifest.permission.RECEIVE_MMS})
void initSMSReceiver() {
    try {
        BCRMachine.registerBroadcastWithoutCheck(this, this, RemoveTime.onDestroy,
                (HttpCallback<Pair<String, String>>) tag -> {
	                //填充验证码
                    mEtRandomCode.setText(tag.second);
                    //触发登录操作
                    mBtLogin.performClick();
                }, MSmsReceiver.class);
    } catch (Exception e) {
        // TODO: 2018/7/7 非需要的短信
    }
}

这段代码用了两个框架:

  • 用于动态获取权限的PermissionsDispatcher
  • 用于监听广播的rregister

MSmsReceiver类代码如下:可以拦截六位数字的验证码

open class MSmsReceiver(httpCallback: HttpCallback<Pair<String, String>>) :
        BaseReceiver<Pair<String, String>>(httpCallback, SMS_DELIVER_ACTION, SMS_RECEIVED_ACTION ,"android.intent.action.MOMS_SMS_RECEIVED") {

    /**
     * 处理短信结果
     */
    override fun apply(intentPairFunction: Intent): Pair<String, String> {
        val contact: String

        val builder = StringBuilder()
        val bundle = intentPairFunction.extras

        if (bundle != null) {
            //从Intent中获取bundle对象,此对象包含了所有的信息,短信是以“pdus”字段存储的。得到的是一个object数组,每个object都包含一条短信,(可能会获取到多条信息)。
            val pdus = bundle.get("pdus") as Array<Any?>? ?: throw RuntimeException("短信无内容")

            //新建SmsMessage数组对象存储短信,每个SmsMessage对应一条短信类。
            val messages = arrayOfNulls<SmsMessage>(pdus.size)
            for (i in messages.indices) {
                messages[i] = SmsMessage.createFromPdu(pdus[i] as ByteArray?)
            }

            //获取得到的最末端短信的联系人地址,赋值给contact
            contact = messages.getOrNull(pdus.size - 1)?.displayOriginatingAddress ?: ""

            //读取短信内容,getDisplayMessageBody()是获取正文消息。
            for (message in messages) {
                builder.append(message?.displayMessageBody)
            }

            Pair.create(contact, builder.toString()).let {
                if (it != null && !TextUtils.isEmpty(it.second)) {
                    return Pair(it.first,
                    //const val REGEX_VERIFY_CODE_SMS_EMAIL = "^.*?[^\\d]([\\d]{6})[^\\d].*?$"
                            it.second.replace(RegexConst.REGEX_VERIFY_CODE_SMS_EMAIL.toRegex(), "$1").let {
                                if (it.matches("[\\d]{6}".toRegex())) it else throw RuntimeException("not require verify code")
                            }
                    )
                }
            }
        }

        throw RuntimeException("无法获取短信内容")
    }
}

当验证码被应用拦截到以后,自动填充到验证码输入区域,然后触发登录操作,至此,逻辑完成。

七、输入控件空值检测

现在很多的设计中,如果界面中输入框没有输入完成,则功能按钮将处于不可点击状态,当然简单的我们可以通过一个个添加Watcher来监控:

et_random_code.addTextChangedListener(this)

然后当监听回调:

   override fun afterTextChanged(s: Editable?) {
    //当内容变化时,检测界面内所有输入框是否有非空值
    }

不过这样做,麻烦不说,还很有可能存在遗漏之处。

对此,我们可以新建一个EditText,当值变化时,自动回调,完成逻辑:

public interface EditTextChangedListener {
    /**
     * 当文本发生变化
     */
    void onExistEditTextChanged(@NonNull WatchInputEditText editText, @NonNull Editable s);
}

public class WatchInputEditText extends AppCompatEditText implements TextWatcher {
    /**
     * 是否绑定空值监听器
     */
    public boolean nullMonitorEnable;
    
    /**
     * 唯一监听器
     */
    private WeakReference<EditTextChangedListener> singleTextNullWatcher;

    public WatchInputEditText(Context context) {
        this(context, null);
    }

    public WatchInputEditText(Context context, AttributeSet attrs) {
        this(context, attrs, R.attr.editTextStyle);
    }

    public WatchInputEditText(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        //检测是否需要监听器
        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.WatchInputEditText);
        nullMonitorEnable = ta.getBoolean(R.styleable.WatchInputEditText_WatchInputEditText_monitoring, true);
        ta.recycle();

        //是否设置监听器
        if (nullMonitorEnable) {
            //添加watcher
            addTextChangedListener(this);

            //开始前主动触发一次
            post(() -> {
                if (singleTextNullWatcher != null && singleTextNullWatcher.get() != null) {
                    singleTextNullWatcher.get().onExistEditTextChanged(this, getText());
                }
            });
        }
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        //移除
        if (nullMonitorEnable) {
            removeTextChangedListener(this);
        }
    }

    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        if (!isInEditMode()) {
            if (nullMonitorEnable && singleTextNullWatcher == null) {
                // 如果 context 满足,则设置为 context
                List<? extends Fragment> fragments = ((BaseActivity) getContext()).getSupportFragmentManager().getFragments();

                //指定应该谁做listener
                Object target = getContext() instanceof EditTextChangedListener ? getContext() : null;

                //查找是否应该设置为fragment
                for (Fragment fragment : fragments) {
                    if (isParentChild(fragment.getView(), this) && fragment instanceof EditTextChangedListener) {
                        target = fragment;
                        break;
                    }
                }

                //如果目标是EditTextChangedListener子类,则设置为监听器
                if (target != null) {
                    singleTextNullWatcher = new WeakReference<>((EditTextChangedListener) target);
                }
            }
        }

    }

    /**
     * 判断两个view是否构成子父节点关系
     */
    private boolean isParentChild(View parent, View child) {
        //如果是同一个对象,也算是有父子关系
        if (parent == child) {
            return true;
        } else if (parent instanceof ViewGroup) {
            //父节点
            ViewParent child_parent = child.getParent();
            while (true) {
                //找到了父类为parent
                if (child_parent == parent) {
                    return true;
                }

                //找不到目标父类
                if (child_parent == null) {
                    return false;
                }

                //重置
                child_parent = child_parent.getParent();
            }
        } else {
            return false;
        }
    }

    /**
     * 设置监听事件
     */
    public void setEditTextChangedListener(EditTextChangedListener singleTextNullWatcher) {
        this.singleTextNullWatcher = new WeakReference<>(singleTextNullWatcher);
    }

    /**
     * This method is called to notify you that, within <code>s</code>,
     * the <code>count</code> characters beginning at <code>start</code>
     * are about to be replaced by new text with length <code>after</code>.
     * It is an error to attempt to make changes to <code>s</code> from
     * this callback.
     *
     * @param s
     * @param start
     * @param count
     * @param after
     */
    @Override
    public void beforeTextChanged(CharSequence s, int start, int count, int after) {

    }

    /**
     * This method is called to notify you that, within <code>s</code>,
     * the <code>count</code> characters beginning at <code>start</code>
     * have just replaced old text that had length <code>before</code>.
     * It is an error to attempt to make changes to <code>s</code> from
     * this callback.
     *
     * @param s
     * @param start
     * @param before
     * @param count
     */
    @Override
    public void onTextChanged(CharSequence s, int start, int before, int count) {

    }

    /**
     * This method is called to notify you that, somewhere within
     * <code>s</code>, the text has been changed.
     * It is legitimate to make further changes to <code>s</code> from
     * this callback, but be careful not to get yourself into an infinite
     * loop, because any changes you make will cause this method to be
     * called again recursively.
     * (You are not told where the change took place because other
     * afterTextChanged() methods may already have made other changes
     * and invalidated the offsets.  But if you need to know here,
     * you can use {@link android.text.Spannable} in {@link #onTextChanged}
     * to mark your place and then look up from here where the span
     * ended up.
     *
     * @param s
     */
    @Override
    public void afterTextChanged(Editable s) {
        if (nullMonitorEnable && singleTextNullWatcher != null && singleTextNullWatcher.get() != null) {
            post(() -> singleTextNullWatcher.get().onExistEditTextChanged(this, s));
        }
    }
}

该控件有自定义属性,用于控制是否添加空值检测,如果不开启,则跟一个普通的EditText相同:

    <!--WatchInputEditText:检测文本变化-->
    <declare-styleable name="WatchInputEditText">
        <attr name="WatchInputEditText_monitoring" format="boolean" />
    </declare-styleable>

注意一点,对于监听器的添加,是在onAttachedToWindow中完成的,只有界面显示出来以后才会去处理监听器的逻辑。

  • view所在的Activity实现了 EditTextChangedListener 接口时,会将该Activity当做监听器
  • view所在的Fragment实现了 EditTextChangedListener 接口时,会将该Fragment当做监听器
  • Fragment和Activity都实现了接口,则默认Fragment为监听器

然后修改Activity和Fragment基类,使其都实现EditTextChangedListener 接口

对于BaseActivity:BaseFragment:(伪代码)

public abstract class BaseActivity/BaseFragment extends AppCompatActivity/android.support.v4.app.Fragment implements  EditTextChangedListener{
	// ...
    /**
     * 当文本发生变化
     *
     * @param editText
     * @param s
     */
    @Override
    public void onExistEditTextChanged(@NonNull WatchInputEditText editText, @NonNull Editable s) {
    
    }
	// ...
}

到这,基本的框架就搭建完了,加入现在有个需求是这样:

当用户在三个输入框内输入内容之前,提交按钮为灰色,不可点击:

当文本框内容有内容时不管内容格式是否正确,只要不为空,提交按钮可点击:

所需代码如下:

首先,搭建布局文件,使用我们刚自定义的控件:

<LinearLayout
    xmlns:android="http://schemas.android/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <!---->
    <include layout="@layout/layout_top_bar"/>

    <!--输入密码-->
    <com.linktech.hrt.view.WatchInputEditText
        android:id="@+id/et_old_password"
        style="@style/TextViewStandard"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="@dimen/view_padding_margin_10dp"
        android:background="@color/main_color_white"
        android:hint="@string/activity_change_password_hint_old_password"
        android:inputType="textPassword"
        android:maxLength="16"
        android:maxLines="1"
        android:paddingEnd="@dimen/view_padding_margin_16dp"
        android:paddingStart="@dimen/view_padding_margin_16dp"
        android:textColorHint="@color/black_text_c7"
        android:textSize="@dimen/text_size_title_15sp"/>

    <com.linktech.hrt.view.DividerView
        android:layout_width="match_parent"
        android:layout_height="@dimen/divider_line_width_1dp"/>

    <com.linktech.hrt.view.WatchInputEditText
        android:id="@+id/et_password_one"
        style="@style/TextViewStandard"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/main_color_white"
        android:hint="@string/activity_change_password_hint_one"
        android:inputType="textPassword"
        android:maxLength="16"
        android:maxLines="1"
        android:paddingEnd="@dimen/view_padding_margin_16dp"
        android:paddingStart="@dimen/view_padding_margin_16dp"
        android:textColorHint="@color/black_text_c7"
        android:textSize="@dimen/text_size_title_15sp"/>

    <com.linktech.hrt.view.DividerView
        android:layout_width="match_parent"
        android:layout_height="@dimen/divider_line_width_1dp"/>

    <com.linktech.hrt.view.WatchInputEditText
        android:id="@+id/et_password_two"
        style="@style/TextViewStandard"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginBottom="@dimen/view_padding_margin_40dp"
        android:background="@color/main_color_white"
        android:hint="@string/activity_change_password_hint_two"
        android:inputType="textPassword"
        android:maxLength="16"
        android:maxLines="1"
        android:paddingEnd="@dimen/view_padding_margin_16dp"
        android:paddingStart="@dimen/view_padding_margin_16dp"
        android:textColorHint="@color/black_text_c7"
        android:textSize="@dimen/text_size_title_15sp"/>

    <!--提交按钮:id为 bt_common_function-->
    <include layout="@layout/layout_common_function_button"/>
</LinearLayout>

控制逻辑也很简单:

@Route(path = ARouterConst.Activity_ChangePasswordActivity, extras = ARouterConst.FLAG_LOGIN)
@InjectLayoutRes(layoutResId = R.layout.activity_change_password)
@InjectActivityTitle(titleRes = R.string.label_change_password)
class ChangePasswordActivity : BaseActivity<BasePresenter<ChangePasswordActivity>>() {
    /**
     * 成员变量
     */
    private var oldPassword: String by viewBind(R.id.et_old_password)
    private var passwordOne: String by viewBind(R.id.et_password_one)
    private var passwordTwo: String by viewBind(R.id.et_password_two)

    override fun initData(savedInstanceState: Bundle?) {
        super.initData(savedInstanceState)

        bt_common_function.run {
            text = "提交"
            dOnClick(::verify_password) {
                mPresenter.changepwd(_username, _signature, oldPassword.toMD5(), passwordOne.toMD5(), passwordTwo.toMD5()) {
                	//初始化一些缓存变量
                	// ...
                	
                	// 跳转到登录界面
                    routeSuccessFinish(ARouterConst.Activity_LoginActivity).toast("密码修改成功,请重新登陆")
                }
            }
        }
    }

    /**
     * @return true if ok
     */
    private fun verify_password(): Boolean {
        return when {
            oldPassword.isEmpty() -> false.toast("请输入原密码")
            oldPassword notMatch RegexConst.REGEX_PASSWORD -> false.toast("旧密码错误")
            passwordOne.isEmpty() -> false.toast("请输入登录密码")
            passwordTwo.isEmpty() -> false.toast("请确认登录密码")
            passwordOne != passwordTwo -> false.toast("两次输入的新密码不同")
            passwordOne notMatch RegexConst.REGEX_PASSWORD -> false.toast("新密码须为8-16位字母数字组合")
            oldPassword == passwordOne -> false.toast("新密码和旧密码不能相同")
            else -> true
        }
    }

    override fun onExistEditTextChanged(editText: WatchInputEditText, s: Editable) {
        super.onExistEditTextChanged(editText, s)

        bt_common_function.isEnabled = listOf(oldPassword, passwordOne, passwordTwo).all { it.isNotBlank() }
    }
}

  • 类上面的注解,用于指出layouttitle的值
  • 整体使用MVP模式,mPresenter相当于P层的表现对象,用于进行网络请求或者其他操作。
  • 使用到的成员变量其实是借用委托模式,其实操作的是对应的是id资源对应的View控件
  • verify_password方法在用户点击bt_common_function按钮时,进行逻辑验证,验证通过则会发起网络请求
  • onExistEditTextChangedWatchInputEditText自定义控件内容对应的回调接口,当输入框有值发生变化时,该方法会被回调。

可以看到,在搭建完框架后,对于空值检测并没有增加任何复杂度,额外的,我们还不需要在verify_password中去判断和提醒用户哪个值没有输入,一举两得!

事实上,EditText功能远不止这些,用的越多,了解的越多,越是能发现其魅力!

更多推荐

Android开发&EditText的使用方式