Flutter中的Widget實在是太多了,很容易忽略很多實用的Widget。那麼我個人很喜歡Flutter官方在YouTube上的Flutter Widget of the Week 系列視訊。真的是可以發現寶藏,比如今天的主角Semantics。
介紹
Semantics(語義)
用於描述Widget的含義最終達到描述應用程式的UI。這些描述可以通過輔助工具、搜尋引擎和其他語義分析軟體使用。它有點像HTML5的語義元素,在Android、iOS上更多是用於讀屏,幫助一些有視力障礙的人使用我們的軟體(Android TalkBack
和 iOS VoiceOver
)。
說真的,做了幾年的Android,基本沒有關注過這方面的問題。唯一能想起來的就是給ImageView
新增contentDescription
屬性,來描述一下圖片的含義。但這肯定遠遠不夠。。。
雖然我們對Semantics
感到陌生 ,但是它在Flutter中可以說是無處不在。
舉幾個例子:Image
中就有 semanticLabel
和excludeFromSemantics(預設false)
這兩個屬性,一個用於描述圖片語義,一個表示是否去除圖片語義。原始碼中表現為:
Semantics
,同時image
屬性為true,告訴我們這個Widget是一個圖片。
再看一個例子:IconButton
的語義屬性button
為true,告訴我們這個Widget是個按鈕,是否可點選通過onPressed來決定。在IconButton
中有一個tooltip
屬性。新增了tooltip
最終就是巢狀一個Tooltip
元件。
Tooltip
它其實就是一個萬能的語義。我們正常使用時,長按就可以看到描述Widget的資訊。使用讀屏時,可以直接讀出對應的描述資訊。
而對於基礎元件的語義,比如Text、Switch、TextField等都在RenderObject
的void describeSemanticsConfiguration(SemanticsConfiguration config)
這個重寫方法中實現。將元件的語義新增至SemanticsConfiguration
中。比如Text
:
如果Text新增了semanticsLabel
屬性,那麼就使用ExcludeSemantics
去除預設生成的語義,以semanticsLabel
為準。
預設的語義在TextSpan
中的 computeSemanticsInformation
方法實現:
@override
void computeSemanticsInformation(List<InlineSpanSemanticsInformation> collector) {
assert(debugAssertIsValid());
if (text != null || semanticsLabel != null) {
collector.add(InlineSpanSemanticsInformation( ///<- 新增
text,
semanticsLabel: semanticsLabel,
recognizer: recognizer,
));
}
if (children != null) {
for (InlineSpan child in children) {
child.computeSemanticsInformation(collector);
}
}
}
List<InlineSpanSemanticsInformation> getSemanticsInformation() {
final List<InlineSpanSemanticsInformation> collector = <InlineSpanSemanticsInformation>[];
computeSemanticsInformation(collector);
return collector;
}
複製程式碼
最終在describeSemanticsConfiguration
方法呼叫getSemanticsInformation
獲取語義描述,新增至SemanticsConfiguration
中。
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
_semanticsInfo = text.getSemanticsInformation(); // <- 獲取
if (_semanticsInfo.any((InlineSpanSemanticsInformation info) => info.recognizer != null)) {
config.explicitChildNodes = true;
config.isSemanticBoundary = true;
} else {
final StringBuffer buffer = StringBuffer();
for (InlineSpanSemanticsInformation info in _semanticsInfo) {
buffer.write(info.semanticsLabel ?? info.text);
}
config.label = buffer.toString();
config.textDirection = textDirection;
}
}
複製程式碼
最後介紹幾個概念:
-
當Flutter渲染控制元件樹時,它還會維護第二個控制元件樹,稱為Semantics Tree。
-
Semantics Tree的每個節點都是
SemanticsNode
,它可能對應於一個或一組Widget。 -
每個
SemanticsNode
都會對應一個SemanticsConfiguration
,儲存著語義屬性資訊。
點到為止,扯得有點多了。這部分主要為了說明Flutter已經在提供的Widget中全面支援了語義。下來說說具體怎麼去使用。
使用
上面的原始碼中,我們應該已經接觸到了Semantics
和 ExcludeSemantics
。下面詳細介紹一下:
Semantics
語義元件包含功能很多,當前有50個屬性。這裡我介紹一些重要的屬性:
-
label
: 提供Widget的文字描述。也就是基礎的語義資訊。 -
container
: 該節點是否在語義樹中引入一個新的語義節點(SemanticsNode)。它可以不受上層的語義拆分、合併,也就是獨立出來。 -
explicitChildNodes
: 預設為false,表示是否強制顯示子Widget的語義資訊。可以理解為拆分語義。 -
scopesRoute
: 如果非空,該節點是否對應於子樹的根,該子樹應該宣告路由名。通常與explicitChildNodes
一起設定為true,使用在路由跳轉地方,比如頁面的跳轉,Dialog
、BottomSheet
、PopupMenu
的彈出部分。
比如MaterialPageRoute
中如下:
namesRoute
: 如果非空,則節點是否包含路由的語義標籤。比如AppBar
上的title,就表示當前路由名稱。
其他的屬性見名知意,我就不多解釋了。語義說到這裡,可能你還是覺得很抽象。那麼你可以在MaterialApp
中新增showSemanticsDebugger: true
來檢視語義檢視。
ExcludeSemantics
作用是排除子Widget中的語義。比如有張圖片只是裝飾作用並不需要解釋含義,可以使用ExcludeSemantics
。
BlockSemantics
它放棄了在它之前的同一個語義容器中繪製的所有Widget的語義。這個Widget很少用到,整個Flutter原始碼中也就只有Drawer
中用到了它,當抽屜開啟時,可以去除其他語義,避免讀屏器讀出被抽屜覆蓋的語義內容,造成使用者的困擾。
IndexedSemantics
用索引表示Widget的語義。索引被TalkBack/Voiceover用來通知當前滾動狀態。比如ListView
預設實現了它。並且ListView
也會將item中的語義合併,便於閱讀。
當然你也可以自定義索引語義。下面的例子處理了一個語義無關的Spacer
分隔符。預設的索引語義會給Spacer
提供一個語義索引,會導致滾動通知錯誤地告訴使用者有四個可見item。
ListView(
addSemanticIndexes: false, /// <-- 去除預設的索引語義
semanticChildCount: 2, /// <-- 指定真實的語義數量
children: const <Widget>[
IndexedSemantics(index: 0, child: Text('First')), /// <---新增對應的索引
Spacer(),
IndexedSemantics(index: 1, child: Text('Second')),
Spacer(),
],
)
複製程式碼
MergeSemantics
作用是將其子Widget的語義合併在一起。這個Widget我認為是很有重要的,通過它我們可以將資訊合併,便於閱讀。
使用案例
我用一個簡單的頁面舉例:
上圖中都是圖片及文字,我們來看看它的語義檢視。
可以看到圖片的部分因為沒有新增語義,導致裡面沒有描述內容。有文字的部分,Widget的觸控範圍很小,不便於操作,並且資訊也不集中。你試想一下,一個視力障礙的人,怎麼知道哪兩段文字是一體?你是橫向排列還是縱向排列?這個頁面在他們那裡就是失敗的,即使你做的UI效果再漂亮。其實優化的方法很簡單。
-
去除
Image
的語義,使用我們上面提到的excludeFromSemantics
屬性或是ExcludeSemantics
都行。我在處理Image
的語義時比較極端,將excludeFromSemantics
都改為了true。我的理解是大多數的圖片都是裝飾作用,螢幕上過多的語義描述也會帶來不必要的困擾。如果有點選事件的圖或者需要描述的圖片,單獨新增Semantics
。 -
合併語義,將縱向排列的一組內容資訊用
MergeSemantics
包裹即可。
程式碼這裡就不貼出來,可以點選這裡檢視。
那麼最終的效果如下:
不用我多說什麼了吧,效果一目瞭然。當然這個例子只是一個很簡單的示例。如果給CustomPainter
新增語義,就相對複雜了。這部分我們可以參考TimePicker
的處理,或者Flutter Deer中餅狀圖的處理:
餅狀圖頁面 | 餅狀圖未新增語義 | 餅狀圖新增語義 |
我這裡就不展開說了,有興趣的可以去了解一下。
語義新增的原則
-
語義資訊的完整。比如日曆上顯示的都是數字,我們需要將完整的日期資訊補足。
-
語義資訊的整合。這個就是上面的例子,將同類資訊合併,便於閱讀。
-
去除多餘的語義資訊。儘量保證語義的簡潔,比如圖片這類的語義我們大多數都可以忽略。
有話想說
其實Flutter已經幫我們做了很多語義化的工作,甚至考慮的很全面(所以學習它的方法就在原始碼中)。我們真正需要處理的內容並不多。
我自己在新增語義的過程中,也嘗試體驗了TalkBack,儘管是看著手機操作的,但是還是很不方便。難以想象一個沒有語義適配的頁面是多麼糟糕。
其實關於Semantics
的資料是很少的,甚至在發展成熟的Android上也很少有人提及(iOS的情況不清楚)。感覺我們忽略了一群人,儘管他們可能不會用到我們的App。寫這篇部落格的初衷也是這樣,補充一下這方面的資料,幫助有需要的人。
我也是根據自己的理解去實現語義化的,並不知道在實際的使用中是不是很合適。但是大方向一定沒錯。
最後我在我的開源專案Flutter Deer中也新增了語義的支援,有興趣的可以檢視,歡迎交流這方面的內容!
最後最後,還不點個贊?給作者我一點鼓勵!馬上過年了,看能不能在掘金衝個v3。月更傷不起啊!