在一个白板类应用的交互中一定会涉及到模式之间的更换和交互冲突。白板类软件的交互模式一般包含了笔迹书写模式,选择模式,擦除笔迹模式等。多个模式之间存在切换,而切换可以发生在某个模式执行过程,如需要在白板软件里面支持笔迹书写功能,在书写的过程打断进入笔迹的擦除模式。本文告诉大家我所在团队的白板内核的模式交互设计方案,本文不会涉及到具体实现的逻辑代码

我从 2017 开始到现在都在做白板软件,我对整个白板体系的软件层面都比较了解。整个开发过程也对整个白板软件的模式交互方案换了有一些方案,当前使用的方案也许不是最优的,但是相对来说比较适合业务

整个框架(不敢说架构)里面三个大块,第一块是输入前置,第二块是输入切换,第三块是业务处理

输入前置

小伙伴都知道,在 Windows 下实现触摸不是简单的事情,而在 WPF 中尽管有大量的封装,但是对于整体触摸来说,依然存在一些业务上的坑。如按下和抬起不成对等。而我期望在上层业务里面不应该每个业务都处理这些交互上的问题。因此就有了对 WPF 层的交互的封装,此封装可以定制交互输入数据,同时隔离框架差异。换句话说是这套框架可以脱离 WPF 执行

在触摸屏幕上面,在 WPF 收到的触摸可以通过监听三个不同的事件 Touch Stylus Mouse 事件,这三个事件的触发顺序以及触摸和触笔的差异,会让上层业务开发者们不得不在开发的时候关注这些细节。如果业务开发者需要关注框架细节,那么肯定会带来业务复杂度以及挖坑。毕竟相同的逻辑写10次,基本上就有一次写出坑

在输入前置的第一层就是 SourceInput 层,这一层将隔离框架和平台差异的交互输入,同时约定一些通用交互。包括定义了 PointerDown PointerMove PointerUp PointerHover 这几个事件。从事件命名上可以了解到,这个事件是参照 UWP 的 Pointer 的设计。无论是鼠标输入还是触摸输入还是触笔的输入,全部统一化。至于鼠标和触摸等之间的差异,会放在事件的参数里面,提供给特殊的业务可以判断

上面有一个细节是添加了 PointerHover 事件,这个事件其实是将原本的 Move 事件拆开为 PointerMove 和 PointerHover 两个事件。表达的含义是在没有按下之间发生的都是 Hover 事件,而按下之后发生的就是 Move 事件。为什么这样做?在阅读大量业务的代码发现,基本上所有用到 Move 事件的地方都需要添加一个字段用来判断当前是否是按下,如果是按下的 Move 才做业务。为了减少相同的业务代码,在框架底层将 Move 分为两个事件,可以让业务开发者用到 Move 的时候就是按下状态的移动

更进一步的封装是将 Down Move Up 封装一层 Drag 拖拽事件。此部分仅仅是封装,方便开发者,不属于框架核心

在隔离输入层之后,就可以统一化输入,在框架层不需要了解输入细节。框架层的输入前置还需要保证一点的是对某个模式的输入里面按下和抬起是成对的,保证输入里面一定是先按下再移动再抬起,这个顺序不会乱

为什么这部分保证是在 SourceInput 层之后?原因是这个保证需要处理一些模拟输入,也就是 SourceInput 层仅封装 WPF 框架的输入。不处理模式交互框架里面的各个模式收到输入的保证输入成对

交互模式

每一个不同的交互模式都应该继承相同的交互模式基类,交互模式指的是如笔迹书写模式,选择模式,擦除笔迹模式等。这些模式基本上都包含了以下定义

  • SwitchOn
  • SwitchOff

  • Down
  • Move
  • Up

这里的 SwitchOn 和 SwitchOff 表示模式的开启和关闭。在用户进行选择模式的之前应该开启选择模式,简单的业务就是我有一个控制条,控制条上面有三个按钮,包含了选择、书写、橡皮擦三个。在没有点击选择按钮的时候,此时就不应该让选择模式工作。那么选择模式如何知道自己当前没有被选中?难道去监听按钮的状态?其实通过上面的 API 设计可以看到 SwitchOn 和 SwitchOff 就是用来解决模式的开启和关闭,让模式内部状态可以了解到当前的模式是否开启,是否需要处理业务

更进一步的是输入内容的转发,假设一个模式不开启,此时这个模式是否应该收到输入。当前我的设计是如果这个模式没有开启,就不要让这个模式收到输入。于是这个功能又需要框架的支持啦

这个框架里面对模式的输入的控制可以放在模式控制器这个类里面,接下来说的模式切换也是这个类应该实现的功能

模式切换

模式切换最简单的切换是用户的行为切换,用户点击了选择按钮就告诉白板框架当前要切换为选择模式,用户点击了书写按钮就告诉白板框架当前要切换为书写模式。而各个模式的切换是需要框架层面的支持的

按照上文输入的约定,每个模式收到的输入里面按下和抬起是成对的。而交互模式本身不监听元素的事件,需要靠框架层转发。那么假设在选择移动的过程,用户切换了模式,那么此时当前的模式不是选择模式,请问选择模式什么时候可以收到抬起事件。请先忽略用户什么时候可以做到在选择移动的过程中切换模式

最好的做法是在模式切换的时候,给旧模式补充抬起事件,而给新模式补充按下事件。补充事件的时候有一些细节。补充的事件里面需要让补充抬起和按下的点的坐标是当前移动的坐标,而同样的在多指触摸的时候需要补充不止一个按下和抬起才可以

整个模式切换里面需要处理的就是多个模式之间的切换,包括切换的旧模式的输入补充,以及新模式如何接手旧模式的数据。这些数据主要包含了当前模式正在操作的元素,例如选择了某些元素等。简单的例子是在选择模式的时候选择了一些元素,在切换到书写模式的时候应该清空选择,而在切换到 xx 模式的时候就不应该去掉选择等的这些业务。这部分的业务应该抽象出来,而不是具体的处理如是否清空选择框等业务,支持各个模式之前的定制

输入过滤

上文有提到用户在选择的过程切换了模式,那么用户是如何做到切换的?其实这里涉及了用户行为的判断,一个很现实的是软件是无法知道用户的未来的行为,而有些行为判断需要用户的多个交互才能确定。最简单的例子,但是可能行业外的小伙伴无法理解哈,就是一个黑板擦功能,或者叫手势擦除功能,更接地气的手背擦除功能。这是一个什么功能?就是当我使用手背触摸屏幕的时候我期望现在是进行擦除笔迹,这个行为就和在黑板上一样,我用粉笔写字,我用手背擦除

这个功能存在什么问题呢?从软件的角度上,在第一时刻,我收到了一个点。在第二时刻,我收到了这个点在移动。此时软件的模式假设是选择模式,那么是不是就开始选择模式的移动了。没错,从逻辑上讲应该是这样的。在第三时刻,我收到了这个点的宽度变大。而在第三时刻我收到的这个点的宽度是满足了手背擦除的触摸面积,应该切换到手势擦除模式里面。当前手势擦除和擦除模式本身是相同的模式,只是因为用户行为不同叫法不同而已

那么此时问题来了,请问谁处理模式的切换,或者说如何知道模式应该切换?因为软件是不知道用户未来的行为,而用户在行为过程可以让软件判断出用户想要的模式。那么就需要一个输入过滤层,这个输入过滤层可以决定之后的模式切换到哪里,或者说输入传输到哪里

在用到输入过滤之前还需要先聊一下这个业务,在用户进行手势擦除完成之后,在抬手之后需要结束手势擦除模式。下一次进行交互的时候应该回到上一个模式。如上一个模式是选择模式,那么在手势擦除结束之后的下一次模式应该回到选择模式。如上一个模式是笔迹书写模式,那么在手势擦除结束之后的下一次模式应该回到笔迹书写模式

上面这个业务的需求也就是框架层面需要支持一个是当前的模式,另一个是激活模式。什么是当前模式,当前模式就是用户选择的行为,也叫主模式。就是用户当前主要在使用的模式,如进行选择或进行书写等。而激活模式是用户瞬时的一个交互行为,一般来说这个行为都是根据用户的行为作出的判断切换到另一个模式里面,如手势擦除等模式

为什么会放两个不同的模式?因为激活模式可以用来取代当前模式收到交互输入,而当前模式保留实例等待激活模式关闭之后再次激活。默认行为都是当前模式,而输入过滤层,可以在收集到必要的行为的时候更改激活模式,开启激活模式,将框架层的用户交互输入传输到激活模式中,关闭当前模式

输入过滤层的作用就是决定输入数据的流向,让交互输入数据走向 CurrentMode 当前模式还是 ActiveMode 激活模式

通过上面的业务可以了解到,激活模式 ActiveMode 与当前模式 CurrentMode 同时只会有一个生效。而激活模式 ActiveMode 的优先级高于当前模式 CurrentMode 只要 ActiveMode 存在,那么所有交互输入数据都应该传入到 ActiveMode 激活模式中

而在当前模式 CurrentMode 接收用户输入过程中,可以被 ActiveMode 激活模式打断

这一点有点难以理解,为什么需要两个模式?原因是两个模式,其中一个表示激活模式表示用户的瞬时操作,可以用来给输入过滤层切换。换句话说是输入过滤层控制的是 ActiveMode 激活模式。而用户明确行为控制的是 CurrentMode 当前模式。使用两个模式的另一个原因是框架内部可以判断是否存在 ActiveMode 激活模式决定交互输入数据是否走向 CurrentMode 当前模式。同时在 ActiveMode 激活模式完成之后,可以知道切换回的当前模式是哪个模式

那么输入过滤层的定义又是什么?和模式层相同,输入过滤层收到的用户信息也是框架转发的,也就是 Filter 层和 Mode 层都继承相同的类 InputProcessor 输入处理者

在输入处理者提供触发输入函数,也就是在输入层经过模式控制器之后,转发的数据到具体的各个 Filter 和 Mode 时,处理转发数据的基类

回到问题本身,这里的 Filter 的没有实际的功能,仅仅是用来决定数据走向,也就是依靠切换为具体处理业务的某个 Mode 为 ActiveMode 完成业务。如手势擦除就应该配套一个 EraserGestureFilter 来判断用户触摸点的面积是否可以触发手势擦除,如可以触发,那么将 ActiveMode 设置为橡皮擦模式

那么可以被作为 ActiveMode 的模式是否需要是特殊模式?从上面的例子就可以看出,本来可以作为当前模式的橡皮擦模式在手势擦除的时候被作为了手势擦除模式。也就是模式本身不应该关心自己是被当前是 CurrentMode 还是 ActiveMode 激活模式,模式只关心输入的数据的业务处理

通过了框架的数据转发和 Filter 决定数据走向就能完成输入切换的功能,在没有界面功能的时候可以依靠用户的行为给软件定义出更多模式

还有一个问题,这个方案里面哪些是属于不变的框架,哪些属于业务逻辑?整个输入层都是框架,这个输入层解决一些 WPF 触摸的白板业务问题。注意,这里的白板业务问题指的是在白板这个行业里面的业务问题不是说具体的业务哈。模式切换的框架层以及 Filter 和 Mode 的基类实现都是框架层面

而具体的 xx Filter 和 xx Mode 就都是业务了

元素交互和通用交互

在白板核心框架设计里面存在的另一个坑就是元素本身的交互和通用交互的交互冲突问题

例如我有一个元素这个元素是一个地图,这个地图元素支持拖动地图内容,就和小伙伴用高德地图一样的交互。但是通用的交互里面由包含了拖拽元素的行为,也就是可以拖动一个元素。这两个行为是交互冲突的,当用户在地图元素上面拖动的时候,请问用户是想拖动地图元素还是想拖动地图

这部分行为就需要具体的业务定了,但是业务定下之后是否框架层能支持?其实还是可以的,通过设计交互优先级可以解决此问题

假设当前的业务需求是用户在地图元素上面拖动的时候,应该拖动地图而不应该拖动元素

在上面的设计在有 Filter 和 ActiveMode 就可以解决此问题。如果某些元素的交互的优先级是大于通用交互的优先级的,那么这些元素可以通过设置特殊的属性,在 Filter 层通过判断当前命中的元素包含了这个特殊的属性,就可以设置 ActiveMode 为一个什么都不做的 NoMode 模式

按照框架的设计,当存在 ActiveMode 时,将会忽略 CurrentMode 的行为,也就是此时是一个什么都不做的 NoMode 模式,用户的行为落到了元素上,用户可以拖动地图。而因为当前模式选择模式没有收到数据,也就不会拖动元素

所以只需要再定义一个 Filter 让这个 Filter 处理元素交互冲突问题就可以了

而又有另一个问题,用户如果是在地图元素上进行手势擦除呢。假设当前业务需求是手势擦除优先,当前是手势擦除不要拖动地图

而手势擦除在软件层面其实也是移动,那么可以如何做,刚才的 Filter 已经判断了命中元素就激活了一个 NoMode 了

其实只需要引入 Filter 的优先级就可以解决此问题,让手势擦除 Filter 的优先级大于元素交互冲突 ExclusiveModeFilter 的优先级。此时手势擦除 Filter 就会设置 ActiveMode 为橡皮擦模式,在 ExclusiveModeFilter 判断如果存在 ActiveMode 了,也就是存在优先级更高的 Filter 满足条件,那么 ExclusiveModeFilter 就知道当前应该禁用元素的交互,可以通过设置元素不可命中等让元素收不到交互

其实上面有一个细节是手势擦除判断一般都会比 ExclusiveModeFilter 判断慢,原因是第一个点按下的时候,元素交互冲突 ExclusiveModeFilter 就判断了命中的元素了,但是手势擦除判断需要等待第N个点按下才知道。不过这些细节问题都很好处理,本文上面的例子仅仅只是为了方便理解

这就是整套白板类应用的模式交互设计方案。里面的细节特别多,每个细节其实都需要大量的开发。现在是 2020.5 这个白板框架有 27,197 次 commit 和 300 多次 NuGet 版本发布。本文说到的模式交互仅仅是这个白板框架的核心一部分


本文会经常更新,请阅读原文: https://dotnet-campus.github.io//post/%E7%99%BD%E6%9D%BF%E7%B1%BB%E5%BA%94%E7%94%A8%E7%9A%84%E6%A8%A1%E5%BC%8F%E4%BA%A4%E4%BA%92%E8%AE%BE%E8%AE%A1%E6%96%B9%E6%A1%88.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。

知识共享许可协议 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 lindexi (包含链接: https://dotnet-campus.github.io/ ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系