Flutter富文字編輯器系列文章3——互動篇

語言: CN / TW / HK

作者:閒魚技術——岑彧

之前的系列文章介紹了協議層渲染層的實現,大家可以知道Mural是基於Flutter TextField進行渲染層的設計與實現,然後對其底層的渲染邏輯進行改造,從而對富文字編輯能力進行支援。也正因如此,在文字編輯互動方面,邏輯基本和它是保持一致的。但是我們在改造過程中發現,其實在互動方面,Flutter有很多相比起Native缺失的功能,本文會圍繞放大鏡模式選區反向選擇兩個比較重要的互動點來展開說明。為了讀者更便於理解,本文將會以官方程式碼來進行講解,因為這些優化思路是普適通用的,不與富文字耦合的。

放大鏡模式

背景與現狀

對於原生控制元件,不管是Android側的EditText,還是iOS側的UITextField,都是預設支援放大鏡模式的。將使用者進行文字選擇時,使用者可以通過放大鏡來進行精確的游標定位和選區移動。如下圖所示:

放大鏡在使用者抓取正確的選擇手柄後出現

放大鏡在使用者抓取正確的選擇手柄後出現

這無疑會對使用者體驗起到很大的改善作用,但是目前Flutter提供的TextField控制元件裡並沒有對該模式進行支援,早在2017年就有人提出了相關issue。Mural的UI渲染層和Flutter TextField除了在文字的渲染機制上不同之外,其他的互動邏輯是基本保持一致的。所以我們決定模擬Android和iOS雙端的放大鏡互動,在Flutter文字編輯器中進行放大鏡模式的支援。

互動分析

眾所周知,Android和iOS有著不同的設計與互動規範,文字編輯控制元件就是一個很好的例子,不過他們的互動也有相似的地方,我們將會求同存異,儘量滿足雙端的設計互動規範。一般來說,放大鏡控制元件通常在兩個場景會出現,一就是游標定位時,二就是在選區移動時。我們接下來對這兩個場景進行分析:

游標定位

  1. 1. 對於Android來說,點選EditText進行聚焦之後,通常游標下方會出現一個把手:通過拖曳這個把手來進行游標的定位,而放大鏡隨著拖曳開始而出現,拖曳結束消失。如圖所示:

Droplet looking thing is the pointer

Droplet looking thing is the pointer

  1. 1. 對於iOS來說,點選UITextField進行聚焦之後,長按,游標會變成一個浮動遊標,然後可以直接進行拖曳,便可以進行游標的定位,而放大鏡隨著拖曳開始而出現,拖曳結束消失。如圖所示:

iOS游標定位

iOS游標定位

選區移動

  1. 1. 對於Android來說,選區移動和游標定位非常相似,通過雙擊或者長按EditText可以選中最近的詞,然後選區的左右兩端會出現兩個把手,以及選區上方會出現一個Toolbar,可以對選中的文字進行復制剪下等操作。拖拽這兩個把手就可以進行選區的移動,拖曳開始時Toolbar會消失,放大鏡出現,拖曳結束時放大鏡消失,Toolbar重新出現。

Navigate Easier with Android's Smart Text Selection and Selected Text  Magnification

Navigate Easier with Android's Smart Text Selection and Selected Text Magnification

  1. 1. iOS和Android的選區移動互動比較相似,不同的是,iOS只能通過雙擊UITextField才能選中最近的詞,因為長按手勢用於游標定位。以及把手的樣式不一樣。

iPhone UI Designer Tells The Story Behind iOS Text Selection Patent | Cult  of Mac

iPhone UI Designer Tells The Story Behind iOS Text Selection Patent | Cult of Mac

程式碼實現

通過以上的分析不難發現,放大鏡有三個特點:

在內容上,放大鏡會以游標或是單邊選區為中心,展示固定尺寸的區域內的螢幕上的內容。

在位置上,放大鏡會浮動在游標或是單邊選區之上,保持固定的距離。

在邏輯上,放大鏡一般隨著拖曳開始而出現,拖曳結束而消失,以及選區移動場景下還需要進行Toolbar的隱藏和恢復,但是雙端有一些不同的互動。

其實還有一些其他的細節互動,比如iOS UITextField放大鏡其實是展示在觸控點上方而並非游標和單邊選區上方,並且在觸控區域和游標沒有重合的時候,放大鏡就會消失等。不過此處暫時以以上三個特點為思路來進行實現,後續會對沒有對齊的互動進行進一步的優化與對齊。以上三個特點可以轉化為三個問題與解決方案:

1.如何把放大鏡定位在游標或單邊選區上方?

Flutter還提供了一組叫做CompositedTransformFollower 與 CompositedTransformTarget的元件,他們通過同一個LayerLink來讓Follower與Target的相對位置保持一致,即Target的位置移動時,Follower也會跟著一起移動。而且TextField中已經存在startHandleLayerLink和endHandleLayerLink用於展示選區的操作把手元件,所以我們直接使用這兩個LayerLink,便可以讓放大鏡吸附在游標上方。定位程式碼如下:

CupertinoMagnifier

CupertinoMagnifier

可以看到,我們需要判定是把放大鏡吸附到左邊的把手上,還是右邊的把手上,而當選區為游標模式時,游標屬於左邊的把手。這個問題我們可以在TextSelectionOverlay中的用於展示把手元件的TextSelectionHandleOverlay元件中解決。在把手元件的_handleDragStart中把當前的currentTextSelectionHandleType更新為當前正在互動的把手型別就可以實現。虛擬碼在後續介紹邏輯部分一併給出。

可以看到Follower元件中還有一個offset引數,這個用於控制Target和Follower的相對位置。可以看到我們向左偏移了半個放大鏡寬度,向上偏移了放大鏡高度再加上一個距離。這樣就可以讓放大鏡懸浮在游標或者單邊選區正上方。

2.如何在放大鏡內展示螢幕上指定區域內的內容?

首先會給大家介紹一個Flutter控制元件叫做BackdropFilter,他可以接收一個矩陣,對位置被該控制元件蓋住(即z軸處於它下方)的元件產生高斯模糊、傾斜等效果。詳細的使用和介紹可參考BackdropFilter。我們把這個控制元件放到Overlay上,他就可以對被其蓋住的螢幕部分進行對映展示,但是我們並非想對該控制元件正下方(z軸)的內容做高斯模糊等特效,而是想展示而是游標附近的內容,即位置處於它下面(y軸)的內容。所以我們在對傳入的矩陣做translate(偏移),scale(放縮)操作,就可以把游標和選區周圍的螢幕內容對映到這個放大鏡中。程式碼如下:

CupertinoMagnifier

CupertinoMagnifier

deltaOffsetFromFocusPoint這個引數跟第一個問題中提到的相對位置有關,需要先確定兩者的相對位置,然後計算出對應的deltaOffsetFromFocusPoint,讓其剛好可以以游標為放大鏡展示內容的中心來進行展示。

3.如何處理雙端放大鏡的不同互動?

對於雙端相同的互動,即選區出現時出現Toolbar,拖動選區時隱藏Toolbar,展示Magnifier,拖動結束時隱藏Magnifier,展示Toolbar。我們同樣可以在TextSelectionOverlay中的展示把手元件的TextSelectionHandleOverlay進行改造實現,在_handleDragStart和_handleDragEnd(新增方法)中顯示和隱藏邏輯。部分程式碼如下:

互動

互動

而對於雙端不同的互動,在Android中,因為游標定位可以看做選區定位的一種特殊場景,游標下方的把手即選區中的左邊把手。無需特殊處理,而對於iOS來說,UITextField通過長按然後拖動來進行游標的定位。所以我們需要對iOS進行特殊處理,長按開始時展示放大鏡,長按結束時隱藏放大鏡。我們對TextSelectionGestureDetectorBuilder進行改造即可。部分程式碼如下:

區別

區別

效果展示

放大鏡

放大鏡

選區支援反向選擇

背景與現狀

在平時的使用中我們注意到,iOS的UITextField是支援反選的,即在操作右邊把手時,可以一直往左邊拖動,超過左邊把手時,把手的位置會進行一個互換,可以繼續操作左邊的把手。而Android很多廠商也支援了這一特性。但是我們發現在Flutter TextField中,這個操作是被禁止使用的。

原因

原因

所以我們決定在富文字編輯器中支援選區的反向選擇。

反向選擇

反向選擇

互動分析

對iOS以及一些支援反向選擇的Android機型的互動進行分析之後,以右邊把手往左邊移動為例,有兩種互動。一種是在左右把手交匯的時候交換兩個把手的位置,繼續往前選擇移動的是左邊樣式的把手。還有一種互動是,左右把手交匯的時候不改變兩個把手的位置,在拖動結束之後,如果發現右邊把手在左邊把手的前面,再進行交換。

結合Flutter TextField的改造成本以及使用者的操作連續性,我們決定採用第二種互動方式,當然iOS端應該保持UITextField的第一種方式,這個會在後續進行繼續對齊和優化。

程式碼實現

可能很多讀者會猜想,是不是在背景中介紹到那行程式碼給刪掉,就可以實現這個Feature的支援。一開始和大家的想法一樣,但是出現了很多問題,接下來會進行具體實現和分析。

上面有說到,去除掉TextField之後,出現了一些問題。第一個就是,兩個把手交匯的時候,兩個把手都消失了,變成了游標形態。原因是因為在Flutter TextField中,選區把手和游標把手(僅Android,iOS游標形態沒有把手)是在同一個地方實現的,當左右選區交匯時,會自動切換成游標形態,導致無法進行反選。

如何在選區交匯時不切換為游標形態?

我們當然不可能刪除這個規則,因為在設定中,本來游標就是收縮態的選區,如果完全刪除,那游標態也不可能存在了,因為左右選區收縮到一起時,一定會展示左右兩個把手,這就有點捨本求末了。

所以在絕大部分情況下我們是需要這個規則的,但是又想實現反選,自然而然會想到,設定一個標記位來標識我們正在操縱選區把手,當處於這種場景下,左右把手交匯時,我們就不將其轉化為游標形態。

1.設定標記位表示把手拖動狀態

設定變數

設定變數

2.處於該狀態時,選區收縮時展示展開態

展開態

展開態

解決了這個問題,我們還剩下一個問題,反選完成之後,如何交換兩個把手。

如何在反選完成之後保證正確的選區把手樣式?

我們需要在在TextSelectionOverlay中的展示把手元件的TextSelectionHandleOverlay進行實現,新增一個_handleDragEnd方法,交換selection的baseOffset和extentOffset

反選

反選

效果展示

反向選擇

反向選擇

總結與展望

縱觀整個系列文章,我們從協議層、渲染層、自定義擴充套件以及互動體驗優化等方面,詳細介紹如何實現一個功能完善、可擴充套件、高效能的Flutter富文字編輯器。目前Mural已經在閒魚的多個場景落地,整體的體驗也有了不錯的提升。

未來會繼續在基礎能力、互動體驗、效能等方面更深入的完善富文字編輯器的能力:

在基礎能力方面,跟隨富文字編輯器的業界標準,提供更加豐富的富文字元件和擴充套件Plugin能力;完善單元測試覆蓋,保證穩定性。

在互動體驗方面,我們儘量給使用者提供iOS和Android的端側互動體驗,優化Flutter現有的一些互動體驗問題;但是還有一些功能是尚未和雙端對齊的,例如iOS的實況本文、三指複製貼上撤銷重做等,這些都正在調研實現以及上線中。

在效能方面,我們優化了超長文字編輯的卡頓問題,與原生的TextField相比,卡頓有了明顯的優化;未來會通過兩個思路進行優化效能:判斷Model的Dom結構是否變化減少不必要的重複重新整理渲染,以及判斷選區、ToolBar是否變化減少不必要的重複計算,來提升編輯器的渲染和編輯的效能。