一时兴起想谈谈UWP按钮的设计。
按钮是UI中最重要的元素之一,可能也是用得最多的交互元素。好的按钮设计可以有效提高用户体验,构造让人眼前一亮的UI。而且按钮通常不会影响布局,小小的按钮无论怎么改也不会对性能有多大影响,所以不少注重细节的设计师最为热衷修改按钮。(例如 这位 )
目前UWP只提供了基础款的按钮样式,网上相关资源也较少,所有写了这篇文章用于介绍在UWP上设计按钮的入门知识。
1. VisualStates
Button的CotrolTemplate(可以参考 这里 )中包含四个VisualState,分别是Normal、PointerOver、Pressed、Disabled。
Normal: Button的默认状态,UWP的按钮是完全扁平化的设计。没有边框。
PointerOver: 鼠标进入的状态,出现边框,背景颜色也会改变。
Windows中通常不会用改变鼠标指针来表明“这是一个Button”,而是让Button进入PointerOver状态。只有HyperlinkButton是特例,符合 W3C的建议 使用了CoreCursorType.Hand作为鼠标指针。
Pressed: 按下的状态,有趣的是除了改变颜色Button还应用了PointerDownThemeAnimation使得按钮向按下方向倾斜,营造一种有深度的设计。不过这个做法会导致Button的内容变模糊(Projection都有这个问题),如果介意的话可以使用ScaleTransform缩小整个按钮,让整个按钮像被按了下去。
Disabled: 当IsEnabled="False"
时的状态,一般控件都有这个状态,大部分都表现为背景变灰,字体颜色变浅,表示不可操作。有时偷懒我会直接将整个Button 设置成 Opacity = 0.4
代替分别为每个元素设置颜色,或者只将Foreground设置成#66000000
。
习惯做触屏设计的话很容易就忽略PointerOver状态,目前UWP大部分的应用场景还是在桌面上,所以应该在ControlTemplate中包含这四种VisualState。
除了这四种VisualState,Button还可以定义FocusStates,具体可以参考 这篇文章 。
2. 按钮设计
在默认的ControlTemplate中VisualState之间的转换没有过渡,点击后弹起的动画也很迅速,我自己设计Button时也不做过渡动画,或者过渡动画的时间十分短,这么做有几点好处:
- 现在流行轻、快的设计。
- 就算过渡动画做得很好看,作为使用最频繁的UI元素,对那些一天使用Windows 十几个小时的人很容易会造成视觉疲劳。假设Windows中所有Button都有很华丽的过渡动画,虽然很酷炫,用一会就烦了。
- Button的天生职责就是响应迅速,如果设计了过长的过渡动画会令人感觉反应迟缓。
- 过渡动画的时间太长,实现得不好有可能在切换状态时上一个状态过渡动画还未执行完成,造成断裂感。
- 被设计师或产品经理发现原来可以实现华丽的动画,他们很容易会得寸进尺。
- 懒。
软件中按钮的风格取决于软件服务的群体,工业软件喜欢沉稳的风格,面向互联网的软件通常鲜艳、轻快。接下来给出一些常用切容易实现的Button设计,还会给出一些花哨的技巧。这些花哨的技巧并不是鼓励任何人在系统上用标新立异的设计,设计良好的Button是生动有趣的UI中重要的一环,但前提是设计良好,如果团队里没有靠谱的设计师,还是用最保守的设计最保险。
2.1 无边框按钮
使用无边框按钮可以去除多余的装饰,留给文字的空间更大,界面也更清爽,通常用在Toolbar里或拥挤的位置。无边框按钮其实最难用,因为它很用以看上去不像个按钮。有几种方法可以避免这个状况:
- 使用明显的颜色。
- 放在约定俗成是按钮该放的位置,如果前后都有按钮就最好。
- 使用简短的动词或直接使用图标,使用形容词或名次的话看上去会像状态标签,使用长语句则看上去像普通文本。
- 如果用在拥挤的位置,一定要有足够的间隔并且用突出的颜色,因为没边框很容易和前后文连在一起。
无边框按钮很容易实现,最简单的是如下所示使用默认Button样式移除边框和背景,或者直接使用AppBarButton
或HyperlinkButton
,我最推荐这种做法:
另外,如果没有及时的反馈会导致用户以为操作失败,然后重复点击,所以不建议Pressed的过渡动画时间太长。如果必要还可以加上声音提示,声音提示在银行或政府机构的程序上很常见,可能也是为了防止重复输入吧。
3.2 Cosplay成导航菜单
有些按钮喜欢Cosplay成ListBoxItem或者TtabItem,虽然他们可能真的是个ListBoxItem控件或TabItem控件,但完全是Button的行为。例如Win10中邮件应用的“新邮件”按钮。通常来说“新建邮件”应该使用动词短语,显眼的颜色或图标,这样容易识别,例如以下这些:





只有Win10邮件应用的“新建邮件”完美地Cosplay成导航菜单:

不止外观上就是导航菜单,“新邮件”这样的文字很容易令人误会成“新收到的邮件”,和“未读邮件”差不多。我老婆是国内某上市软件公司的用户体验设计师,对软件UI也算是见多识广。之前她一直用Outlook桌面或网页版收发邮件,前两个月她第一次用邮件应用的时候用了几分钟都找不到在哪里新建邮件,最后还是我看不下去好心告诉她的(虽然也有我一直和她说话让她分心的原因)。按钮应该用简单的动词,放在合理的位置,和其它功能区分开,显然邮件应用完全做不到。
3.3 用错“确定、取消”的顺序
在Windows上所有应用程序的对话框的操作按钮顺序一直是“确定、取消”,理由有不少:
- 一直以来的传统,如果改成“取消、确定”会有很多误操作。
- 符合“Yes or No”、“是否”这样的语言习惯。
- 如果用Tab键导航首先会去到“确定”,再去到“取消”。用鼠标移动也是先进入“确定”。毕竟没有人是为了点“取消”才填一大堆表单内容的,所以“确定”放在前面很合理。
- 窗口的“关闭”按钮在右边,同样能关闭窗口的“取消”按钮也应该在右边。
最近几年随着IOS和Android越来越流行,Windows或网络上也出现不少用“取消、确定”顺序的,它们也有合理的理由:
- “返回”按钮在左边,“取消”按钮也应该在左边。
- 通常点击“确定”后整个向右滑动,所以“确定”按钮应该在右边。
- 右撇子用右手拿着手机时右边的按钮比较容易按到。
- 根据古腾堡法则,确定按钮放在右下角会比较容易看到。
虽然各自的理由都挺合理,但在Windows上应该符合一直的习惯按“确定、取消”排放按钮。在UWP中可以ContentDialog默认就是使用“确定、取消”顺序。另外,使用ContentDialog时最好将主按钮换成要执行的操作的动词,代替“OK”:

如果公司里Android/IOS组十分强势,或者设计师十分喜欢Android/IOS,那么妥协将主要按钮放在右边也是没问题的。最重要是保持一致性,就算不是与系统保持一致,在程序内也应该保持一次,避免用户混淆,而混淆是导致操作出错的主要原因。
现在来看看Win10上的邮件应用是怎么做的:


我是斯文人,我才不会用现在很流行的张学友那句经典台词来表达我的感想(虽然我心里千真万确是这样想的)。
除了确定按钮放左边还是右边这个问题,还可以延伸出放在上面还是下面这个问题。
放在上面的理由有:
- 标题栏下面是菜单栏,再下面是工具栏,大部分操作都集中在上面那,所以将确定(或者发送、保存等)按钮放在工具栏很合理。
- 如果是手机应用,将按钮和标题放在一栏可以节省空间。
- 同样是手机应用,下方整个区域要留给内容,不要分别在上面和下面都有操作。
- 同样是手机,放下面有可能被键盘挡住,需要收起键盘才能点击;有些时候又会被弹出的键盘挤到中间。
- 如果不是相对屏幕固定,内容过长的话,可能滚到下面才能看见,如果第一个输入框就输入错误又要回到最上面编辑,然后又滚到最下面才能点击确定;如果相对屏幕固定的话又太占地方。
放在下面的理由有:
- 手机的话操作放下面操作起来方便。
- 视觉上的次序,表单的输入顺序是一路向下的,最后按钮也应该是下面。(Android刚把确定按钮放到右上角的时候就很不适应,常常找不到确定按钮,有些应用还做成要输入最后一项右上角才出现确定按钮,还好现在套路熟了就没什么问题了。)
- 确保用户看完表单,即使出现了滚动条表明这个表单很长,有时用户可能根本察觉不到原来还有内容没看完,如果按钮在下面用户就会为了找到按钮滚动到下一屏幕而看到所有内容。
- 和菜单栏工具栏一起,所有按钮都放在上面会造成画面太过拥挤。
- 右上角通常是关闭按钮的位置,在同一个地方放确定按钮很容易让人混淆。
放在上面还是下面都各有好处,如果在Windows系统上我不建议放在右上角,因为太容易点击到窗口的关闭按钮,一不小心辛辛苦苦输入的内容就随风而去了。而且很多时候“取消”按钮也能关闭窗口,将两个同样功能的按钮放在一起有些别扭。

为了避免按错,通常会把默认按钮做突出显示。ContentDialog可以设置DefaultButton
令指定的按钮变得突出:

ContentDialog subscribeDialog = new ContentDialog
{
Title = "Subscribe to App Service?",
Content = "Listen, watch, and play in high definition for only $9.99/month. Free to try, cancel anytime.",
CloseButtonText = "Not Now",
PrimaryButtonText = "Subscribe",
SecondaryButtonText = "Try it",
DefaultButton = ContentDialogButton.Primary
};
3.4 放在错误的位置
如果用无边框按钮,真的要小心摆放的位置,一不小心就会被误会成标签或状态信息。放在约定俗成的位置的话就算看上去很像但也分得清哪些是标签,哪些是按钮。例如窗体顶部中间的文字就不会和其他白色文字一样被认为是按钮:

放在错误位置的另一个坏处就是很容易点错,实在没办法可以将按钮做大些以免点错成旁边的按钮。
像邮件应用的这个“发送”按钮,不但用了“取消、确定”的模式,而且用窗口的“关闭”按钮和“删除”按钮夹着“发送”按钮,每次点击都要小心翼翼的:

另外要吐槽下这个“锁定”按钮:

要点中“锁定”,首先要避开“关机”按钮点中那个小小的“三角形”,然后再小心翼翼选中被“重新启动”和“注销”夹着的“锁定”按钮。注意在知道锁定的快捷键是“Win+L”的情况下,看到"注销(L)"要保证自己不被迷惑。我觉得比起要快速点中“锁定”,用黑百合一枪爆头开大中的源氏还容易一些。如果“注销”和“锁定”互换位置,即使不小心点中了“切换用户”破坏性都没这么大。误操作了很多次后我终于放弃了这个按钮,学会了"Win+L"这个快捷键。Win10中将“关机、重启”和“注销、锁定”的功能分开了,而且增大了按钮面积,终于解决了这个问题。
最后一点,按钮最好摆放在出现反馈的提示附近(或者反过来说反馈的提示要放在按钮附近)。人的视觉区域很有限,点击“提交”时很可能完全看不到表单顶部弹出的错误提示,或是点击“转账”时看不到屏幕顶部的进度条已经出现而重复点击多次。
3.5 使用错误的图标
这倒是不常见,可能是因为有使用图标的团队通常都有设计师。暂时只想起这个:

看起来很像TreeView或Expander,其实这三个都是HyperLink。虽然这个图标也不算错,如果图标放在右边也就不会被误会成TreeView了。
3.6 使用错误的文字
典型的错误是没有遵循“使用动词”这个原则,如上述邮件应用的“新邮件”。
另一个常见错误是用不同的术语表述同一个动作,典型的如“Create”和"Add","Search"和"Find"。
另外没有正确使用省略号也是常见的错误。
省略号帮助用户预先判断一个命令是会立即执行还是会提示要求输入附加信息。如果用户能够预先知道点击一个不熟悉的按钮仅仅会弹出一个对话框,他们会觉得这样更加安全。 --《GUI设计禁忌 2.0》
这一点Windows自带的传统软件都做得比较好。

并不是所有打开窗口的按钮都需要省略号,而且现在很多软件为了界面简洁连省略号都会省略,有时则是使用了图标的按钮再用省略号设计上不好看。重要的仍是保持软件内的一致性。
3.7 错误的颜色搭配

这是Win7的任务栏,我用了张浅色的背景壁纸,选中的状态下只能勉强看到右边是“新建文件夹”几个字。但在白天我的镜面屏显示器就不怎么靠谱了,几乎分辨不出那几个字。虽然没有这么极端,现在很多人都在阳光下使用笔记本、平板、手机,遇到高光的按钮还是会很困扰。根据Web Content Accessibility Guidelines (WCAG) 2.0,文本的视觉呈现以及文本图像至少要有4.5:1的对比度。
3.8 缺乏一致性
有时同一个场景中类似功能的按钮使用了不同的样式,或者大小,破坏了UI的一致性。同一个系统中适当的多样式可以提高用户体验,但也要保持必要的一致性。“确定”、“取消”是不同功能使用不同的样式没有问题;如果只是宽度不一致也没什么问题。要避免出现高度或者Padding等属性不一致的按钮。

3.9 缺乏多样性
虽然我一直强调用UWP原本的按钮设计是最保险的,但有时完全不修改就用也很难看,适当的多样性是必须的,否则我也不会在前面提出这么多按钮设计。
UWPCommunityToolkit中的 Expander 就是个让人失望的设计,Header部分的按钮保留了Pressed状态向下倾斜的设计,导致当点击Header时整个控件从中间断裂。

4. 如果要自己从头开始实现一个Button
如果真的要自己实现一个Button,又不想从ButtonBase继承,可以参考下面的代码自己实现:
[TemplateVisualState(Name = StateNormal, GroupName = GroupCommon)]
[TemplateVisualState(Name = StatePointerOver, GroupName = GroupCommon)]
[TemplateVisualState(Name = StatePressed, GroupName = GroupCommon)]
[TemplateVisualState(Name = StateDisabled, GroupName = GroupCommon)]
public class SimpleButton : ContentControl
{
internal const string StateNormal = "Normal";
internal const string StatePointerOver = "PointerOver";
internal const string StatePressed = "Pressed";
internal const string StateDisabled = "Disabled";
internal const string GroupCommon = "CommonStates";
internal const string StateFocused = "Focused";
private bool _isPointerCaptured;
public SimpleButton()
{
DefaultStyleKey = typeof(SimpleButton);
IsEnabledChanged += OnIsEnabledChanged;
}
public bool IsPressed { get; private set; }
public bool IsPointerOver { get; private set; }
public event RoutedEventHandler Click;
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
UpdateVisualState(false);
}
internal void UpdateVisualState(bool useTransitions = true)
{
if (IsEnabled == false)
VisualStateManager.GoToState(this, StateDisabled, useTransitions);
else if (IsPressed)
VisualStateManager.GoToState(this, StatePressed, useTransitions);
else if (IsPointerOver)
VisualStateManager.GoToState(this, StatePointerOver, useTransitions);
else
VisualStateManager.GoToState(this, StateNormal, useTransitions);
}
protected override void OnPointerPressed(PointerRoutedEventArgs e)
{
base.OnPointerPressed(e);
if (e.Handled)
return;
if (IsEnabled == false)
return;
e.Handled = true;
_isPointerCaptured = CapturePointer(e.Pointer);
if (_isPointerCaptured == false)
return;
IsPressed = true;
Focus(FocusState.Pointer);
UpdateVisualState();
}
protected override void OnPointerReleased(PointerRoutedEventArgs e)
{
base.OnPointerReleased(e);
if (e.Handled)
return;
if (IsEnabled == false)
return;
e.Handled = true;
if (IsPressed)
Click?.Invoke(this, new RoutedEventArgs());
IsPressed = false;
ReleasePointerCapture(e.Pointer);
_isPointerCaptured = false;
UpdateVisualState();
}
protected override void OnPointerMoved(PointerRoutedEventArgs e)
{
base.OnPointerMoved(e);
if (_isPointerCaptured == false)
return;
var position = e.GetCurrentPoint(this).Position;
if (position.X < 0 || position.Y < 0 || position.X > ActualWidth || position.Y > ActualHeight)
IsPressed = false;
else
IsPressed = true;
UpdateVisualState();
}
protected override void OnPointerEntered(PointerRoutedEventArgs e)
{
base.OnPointerEntered(e);
IsPointerOver = true;
UpdateVisualState();
}
protected override void OnPointerExited(PointerRoutedEventArgs e)
{
base.OnPointerExited(e);
IsPointerOver = false;
UpdateVisualState();
}
private void OnIsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (!IsEnabled)
{
IsPressed = false;
IsPointerOver = false;
_isPointerCaptured = false;
}
UpdateVisualState();
}
}
不考虑Command、ClickMode等属性的话实现起来还算简单,只需要继承ContentControl并处理好Pointer事件就可以,ControlTemplate可以直接复制Button的ControlTemplate。要注意的是在OnPointerPressed
函数中调用public System.Boolean CapturePointer(Pointer value)
捕获指针,这样可以防止点击后没释放鼠标的情况下,又将鼠标移动到其它UIElement上时触发它们的Pointer事件。例如下面的场景,在左边按钮按下鼠标后鼠标再移动到其它按钮并不会改变那个按钮的状态:

而没处理好的按钮则是这样,如果在右边的按钮上放开鼠标,看上去就是点击了右边的按钮:

5. 结语
虽然这篇文章很长,但没什么深入的技术,我也不是专业设计人员,各方面都只保留在“浅谈”的范畴。
和以往一样,大部分内容都适用于WPF。
并没有鼓励任何人在UWP中使用自定义按钮样式的意思,只是看到太多不好的设计,想贡献自己的经验和建议以供参考。除非必要还是用UWP提供的按钮样式最保险,只要使用合理的文字、摆在合理的位置就足够了。按钮设计的最基本要求是容易识别、容易操作、不易出错,华丽的外表只是其次。
当年在WP7上看到开始屏幕的磁贴向下倾斜的动画真的很惊艳,但那是2010年的事了,当时Android刚出2.0,现在Android都快出8了。多年来磁贴只进行了小幅更新,Windows自带按钮也没什么惊艳的设计。其实我很喜欢Win8、Win10的按钮设计(除了Focus状态下的粗边框),说难听点是平庸,说好听点是含蓄。但也想UWP提供一些惊艳的、多样的设计给开发者选择。Reveal正好满足了我这个需求。目前Reveal还不是那么完美,还在调整,希望正式推出时能给我更多惊喜吧。
6. 参考
Modern Design at Microsoft
Fluent Design System
扁平化设计
处理指针输入 - UWP app developer
从“按钮”看设计风格的演变
7. 源码
GitHub - ButtonStyleGallery
本文中出现的按钮设计都可以在这里找到,只是作为Demo没有好好调整颜色和过渡动画,如果拿去用可能还是需要自己修改。