在『MOJiKana』中你可以看到我们大量使用了模仿实体按键的按压动效:

模仿实体按键动效

一开始这种动效仅在五十音列表中使用到,我们只需在 Adapter 中对子项进行处理即可,随着版本的迭代,该动效开始广泛应用到其他按钮设计中,如果针对每个按钮都做这种动效处理,项目中会存在大量的冗余代码,不易于维护,所以我考虑将其封装成一个组件,让每个需要用到的地方都尽可能简单地接入。

先来看看原理,如下图所示,我们只需要通过叠加两个图层,上面的图层采用较浅的颜色,下面的图层采用较深的颜色,并在上下两个图层的底部制造一个间距,即可在视觉上形成一个实体按键的效果。

图层分解

当按钮被按下时,我们取消两个图层底部的间距,使底部的深色图层被覆盖隐藏,再给图层与父布局顶部添加同样的间距,就能够模仿出实体按键被按下的效果。

使用两个图层其实是 Android 的能力限制,像 ArkUI 中我们实际上只采用一个图层,然后通过调整底部边框的宽度来实现的,尽管如此,实现思路大体一致。

知道了原理,下面开始编码。

考虑到需要封装成通用组件,我们把几个重要的属性提取出来:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="KeyCapLayout">
        <attr name="keyCapFlatColor" format="color" />
        <attr name="keyCapShadowColor" format="color" />
        <attr name="keyStroke" format="dimension" />
        <attr name="keyCapRadius" format="dimension" />
    </declare-styleable>
</resources>

我把这个组件命名为 KeyCapLayout,因为它很像我们键盘中的键帽。keyCapFlatColor 表示的是按键表面的颜色,也就是原理中上面图层的颜色,keyCapShadowColor 表示的是按键的侧面阴影颜色,也就是原理中下面图层的颜色,keyStroke 表示的是按键的厚度,也可以理解为在机械键盘中我们常说的键程,keyCapRadius 表示的是按键的圆角半径,我这里没有区分上下两个图层的半径,而是采用同样的大小,读者可按需添加。

接下来封装组件:

class KeyCapLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : FrameLayout(context, attrs, defStyle) {

    private val keyCapFlat: QMUIFrameLayout     // 键盘顶部
    private val keyCapShadow: QMUIFrameLayout   // 键盘侧面
    private val tagFlat = "keyCapFlat"
    private val tagShadow = "keyCapShadow"
    private var keystroke = 0                   // 键程

    init {
        val a = context.obtainStyledAttributes(attrs, R.styleable.KeyCapLayout)
        val flat = a.getColor(R.styleable.KeyCapLayout_keyCapFlatColor, Color.YELLOW)
        val shadow = a.getColor(R.styleable.KeyCapLayout_keyCapShadowColor, Color.GRAY)
        keystroke = a.getDimension(R.styleable.KeyCapLayout_keyStroke, 4.dp.toFloat()).toInt()
        val radius = a.getDimension(R.styleable.KeyCapLayout_keyCapRadius, 8.dp.toFloat()).toInt()
        a.recycle()

        keyCapFlat = QMUIFrameLayout(context).apply {
            layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
            tag = tagFlat
        }
        keyCapShadow = QMUIFrameLayout(context).apply {
            layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
            tag = tagShadow
        }
        setKeyCapFlatColor(flat)
        setKeyCapShadowColor(shadow)
        setKeyCapRadius(radius)
        setKeyStroke(keystroke)
        addView(keyCapShadow)
        addView(keyCapFlat)
    }

    /**
     * 设置键帽颜色
     */
    fun setKeyCapFlatColor(@ColorInt color: Int) {
        keyCapFlat.setBackgroundColor(color)
    }

    /**
     * 设置竖面阴影色
     */
    fun setKeyCapShadowColor(@ColorInt color: Int) {
        keyCapShadow.setBackgroundColor(color)
    }

    /**
     * 设置圆角
     */
    fun setKeyCapRadius(radius: Int) {
        keyCapFlat.radius = radius
        keyCapShadow.radius = radius
    }

    /**
     * 设置键程
     */
    fun setKeyStroke(height: Int) {
        val lp = keyCapFlat.layoutParams as LayoutParams
        lp.setMargins(0, 0, 0, height)
        keyCapFlat.layoutParams = lp
    }

    /**
     * 处理按压
     * @param press 是否按压
     */
    private fun handlePress(press: Boolean) {
        val lpFlat = keyCapFlat.layoutParams as LayoutParams
        val lpShadow = keyCapShadow.layoutParams as LayoutParams
        if (press) {
            lpFlat.setMargins(0, keystroke, 0, 0)
            lpShadow.setMargins(0, keystroke, 0, 0)
        } else {
            lpFlat.setMargins(0, 0, 0, keystroke)
            lpShadow.setMargins(0)
        }
        keyCapFlat.layoutParams = lpFlat
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        when (event?.action) {
            MotionEvent.ACTION_DOWN -> {
                handlePress(true)
            }

            MotionEvent.ACTION_UP -> {
                handlePress(false)
                performClick()
            }

            MotionEvent.ACTION_CANCEL -> {
                handlePress(false)
            }
        }
        return true
    }

    override fun addView(child: View?) {
        if (child?.tag == tagFlat || child?.tag == tagShadow) {
            super.addView(child)
        } else {
            keyCapFlat.addView(child)
        }
    }

    override fun addView(child: View?, index: Int) {
        if (child?.tag == tagFlat || child?.tag == tagShadow) {
            super.addView(child, index)
        } else {
            keyCapFlat.addView(child, index)
        }
    }

    override fun addView(child: View?, params: ViewGroup.LayoutParams?) {
        if (child?.tag == tagFlat || child?.tag == tagShadow) {
            super.addView(child, params)
        } else {
            keyCapFlat.addView(child, params)
        }
    }

    override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
        if (child?.tag == tagFlat || child?.tag == tagShadow) {
            super.addView(child, index, params)
        } else {
            keyCapFlat.addView(child, index, params)
        }
    }

    override fun addView(child: View?, width: Int, height: Int) {
        if (child?.tag == tagFlat || child?.tag == tagShadow) {
            super.addView(child, width, height)
        } else {
            keyCapFlat.addView(child, width, height)
        }
    }
}

KeyCapLayout 继承自 FrameLayout,无论是简单的文字图片,或是复杂的自定义视图,都可以很方便地往里面添加。

原理中提到的上下两个图层,为了方便圆角的设置,我采用了 @Tencent/QMUI_Android 这个库的 QMUIFrameLayout 来作容器,你也可以采用其他的圆角方案。

写 XML 布局时,如果我们直接在 KeyCapLayout 中添加子视图,子视图默认会被插入到 KeyCapLayout 中,这样在处理按压效果时还需要额外处理子视图的上下偏移,如果我们能够将它插入到上图层中,它就仿佛印在按键表面一样,跟随图层上下偏移,所以我们重写 addView() 方法,将子视图添加到上图层中。

因为我们首先需要调用 addView() 把上下两个图层添加到 KeyCapLayout 中,所以为了避免递归死循环,我们给上下图层分别配置 TagaddView() 时判断不是这两个上下图层则添加到上图层中。

按压效果就重写触摸事件 onTouchEvent() 处理,上面的原理已经介绍过了,不再重复。

调用时只需包裹在我们的内容上即可:

<com.example.banner.KeyCapLayout
    android:layout_width="120dp"
    android:layout_height="56dp"
    app:keyCapFlatColor="#595959"
    app:keyCapRadius="56dp"
    app:keyCapShadowColor="#404040"
    app:keyStroke="6dp">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:textStyle="bold"
        android:textColor="@color/white"
        android:textSize="18sp"
        android:text="Button" />
</com.example.banner.KeyCapLayout>

点击事件也是直接调用 KeyCapLayoutsetOnClickListener() 方法,我们在 onTouchEvent() 中处理了相关逻辑,当接收到 MotionEvent.ACTION_UP 事件时,会自动触发 onClick() 方法。

MOJiKana』中同时也使用了 Jetpack Compose 技术,为了复用上面的代码,我不得不每次都使用 AndroidView 进行包裹,并在内部完成整个 KeyCapLayout 的创建、配置,还要创建它的子 View 再添加到其中。比如:

@Preview
@Composable
private fun KeyCapLayoutPreview() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(color = Color.White),
        contentAlignment = Alignment.Center
    ) {
        AndroidView(factory = { context ->
            KeyCapLayout(context).apply {
                layoutParams = ViewGroup.LayoutParams(SizeUtils.dp2px(120f), SizeUtils.dp2px(54f))
                setKeyCapFlatColor(context.getColor(R.color.color_595959))
                setKeyCapShadowColor(context.getColor(R.color.color_404040))
                setKeyCapRadius(SizeUtils.dp2px(60f))
                setKeyStroke(SizeUtils.dp2px(6f))
                val imageView = ImageView(context).apply {
                    layoutParams = ViewGroup.LayoutParams(SizeUtils.dp2px(24f), SizeUtils.dp2px(24f))
                    setImageResource(R.drawable.ic_common_complete)
                }
                addView(
                    imageView,
                    FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT).apply { gravity = Gravity.CENTER }
                )
                setOnClickListener {
                    // do something...
                }
            }
        })
    }
}

随着项目中使用的场景逐渐增加,这种胶水代码也在慢慢膨胀。终于到了无法忍受之际,我又顺手搓了个 Compose 版本:

@Composable
fun KeyCap(
    modifier: Modifier = Modifier,
    keyFlatColor: Color = Color.Transparent,
    keyShadowColor: Color = Color.Transparent,
    keyStroke: Dp = 0.dp,
    keyCapRadius: Dp = 0.dp,
    content: @Composable BoxScope.() -> Unit
) {
    var pressed by remember { mutableStateOf(false) }

    Box(modifier = modifier.pointerInput(pressed) {
        awaitPointerEventScope {
            if (pressed) {
                waitForUpOrCancellation()
                pressed = false
            } else {
                awaitFirstDown(false)
                pressed = true
            }
        }
    }) {
        Box(
            modifier = Modifier
                .padding(top = keyStroke)
                .fillMaxSize()
                .clip(RoundedCornerShape(size = keyCapRadius))
                .background(keyShadowColor)
        )
        Box(
            modifier = Modifier
                .padding(
                    top = if (pressed) keyStroke else 0.dp,
                    bottom = if (pressed) 0.dp else keyStroke
                )
                .fillMaxSize()
                .clip(RoundedCornerShape(size = keyCapRadius))
                .background(color = keyFlatColor),
            contentAlignment = Alignment.Center
        ) {
            content()
        }
    }
}

属性依然是那几个属性,不再赘述。

触摸事件在 awaitPointerEventScope() 内处理,使用一个状态来标记按钮是否被按下。

得益于 Jetpack Compose 的声明式理念,无需再考虑上述原始 View 体系中 addView() 的问题,而是直接添加到对应的组件中即可。为了符合 Compose 的编写方式,可以直接参考其他组件的设计,在最后一个参数传入 @Composable 方法即可。

使用方式也很简单:

@Preview
@Composable
fun KeyCapPreview() {
    val interactionSource = remember { MutableInteractionSource() }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(color = Color.White),
        contentAlignment = Alignment.Center
    ) {
        KeyCap(
            keyFlatColor = colorResource(R.color.color_595959),
            keyShadowColor = colorResource(R.color.color_404040),
            keyStroke = 6.dp,
            keyCapRadius = 60.dp,
            modifier = Modifier
                .size(120.dp, 54.dp)
                .clickableWithoutRipple(interactionSource) {    // 去除默认的点击效果
                    // do something...
                }
        ) {
            Text(
                text = stringResource(id = R.string.confirm),
                color = Color.White
            )
        }
    }
}

需要留意的是,Compose 默认情况下会给组件添加一个点击的 Ripple 效果,因为我们是通过组合视图来实现,并且还有圆角,所以这个默认的点击效果反而会影响体验,要把它去除。

去除点击效果的方法有很多种,Compose 官方也在后续的版本中做了更新,因为我目前使用的版本还比较低,所以我使用扩展方法来支持,其他实现方式可以自行搜索。

fun Modifier.clickableWithoutRipple(
    interactionSource: MutableInteractionSource,
    onClick: () -> Unit
) = composed(
    factory = {
        this.then(
            Modifier.clickable(
                interactionSource = interactionSource,
                indication = null,
                onClick = { onClick() }
            )
        )
    }
)

最后浅浅吐槽一下,虽然这个设计挺有意思,但是我个人觉得其实违反了透视原理。

因为视觉上近大远小,所以按钮上的文字或图片按道理来说也应当被拉伸,左右两侧的延长线最终会在消失点相交。即使这个变换并不难实现,可是由于按钮的大小是动态配置的,导致变换的角度以及消失点就不太好确定,因此我们最终并没有实现这个效果。