flutter刷新页面_用Flutter实现58App的首页
背景
Flutter作為全新跨平臺應用框架,在頁面渲染和MD開發上優勢明顯,可謂是業界一枝獨秀。正好最近有這樣的一個機會學習Flutter開發,我們便嘗試用它開發一個MD風格的較復雜頁面,來比較跟原生應用開發的優勢。也是想通過對新框架的學習探索,找到適合自身應用的框架。
頁面展示
首頁是整個應用里邊交互最為復雜的一個頁面了,它集合了各種滑動方式,包括:縱向滑動、橫向滑動、嵌套滑動;同時,也集合了各種動效,包括:下拉刷新、上拉加載、頭圖視差、二級吸頂、回到頂部、橫向Banner和縱向News輪播等。
開發歷程
- 搭建了開發環境,新建flutter module并學習dart語法 
- 調研用Flutter實現CoordinatorLayout的方案 
- 實現了首頁主框架的demo搭建,目前同樣遇到了滑動沖突的問題,在調研解決方案 
- 解決了滑動沖突的問題,并集成了下拉刷新能力 
- 完成了各區塊和feed流的靜態UI內容,目前剩余feed流加載更多和負二樓動效 
- 實現首頁feed流的加載更多功能 
技術難點
兩級吸頂
在Flutter中實現吸頂功能比較容易,使用SliverPersistentHeader控件或者間接使用該控件都可以滿足吸頂的功能;更重要的是,它支持滑動過程中任意組件的吸頂,即多級吸頂功能。
既然多級吸頂都支持,那么兩級吸頂就很輕松了,首頁頭部和feed流tab的兩級吸頂是這樣實現的:第一級,使用SliverAppBar(它內部就是一個SliverPersistentHeader控件),不僅可以吸頂,還帶有折疊屬性,折疊屬性能更好的滿足頭部滑動時的動效處理;第二級,使用SliverPersistentHeader并自定義它的delegate,通過pinned屬性靈活選擇當前模塊吸頂與否,這樣可以實現任意組件的吸頂功能。
SliverAppBar(pinned: true,
...,
bottom: PreferredSize(
child: Header(...),
preferredSize: Size(screenWidth, 15),
),
),
SliverPersistentHeader(
pinned: false,
delegate: _SliverColumnDelegate(
Column(...),
)
),
SliverPersistentHeader(
pinned: true,
delegate: _SliverTabBarDelegate(
TabBar(...)
),
),
pinned的原理很簡單,將它設置為true內容到達頂部后不會再跟隨外層的ScrollView繼續滾動;反之,內容則會滾動出容器外。
而native端實現這個二級吸頂卻很費力,通常你可能需要事先隱藏一個跟吸頂內容一樣的駐頂view在那里,然后在頁面滾動時計算吸頂內容是否已經劃至頂部,維護駐頂view的可見屬性達到吸頂效果。
上面粗獷的兩級吸頂完成了,但想要充分滿足首頁的折疊效果和準確的二級吸頂需求,還得深挖一下AppBar內部的折疊計算方法。
SliverAppBar折疊計算
SliverAppBar通常作為頁面頭部使用,是會隨內容一起滑動的一個組件;它的構造方法中有四個Widget類型的參數。分析Widget類型的參數,是因為我們需要一個容器來滿足自定義首頁頭部——它既能實現吸頂,又可以接入自定義組件。
- leading // 左側按鈕 
- title // 標題 
- flexibleSpace // 可以展開區域 
- bottom // 底部內容區域 
回顧一下首頁的折疊展示效果,首先排除了leading,因為它的位置大小只是一個按鈕的位置,顯示比較局限;然后title受leading占位影響寬度有限制也無法滿足需要;之后,就剩下兩個參數可選了,從命名上看,感覺flexibleSpace更符合折疊效果的實現思路,然后一直在嘗試使用其實現頭部折疊的需求,但開發過程中發現折疊后的高度是無法達到預期的,最大高度也滿足不了設計圖給的高度。本來想直接排除法使用起bottom的,但是想到一遇到問題就繞過還是有點SUI。那么想知道為什么flexibleSpace會有高度限制,必然得看一下SliverAppBar的實現源碼了。
class _SliverAppBarState extends State<SliverAppBar> with TickerProviderStateMixin {...
@override
Widget build(BuildContext context) {
assert(!widget.primary || debugCheckHasMediaQuery(context));
final double topPadding = widget.primary ? MediaQuery.of(context).padding.top : 0.0;
final double collapsedHeight = (widget.pinned && widget.floating && widget.bottom != null)
? widget.bottom.preferredSize.height + topPadding : null;
return MediaQuery.removePadding(
context: context,
removeBottom: true,
child: SliverPersistentHeader(
floating: widget.floating,
pinned: widget.pinned,
delegate: _SliverAppBarDelegate(
...
collapsedHeight: collapsedHeight,
topPadding: topPadding,
),
),
);
}
}
final double collapsedHeight = (widget.pinned && widget.floating && widget.bottom != null)
? widget.bottom.preferredSize.height + topPadding : null;
變量collapsedHeight代表了折疊后頭部的高度,從它的計算表達式可看出:當widget.bottom == null的時候,collapsedHeight的值為null。換言之,如果不使用bottom,那么折疊高度是沒有的。如果沒有折疊后的高度會發生什么?這個需要進一步驗證。
const double kToolbarHeight = 56.0;class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
...
final double _bottomHeight = bottom?.preferredSize?.height ?? 0.0;
@override
double get minExtent => collapsedHeight ?? (topPadding + kToolbarHeight + _bottomHeight);
}
從上面的源碼看,如果collapsedHeight == null,那么折疊后的頭部高度就是topPadding + kToolbarHeight了。topPadding是系統狀態欄的高度,kToolbarHeight是個自定義常量。不難看出,bottom為空時折疊頭部的高度就會是一個固定高度。那么反過來,想要自定義高度,就必須得使用bottom,折疊后的頭部高度完全取決于bottom的高度(一般,系統狀態欄的高度是確定的)。
你看,不是我們用排除法用了最后一個參數bottom,而是我們分析后知道不用它真得不行。
實現兩級吸頂并明確了頭部參數設置后,其實整個頁面框架就基本擬定了。接下來,我們細化一下,看看頭部控件具體怎么實現。
自定義頭部
首頁頭部組件包括以下內容:
搜索欄和城市名吸頂
頭圖視差
基于之前首頁native的開發經驗,這些效果的實現其實可以由一個變量驅動完成,即首頁頭部的縱向滑動偏移值。這個偏移值參照它的初始位置,分為上偏移和下偏移。上偏移驅動處理搜索欄和城市名的動效,下偏移則驅動處理頭圖視差的動效。
通過自定義Header組件來處理搜索欄和城市名吸頂的動畫,其中主要是借助外部傳入的上偏移值驅動整個動畫的完成。
import 'package:flutter/material.dart';import 'package:wuba_flutter_lib/home/search_bar.dart';
const double SEARCH_MARGIN_LEFT = 15.0; // 搜索欄left居左位置
typedef OnOffsetChangeListener = Function(double percent);
class Header extends StatefulWidget {
Header({
Key key,
this.offset: 0.0,// 外部驅動的偏移屬性
this.cityName,
this.onOffsetChangeListener,
}) : super(key: key);
final double offset;
final String cityName;
final OnOffsetChangeListener onOffsetChangeListener;
double searchLeft = SEARCH_MARGIN_LEFT;
double searchLeftEnd = SEARCH_MARGIN_LEFT;
@override
State<StatefulWidget> createState() => HeaderState();
}
class HeaderState extends State<Header> with TickerProviderStateMixin {
AnimationController searchBgColorAnimController;
Animation<Color> searchBgColor;
// 偏移值驅動動畫屬性
drive(offset) {
// 過渡比例
double percent = offset / 80.0 > 1.0 ? 1.0 : offset / 80.0;
// 偏移比例回調
if (widget.onOffsetChangeListener != null) {
widget.onOffsetChangeListener(percent);
}
// 搜索欄居左吸頂后的位置
widget.searchLeftEnd = SEARCH_MARGIN_LEFT + (widget.cityName ?? '').length * 22 + CITY_MARGIN_RIGHT;
// 搜索欄居左位置
widget.searchLeft = (SEARCH_MARGIN_LEFT + (widget.searchLeftEnd - SEARCH_MARGIN_LEFT) * percent);
// 背景顏色控制
searchBgColorAnimController.value = percent;
}
@override
void didUpdateWidget(Header oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.offset != oldWidget.offset) {
drive(widget.offset);
}
}
@override
void initState() {
super.initState();
searchBgColorAnimController = new AnimationController(vsync: this);
searchBgColor = ColorTween(
begin: Color(0xffffffff),
end: Color(0xffDADDE1),
).animate(
CurvedAnimation(
parent: searchBgColorAnimController,
curve: Interval(0.0, 1.0, curve: Curves.linear),
),
);
}
@override
Widget build(BuildContext context) {
return Stack(
overflow: Overflow.visible,
children: <Widget>[
// 搜索欄
SearchBar(
left: widget.searchLeft,
bgColor: searchBgColor.value,
...
),
...
],
);
}
}
頭圖視差 則使用了Container的矩陣變換屬性,主要是對y軸進行位移,這個位移加以視差系數便能產生跟Header組件的視差效果。
// 矩陣Matrix4 matrix = Matrix4.translationValues(0.0, _offset/*驅動y軸偏移*/, 0.0);
// 容器
Container(
transform: matrix,// 矩陣變換
width: screenWidth,
height: screenWidth,
child: Image.asset("assets/images/home_bg.jpg", fit: BoxFit.fill)
),
組件化思考
Flutter中分無狀態組件StatelessWidget和有狀態組件StatefulWidget,React中分無狀態組件Stateless Component和高級組件Stateful Component,它們在組件化方面的設計思路是一樣的。
組件化?越來越趨向于按狀態劃分設計,因為這樣更貼合實際場景并滿足需要。比如首頁的區塊列表場景中,有一些區塊一旦設置后不會再發生狀態改變,可理解為無狀態的;而另有一些區塊初始化后還需要做狀態變更,它有了狀態,可視為有狀態的。無狀態的區塊和有狀態的區塊進行組件封裝,便成了無狀態組件和有狀態組件。
首頁區塊中,無狀態的組件主要包括:
- BigGroup,大類頁 
- SmallGroup,小類頁 
- LocalNews,同城頭條 
- LocalTribe,同城部落 
- BannerAd,廣告Banner 
- … 
有狀態的組件目前只有一個:
- Notification,通知提醒;沒有下發通知鏈接或者請求后臺后發現沒有通知內容時需要隱藏 
如此,按照首頁區塊的場景,我們便基于無狀態組件設計封裝了首頁無狀態組件類HomeStatelessWidget,而基于有狀態組件實現了首頁有狀態組件HomeStatefulWidget。
HomeStatelessWidget類封裝,內部設有一個容器,然后需要指定它的大小,僅此而已。
abstract class HomeStatelessWidget<T> extends StatelessWidget implements PreferredSizeWidget {HomeStatelessWidget(this.context, this.key, this.value);
final BuildContext context;
final String key;
final T value;
Widget get child;
double get height;
@override
Size get preferredSize {// 指定容器大小
return Size(MediaQuery.of(context).size.width, height);
}
@override
Widget build(BuildContext context) {
return Container(// 容器
width: preferredSize.width,
height: preferredSize.height,
color: Colors.white,
alignment: Alignment.centerLeft,
child: child,
);
}
}
HomeStatefulWidget類封裝,和HomeStatelessWidget類近似,只是多了一個狀態類HomeStatefulWidgetState,它用于管理組件的各種狀態。
abstract class HomeStatefulWidget<T> extends StatefulWidget implements PreferredSizeWidget {HomeSizeStatefulWidget(this.context, this.key, this.value);
final BuildContext context;
final String key;
final T value;
Widget get child;
double get height;
void initState(State state) {}
@override
Size get preferredSize {
return Size(MediaQuery.of(context).size.width, height);
}
@override
State<StatefulWidget> createState() => HomeStatefulWidgetState();
}
// 狀態類
class HomeStatefulWidgetState extends State<HomeStatefulWidget> {
@override
void initState() {
super.initState();
widget.initState(this);
}
@override
Widget build(BuildContext context) {
return Container(
width: widget.preferredSize.width,
height: widget.preferredSize.height,
color: Colors.white,
alignment: Alignment.centerLeft,
child: widget.child,
);
}
}
無狀態組件實現起來很容易,只需要給它一次性賦值就可以了,這里不做過多解釋。接下來,看看有狀態的組件是如何實現的!
通知提醒組件因為需要改變可見性狀態,所以要實現首頁有狀態的組件類HomeStatefulWidget才能滿足狀態的管理,如下是通知提醒組件的代碼實現。
這一點跟native相比,優勢還是很明顯的。因為native端在view的設計上沒有“狀態”這個概念,它對狀態的概念完全是模糊的。
class Notification extends HomeStatefulWidget<String> {// 狀態字段,當通知內容為空時控制當前組件是否可見
bool isContentEmpty = true;
Notification(BuildContext context, String key, String value) : super(context, key, value);
@override
void initState(State<StatefulWidget> state) {
super.initState(state);
// 如果url不為空,則請求通知接口數據
if (!isUrlEmpty()) {
HomeDataManager.getNotification(value).then((object) {
// 獲取到通知數據,改變組件的可見性狀態
state.setState(() {
isContentEmpty = object == null;
});
});
}
}
@override
Widget get child => isUrlEmpty() || isContentEmpty ? Container() : Center(child: Text(value));
/// 如果url為空,或是通知接口返回的內容為空,則隱藏自己;
/// 否則,顯示自己。
@override
double get height => isUrlEmpty() || isContentEmpty ? 0 : 40;
// 判斷傳入的url是否為空
bool isUrlEmpty() => (value == null || '' == value);
}
滑動沖突
Android中,只要兩個“輪子”有嵌套關系,那么勢必存在滑動沖突的問題。要解決嵌套滑動沖突,就只能允許一個輪子驅動,而另一個輪子被帶動;而不是兩個輪子同時驅動
首頁中存在兩級沖突問題,也就是說有兩層嵌套關系。一,下拉刷新和首頁主體;二,首頁主體和feed流內容。這相當于有三個輪子存在相互嵌套的關聯,如何解決三個輪子的滑動沖突問題,這里有三種思路:
由一個輪子驅動,另外兩個輪子同步被帶動;
由一個輪子驅動,另一個輪子被帶動,還有一個輪子卸載;
由一個輪子先驅動,到達某個位置后轉換為另一個輪子驅動,然后剩下的兩個輪子跟1和2情況。
三種思路其實都是將三個輪子的嵌套關系進行了降維處理,本質上都在解決兩個輪子的沖突問題;總之,核心思想是不能出現兩個輪子同時驅動。
NestedScrollViewRefreshIndicator(// 下拉刷新child: ExtendedNestedScrollView(// 首頁主體
keepOnlyOneInnerNestedScrollPositionActive: true,
headerSliverBuilder: (c, f) {
return <Widget>[
SliverAppBar(), // 頭部搜索
SliverPersistentHeader(),// 區塊列表
SliverPersistentHeader(),// feed流TabBar
];
},
body: TabBarView(// feed流內容
children: [
Container(
child: ListView(),// 推薦
),
Container(
child: ListView(),// 家鄉
),
Container(
child: ListView(),// 部落?
),
],
),
),
)
首頁主體控件使用了NestedScrollView的擴展類ExtendedNestedScrollView,前者允許嵌套滾動,但是對子視圖的高度有要求——確定的高度。做過feed流的開發都知道,它的高度并不好計算,因為模板類型不同對應各自的高度不等,加以本身又可以無限加載擴展,高度一直在變化計算起來難度很大?;贜estedScrollView的擴展類ExtendedNestedScrollView解決了這個痛點,在不依賴子視圖高度的情況下同樣能夠滿足嵌套滾動。
解決滑動沖突問題,離不開它的這個重要屬性keepOnlyOneInnerNestedScrollPositionActive,直譯是僅?;钜粋€內部嵌套的滾動位置,意譯便是僅允許一個內部嵌套的視圖滾動,即僅允許一個輪子驅動。
if (widget.keepOnlyOneInnerNestedScrollPositionActive) {///get notifications and compute active one in _innerController.nestedPositions
body = NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
if (((notification is ScrollEndNotification) ||
(notification is UserScrollNotification &&
notification.direction == ScrollDirection.idle)) &&
notification.metrics is PageMetrics &&
notification.metrics.axis == Axis.horizontal) {
_coordinator._innerController
._computeActivatedNestedPosition(notification);
}
return false;
},
child: body);
}
這里的條件判斷計算,其實已經能看出來了,它是實現了上面思路3的做法。此時,首頁的兩級嵌套滾動沖突解決方案其實已經浮出水面了,只剩下最后一個輪子的處理了,具體是使用情況1還是情況2呢?
ScrollController _scrollController = ScrollController();_scrollListener() {
setState(() {
_offset = _scrollController.offset;
});
}
@override
void initState() {
super.initState();
_scrollController.addListener(_scrollListener);
}
NestedScrollViewRefreshIndicator(// 下拉刷新
// _offset > 0.0 表示頭部上移動,這時候禁止notification事件處理
notificationPredicate: (notification) => _offset == 0.0,
child: ExtendedNestedScrollView(// 首頁主體
controller: _scrollController,
keepOnlyOneInnerNestedScrollPositionActive: true,
...
),
)
這個問題其實不是一個單選,具體在應用場景中,最終兩者都有用到。下滑到達頂部,此時需要觸發下拉刷新操作,隨即下拉刷新模塊被帶動,那么就實現了思路1的做法;而其他位置的滑動,則不會觸發這個操作,所以可以理解為將其暫時卸載,那么就有了思路2的做法。整體首頁的實現,其實是綜合應用了這三種思路。
下拉刷新
- 下拉高度限制 
- 負二樓 // TODO 
默認的下拉刷新組件在下拉時可以一直往下,沒有對滑動距離做限制,而首頁要求下拉至頭圖完整出現后不再滾動。這個特定的需求RefreshIndicator并不能滿足,需要改動一下這個組件才可以。
class NestedScrollViewRefreshIndicator extends StatefulWidget {final OnOffsetCallback onOffset;// 下拉偏移量回調
final double offsetLimit;// 下拉偏移的限制值
const NestedScrollViewRefreshIndicator({
this.onOffset,
this.offsetLimit = 0.0,
...
});
}
class NestedScrollViewRefreshIndicatorState
extends State<NestedScrollViewRefreshIndicator>
with TickerProviderStateMixin<NestedScrollViewRefreshIndicator> {
AnimationController _positionController;
AnimationController _scaleController;
Animation<RelativeRect> _positionRect;
Animation<RelativeRect> _positionRectDown;
Animation<RelativeRect> _positionRectUp;
Animatable _headerPositionTweenDown;
Animatable _headerPositionTweenUp;
double _headerOffset = 0.0;// 頭部偏移值
@override
void initState() {
super.initState();
_headerPositionTweenDown = RelativeRectTween(
begin: RelativeRect.fromLTRB(0.0, 0.0, 0.0, 0.0),
end: RelativeRect.fromLTRB(0.0, widget.offsetLimit, 0.0, 0.0)
);
_positionController = AnimationController(vsync: this);
_scaleController = AnimationController(vsync: this);
_positionRectDown = _positionController.drive(_headerPositionTweenDown);
_positionRect = _mode != _RefreshIndicatorMode.done ? _positionRectDown : _positionRectUp;
if (widget.onOffset != null) {
_positionController.addListener(() {
_headerOffset = _positionController.value;
widget.onOffset(_headerOffset);
double value = widget.offsetLimit * _headerOffset;
_headerPositionTweenUp = RelativeRectTween(
begin: RelativeRect.fromLTRB(0.0, value, 0.0, 0.0),
end: RelativeRect.fromLTRB(0.0, 0, 0.0, 0.0)
);
_positionRectUp = _scaleController.drive(_headerPositionTweenUp);
});
_scaleController.addListener(() {
double value = (1.0 - _scaleController.value) * _headerOffset;
widget.onOffset(value);
});
}
}
setPositionRect(newMode) {
setState(() {
_positionRect = newMode != _RefreshIndicatorMode.done ? _positionRectDown : _positionRectUp;
});
}
// _RefreshIndicatorMode.drag
bool _handleScrollNotification(ScrollNotification notification) {
...
setPositionRect(_RefreshIndicatorMode.drag);
return false;
}
// _RefreshIndicatorMode.canceled || _RefreshIndicatorMode.done
Future<void> _dismiss(_RefreshIndicatorMode newMode) async {
...
setPositionRect(newMode);
switch (newMode) {
case _RefreshIndicatorMode.done:
await _scaleController.animateTo(1.0, duration: _kIndicatorScaleDuration);
break;
case _RefreshIndicatorMode.canceled:
await _positionController.animateTo(0.0, duration: _kIndicatorScaleDuration);
break;
default:
assert(false);
}
}
// _RefreshIndicatorMode.refresh
void _show() {
...
_positionController.animateTo(1.0 / _kDragSizeFactorLimit, duration: _kIndicatorSnapDuration).then<void>((void value) {
setPositionRect(_RefreshIndicatorMode.refresh);
});
}
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
// child, // 改動前
PositionedTransition(// 改動后
rect: _positionRect,
child: child,
),
...
],
);
}
}
以上便是摘出的改動了下拉刷新控件的代碼邏輯,主要是通過位置動畫限定了首頁主體向下滾動的最大位移。同時,通過動畫偏移的計算,向外輸出了頭部偏移的值,以便于外部通過監聽這個偏移值做更多的動效處理;比如:搜索框、天氣、城市、頭部、掃碼、背景圖等頭部元素的動畫處理。
負二樓的效果實現其實并不復雜,能理解如何通過動畫原理改動下拉刷新控件從而實現個性化的動效,那么實現負二樓的效果就是個舉一反三的事情。
加載更多
加載更多的原理其實跟native的思路是一樣的——判斷列表滾動到最末位置觸發特定事件。之前native的做法就是判斷RecyclerView滑動到最后一項時向feed流最末位置插入一個特定的動畫模板,等加載結束后再把這個模板去掉,然后把請求到的內容添加到視圖列表中去,這樣列表組件就擁有了一個加載更多的能力。
ExtendedNestedScrollView的改動:
double nestOffset(double value, _NestedScrollPosition target) {// 滑動到小于50的時候觸發加載更多事件
if (target.extentAfter < 50) {
_onLoadMore();
}
}
總結
這樣,一個由Flutter開發的首頁就已經基本落地了。整個開發過程總結下來,有這樣幾點可以分享:
用Flutter和Android開發首頁,都依賴了MD組件,它們對此支持得都比較完善;由于Dart語言的特性,Flutter在使用這些組件時更容易擴展、靈活性更強。
Flutter狀態化的組件管理機制,顯得比Android更切合場景,在區塊列表的設計上得心應手,這點也是眾多前端框架的亮點。
Flutter的動畫設計api很豐富,能充分滿足各種UI動效,讓頁面開發更輕松且不復雜。
Flutter表達性更強,又加以跨平臺的解決方案,減少了代碼量并大大提升了開發效率,為應用開發起到了開源節流的作用。
Flutter作為新秀,在Java老大哥已經爛熟于MVP等模式設計后,Flutter在此方面還需要積累;也可能Flutter本身并不需要這樣的積累,它等待的是比Java中更好的開發模式。
參考文檔
Flutter擴展NestedScrollView
總結
以上是生活随笔為你收集整理的flutter刷新页面_用Flutter实现58App的首页的全部內容,希望文章能夠幫你解決所遇到的問題。
 
                            
                        - 上一篇: 神秘的咒语
- 下一篇: 小米网关控制空调伴侣_小爱同学怎么控制灯
