因爲這篇總結是產品需求驅動的,先簡要描述下 Sofanovel 項目的需求:仿照 inkitt 首頁,實現個帶有 hover 效果的橫向列表,咱們先直接來看下最後實現效果: git
這個需求在 iOS 原生的 UIKIt 下 很好解決的,UIScrollView 原本就有個 paging 的屬性,來實現這個 「翻頁」 效果。而 Flutter 也有個相似的控件 PageView, 咱們先來看下 PageView 的實現:github
普通的 PageView 實現是這樣的:spring
return Container(
height: 200,
width: 200,
child: PageView(
children: TestDatas.map((color) {
return Container(
width: 100,
height: 200,
color: color,
);
}).toList(),
),
)
複製代碼
效果是 width 永遠不受控制,充滿屏幕,如圖: bash
另外一種實現: 加上 PageController 的 viewportFraction 修飾:app
return Container(
height: 200,
child: PageView(
controller: PageController(initialPage: 0, viewportFraction: 0.8),
children: TestDatas.map((color) {
return Container(
width: 100,
height: 200,
color: color,
);
}).toList(),
),
)
複製代碼
實現效果是這個樣子的: dom
viewportFraction 這個參數只能粗略地表示 選中區域 佔屏幕的百分比,而這個區域永遠落在中央,不能簡單實現偏左或者偏右的自定義化,所以捨棄了 pageView 的實現。ide
從橫向佈局的 ListView 入手開搞,自定義一個帶有 pageView 特性的 physics函數
class PagingScrollPhysics extends ScrollPhysics {
final double itemDimension; // ListView children item 固定寬度
final double leadingSpacing; // 選中 item 離左邊緣留白
final double maxSize; // 最大可滑動區域
PagingScrollPhysics(
{this.maxSize,
this.leadingSpacing,
this.itemDimension,
ScrollPhysics parent})
: super(parent: parent);
@override
PagingScrollPhysics applyTo(ScrollPhysics ancestor) {
return PagingScrollPhysics(
maxSize: maxSize,
itemDimension: itemDimension,
leadingSpacing: leadingSpacing,
parent: buildParent(ancestor));
}
double _getPage(ScrollPosition position, double leading) {
return (position.pixels + leading) / itemDimension;
}
double _getPixels(double page, double leading) {
return (page * itemDimension) - leading;
}
double _getTargetPixels(
ScrollPosition position,
Tolerance tolerance,
double velocity,
double leading,
) {
double page = _getPage(position, leading);
if (position.pixels < 0) {
return 0;
}
if (position.pixels >= maxSize) {
return maxSize;
}
if (position.pixels > 0) {
if (velocity < -tolerance.velocity) {
page -= 0.5;
} else if (velocity > tolerance.velocity) {
page += 0.5;
}
return _getPixels(page.roundToDouble(), leading);
}
}
@override
Simulation createBallisticSimulation(
ScrollMetrics position, double velocity) {
// If we're out of range and not headed back in range, defer to the parent // ballistics, which should put us back in range at a page boundary. if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent)) return super.createBallisticSimulation(position, velocity); final Tolerance tolerance = this.tolerance; final double target = _getTargetPixels(position, tolerance, velocity, leadingSpacing); if (target != position.pixels) return ScrollSpringSimulation(spring, position.pixels, target, velocity, tolerance: tolerance); return null; } @override bool get allowImplicitScrolling => false; } 複製代碼
代碼一大堆,咱們聚焦入口 createBallisticSimulation ,這是每次滑動手勢結束後會觸發,最終都是爲了調用下面這句,來產生滑動效果:佈局
ScrollSpringSimulation(spring, position.pixels, target, velocity,
tolerance: tolerance);
複製代碼
target 這個參數是整個類的主角,其餘輔助函數都是爲了計算出這個值而已,target 是表示此次滑動的終點,也就是說,咱們經過控制這個參數來控制此次觸摸結束後,listview 停在哪裏。ui
其次,構造方法裏面裏面的 parent 參數也是挺重要的,主要用來組合各類 physics 屬性,這裏留在後面再說。
這一步無非就是用 scrollView 監聽 scroll offset, 到了指定位置就 setState ,已觸發選中效果。
_scrollCtl.addListener(() {
double test =
_bookWidth != null ? _scrollCtl.offset / (_bookWidth + margin) : 1;
int next = test.round();
if (next < 0) {
next = 0;
}
if (next >= testData.length) {
next = testData.length - 1;
}
if (_currentPage != next) {
setState(() {
_currentPage = next;
});
}
});
複製代碼
_buildBookItem(Map data, bool active, {num width}) {
width = _bookWidth;
// Animated Properties
final double blur = active ? 5 : 0;
final double offset = active ? 2 : 0;
final double top = active ? 10 : 20;
final double bottom = active ? 10 : 20;
return GestureDetector(
onTap: () {
if (data['index'] == _currentPage) {
_jump();
} else {
scrollToPage(data['index']);
}
},
child: AnimatedContainer(
width: width,
height: 1.38 * width,
child: Center(child: Text(data['index'].toString())),
duration: Duration(milliseconds: 500),
curve: Curves.easeOutQuint,
margin: EdgeInsets.only(top: top, bottom: bottom, right: margin),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: randomColor,
boxShadow: [
BoxShadow(
color: Colors.black87,
blurRadius: blur,
offset: Offset(offset, offset))
]),
),
);
}
複製代碼
在自測時發現過這樣一個問題:當 listView 裏面的 children 過少時, 整個 listView 壓根不能滑動, physics 裏面的 createBallisticSimulation 實現得再完美,也觸發不了其中的回調的。爲了不這種狀況,比較粗暴的方法是,在 children 加空白 Container,以充滿 listView 固有的寬度或者高度,來讓 listView 知足可滑動的前提。
爲什麼 chidren 過少就滑動不了?這裏要看下 ScrollPhysics 的源碼了,裏面有這樣一個方法:
/// Whether the scrollable should let the user adjust the scroll offset, for
/// example by dragging.
///
/// By default, the user can manipulate the scroll offset if, and only if,
/// there is actually content outside the viewport to reveal.
///
/// The given `position` is only valid during this method call. Do not keep a
/// reference to it to use later, as the values may update, may not update, or
/// may update to reflect an entirely unrelated scrollable.
bool shouldAcceptUserOffset(ScrollMetrics position) {
if (parent == null)
return position.pixels != 0.0 || position.minScrollExtent != position.maxScrollExtent;
return parent.shouldAcceptUserOffset(position);
}
複製代碼
源碼裏面註釋得很清楚了,惟有內容超出顯示範圍時,才能夠觸發他的滾動,即 position.minScrollExtent != position.maxScrollExtent 的時候。 因此,咱們重載一下這個方法就能夠了。
@override
bool shouldAcceptUserOffset(ScrollMetrics position) => true;
複製代碼
另外,也能夠經過構造方法 parent 這個入參去組合多個的已有的 physics 來完成這種特性:
_physics = PagingScrollPhysics(
itemDimension: itemWidth,
leadingSpacing: _leadingPortion,
maxSize: itemWidth * (testData.length - 1) - _leadingPortion,
parent: BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()));
複製代碼
Author:Terrence