用Flutter实现一个复杂页面

Flutter从入门到放弃

Posted by Tristan on April 22, 2019

背景

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,而是我们分析后知道不用它真得不行。

实现两级吸顶并明确了头部参数设置后,其实整个页面框架就基本拟定了。接下来,我们细化一下,看看头部控件具体怎么实现。

自定义头部

首页头部组件包括以下内容:

  1. 搜索栏和城市名吸顶
  2. 头图视差

基于之前首页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. 由一个轮子驱动,另一个轮子被带动,还有一个轮子卸载;
  3. 由一个轮子先驱动,到达某个位置后转换为另一个轮子驱动,然后剩下的两个轮子跟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开发的首页就已经基本落地了。整个开发过程总结下来,有这样几点可以分享:

  1. 用Flutter和Android开发首页,都依赖了MD组件,它们对此支持得都比较完善;由于Dart语言的特性,Flutter在使用这些组件时更容易扩展、灵活性更强。
  2. Flutter状态化的组件管理机制,显得比Android更切合场景,在区块列表的设计上得心应手,这点也是众多前端框架的亮点。
  3. Flutter的动画设计api很丰富,能充分满足各种UI动效,让页面开发更轻松且不复杂。
  4. Flutter表达性更强,又加以跨平台的解决方案,减少了代码量并大大提升了开发效率,为应用开发起到了开源节流的作用。
  5. Flutter作为新秀,在Java老大哥已经烂熟于MVP等模式设计后,Flutter在此方面还需要积累;也可能Flutter本身并不需要这样的积累,它等待的是比Java中更好的开发模式。

参考文档

Flutter扩展NestedScrollView