用Flutter实现一个精美的点单功能

前一段时间项目集成了Flutter做了许多的功能模块,再加上很久没有文章产出,所以打算写这么一篇文章来总结和记录Flutter开发中的一些问题
Demo地址:https://github.com/weibindev/flutter_order

ps:demo中的数据都从assets\data\文件夹下的json文件读取,所以并没有涉及到网络请求封装,项目架构等相关知识,这个demo偏注重于点单结构的实现。

总体的效果如下所示:

点单.gif

整体结构分析

首页的店铺入口没什么好说的,它主要是我们点单功能的入口和店铺购物车商品数的展示。

下面我们主要来分析下点单界面的结构组成。

点单界面结构

根据上面这张图,按照数字标识框出的地方分析如下:

  • 1:顶部的搜索框,相当于Android中的statusBar+toolbar
  • 2:左侧一级商品分类栏目,部分栏目会有二级分类的情况出现
  • 3:二级商品分类栏目,是对一个大类商品做进一步划分
  • 4:一级或二级分类的商品列表,点击单个商品条目进入商品的详情页
  • 5:底部购物车,它位于整个点单界面的最顶层,这个界面的所有功能均不会遮挡住购物车(具有overlays属性的控件除外)

其中1,2,3,4可以看作一个整体,5可以看作一个整体。

底部购物车实现

关于底部购物车,我刚开始的实现思路是用Overlay去做,源码中对它的描述如下

/// A [Stack] of entries that can be managed independently.
///
/// Overlays let independent child widgets "float" visual elements on top of
/// other widgets by inserting them into the overlay's [Stack]. The overlay lets
/// each of these widgets manage their participation in the overlay using
/// [OverlayEntry] objects.
///
/// Although you can create an [Overlay] directly, it's most common to use the
/// overlay created by the [Navigator] in a [WidgetsApp] or a [MaterialApp]. The
/// navigator uses its overlay to manage the visual appearance of its routes.
///
/// See also:
///
///  * [OverlayEntry].
///  * [OverlayState].
///  * [WidgetsApp].
///  * [MaterialApp].
class Overlay extends StatefulWidget {

意思是Overlay是一个Stack组件,可以将OverlayEntry插入到Overlay中,使其独立的child窗口悬浮于其它组件之上,利用这个特性我们可以用Overlay将底部购物车组件包裹起来,覆盖在其它的组件之上。

然而实际使用过程中问题多多,需要自己精准的控制好Overlay包裹的悬浮控件的显隐等,不然人家都退出这个界面了,咱们的购物车还搁下面显示着。个人认为这玩意还是更适合Popupindow和全局自定义Dialog之类的。

那么Flutter中有没有方便管理一堆子组件的组件呢?

在编写Flutter应用的时候,我们程序的入口是通过main()函数的runApp(MyApp())执行的,MyApp通常会build出一个MaterialApp组件

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '我要点东西',
      home: HomePage(),
    );
  }
}

对于不同界面之间的路由我们会交由Navigator管理,比如: Navigator.pushNavigator.pop等。为什么MaterialApp能够对Navigator的操作作出感应呢?

MaterialApp的构造方法中有这么一个字段navigatorKey

class MaterialApp extends StatefulWidget {

  final GlobalKey<NavigatorState> navigatorKey;
    
  ///省略一些代码
}

class _MaterialAppState extends State<MaterialApp> {
    
  @override
  Widget build(BuildContext context) {
    Widget result = WidgetsApp(
      key: GlobalObjectKey(this),
      navigatorKey: widget.navigatorKey,
      navigatorObservers: _navigatorObservers,
      pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) {
        return MaterialPageRoute<T>(settings: settings, builder: builder);
      },
  ///省略一些代码
  }
}

往深入的去看它会传递给WidgetsApp构造方法中的navigatorKeyWidgetsAppnavigatorKey在组件初始化时会默认的创建一个全局的NavigatorState,然后对build(BuildContext context)中创建的Navigator进行状态管理。

class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
    @override
    void initState() {
        super.initState();
        _updateNavigator();
        _locale = _resolveLocales(WidgetsBinding.instance.window.locales, widget.supportedLocales);
        WidgetsBinding.instance.addObserver(this);
    }
  
    // NAVIGATOR
    GlobalKey<NavigatorState> _navigator;

    void _updateNavigator() {
        //MaterialApp中不指定navigatorKey会默认初始化一个全局的NavigatorState
        _navigator = widget.navigatorKey ?? GlobalObjectKey<NavigatorState>(this);
    }
    
    @override
  Widget build(BuildContext context) {
    //这里会构建出一个Navigator组件,并把上面的navigatorKey写进去,这样就做到了Navigator的栈操作
    Widget navigator;
    if (_navigator != null) {
      navigator = Navigator(
        key: _navigator,
        // If window.defaultRouteName isn't '/', we should assume it was set
        // intentionally via `setInitialRoute`, and should override whatever
        // is in [widget.initialRoute].
        initialRoute: WidgetsBinding.instance.window.defaultRouteName != Navigator.defaultRouteName
            ? WidgetsBinding.instance.window.defaultRouteName
            : widget.initialRoute ?? WidgetsBinding.instance.window.defaultRouteName,
        onGenerateRoute: _onGenerateRoute,
        onGenerateInitialRoutes: widget.onGenerateInitialRoutes == null
          ? Navigator.defaultGenerateInitialRoutes
          : (NavigatorState navigator, String initialRouteName) {
            return widget.onGenerateInitialRoutes(initialRouteName);
          },
        onUnknownRoute: _onUnknownRoute,
        observers: widget.navigatorObservers,
      );
    }
  }
}

到这里基本上可以想到该如何实现底部购物车的功能了。

是的,我们可以在点单界面自定义一个Navigator来管理搜索商品、商品详情、商品购物车列表等路由的跳转,其它的交由我们MaterialAppNavigator控制。

image

下面是功能代码大致实现:

class OrderPage extends StatefulWidget {
  @override
  _OrderPageState createState() => _OrderPageState();
}

class _OrderPageState extends State<OrderPage> {

  ///管理点单功能Navigator的key
  GlobalKey<NavigatorState> navigatorKey = GlobalKey();

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
        onWillPop: () {
          //监听系统返回键,先对自定义Navigator里的路由做出栈处理,最后关闭OrderPage
          navigatorKey.currentState.maybePop().then((value) {
            if (!value) {
              NavigatorUtils.goBack(context);
            }
          });
          return Future.value(false);
        },
        child: Stack(
          children: <Widget>[
            Navigator(
              key: navigatorKey,
              onGenerateRoute: (settings) {
                if (settings.name == '/') {
                  return PageRouteBuilder(
                    opaque: false,
                    pageBuilder:
                        (childContext, animation, secondaryAnimation) =>
                            //构建内容层
                            _buildContent(childContext),
                    transitionsBuilder:
                        (context, animation, secondaryAnimation, child) =>
                            FadeTransition(opacity: animation, child: child),
                    transitionDuration: Duration(milliseconds: 300),
                  );
                }
                return null;
              },
            ),
            Positioned(
              bottom: 0,
              right: 0,
              left: 0,
              //购物车组件,位于底部
              child: ShopCart(),
            ),
            //添加商品进购物车的小球动画
            ThrowBallAnim(),
          ],
        ),
      );
  }
}


页面过渡动画Hero的使用

效果可以看最开始的那一张GIF。

Hero的使用非常的简单,需要关联的两个组件用Hero组件包裹,并指定相同的tag参数,代码如下:

///列表item
InkWell(
      child: ClipRRect(
        borderRadius: BorderRadius.circular(4),
        child: Hero(
          tag: widget.data,
          child: LoadImage(
            '${widget.data.img}',
            width: 81.0,
            height: 81.0,
            fit: BoxFit.fitHeight,
          ),
        ),
      ),
      onTap: () {
        Navigator.of(context).push(MaterialPageRoute(
            builder: (context) => GoodsDetailsPage(data: widget.data)));
      },
    );



///详情
 Hero(
    tag: tag,
    child: LoadImage(
        imageUrl,
        width: double.infinity,
        height: 300,
        fit: BoxFit.cover,
        ),
    )

是不是觉得这样写好就完事了呢,Hero的效果就会出来了?在正常情况下是会有效果,但是在我们这里却没有任何效果,就跟普通的路由跳转一样样的,这是为啥呢?

我们在MaterialApp中的是有效果的,自定义的Navigator的却没效果,那么肯定是MaterialAppNavigator做了什么配置。

还是通过MaterialApp的源码可以发现,在其初始化的时候会new一个HeroController并在构造参数navigatorObservers中添加进去

class _MaterialAppState extends State<MaterialApp> {
  HeroController _heroController;

  @override
  void initState() {
    super.initState();
    _heroController = HeroController(createRectTween: _createRectTween);
    _updateNavigator();
  }

  @override
  void didUpdateWidget(MaterialApp oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.navigatorKey != oldWidget.navigatorKey) {
      // If the Navigator changes, we have to create a new observer, because the
      // old Navigator won't be disposed (and thus won't unregister with its
      // observers) until after the new one has been created (because the
      // Navigator has a GlobalKey).
      _heroController = HeroController(createRectTween: _createRectTween);
    }
    _updateNavigator();
  }

  List<NavigatorObserver> _navigatorObservers;

  void _updateNavigator() {
    if (widget.home != null ||
        widget.routes.isNotEmpty ||
        widget.onGenerateRoute != null ||
        widget.onUnknownRoute != null) {
      _navigatorObservers = List<NavigatorObserver>.from(widget.navigatorObservers)
        ..add(_heroController);
    } else {
      _navigatorObservers = const <NavigatorObserver>[];
    }
  }

    ///.... 
}

最终是添加进WidgetsApp构建的Navigator构造参数observers

navigator = Navigator(
        key: _navigator,
        // If window.defaultRouteName isn't '/', we should assume it was set
        // intentionally via `setInitialRoute`, and should override whatever
        // is in [widget.initialRoute].
        initialRoute: WidgetsBinding.instance.window.defaultRouteName != Navigator.defaultRouteName
            ? WidgetsBinding.instance.window.defaultRouteName
            : widget.initialRoute ?? WidgetsBinding.instance.window.defaultRouteName,
        onGenerateRoute: _onGenerateRoute,
        onGenerateInitialRoutes: widget.onGenerateInitialRoutes == null
          ? Navigator.defaultGenerateInitialRoutes
          : (NavigatorState navigator, String initialRouteName) {
            return widget.onGenerateInitialRoutes(initialRouteName);
          },
        onUnknownRoute: _onUnknownRoute,
        //MaterialApp的HeroController会添加进去
        observers: widget.navigatorObservers,
      );

所以我们只要同理在自己定义的Navigator里添加进去即可:

    Stack(
          children: <Widget>[
            Navigator(
              key: navigatorKey,
                //自定Navigator使用不了Hero的解决方案
              observers: [HeroController()],
              onGenerateRoute: (settings) {
                if (settings.name == '/') {
                  return PageRouteBuilder(
                    opaque: false,
                    pageBuilder:
                        (childContext, animation, secondaryAnimation) =>
                            _buildContent(childContext),
                    transitionsBuilder:
                        (context, animation, secondaryAnimation, child) =>
                            FadeTransition(opacity: animation, child: child),
                    transitionDuration: Duration(milliseconds: 300),
                  );
                }
                return null;
              },
            ),
            Positioned(
              bottom: 0,
              right: 0,
              left: 0,
              child: ShopCart(),
            ),
            //添加商品进购物车的小球动画
            ThrowBallAnim(),
          ],
        )

高斯模糊的实现

image

底部购物车的灰色区域使用到了高斯模糊的效果

该效果在Flutter中的控件是BackdropFilter,用法如下:

BackdropFilter(
    filter: ImageFilter.blur(sigmaX, sigmaY),
    child: ...)

不过使用的时候也有小坑,如果没有进行剪辑,那么高斯模糊的效果会扩散至全屏,正确的写法应该如下:

ClipRect(
    BackdropFilter(
        filter: ImageFilter.blur(sigmaX, sigmaY),
        child: ...)
)

ps:其实在BackdropFilter的源码中有更详细的说明,建议大家去看看

商品栏目分类的实现

商品栏目的分类说的笼统点就是一、二级菜单对PageView的page切换处理。

image

可以把上图右侧框出的部分看成一个PageView,左侧tab的点击就是对PageView进行的一个竖直方向的page切换操作,对应的tab下没有二级tab的话,那么当前page展示的就是一个ListView

image

那如果有二级tab的话,当前page展示的是TabBar+PageView联动,这个PageView的方向是横向水平

image

如果上述的描述还不是很懂的话,没关系,我准备了一张总的结构图,清晰的描述了它们之间的关系:

image

还有一点需要注意的地方,我们不希望每次切换tab的时候,Widgets都会重新加载一次,这样对用户的体验是极差的,我们要对已经加载过的page保持它的一个页面状态。这一点使用AutomaticKeepAliveClientMixin可以做到。

class SortRightPage extends StatefulWidget {
  final int parentId;
  final List<Sort> data;

  SortRightPage(
      {Key key,
      this.parentId,
      this.data})
      : super(key: key);

  @override
  _SortRightPageState createState() => _SortRightPageState();
}

class _SortRightPageState extends State<SortRightPage>
    with AutomaticKeepAliveClientMixin {
  @override
  Widget build(BuildContext context) {
    super.build(context);
    if (widget.data == null || widget.data.isEmpty) {
      if (widget.parentId == -1) {
        //套餐Page
        return DiscountPage();
      } else {
        //商品列表
        return SubItemPage(
          key: Key('subItem${widget.parentId}'),
          id: widget.parentId
        );
      }
    } else {
      //二级分类
      return SubListPage(
        key: Key('subList${widget.parentId}'),
        data: widget.data
      );
    }
  }

  @override
  bool get wantKeepAlive => true;
}

结束

好了,文章到这里七七八八的差不多了,其他更加细节的地方大家可以去Github上看我写的demo,里面对用户交互的处理还是蛮妥当的,希望能够帮助到大家。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350