手写 MaterialEditText:实现浮动标签(Floating Label)效果

前言

现在,我们就来手写一个 MaterialEditText

效果图:

当然,我们不会实现它完整的特性,只实现其 Floating Label (浮动标签) 效果。

准备工作

首先,创建 MaterialEditText 类,继承自 AppCompatEditText

kotlin 复制代码
class MaterialEditText(context: Context, attrs: AttributeSet?) : AppCompatEditText(context, attrs) {
}

然后,在布局中使用。

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<com.example.customview.MaterialEditText xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/editText"
    android:layout_width="match_parent"
    android:layout_height="wrap_content" />

运行效果:

准备工作完成后,开始实现。

实现过程

首先,添加上边距,为浮动标签留出空间。

kotlin 复制代码
class MaterialEditText(context: Context, attrs: AttributeSet?) : AppCompatEditText(context, attrs) {

    // 标签的文字大小
    private val LABEL_TEXT_SIZE = 20.dp 

    // 标签和输入框之间的距离
    private val LABEL_PADDING_BOTTOM = 8.dp

    // 保存View原始的paddingTop
    private var originPaddingTop = 0

    init {
        // 保存原始 paddingTop,以便后续计算
        originPaddingTop = paddingTop
        setPadding(
            paddingLeft,
            originPaddingTop + (LABEL_TEXT_SIZE + LABEL_PADDING_BOTTOM).toInt(),
            paddingRight,
            paddingBottom
        )
    }
}

运行效果:

然后,绘制标签文字,它的文字就是 EditText 组件的提示文字,所以先在布局中加上 hint 属性。

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<com.example.customview.MaterialEditText xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/editText"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:hint="Email" />

接着绘制标签文字:

kotlin 复制代码
// MaterialEditText.kt
private val myPaint by lazy {
    Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.parseColor("#3F51B5")
        textSize = LABEL_TEXT_SIZE
        textAlign = Paint.Align.LEFT
    }
}

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)

    // 绘制标签
    canvas.drawText(
        hint.toString(),
        paddingLeft.toFloat(), // x坐标与输入文本左侧对齐
        (originPaddingTop + LABEL_TEXT_SIZE), // y坐标为标签文字的基线
        myPaint
    )
}

运行效果:

接着,就是实现浮动标签的特性。

但我们分步来完成,先来实现标签简单的显示和隐藏。当输入框中有文字时,才绘制标签。

kotlin 复制代码
override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    
    // 如果输入框中有内容,才绘制标签
    if (!text.isNullOrEmpty()){
        // 绘制标签
        canvas.drawText(
            hint.toString(),
            paddingLeft.toFloat(),
            (originPaddingTop + LABEL_TEXT_SIZE),
            myPaint
        )
    }
}

运行效果:

现在,我们用动画来代替瞬间的显隐。代码如下:

kotlin 复制代码
class MaterialEditText(context: Context, attrs: AttributeSet?) : AppCompatEditText(context, attrs) {

    ...
    
    // 标签是否正在显示
    private var floatingLabelShown = false

    // 标签透明度
    var floatingLabelAlpha = 0 // 改为public,以便动画框架访问
        set(value) {
            field = value
            invalidate()
        }

    // 标签的偏移量
    var floatingLabelOffsetY = 0f 
        set(value) {
            field = value
            invalidate()
        }

    private val alphaObjectAnimator by lazy { 
        ObjectAnimator.ofInt(this, "floatingLabelAlpha", 0x00, 0xff)
    }

    private val offsetObjectAnimator by lazy {
        ObjectAnimator.ofFloat(
            this,
            "floatingLabelOffsetY",
            // 动画起始位置:在输入框文字的基线上
            originPaddingTop + LABEL_TEXT_SIZE + LABEL_PADDING_BOTTOM, 
            // 动画结束位置:在标签文字的基线上
            originPaddingTop + LABEL_TEXT_SIZE 
        )
    }

    private val animatorSet by lazy {
        AnimatorSet().apply {
            playTogether(alphaObjectAnimator, offsetObjectAnimator)
        }
    }
    
    @RequiresApi(Build.VERSION_CODES.O)
    override fun onTextChanged(
        text: CharSequence?,
        start: Int,
        lengthBefore: Int,
        lengthAfter: Int,
    ) {
        super.onTextChanged(text, start, lengthBefore, lengthAfter)
        if (text.isNullOrEmpty()) {
            if (floatingLabelShown) {
                // 隐藏标签:反向执行动画
                animatorSet.reverse()
                floatingLabelShown = false
            }
        } else {
            if (!floatingLabelShown) {
                // 显示标签:正向执行动画
                animatorSet.start()
                floatingLabelShown = true
            }
        }
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        // 设置标签的透明度
        myPaint.alpha = floatingLabelAlpha

        // 绘制标签
        canvas.drawText(
            hint.toString(),
            paddingLeft.toFloat(),
            // 使用动画计算出的实时偏移量
            floatingLabelOffsetY,
            myPaint
        )
    }
}

运行效果:

你可以先完成透明度动画,再完成位移动画,会更加简单。

添加特性开关

现在,浮动标签特性已经实现了,最后我们给它添加一个开关,让调用方能够控制是否开启这个特性。

在代码中

在代码中的开关,只需提供一个公有属性即可。

kotlin 复制代码
var useFloatingLabel = true // 默认开启
    set(value) {
        if (field != value) {
            field = value
            updatePadding()
        }
    }

init {
    originPaddingTop = paddingTop
    updatePadding() // 根据初始状态更新一次padding
}

private fun updatePadding() {
    val finalPaddingTop = if (useFloatingLabel) {
        // 开启:增加额外空间
        originPaddingTop + (LABEL_TEXT_SIZE + LABEL_PADDING_BOTTOM).toInt()
    } else {
        // 关闭:恢复原始空间
        originPaddingTop
    }
    setPadding(paddingLeft, finalPaddingTop, paddingRight, paddingBottom)
}

@RequiresApi(Build.VERSION_CODES.O)
override fun onTextChanged(
    text: CharSequence?,
    start: Int,
    lengthBefore: Int,
    lengthAfter: Int,
) {
    super.onTextChanged(text, start, lengthBefore, lengthAfter)
    // 启动动画前,增加对 useFloatingLabel 的判断
    if (text.isNullOrEmpty()) {
        if (floatingLabelShown && useFloatingLabel) { 
            animatorSet.reverse()
            floatingLabelShown = false
        }
    } else {
        if (!floatingLabelShown && useFloatingLabel) {
            animatorSet.start()
            floatingLabelShown = true
        }
    }
}

在XML中

在 XML 中添加开关,首先要创建一个 values/attrs.xml 文件,在文件中添加如下内容:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="MaterialEditText">
        <attr name="useFloatingLabel" format="boolean" />
    </declare-styleable>
</resources>

然后在 MaterialEditText 内部读取该属性的值,让属性生效。

kotlin 复制代码
init {
    originPaddingTop = paddingTop

    val typedArray = context.obtainStyledAttributes(attrs, R.styleable.MaterialEditText)
    useFloatingLabel =
        typedArray.getBoolean(R.styleable.MaterialEditText_useFloatingLabel, true) 
    typedArray.recycle() // 必须回收资源

    updatePadding() // 根据从xml读到的值,更新padding
}

这样就完成了,我们来看看这三行代码的原理。

首先是最后一行代码,因为 TypedArray 是系统为了性能优化而维护的共享资源池,它不会被自动回收,所以我们要在使用后主动回收掉它,否则会造成内存泄露。

然后是前两行代码,attrs 就是构造函数传入的 AttributeSet 对象,用于存放在 XML 布局中为当前 View 设置的所有属性和值。

例如,我们打印看看 attrs 中有什么:

kotlin 复制代码
init {
    // ...
    
    for (index in 0 until attrs.attributeCount){
        println("AttributeName is ${attrs.getAttributeName(index)},AttributeValue is ${attrs.getAttributeValue(index)}.")
    }
}

日志:

less 复制代码
I/System.out: AttributeName is id,AttributeValue is @2131231235.
I/System.out: AttributeName is layout_width,AttributeValue is -1.
I/System.out: AttributeName is layout_height,AttributeValue is -2.
I/System.out: AttributeName is hint,AttributeValue is Email.
I/System.out: AttributeName is useFloatingLabel,AttributeValue is true.

可以看到 attrs 确实包含了所有的 XML 属性。

id 的值存放在了 app/build/intermediates/runtime_symbol_list/debug/processDebugResources/R.txt 文件中,查找该值的 16 进制形式,可以找到:

java 复制代码
int id editText 0x7f080203

在该文件中搜索 MaterialEditText,可以找到:

java 复制代码
int[] styleable MaterialEditText { 0x7f0304ed } // int数组
int styleable MaterialEditText_useFloatingLabel 0 // useFloatingLabel 在数组中的索引是 0

这么一串联就明白了:MaterialEditText 是我们在 attrs,xml 文件中定义的自定义属性的数组,其中的 0x7f0304ed 记录着每个元素的地址,而 MaterialEditText_useFloatingLabel 记录了元素在数组中的位置。

再回去看代码,可以知道 obtainStyledAttributes 方法实际上是将 attrs 中属于 MaterialEditText 数组中的属性留下,并将其解析成 TypeArray 对象。TypeArray.getBoolean 方法是获取第 MaterialEditText_useFloatingLabel(0) 个属性的值。

了解了这些,我们就可以自定义属性的取用,比如我们可以只获取某一个我们想要的属性:

kotlin 复制代码
val typedArray = context.obtainStyledAttributes(attrs, intArrayOf(R.attr.useFloatingLabel))
useFloatingLabel =
    typedArray.getBoolean(0, false)
typedArray.recycle()
相关推荐
安卓开发者1 小时前
Android Glide最佳实践:高效图片加载完全指南
android·glide
菠萝加点糖2 小时前
Android 使用MediaMuxer+MediaCodec编码MP4视频
android·音视频·编码
CYRUS_STUDIO5 小时前
使用 readelf 分析 so 文件:ELF 结构解析全攻略
android·linux·逆向
小强开学前6 小时前
WebView 静态页面秒加载方案要点
android·webview
纽马约7 小时前
Android Room的使用详解
android
游戏开发爱好者88 小时前
基于uni-app的iOS应用上架,从打包到分发的全流程
android·ios·小程序·https·uni-app·iphone·webview
深盾科技8 小时前
Android Keystore签名文件详解与安全防护
android·安全·gitee
安卓开发者8 小时前
Android Glide插件化开发实战:模块化加载与自定义扩展
android·glide