用Flutter實現一個精美的點單功能

Vico君發表於2020-05-25

前一段時間專案整合了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控制。

用Flutter實現一個精美的點單功能

下面是功能程式碼大致實現:

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實現一個精美的點單功能

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

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

BackdropFilter(
    filter: ImageFilter.blur(sigmaX, sigmaY),
    child: ...)
複製程式碼

不過使用的時候也有小坑,如果沒有進行剪輯,那麼高斯模糊的效果會擴散至全屏,正確的寫法應該如下:

ClipRect(
    BackdropFilter(
        filter: ImageFilter.blur(sigmaX, sigmaY),
        child: ...)
)
複製程式碼

ps:其實在BackdropFilter的原始碼中有更詳細的說明,建議大家去看看

商品欄目分類的實現

商品欄目的分類說的籠統點就是一、二級選單對PageView的page切換處理。

用Flutter實現一個精美的點單功能

可以把上圖右側框出的部分看成一個PageView,左側tab的點選就是對PageView進行的一個豎直方向的page切換操作,對應的tab下沒有二級tab的話,那麼當前page展示的就是一個ListView

用Flutter實現一個精美的點單功能

那如果有二級tab的話,當前page展示的是TabBar+PageView聯動,這個PageView的方向是橫向水平

用Flutter實現一個精美的點單功能

如果上述的描述還不是很懂的話,沒關係,我準備了一張總的結構圖,清晰的描述了它們之間的關係:

用Flutter實現一個精美的點單功能

還有一點需要注意的地方,我們不希望每次切換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,裡面對使用者互動的處理還是蠻妥當的,希望能夠幫助到大家。

相關文章