用Flutter實(shí)現(xiàn)一個(gè)精美的點(diǎn)單功能

前一段時(shí)間項(xiàng)目集成了Flutter做了許多的功能模塊篇梭,再加上很久沒有文章產(chǎn)出兄渺,所以打算寫這么一篇文章來總結(jié)和記錄Flutter開發(fā)中的一些問題
Demo地址:https://github.com/weibindev/flutter_order

ps:demo中的數(shù)據(jù)都從assets\data\文件夾下的json文件讀取,所以并沒有涉及到網(wǎng)絡(luò)請求封裝,項(xiàng)目架構(gòu)等相關(guān)知識,這個(gè)demo偏注重于點(diǎn)單結(jié)構(gòu)的實(shí)現(xiàn)沿侈。

總體的效果如下所示:

點(diǎn)單.gif

整體結(jié)構(gòu)分析

首頁的店鋪入口沒什么好說的,它主要是我們點(diǎn)單功能的入口和店鋪購物車商品數(shù)的展示市栗。

下面我們主要來分析下點(diǎn)單界面的結(jié)構(gòu)組成。

點(diǎn)單界面結(jié)構(gòu)

根據(jù)上面這張圖咳短,按照數(shù)字標(biāo)識框出的地方分析如下:

  • 1:頂部的搜索框填帽,相當(dāng)于Android中的statusBar+toolbar
  • 2:左側(cè)一級商品分類欄目,部分欄目會有二級分類的情況出現(xiàn)
  • 3:二級商品分類欄目咙好,是對一個(gè)大類商品做進(jìn)一步劃分
  • 4:一級或二級分類的商品列表篡腌,點(diǎn)擊單個(gè)商品條目進(jìn)入商品的詳情頁
  • 5:底部購物車,它位于整個(gè)點(diǎn)單界面的最頂層勾效,這個(gè)界面的所有功能均不會遮擋住購物車(具有overlays屬性的控件除外)

其中1嘹悼,2,3层宫,4可以看作一個(gè)整體杨伙,5可以看作一個(gè)整體。

底部購物車實(shí)現(xiàn)

關(guān)于底部購物車萌腿,我剛開始的實(shí)現(xiàn)思路是用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是一個(gè)Stack組件,可以將OverlayEntry插入到Overlay中毁菱,使其獨(dú)立的child窗口懸浮于其它組件之上米死,利用這個(gè)特性我們可以用Overlay將底部購物車組件包裹起來,覆蓋在其它的組件之上贮庞。

然而實(shí)際使用過程中問題多多峦筒,需要自己精準(zhǔn)的控制好Overlay包裹的懸浮控件的顯隱等,不然人家都退出這個(gè)界面了窗慎,咱們的購物車還擱下面顯示著物喷。個(gè)人認(rèn)為這玩意還是更適合Popupindow和全局自定義Dialog之類的。

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

在編寫Flutter應(yīng)用的時(shí)候脯丝,我們程序的入口是通過main()函數(shù)的runApp(MyApp())執(zhí)行的商膊,MyApp通常會build出一個(gè)MaterialApp組件

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '我要點(diǎn)東西',
      home: HomePage(),
    );
  }
}

對于不同界面之間的路由我們會交由Navigator管理,比如: Navigator.pushNavigator.pop等宠进。為什么MaterialApp能夠?qū)?code>Navigator的操作作出感應(yīng)呢晕拆?

MaterialApp的構(gòu)造方法中有這么一個(gè)字段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構(gòu)造方法中的navigatorKeyWidgetsAppnavigatorKey在組件初始化時(shí)會默認(rèn)的創(chuàng)建一個(gè)全局的NavigatorState材蹬,然后對build(BuildContext context)中創(chuàng)建的Navigator進(jìn)行狀態(tài)管理实幕。

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會默認(rèn)初始化一個(gè)全局的NavigatorState
        _navigator = widget.navigatorKey ?? GlobalObjectKey<NavigatorState>(this);
    }
    
    @override
  Widget build(BuildContext context) {
    //這里會構(gòu)建出一個(gè)Navigator組件,并把上面的navigatorKey寫進(jìn)去堤器,這樣就做到了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,
      );
    }
  }
}

到這里基本上可以想到該如何實(shí)現(xiàn)底部購物車的功能了昆庇。

是的,我們可以在點(diǎn)單界面自定義一個(gè)Navigator來管理搜索商品闸溃、商品詳情整吆、商品購物車列表等路由的跳轉(zhuǎn),其它的交由我們MaterialAppNavigator控制辉川。

image

下面是功能代碼大致實(shí)現(xiàn):

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

class _OrderPageState extends State<OrderPage> {

  ///管理點(diǎn)單功能Navigator的key
  GlobalKey<NavigatorState> navigatorKey = GlobalKey();

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
        onWillPop: () {
          //監(jiān)聽系統(tǒng)返回鍵表蝙,先對自定義Navigator里的路由做出棧處理,最后關(guān)閉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) =>
                            //構(gòu)建內(nèi)容層
                            _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(),
            ),
            //添加商品進(jìn)購物車的小球動(dòng)畫
            ThrowBallAnim(),
          ],
        ),
      );
  }
}


頁面過渡動(dòng)畫Hero的使用

效果可以看最開始的那一張GIF府蛇。

Hero的使用非常的簡單,需要關(guān)聯(lián)的兩個(gè)組件用Hero組件包裹屿愚,并指定相同的tag參數(shù),代碼如下:

///列表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的效果就會出來了?在正常情況下是會有效果妆距,但是在我們這里卻沒有任何效果穷遂,就跟普通的路由跳轉(zhuǎn)一樣樣的,這是為啥呢娱据?

我們在MaterialApp中的是有效果的塞颁,自定義的Navigator的卻沒效果,那么肯定是MaterialAppNavigator做了什么配置吸耿。

還是通過MaterialApp的源碼可以發(fā)現(xiàn)祠锣,在其初始化的時(shí)候會new一個(gè)HeroController并在構(gòu)造參數(shù)navigatorObservers中添加進(jìn)去

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>[];
    }
  }

    ///.... 
}

最終是添加進(jìn)WidgetsApp構(gòu)建的Navigator構(gòu)造參數(shù)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會添加進(jìn)去
        observers: widget.navigatorObservers,
      );

所以我們只要同理在自己定義的Navigator里添加進(jìn)去即可:

    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(),
            ),
            //添加商品進(jìn)購物車的小球動(dòng)畫
            ThrowBallAnim(),
          ],
        )

高斯模糊的實(shí)現(xiàn)

image

底部購物車的灰色區(qū)域使用到了高斯模糊的效果

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

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

不過使用的時(shí)候也有小坑,如果沒有進(jìn)行剪輯,那么高斯模糊的效果會擴(kuò)散至全屏咽安,正確的寫法應(yīng)該如下:

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

ps:其實(shí)在BackdropFilter的源碼中有更詳細(xì)的說明伴网,建議大家去看看

商品欄目分類的實(shí)現(xiàn)

商品欄目的分類說的籠統(tǒng)點(diǎn)就是一、二級菜單對PageView的page切換處理妆棒。

image

可以把上圖右側(cè)框出的部分看成一個(gè)PageView澡腾,左側(cè)tab的點(diǎn)擊就是對PageView進(jìn)行的一個(gè)豎直方向的page切換操作沸伏,對應(yīng)的tab下沒有二級tab的話,那么當(dāng)前page展示的就是一個(gè)ListView动分。

image

那如果有二級tab的話毅糟,當(dāng)前page展示的是TabBar+PageView聯(lián)動(dòng),這個(gè)PageView的方向是橫向水平

image

如果上述的描述還不是很懂的話澜公,沒關(guān)系姆另,我準(zhǔn)備了一張總的結(jié)構(gòu)圖,清晰的描述了它們之間的關(guān)系:

image

還有一點(diǎn)需要注意的地方坟乾,我們不希望每次切換tab的時(shí)候,Widgets都會重新加載一次迹辐,這樣對用戶的體驗(yàn)是極差的,我們要對已經(jīng)加載過的page保持它的一個(gè)頁面狀態(tài)甚侣。這一點(diǎn)使用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;
}

結(jié)束

好了,文章到這里七七八八的差不多了,其他更加細(xì)節(jié)的地方大家可以去Github上看我寫的demo殷费,里面對用戶交互的處理還是蠻妥當(dāng)?shù)挠±螅M軌驇椭酱蠹摇?/p>

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市详羡,隨后出現(xiàn)的幾起案子躏鱼,更是在濱河造成了極大的恐慌,老刑警劉巖殷绍,帶你破解...
    沈念sama閱讀 218,755評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異鹊漠,居然都是意外死亡主到,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,305評論 3 395
  • 文/潘曉璐 我一進(jìn)店門躯概,熙熙樓的掌柜王于貴愁眉苦臉地迎上來登钥,“玉大人,你說我怎么就攤上這事娶靡∧晾危” “怎么了?”我有些...
    開封第一講書人閱讀 165,138評論 0 355
  • 文/不壞的土叔 我叫張陵姿锭,是天一觀的道長塔鳍。 經(jīng)常有香客問我,道長呻此,這世上最難降的妖魔是什么轮纫? 我笑而不...
    開封第一講書人閱讀 58,791評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮焚鲜,結(jié)果婚禮上掌唾,老公的妹妹穿的比我還像新娘放前。我一直安慰自己,他們只是感情好糯彬,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,794評論 6 392
  • 文/花漫 我一把揭開白布凭语。 她就那樣靜靜地躺著,像睡著了一般撩扒。 火紅的嫁衣襯著肌膚如雪似扔。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,631評論 1 305
  • 那天却舀,我揣著相機(jī)與錄音虫几,去河邊找鬼。 笑死挽拔,一個(gè)胖子當(dāng)著我的面吹牛辆脸,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播螃诅,決...
    沈念sama閱讀 40,362評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼啡氢,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了术裸?” 一聲冷哼從身側(cè)響起倘是,我...
    開封第一講書人閱讀 39,264評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎袭艺,沒想到半個(gè)月后搀崭,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,724評論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡猾编,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,900評論 3 336
  • 正文 我和宋清朗相戀三年瘤睹,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片答倡。...
    茶點(diǎn)故事閱讀 40,040評論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡轰传,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出瘪撇,到底是詐尸還是另有隱情获茬,我是刑警寧澤,帶...
    沈念sama閱讀 35,742評論 5 346
  • 正文 年R本政府宣布倔既,位于F島的核電站恕曲,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏渤涌。R本人自食惡果不足惜码俩,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,364評論 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望歼捏。 院中可真熱鬧稿存,春花似錦笨篷、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,944評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至袖迎,卻和暖如春冕臭,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背燕锥。 一陣腳步聲響...
    開封第一講書人閱讀 33,060評論 1 270
  • 我被黑心中介騙來泰國打工辜贵, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人归形。 一個(gè)月前我還...
    沈念sama閱讀 48,247評論 3 371
  • 正文 我出身青樓托慨,卻偏偏與公主長得像,于是被迫代替她去往敵國和親暇榴。 傳聞我的和親對象是個(gè)殘疾皇子厚棵,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,979評論 2 355