背景
由於這篇總結是產品需求驅動的,先簡要描述下 Sofanovel 專案的需求:仿照 inkitt 首頁,實現個帶有 hover 效果的橫向列表,我們先直接來看下最後實現效果:
解決思路
這個需求在 iOS 原生的 UIKIt 下 很好解決的,UIScrollView 本來就有個 paging 的屬性,來實現這個 “翻頁” 效果。而 Flutter 也有個類似的控制元件 PageView, 我們先來看下 PageView 的實現:
PageView
普通的 PageView 實現是這樣的:
return Container(
height: 200,
width: 200,
child: PageView(
children: TestDatas.map((color) {
return Container(
width: 100,
height: 200,
color: color,
);
}).toList(),
),
)
複製程式碼
效果是 width 永遠不受控制,充滿螢幕,如圖:
另一種實現: 加上 PageController 的 viewportFraction 修飾:
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(),
),
)
複製程式碼
實現效果是這個樣子的:
viewportFraction 這個引數只能粗略地表示 選中區域 佔螢幕的百分比,而這個區域永遠落在中央,不能簡單實現偏左或者偏右的自定義化,因此捨棄了 pageView 的實現。
ListView
賦予翻頁效果
從橫向佈局的 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 停在哪裡。
其次,構造方法裡面裡面的 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