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

3,324 阅读5分钟

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

Demo地址:github.com/weibindev/f…

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控制。

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

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(),
          ],
        )

高斯模糊的实现

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

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

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

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

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

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

商品栏目分类的实现

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

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

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

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

还有一点需要注意的地方,我们不希望每次切换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,里面对用户交互的处理还是蛮妥当的,希望能够帮助到大家。