在一個白板類應用的交互中一定會涉及到模式之間的更換和交互衝突。白板類軟體的交互模式一般包含了筆跡書寫模式,選擇模式,擦除筆跡模式等。多個模式之間存在切換,而切換可以發生在某個模式執行過程,如需要在白板軟體裡面支持筆跡書寫功能,在書寫的過程打斷進入筆跡的擦除模式。本文告訴大家我所在團隊的白板內核的模式... ...
在一個白板類應用的交互中一定會涉及到模式之間的更換和交互衝突。白板類軟體的交互模式一般包含了筆跡書寫模式,選擇模式,擦除筆跡模式等。多個模式之間存在切換,而切換可以發生在某個模式執行過程,如需要在白板軟體裡面支持筆跡書寫功能,在書寫的過程打斷進入筆跡的擦除模式。本文告訴大家我所在團隊的白板內核的模式交互設計方案,本文不會涉及到具體實現的邏輯代碼
我從 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 版本發佈。本文說到的模式交互僅僅是這個白板框架的核心一部分