一个活跃通话的界面,被做成了三套样子。通话页是一排实心灰圆盘、末尾一个红色;结束卡片用的是 Material 现成的填充按钮和描边按钮;左上角收起键则是半透明黑圆加白色箭头。单看每一个都说得过去,凑在一起,这通电话看起来不像发起它的那个应用。

打开网易新闻 查看精彩图片

修法是一个圆形控件加两种强调色,外加一个跟样式毫无关系的键盘问题。

三个界面,三种方言

通话页是全应用视觉暴露最彻底的状态:它盖住一切,通常压在实时视频上,用户一只手握机、眼睛看着对方的脸。这也是按钮必须不靠阅读就能找到的状态。

旧的那排按钮是手搓的:一个圆、一个固定颜色、一个图标、一个手势检测器。这套写法用一次没问题,复制三遍就产出三套略有差异的尺寸,以及三种对“关闭长什么样”的理解。

与此同时,其他所有浮动控件——导航栏的目的地、好友列表操作、资料编辑按钮——早已统一到另一套语汇:表面填充的圆,加两像素强调色描边。通话页写在这套语汇出现之前,也没人有理由回头改它。

共用件是 CircleSurface

它是一个用主题表面色填充的圆,两像素描边,图标染成同色。AppCircleButton 把它包进 IconButton,并把可见的圆与周围的点击区域拆开。

真正起作用的是两个属性。填充用主题的表面色,所以这个圆能坐在玻璃、视频或照片上,而不是化进半透明遮罩里。描边在激活控件上按全强度绘制,而不是非激活导航项用的六成弱化——在明亮的视频通话上,弱化描边等于一个看不见的按钮。

通话页组合出 CallActionButton:那个圆加通话专用尺寸,再加语义标签。结束卡片用同一个控件。收起键用默认尺寸的 AppCircleButton,因为它属于顶栏,不属于拇指那一排。

为什么通话排是 72dp,导航是 40

CallActionButton 只定义一个数字:72dp 直径,圆和点击区域一起算。应用里的迷你浮动按钮是 40dp,对于你盯着点的列表行,这个尺寸是对的。

通话不一样。用户常常根本没在看控件,他们在看人,或者看房间里的东西,靠肌肉记忆伸手。实时视频上放一个 40dp 的目标就是误触磁铁,而通话排上的一次误触,可能意味着挂断某人的电话。72 大致就是“瞄准”和“祈祷”之间的差距。

字形跟着尺寸走:72dp 圆里放 32px 图标,视觉重量贴近无文字的导航按钮,而不是变成大圆环里的小图标。

语音通话只有三个控件,不是四个:静音、扬声器、挂断。摄像头切换只存在于视频,藏掉它之后这一排是均匀分布的,而不是在第四个按钮该在的位置留个缺口。

一个形状,两种强调

破坏性操作应该一眼可辨,而常见的三种做法都不如颜色。换形状会打断这一排的节奏;放大按钮会让整排不稳;换材质则像是从别的组件库里借来的。

所以挂断保留完全相同的圆、描边粗细和图标尺寸,只换强调色:主题的错误色替换主色。静音中的麦克风同理,因为“你的麦克风关了”也是用户需要一眼注意到的状态。其余一律保持主色。形状承载身份,颜色承载状态。

这也是强调色做成共用表面上的可选参数、而不是第二个控件的原因。导航、聊天头部和发送按钮都用主色默认值,这次覆盖没有动到它们依赖的任何东西。

无障碍标签刻意没有改。静音与取消静音、切换摄像头、扬声器开与关、挂断——图标本身天然有歧义,是这些标签让这一排对屏幕阅读器可用。一次视觉重构如果悄悄改了它们的名字,那是把退化包装成改版。

结束卡片挪到拇指已经在的地方

结束卡片原本把两个操作放在列中间:通话摘要下方一个填充按钮、一个描边按钮。看得清,位置错了——刚挂断电话的那只手在屏幕底部。

现在两个操作都是 CallActionButton,放在同一个底部行里,同样的 24dp 内边距、同样的 spaceEvenly,于是重拨或离开占用的正是拇指已经熟悉的坐标。图标是电话和箭头,“再拨一次”和“返回”作为语义标签保留下来,而不是可见文字。

有一个顺序细节比听起来更重要。这一行绘制在卡片的关闭层之上,所以按任一按钮都立即生效。如果它位于那一层之下,第一次点击只会关掉浮层,用户得点两次——这个 bug 看起来像延迟。

收起窗口保留自己更小的一对

通话收起后变成的浮动窗口是刻意不同的表面。它是 132×180 逻辑像素,浮在用户正在做的事情之上,装着对方视频、名字、走动的时长和两个快捷操作:静音与挂断。

72dp 的圆塞不进那张卡片,硬塞会吃掉视频。所以这个窗口保留两个紧凑的 36dp 圆盘。它们用同样的红色表示挂断、同样的语义标签,静音时静音键变红。这是通话控件唯一不用共用件的地方,而那是尺寸决定,不是样式决定。

这个窗口更有意思的部分是它的位置。

键盘是已被占用的空间

收起通话,回到某个聊天,点输入框。软键盘升起——浮动通话窗口还停在原处,压在键盘下面,按钮和对方视频都被按键挡住。

这个窗口活在导航器之上,挂在应用层级,这样通话进行中它能扛住路由切换。这也意味着键盘永远不会改变它的尺寸:那个内边距属于另一棵子树,窗口必须自己察觉并移动。

修法从把键盘当作不可用区域的一部分开始:取 MediaQuery 的 padding 与 viewInsets 底部值,取两者中较大的那个作为 bottomInset。这个值从窗口可占用的高度里减掉,于是它左上角能到达的位置停在键盘开始的地方。拖拽、夹取和边缘吸附都继续在缩小后的矩形上工作。

这里刻意用 max 而不是相加。键盘内边距本身已经向下延伸穿过系统底部内边距,两者相加会把窗口浮到远高于键盘实际顶边的位置——技术上没遮挡,视觉上是错的,在底部指示条区域大的手机上最糟。

因为两个内边距都作为 MediaQuery 依赖读取,键盘动画和安全区变化时窗口会重建——没有要泄漏的监听器,没有要跟系统自有动画同步的控制器。

一次不写入位置的抬升

这个修法微妙的一半是:抬升不能被记住。

窗口把位置存成可用区域的比例——哪一侧、往下多少——并在下次通话时恢复。如果键盘驱动的抬升写进了那个存储值,一个在通话中打了一条消息的用户,回来就会看到一个永远钉在屏幕中央的窗口。记住的位置会变成一次临时布局的化石。

所以抬升只作用于显示。存储位置只由一件事写入:拖拽。键盘弹起时,窗口在缩小后的区域里按记住的比例解算,自然就落在键盘上方。键盘关闭,区域长回去,窗口回到它记住的那一侧和那个比例。

键盘可见时拖拽是刻意的例外。那是用户对窗口该住哪里的真实决定,所以它保存新位置——并且是按临时缩小区域的比例保存,这意味着两种状态下它都落在合理的地方。

因为记住的位置是可用区域的比例而非像素坐标,窗口按比例落位:靠近该区域底部时它抬离键盘,靠近顶部时几乎不动。边缘吸附仍在松手时发生,按缩小后的宽度测量。

那个伪造 300px 键盘的测试

Widget 测试叫不出真键盘,但可以设置键盘产生的内边距。测试把设备像素比设为一,让物理像素与逻辑像素一致,设定表面尺寸,按应用挂载的方式泵入浮层,并记录窗口的矩形。

然后它断言默认右下位置时底边距安全区一个边距,设置 300px 底部内边距,再断言窗口底边已移到键盘上方——仍然留一个边距。清除内边距后它回到原始矩形,正是这一条证明抬升只作用于显示,而不是新的记忆位置。

最后那条断言,是我在任何带浮动表面的应用里都会保留的。“它正确地移动”很容易碰巧做对;“它会回去”才是会悄悄退化的部分。

短版本

  • 一个圆、一种描边粗细、一个图标尺寸:身份来自形状,状态来自强调色。
  • 按手来定这一排的尺寸,不是按网格。实时视频上 72dp 是功能,40dp 是赌博。
  • 视觉重构中保持语义标签稳定,它们是契约的一部分。
  • 把通话后的操作放在控件原来的位置,并绘制在任何关闭层之上。
  • 把 viewInsets.bottom 当作已占用空间,按底部内边距与键盘中的较大值内缩,而不是两者之和。
  • 永远不要让临时布局的写入污染记忆状态;拖拽是位置的唯一作者。
  • 测试回程,不只是测试抬升。

两半都来自同一个问题:这个表面要正确,需要什么?对按钮来说是一个尺寸和一个形状。对窗口来说是一个会缩小的矩形和一个不会变的定位。浮动导航栏教给我们的关于界面装饰的道理也一样:先几何,后颜色。