聊天列表是一個(gè)很扣細(xì)節(jié)的場(chǎng)景,在之前的 《Flutter 實(shí)現(xiàn)完美的雙向聊天列表效果,滑動(dòng)列表的知識(shí)點(diǎn)》 里,通過(guò) CustomScrollView 和配置它的 center 從而解決了數(shù)據(jù)更新時(shí)的列表跳動(dòng)問(wèn)題,但是這時(shí)候又有網(wǎng)友提出了新的問(wèn)題:

如下動(dòng)圖所示,可以看到雖然列表在添加新數(shù)據(jù)后雖然沒(méi)有發(fā)生跳動(dòng),但是在列表數(shù)據(jù)長(zhǎng)度足夠的情況下,頂部會(huì)有一篇空白。

如下代碼所示,這個(gè)問(wèn)題的起因正是在解決跳動(dòng)問(wèn)題而增加的 center ,因?yàn)榱斜硎? reverse ,并且紅色的 SliverList 長(zhǎng)度只有 3 條,高度不夠?qū)е马敳苛艨瞻住?/p>
CustomScrollView(
controller: scroller,
reverse: true,
center: centerKey,
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
var item = newData[index];
if (item.type == "Right")
return renderRightItem(item);
else
return renderLeftItem(item);
},
childCount: newData.length,
),
),
SliverPadding(
padding: EdgeInsets.zero,
key: centerKey,
),
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
var item = loadMoreData[index];
if (item.type == "Right")
return renderRightItem(item);
else
return renderLeftItem(item);
},
childCount: loadMoreData.length,
),
),
],
)
如下圖結(jié)合圖片理解更形象:
-
center其實(shí)就是列表的起始錨點(diǎn),我們把錨點(diǎn)給了SliverPadding,而因?yàn)榱斜硎?reverse,所以起始位置是在屏幕下方; - 紅色的 old 數(shù)據(jù)
SliverList,在代碼里是處于center的下方,而因?yàn)?reverse所以它實(shí)際就是黃色的部分; - 所以雖然綠色的
SliverList雖然新增了數(shù)據(jù),但是從center往上的高度還是不夠,所以就出現(xiàn)了黃色SliverList頂部空白的問(wèn)題;

結(jié)合這個(gè)問(wèn)題,這里可以發(fā)現(xiàn)關(guān)鍵的點(diǎn)就在于 reverse ,而對(duì)比微信和QQ的聊天列表需求,在沒(méi)有數(shù)據(jù)時(shí),消息數(shù)據(jù)應(yīng)該是從頂部開(kāi)始,所以這時(shí)候就需要我們調(diào)整列表實(shí)現(xiàn),參考微信/QQ 的實(shí)現(xiàn)模式。

如下代碼所以,這里針對(duì)新交互場(chǎng)景做了優(yōu)化調(diào)整:
- 去除
CustomScrollView的reverse; - 對(duì)調(diào)兩個(gè)
SliverList的位置,把加載 old 數(shù)據(jù)的SliverList放到center的前面;
CustomScrollView(
controller: scroller,
center: centerKey,
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
var item = loadMoreData[index];
if (item.type == "Right")
return renderRightItem(item);
else
return renderLeftItem(item);
},
childCount: loadMoreData.length,
),
),
SliverPadding(
padding: EdgeInsets.zero,
key: centerKey,
),
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
var item = newData[index];
if (item.type == "Right")
return renderRightItem(item);
else
return renderLeftItem(item);
},
childCount: newData.length,
),
),
],
)
是不是很簡(jiǎn)單,就這?運(yùn)行后也如下圖所示,可以看到運(yùn)行后的代碼不會(huì)再有空白的情況,也沒(méi)有新增數(shù)據(jù)跳動(dòng)的情況,雙向滑動(dòng)也正常,那你知道為什么嗎?

如下圖所示,調(diào)整后從結(jié)構(gòu)上變成了右邊的邏輯:
- 數(shù)據(jù)起始錨點(diǎn)在頁(yè)面頂部,所以不會(huì)存在頂部留空問(wèn)題;
- 在
center下面的SliverList按照正向排序正常顯示,用于顯示新數(shù)據(jù); - 在
center上面的SliverList列表會(huì)被變成以center為起點(diǎn)反向順序顯示,用于加載舊數(shù)據(jù);

當(dāng)然,這里有一點(diǎn)需要注意的局就是:起始進(jìn)來(lái)時(shí)加載的第一頁(yè)數(shù)據(jù)應(yīng)該是用綠色的正向 SliverList ,因?yàn)槠鹗键c(diǎn)在頂部,如果不用下面綠色的正向 SliverList ,就會(huì)導(dǎo)致第一次數(shù)據(jù)看不到的情況。
這時(shí)候就有人可能會(huì)說(shuō),如果是下圖所示場(chǎng)景,只加載舊數(shù)據(jù),不加載新數(shù)據(jù),那不就出現(xiàn)底部留空了嗎?

是的,我們其實(shí)是把頂部留空的問(wèn)題轉(zhuǎn)移到了底部,但是這個(gè)問(wèn)題在實(shí)際業(yè)務(wù)場(chǎng)景是不成立,進(jìn)入聊天列表首先就需要先加載滿一頁(yè)的數(shù)據(jù),所以:
- 如果 old 數(shù)據(jù)本來(lái)就不夠,例如例子里只有3條,那也就不會(huì)有加載更多 old 數(shù)據(jù)的場(chǎng)景,所以不會(huì)產(chǎn)生滑動(dòng);
- 如果 old 數(shù)據(jù)足夠,那默認(rèn)就足以撐滿列表;
而隨著 new 數(shù)據(jù)的增加,頁(yè)面也會(huì)被填滿從而可以正?;瑒?dòng)并且充滿,所以從這個(gè)實(shí)現(xiàn)上看會(huì)更加合理。
那有人可能會(huì)說(shuō),就這?還有什么可以?xún)?yōu)化的小技巧? 比如增加判斷列表是否處于底部,決定在接受到新數(shù)據(jù)時(shí)是否滑動(dòng)到最新消息。
實(shí)現(xiàn)這個(gè)優(yōu)化也很簡(jiǎn)單,首先我們可以嵌套一個(gè) NotificationListener , 在這里我們主要是獲取 notification.metrics.extentAfter 這個(gè)參數(shù)。
NotificationListener(
onNotification: (notification) {
if (notification is ScrollNotification) {
if (notification.metrics is PageMetrics) {
return false;
}
if (notification.metrics is FixedScrollMetrics) {
if (notification.metrics.axisDirection == AxisDirection.left ||
notification.metrics.axisDirection == AxisDirection.right) {
return false;
}
}
///取到這個(gè)值
extentAfter = notification.metrics.extentAfter;
}
return false;
},
)
這里的
if判斷,只是為了規(guī)避其他控件的影響,比如列表里的PageView或者TextFiled的影響。
那 extentAfter 參數(shù)的作用是什么? 事實(shí)上在 FixedScrollMetrics 里有 extentBefore 、 extentInside 和 extentAfter 三個(gè)參數(shù),它們的關(guān)系類(lèi)似下圖所示:

一般情況下:
-
extentInside就是視圖窗口大小; -
extentBefore就是前面還可以滑動(dòng)距離; -
extentAfter就是后面還可以滑動(dòng)距離;
所以我們只需要判斷 extentAfter 是否為 0 ,就可以判斷列表是不是處于底部 ,從而針對(duì)場(chǎng)景首先不同的業(yè)務(wù)邏輯,例如下圖所示,針對(duì)列表是否處于底部,在接收到新數(shù)據(jù)時(shí)是直接跳到最新數(shù)據(jù),還是彈出提示用讓用戶點(diǎn)擊跳轉(zhuǎn)。

if (extentAfter == 0) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text("你目前位于最底部,自動(dòng)跳轉(zhuǎn)新消息item"),
duration: Duration(milliseconds: 1000),
));
Future.delayed(Duration(milliseconds: 200), () {
scroller.jumpTo(scroller.position.maxScrollExtent);
});
} else {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: InkWell(
onTap: () {
scroller.jumpTo(scroller.position.maxScrollExtent);
},
child:Text("點(diǎn)擊我自動(dòng)跳轉(zhuǎn)新消息item")
),
duration: Duration(milliseconds: 1000),
));
}
所以從聊天列表的場(chǎng)景上看,實(shí)現(xiàn)一個(gè)聊天列表并不難,但是需要優(yōu)化的細(xì)節(jié)可能會(huì)很多,如果你在這方面還有什么問(wèn)題,歡迎評(píng)論交流。
實(shí)例代碼可見(jiàn):https://github.com/CarGuo/gsy_flutter_demo/blob/master/lib/widget/chat_list_scroll_demo_page_2.dart