模态和对话框

最近这两年,我对软件 UI 设计经常反复强调的一个意见是尽量避免使用模态对话框 (modal dialog box) 。这个曾经广泛应用的 UI 元素逐渐式微。不严肃的说,模态对话框的多寡似乎成了区分「企业开发」的垃圾 UI 和消费者/专业级软件 UI 的一个新指标。

有些设计源自妥协,一旦妥协的前提不复存在,建立于其上的设计也随之衰败。比如说,一度公认的观念是应用程序必须「正常」退出,强行中止程序或者 crash 可能导致严重的数据丢失。但是近年来 iOS app 和 OS X 的 sudden termination 概念改变了旧观念。因为硬件性能的提高,原来把重要数据只保留在内存中直到用户明确表示存盘或者退出的做法,逐渐被更安全的方式替代 —— 重要数据一旦被创建或者修改立即写入磁盘,称为 crash-only 方式。「正常」退出成为不必要的 performance hack。

模态对话框也源自类似的妥协,我称之为「系统范化 hack 」。将本该由软件设计者承担的责任过多地由系统和 UI framework 代劳,根源在于对非功能性需求的轻视。OS X 和 iOS 将用户体验提升为最重要的竞争指标,「系统范化 hack 」也失去了立足的基础。

模态操作、消息循环和对话框

基于 model-view-controller 模式的 app 的用户体验由一系列修改 model 数据的「模态 (modal) 」过程组成。在一个模态过程中,某些 UI 元素接受用户输入并修改 model,其它 UI 元素接受用户输入的能力被暂时禁止或限制,以此避免过多不确定顺序的修改操作破坏 model 的数据一致性 (integratiy) 。「模态」的名称就是表示 UI 时刻处在不同的状态 (mode) 中,每个状态中只有特定的元素可以接受用户输入。「模态」过程由下面的概念构成:

  • 过程的开始和结束,即本次 mode 的时间范围;
  • 过程的边界,即在本次 mode 的时间范围内可以接受用户输入的 UI 元素和禁止接受用户输入的 UI 元素的界限;
  • 过程的结果:根据用户的输入修改 model (submit),或者在不修改 model 的情况下中止 mode (cancel)。

最基本的模态过程实现是把完成该过程的所有代码都放在单次 event-handler 调用中,在过程结束之前,消息循环无法获得控制权,以此禁止所有 UI 元素接受用户输入。这种方式有两个局限:

  • 执行的操作耗时不可过长,否则会导致界面假死;
  • 对用户输入简单地完全禁止,而非有选择的限制。在过程开始之后无法追加修改输入。适合诸如「键盘输入一个字符」或者「按下一个按钮」这类无追加输入的操作。而像文件存盘过程中涉及文件系统的 naviagation,起名等操作,就不能采用阻塞消息循环的方式。

根据「模态」过程的一般定义可以显而易见的分析模态对话框的行为。开始和结束对应对话框的打开和关闭,边界即对话框的边界,submit 和 cancel 由对话框的不同关闭操作完成。

从实现角度看,模态对话框是 UI framework 对「第二消息循环」的粗糙封装。内层循环可以阻塞主消息循环,而且消息循环具备过滤 event 的能力,不但可禁止 UI 元素接受用户输入,还可以有选则的允许用户追加修改输入 [1],所以「第二消息循环」很适合实现模态过程的边界和时间范围,自然而然的成为模态过程的主要实现方式。但是消息循环本身是一个比较难理解的概念,MFC 和 Java Swing 甚至企图完全隐藏它 [2]。所以,早期的 app 开发者和 UI framework 串通好采用模态对话框这个粗劣封装。本该由开发者决定的丰富多彩的「模态」过程被系统僵化同质的罐装行为替代。

模态对话框这个 one-size-fit-all 的封装面临一个两难问题:模态过程和整体界面的融合度高不高?如果融合度很高,那么弹出对话框的行为就会撕裂用户体验。如果融合度不高,那么在模态过程中,对话框下的主窗口就是干扰用户注意力的视觉污染。所以 UI 设计应该尽可能抛弃模态对话框,根据模态过程和整体 UI 的融合度来决定时间范围和边界行为。

鼠标拖放

鼠标拖放 (drag and drop) 第一代 Macintosh 和 Windows 3.x 时代就进入了用户体验。但是相对复杂的编程接口让程序员总是希望逃避它,所以一直以来只有 Finder、Windows Explorer 以及 Text Field 之类的系统控件支持有限的缺省拖放行为。很多开发者没有意识到鼠标拖放是一种能被高度定制的模态过程。这是非常可惜的。鼠标拖放巧妙的利用了「冲压机原理」[3] 来限制边界。通过把模态过程的开始和结束分别定义为鼠标键的按下和抬起来占据鼠标设备,避免边界之外的 UI 元素接受用户输入 [4]。

鼠标拖放过程的边界由三种 UI 元素构成:拖动的元素,开始拖动的源,drop 的目的元素(有多个可能的目的元素,最终在其中之一上进行 drop 而结束过程)。如果对 model 的某种修改能够反映为 UI 元素的空间位置变化,就可以采用鼠标拖放。比如常见的图片编辑器中对图片的平移、旋转、缩放。常见的文件移动操作因为 spatial file manager 的概念把文件操作对应为空间操作,也采用了鼠标拖放。

鼠标拖放由「第二消息循环」来实现 [5]。由于 MFC 和 Swing 等 framework 试图隐藏消息循环概念,它们支持鼠标拖放的 API 相当蹩脚,文档晦涩不明,是阻碍拖放的流行的一个因素。在 Cocoa 和 Qt 这类 framework 中鼠标拖放的编程模型回归到基于消息循环的概念 [6]。由鼠标键的按下开始拖放过程。在合法目的元素上,鼠标键的抬起引发 submit,在非法目的上,引发 cancel 操作。键盘的 ESC 键也可以进行 cancel 操作。鼠标拖放可以配合键盘修饰键(比如文件拖放中 Shift 的按下与否表示拷贝还是移动),磁力粘合,拖动手势,不同的目的元素接受 drop 的行为等追加输入完成复杂的操作。还可以配合 UI 元素上不同的 handler 完成旋转、缩放等操作。和整体 UI 的融合度很高的模态过程几乎都可以采用鼠标拖放来完成。设计良好的图形化 UI 的重要特征是把 model 的表示空间化 [7]。

进度条

使用进度条,是避免 UI 过度复杂的前提下,在耗时较长的计算过程中禁止用户的一切输入,同时避免 UI 呈现假死。用户可以中止该过程,以放弃部分完成的计算结果为代价换取立即执行其它操作的机会。在老式 UI 中这类过程通常由带有一个进度条 (progress bar) 和一个 cancel 按钮的模态对话框实现。

这种模态过程的 submit 不由用户控制,用户或者等待过程结束自动 submit,或者主动 cancel。如果换一个思路,此类模态过程无需禁止用户输入 —— 对 UI 元素操作视为 cancel 即可。因为没有复杂的边界定义(也就无需 event 过滤),这种模态过程可以采用「第二消息循环」以外的方式实现(比如 timer)。可以直接在主窗口上显示进度条。过程完成后隐藏进度条,在操作过程中如果用户进行其它操就 cancel 过程。

进度条对话框还有一个略带病态的变种 —— 提示对话框 (alert box) ,仅仅向用户提供一条信息。这种操作应该由主窗口底部的状态条来代替。

整体状态切换

如果模态过程和整体 UI 的融合度非常低,应该考虑让主窗口整体进入模态。Photoshop 的 Filter plug-in 是采用模态对话框实现的。但是近年来随着新出现的 Filter UI 越来越复杂,新的 Filter 如 CS6 版本的 Lighting Effect 和 Field Blur 均采用了主窗口整体模态的方式,在 Filter 工作时不再弹出模态对话框,而是将整个 Photoshop 主窗口切换为 Filter Workspace。

在这种模态过程中,边界之外的 UI 元素被隐藏,甚至完全销毁,所以不存在 event 过滤问题。实现的难度在于模态过程之前的 UI 状态保存和之后的状态恢复。需要应用程序开发者实现的细节比较繁琐,UI framework 提供的编程接口则相对简单。

附着型系统控件

上面谈的例子宗旨是让 app 开发者多做一些,让 UI framework 的责任少一些,接口灵活一些。但是在快速开发中,app 开发者还是期望有一些简单的方案。现代的 UI framwork 也提供了一些新的罐装机制 —— 虽然不如手工打造的模态过程精美,也比老旧的模态对话框好得多。

除了提过的融合度难题,老式模态对话框还有两大弱点:第一,它阻止用户访问同一 app 的所有其它窗口;第二,弹出模态对话框的窗口 (owner) 和对话框本身没有任何视觉联系。从逻辑上来说,每个模态对话框都有自己的 owner,而且在多窗口 app 中,每个窗口代表一个独立的 document,实在没有必要让一个模态对话框阻断用户对所有窗口操作。新的罐装模态过程机制虽然不能解决融合度问题,但是针对这两个次要弱点做了不小的改进。

OS X Cocoa 提供了 pull-down sheet,解决与 owner 缺乏视觉联系和禁止所有窗口操作的问题。在多窗口的 document-based app 中,pull-down sheet 可以在不增加代码复杂度的情况下完全替代模态对话框。Lion 又提供了 Pop Over  控件,将模态对话框的 owner 关系进一步突显到控件级别。

脚注:

  1. 这也是「对话 (dialog) 」一词的由来。
  2. MFC、Swing 等 UI framework 试图隐藏消息循环的做法给开发者的学习制造了不小的障碍。我在 MFC 和 Swing 中滚打多年,对这个概念的理解也一直比较模糊。多年以后,在 Cocoa 的文档中看到开篇明示消息循环的概念,才有醍醐灌顶的感觉(见注释 5)。
  3. 为了防止意外伤害工人的手,冲压机有两个冲压按钮,在远离冲压孔的两个方向,只有两个钮同时按下冲压机才会执行冲压操作。所以只有两只手的生物绝对没有机会在冲压操作中把手放到冲压孔内。
  4. 至少避免了指点设备 (pointing device) 的输入。键盘成为拖放操作中追加后续修改操作的手段。
  5. 这点可以参考 Cocoa 的 Event Handling Guide,Handling Mouse Dragging Operations
  6. 同注释 3。特别是其中的 The Mouse Tracking-Loop Approach。注意,鼠标拖放 API 的设计模型基于第二消息循环,并不意味着 API 本身必须完全暴露第二消息循环。
  7. 从另一方面说,本质上高于三维的 model 已经不适合图形化 UI。比如编程语言。参考知乎问题

一条回应 to “模态和对话框”

  1. 混沌周刊 #19 | 永久链接不永久 – 混沌周刊 Says:

    […] 为什么要尽量少使用模态对话框? […]

留下评论