在『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
中,所以为了避免递归死循环,我们给上下图层分别配置 Tag
,addView()
时判断不是这两个上下图层则添加到上图层中。
按压效果就重写触摸事件 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>
点击事件也是直接调用 KeyCapLayout
的 setOnClickListener()
方法,我们在 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() }
)
)
}
)
最后浅浅吐槽一下,虽然这个设计挺有意思,但是我个人觉得其实违反了透视原理。
因为视觉上近大远小,所以按钮上的文字或图片按道理来说也应当被拉伸,左右两侧的延长线最终会在消失点相交。即使这个变换并不难实现,可是由于按钮的大小是动态配置的,导致变换的角度以及消失点就不太好确定,因此我们最终并没有实现这个效果。