背景
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中,只要两个“轮子”有嵌套关系,那么势必存在滑动冲突的问题。要解决嵌套滑动冲突,就只能允许一个轮子驱动,而另一个轮子被带动;而不是两个轮子同时驱动。[图片上传失败…(image-a6a76b-1593325302649)]
首页中存在两级冲突问题,也就是说有两层嵌套关系。一,下拉刷新和首页主体;二,首页主体和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流的开发都知道,它的高度并不好计算,因为模板类型不同对应各自的高度不等,加以本身又可以无限加载扩展,高度一直在变化计算起来难度很大。基于NestedScrollView的扩展类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中更好的开发模式。