【第二五天 - Flutter 知名外送平台画面练习(上)】

前言

今日的程序码 => GITHUB

灵感来自於我在使用某某知名外送平台的时候,突然在想有这个画面要怎麽做,因为我觉得我在 TabBar 的部分,印象也没有相关的元件用,会做不出来,於是我就开始了实作这个画面,并努力解决。

很容易的,一看就会知道他是一个 Sliver 的效果。

专案架构

main
 - |myapp
     - |homepage
         - |FAppBar
             - |PromoText
             - |FlutterHead
             - |DiscountCard
             - |FIconButton
             - |HeaderClip
                 - |CustomShape
         - |CategorySection

SliverAppBar 属性

SliverAppBar 官方范例

下面我介绍一下属性,我认为重要的属性。

  • flexibleSpace:FlexibleSpaceBar 实现滚动折叠效果的地方
  • bottom:PreferredSizeWidget 通常用来实现 Tab 导航栏
  • elevation:阴影
  • shadowColor:阴影颜色
  • forceElevated = false:当 elevation 不为 0 的时候,是否显示阴影
  • collapsedHeight:折叠高度
  • expandedHeight:展开高度
  • floating = false:true 的时候下滑先show SliverAppBar,完成後才展示其他滑动元件内容
  • pinned = false:SliverAppBar 收缩到最小高度的时候 SliverAppBar 是否可见,true:SliverAppBar 会以折叠高度固定显示在头部,false:缩小到折叠高度後滑出页面
  • snap = false:snap == true,floating 也要为 true 才会有效果。 true 的时候会监听你的手势结束时的动作时是下滑,那麽 SliverAppBar 展开,上滑则是收缩折叠至上一次折叠的位置处,但是这个效果需要一个基础就是存在上一次折叠的位置,否则不生效。
  • stretch = false:true:SliverAppBar 完全展开後是否可以继续展开,注意这个需要外部滑动元件physics的支持(设置BouncingScrollPhysics(),滑动到标界可以继续滑动拥有回弹效果),否则是不会生效的
  • stretchTriggerOffset = 100.0:展开监听触发的偏移
  • onStretchTrigger:展开监听

FlexibleSpaceBar 属性

  • title:标题
  • background:widget背景
  • centerTitle:标题是否置中
  • titlePadding:标题内距
  • collapseMode:折叠模式
  • stretchModes:展开模式

Color

这边设定一个通用的 Color,设定 13 种。

/// 一组13种颜色,可用於配置大多数元件的颜色属性
const ColorScheme scheme = const ColorScheme(
  background: Color(0xFFF6F6F6),
  surface: Color(0xFFFFFFFF),
  primary: Color(0xFFC63065),
  secondary: Color(0xFF1E1E1E),
  onBackground: Color(0xFF1E1E1E),
  onSurface: Color(0xFF1E1E1E),
  onPrimary: Color(0xFFFFFFFF),
  onSecondary: Color(0xFFFFFFFF),
  primaryVariant: Color(0xFFC63065),
  secondaryVariant: Color(0xFF000000),
  error: Color(0xFFE74C3C),
  onError: Color(0xFFFFFFFF),
  brightness: Brightness.light,
);

text 的样式

什麽事 StruStyle?
相信大家看完这一篇文章就会懂了 => https://medium.com/@najeira/control-text-height-using-strutstyle-4b9b5151668b

/// text 的样式
class Helper {
  Helper._internal();

  static StrutStyle buildStrutStyle(TextStyle? textStyle) {
    return StrutStyle(
      forceStrutHeight: true,
      fontWeight: textStyle?.fontWeight,
      fontSize: textStyle?.fontSize,
      fontFamily: textStyle?.fontFamily,
      fontStyle: textStyle?.fontStyle,
      fontFamilyFallback: textStyle?.fontFamilyFallback,
      debugLabel: textStyle?.debugLabel,
    );
  }
}

假资料

这边我就直接来介绍会用到的资料有哪一些。因为程序码有点长,所以物件的宣告我没有列出来。
完整的程序码 => 假资料

class ExampleData {
  ExampleData._internal();
  /// 饮料的图片
  static List<String> images = [
    "https://d1sag4ddilekf6.cloudfront.net/compressed/items/6-CYXCTZAEEEECJE-CZAYA3CERF5ETJ/photo/b44c9b4be5044923b3f5b8f8f6e7e55b_1581506444759847068.jpg",
    "https://d1sag4ddilekf6.cloudfront.net/compressed/items/6-CY21EXXWSEV2E2-CZKKV8MFGPUTMA/photo/321adfd29ded4d9eae3488848ecfbb05_1592997965388846905.jpg",
    "https://d1sag4ddilekf6.cloudfront.net/compressed/items/6-CY4ETPUKCCCYTX-CZAYA3BKLEN2KE/photo/8d2d5939ec5a42269a0d8ec3c0a97e44_1581506429557055566.jpg",
    "https://d1sag4ddilekf6.cloudfront.net/item/6-CY21EXXWUFW1CN-CZAYA25ZSEUJV6/photos/c3f51cd36f2344e28abae3a91b94ef9b_1581506376835073709.jpg",
    "https://d1sag4ddilekf6.cloudfront.net/compressed/items/6-CZADR6NJMB3UL6-CZADR6UYL65GSE/photo/d4e13ca45a4747b78364dcf643095124_1580377235610503360.jpg",
  ];
  /// 全部的资料
  static PageData data = PageData(
    title: " 瘾茶",
    deliverTime: "外送 15 分钟",
    bannerText:
        "指定地区使用线上支付,满\$150现折\$30,输入优惠码【AUT30】,秋高Chill爽立即点!",
    backgroundUrl:
        "https://www.browncoffee.com.kh/uploads/ximg/item_menus/20210515062936c2531deff29845101d3f6f5691943c98.jpg",
    rate: 4.2,
    rateQuantity: 331,
    optionalCard: OptionalCard(
      title: "折扣 30%",
      subtitle: "On the entire menu",
    ),
    categories: [
      category1,
      category2,
      category3,
      category4,
      category4,
      category4,
      category3,
    ],
  );
  /// 每一个 section 的资料
  static Category category1 = Category(
    title: "人气精选",
    subtitle: "大家都点这些 ? 手刀点起来",
    isHotSale: true,
    foods: List.generate(
      5,
      (index) {
        return Food(
          name: "冰淇淋红茶",
          price: "40",
          comparePrice: "\$35",
          imageUrl: images[index % images.length],
          isHotSale: index == 3 ? true : false,
        );
      },
    ),
  );

  static Category category2 = Category(
    title: "明星商品",
    subtitle: null,
    isHotSale: false,
    foods: List.generate(
      3,
      (index) {
        return Food(
          name: "耶果青茶",
          price: "35",
          comparePrice: "\$30",
          imageUrl: images[index % images.length],
          isHotSale: index == 2 ? true : false,
        );
      },
    ),
  );

  static Category category3 = Category(
    title: "找奶茶",
    subtitle: null,
    isHotSale: false,
    foods: List.generate(
      1,
      (index) {
        return Food(
          name: "波霸奶茶",
          price: "40",
          comparePrice: "\$35",
          imageUrl: images[index % images.length],
          isHotSale: false,
        );
      },
    ),
  );

  static Category category4 = Category(
    title: "找拿铁",
    subtitle: null,
    isHotSale: false,
    foods: List.generate(
      5,
      (index) {
        return Food(
          name: "红茶拿铁",
          price: "40",
          comparePrice: "\$35",
          imageUrl: images[index % images.length],
          isHotSale: index == 3 ? true : false,
        );
      },
    ),
  );
}

SliverAppBar

这边的话,我在程序码里面都写很清楚了,想要补充的点是 onCollapsedonTap 是两个 callBack,
利用 callback 转换传递现在的 isCollapsed、index 给 HomePage 知道。

WidgetsBinding.instance?.addPostFrameCallback((timeStamp) {}); 此时如果立刻执行下面的代码,是获取不到 BuildContext,因为 widget 还没有完成绘制,addPostFrameCallback 是 StatefulWidget 渲染结束的回调,只会被调用一次,之後 StatefulWidget 需要刷新 UI 也不会被调用

/// SliverAppBar
class FAppBar extends SliverAppBar {
  final PageData data;
  final BuildContext context;
  final bool isCollapsed;
  final double? expandedHeight;
  final double collapsedHeight;
  final TabController tabController;
  final void Function(bool isCollapsed) onCollapsed;
  final void Function(int index) onTap;

  FAppBar({
    required this.data,
    required this.context,
    required this.isCollapsed,
    required this.expandedHeight, // 展开的高度。
    required this.collapsedHeight,
    required this.onCollapsed,
    required this.onTap,
    required this.tabController,
  }) : super(
            elevation: 4.0,
            pinned: true,
            forceElevated: true,
            expandedHeight: expandedHeight);

  /// super() 是用来继承父亲 Widget 里面的属性 or function
  @override
  Color? get backgroundColor => scheme.surface;

  /// SliverBar 的 leading
  @override
  Widget? get leading {
    return FIconButton(
      iconData: Icons.arrow_back,
      onPressed: () {},
    );
  }

  /// SliverAppBar 的 actions
  @override
  List<Widget>? get actions {
    return [
      FIconButton(iconData: Icons.share_outlined, onPressed: () {}),
      FIconButton(iconData: Icons.info_outline, onPressed: () {}),
    ];
  }

  /// SliverAppBar Title 慢慢出现的动画,只有在缩小才看得到,subTitle 也写在这。
  @override
  Widget? get title {
    var textTheme = Theme.of(context).textTheme;
    // AnimatedOpacity => https://api.flutter.dev/flutter/widgets/AnimatedOpacity-class.html
    return AnimatedOpacity(
      // 0 == invisible, 1 == visible
      opacity: this.isCollapsed ? 0 : 1, // 判断 SliverAppBar 是展开还是缩小。
      duration: const Duration(milliseconds: 250),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            "瘾茶",
            style: textTheme.subtitle1?.copyWith(color: scheme.onSurface),
            strutStyle: Helper.buildStrutStyle(textTheme.subtitle1),
          ),
          const SizedBox(height: 4.0),
          Text(
            data.deliverTime,
            style: textTheme.caption?.copyWith(color: scheme.primary),
            strutStyle: Helper.buildStrutStyle(textTheme.caption),
          ),
        ],
      ),
    );
  }

  /// AppBar 的 bottom 不会被缩小。
  @override
  PreferredSizeWidget? get bottom {
    return PreferredSize(
      preferredSize: const Size.fromHeight(48),
      child: Container(
        color: scheme.surface,
        child: TabBar(
          isScrollable: true,
          // 是否可以滚动
          controller: tabController,
          // https://api.flutter.dev/flutter/material/TabController-class.html
          indicatorPadding: const EdgeInsets.symmetric(horizontal: 16.0),
          indicatorColor: scheme.primary,
          // tabBar 下面一条线的颜色
          labelColor: scheme.primary,
          // 被选到标签颜色
          unselectedLabelColor: scheme.onSurface,
          // 为被选到的颜色
          indicatorWeight: 3.0,
          // 下面标签的高度
          tabs: data.categories.map((e) {
            return Tab(text: e.title);
          }).toList(),
          // 想要把 list 里面的 data 转换成 Widget
          onTap: onTap,
        ),
      ),
    );
  }

  /// 只有展开才看得到的 FlexibleSpaceBar 属性
  @override
  Widget? get flexibleSpace {
    return LayoutBuilder(
      builder: (
        BuildContext context,
        BoxConstraints constraints,
      ) {
        // 现在整块 flexibleSpace 的高度
        final top = constraints.constrainHeight();
        final collapsedHight =
            MediaQuery.of(context).viewPadding.top + kToolbarHeight + 48;
        // 尚未展开的 flexibleSpace 高度。
        WidgetsBinding.instance?.addPostFrameCallback((timeStamp) {
          // 此时如果立刻执行下面的代码,是获取不到 BuildContext,因为 widget 还没有完成绘制
          // addPostFrameCallback 是 StatefulWidget 渲染结束的回调,只会被调用一次,之後 StatefulWidget 需要刷新 UI 也不会被调用
          onCollapsed(collapsedHight != top); // 利用 callback 转换传递现在的 isCollapsed
        });

        return FlexibleSpaceBar(
          collapseMode: CollapseMode.pin, // 展开模式
          background: Column(
            children: [
              Stack(
                children: [
                  PromoText(title: data.bannerText), // 粉红色部分(有点类似广告)(宣传文字)
                  FlutterHead(), // flutter 头像
                  Column(
                    children: [
                      HeaderClip(data: data, context: context),
                      // 餐厅上方图片,有形状的那个。
                      SizedBox(height: 90),
                    ],
                  ),
                ],
              ),
              DiscountCard(
                title: data.optionalCard.title,
                subtitle: data.optionalCard.subtitle,
              ),
            ],
          ),
        );
      },
    );
  }
}

其他元件

其他的元件将会在後面两天补充讲完。

  • FIconButton
    • AppBar 的按钮
  • DiscountCard
    • 打折卡片
  • HeaderClip
    • App 餐厅图片(弧形的)
  • PromoText
    • 宣传框
  • FlutterHead
    • Logo

<<:  Day7 — GPIO 功能

>>:  [DAY 26] _STM32 看门狗简介_独立看门狗(2)

Day 29: Flutter Development

Day 29: Flutter Development tags: Others Quest htt...

制作婚礼现场即时留言版- Azure SignalR Service I

第12 届iT邦帮忙铁人赛系列文章 (Day28) SignalR是实现即时通讯的框架,如下图,在S...

[Java Day26] 6.3. super

教材网址 https://coding104.blogspot.com/2021/06/java-s...

Python3下载pandas,执行Run之後,下面的Terminal一直闪烁,且并没有Run出东西

如题,有下载 pip install pandas pip install pandas_datar...

Rust-资料型别-整数、浮点数

Rust是静态型别语言,所以在编译时需要知道变数的型别是什麽 前面的程序范例很多是没有宣吿型别但是却...