前言
现在,我们就来手写一个 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()