在 Flutter 中,ListView 和 PageView 都是用於顯示多個元素的 Widget,使用者可以滑動瀏覽列表中的 Widget。如果單看功能性,ListView 和 PageView 可能沒太大區別。但是實際與其互動之後,就會發現他們在畫面上的表現還是有所不同。
使用 ListView,使用者可以讓 ListView 停在列表中的任意位置,可以讓它停在某個元素的開頭,也可以停在某個元素一半的位置。
而 PageView 就有點不同,雖然使用者一樣可以透過滑動來把 PageView 中的元素滑到任意位置,但是只要手一放開,PageView 就會自動的把元素歸位到正中央。
大多數情況下,我們可以用 ListView 與 PageView 來完成功能。但是有些時候,我們也會需要客製化一些特別的行為,就像是今天要介紹的:用 ListView 來達到自動對齊的效果。
首先我們需要一個可以水平滑動的 ListView 並先命名為 SnapListView。
class SnapListView extends StatelessWidget {
const SnapListView({super.key});
@override
Widget build(BuildContext context) {
const itemExtent = 200.0;
return ListView.builder(
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) => Container(
width: 200,
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border.all(color: Colors.black),
),
child: Text('Item $index', style: const TextStyle(fontSize: 30)),
),
);
}
}
接下來我們要如何實現自動對齊的效果呢?答案就是修改 ListView 中的 physics 參數。physics 參數定義在 ListView 祖父類別 ScrollView 中,型別為 ScrollPhysics。
const ScrollView({
super.key,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.controller,
this.primary,
ScrollPhysics? physics,
this.scrollBehavior,
this.shrinkWrap = false,
this.center,
this.anchor = 0.0,
this.cacheExtent,
this.semanticChildCount,
this.dragStartBehavior = DragStartBehavior.start,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
this.restorationId,
this.clipBehavior = Clip.hardEdge,
})
Flutter 中原本就內建許多類別繼承自 ScrollPhysics ,例如:Android 預設的 ClampingScrollPhysics 或者是 iOS 預設 BouncingScrollPhysics 。這些 ScrollPhysics 可以套在很多可滑動的 Widget,例如:CustomeScrollView 或 GridView …等,套用不同的 ScrollPhysics 可在滑動的時候產生不同效果。
雖然 Flutter 提供許多不同的 ScrollPhysics,但是這些內建的 ScrollPhysics 並不能幫助我們的目標:自動對齊第一個元素。所以我們需要自定義一個 ScrollPhysics 來幫助我們達到目的。
讓我們新增一個 SnapScrollPhysics 類別,並繼承 ScrollPhysics 。我們傳入 itemExtent 來表明列表中每個元素的大小,用於計算模擬滑動的最終位置,畢竟使用者不會每次都停在相同位置,所以我們必須根據停止的位置與元素大小來計算最終位置。
class SnapScrollPhysics extends ScrollPhysics {
final double itemExtent;
const SnapScrollPhysics({
required this.itemExtent,
super.parent,
});
@override
SnapScrollPhysics applyTo(ScrollPhysics? ancestor) {
return SnapScrollPhysics(
itemDimension: itemExtent,
parent: buildParent(ancestor),
);
}
@override
Simulation? createBallisticSimulation(
ScrollMetrics position,
double velocity,
) {
return null;
}
}
在 ScrollPhysics 中有許多方法可提供覆寫。而在這個需求中,我們需要覆寫 createBallisticSimulation ,這個方法可以回傳一個 Simulation,讓使用者手指放開時,讓程式依據回傳的 Simulation,自動的把列表滾到 Simulation 所設定的位置。
值得一提的是,我們也需要覆寫 applyTo 方法,讓 ScrollView 在引入效果時,可以套用到我們定義的 SnapScrollPhysics 。雖然我們只在 ListView 中只傳入了 SnapScrollPhysics ,但是其實 Flutter 底層還會繼續套用其他的 ScrollPhysics ,讓 ListView 具有多種滑動效果。
首先,我們先處理一些例外狀況,當使用者滑超出列表範圍時,我們呼叫 super 的 createBallisticSimulation 的方法即可,讓其他 ScrollPhysics 來處理超出列表的行為。
class SnapScrollPhysics extends ScrollPhysics {
@override
Simulation? createBallisticSimulation(
ScrollMetrics position,
double velocity,
) {
if (position.pixels <= position.minScrollExtent ||
position.pixels >= position.maxScrollExtent) {
return super.createBallisticSimulation(position, velocity);
}
return null;
}
}
接著我們透過 position.pixels 取得列表當前的位置,並使用 roundToDouble 四捨五入,來決定應該要自動移動到上一個元素或下一個元素。最後建立 ScrollSpringSimulation 並指定起始位置與目標位置,讓 Flutter 在使用者手放開之後,模擬使用者滑動,讓使用者體驗更好。
class SnapScrollPhysics extends ScrollPhysics {
@override
Simulation? createBallisticSimulation(
ScrollMetrics position,
double velocity,
) {
if (position.pixels <= position.minScrollExtent ||
position.pixels >= position.maxScrollExtent) {
return super.createBallisticSimulation(position, velocity);
}
double page = position.pixels / itemExtent;
double target = page.roundToDouble() * itemExtent;
if (target != position.pixels) {
return ScrollSpringSimulation(
spring,
position.pixels,
target,
velocity,
);
}
return null;
}
}
但是當我們實際執行後,卻會發現用起來十分不順,原因是當使用者一放開手,Flutter 就會馬上開始模擬滑動,造成滑動不順暢。在真實滑動的過程中,使用者的手指是會頻繁地離開的手機螢幕,不會一直貼在螢幕上,所以我們必須在使用者手離開螢幕時,依據使用者滑動方向與速率來微調一下目標位置。
class SnapScrollPhysics extends ScrollPhysics {
@override
Simulation? createBallisticSimulation(
ScrollMetrics position,
double velocity,
) {
if (position.pixels <= position.minScrollExtent ||
position.pixels >= position.maxScrollExtent) {
return super.createBallisticSimulation(position, velocity);
}
double page = position.pixels / itemExtent;
var tolerance = toleranceFor(position);
if (velocity < -tolerance.velocity) {
page -= 1;
} else if (velocity > tolerance.velocity) {
page += 1;
}
double target = page.roundToDouble() * itemExtent;
if (target != position.pixels) {
return ScrollSpringSimulation(
spring,
position.pixels,
target,
velocity,
);
}
return null;
}
}
當發現使用者滑動的速率大於容忍值時,表示使用者想要快速滑動。所以我們必須依照使用者快速滑動的方向,創建一個往使用者滑動方向的滑動模擬,避免使用者手指一離開,列表就往反方向滑動。最後我們把完成的 SnapScrollingPhysics 放回 ListView 中,就能得到一個比較順暢的自動對齊效果。
class SnapListView extends StatelessWidget {
const SnapListView({super.key});
@override
Widget build(BuildContext context) {
return ListView.builder(
scrollDirection: Axis.horizontal,
physics: const SnapScrollPhysics(itemExtent: 200.0),
itemCount: 10,
itemBuilder: (context, index) => Container(
width: 200,
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border.all(color: Colors.black),
),
child: Text('Item $index', style: const TextStyle(fontSize: 30)),
),
);
}
}
雖然我們完成了自動對齊的效果,但其實程式碼還是有一些 Bug。例如:滑到最後一個元素時,列表不會像預期中的自動對齊,因為它採用了其他 ScrollPhysics 而非我們設定的 SnapScrollPhysics ,有興趣的觀眾可以嘗試修改看看。
除此之外,ScrollPhysics 還有許多方法可以覆寫,讓開發人員可以調整許多滑動細節,有興趣的觀眾也可以也可以參考 ScrollPhysics 。