Flutter

Starter

  • 新兴的 New
  • 移动端 Mobile
  • 动态化 Dynamic
  • 跨平台 Cross-platform

创建2D App的SDK Dart语言构建的编程框架 用一种语言编程(dart) => 为不同的平台定制(ios/android)=> Build & 发布

Flutter Dev | Flutter中文网 | Flutter Github

Install

install doc

  1. 下载zip包解压 flutter_macos_1.20.4-stable => /Users/cj/soft/flutter

  2. 配置环境变量

     > vi ~/.bash_profile
     # Flutter
     export FLUTTER_HOME=/Users/cj/soft/flutter
     export DART_HOME=$FLUTTER_HOME/bin/cache/dart-sdk
     # Flutter temp visit images (创建的flutter项目会使用这个镜像下载依赖)
     export PUB_HOSTED_URL=https://pub.flutter-io.cn
     export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
    
     # Path
     export PATH=$PATH:$FLUTTER_HOME/bin:$DART_HOME/bin
    
     > source ~/.bash_profile
    
     > flutter --version
     Flutter 1.20.4 • channel stable • https://github.com/flutter/flutter.git
     Framework • revision fba99f6cf9 (9 days ago) • 2020-09-14 15:32:52 -0700
     Engine • revision d1bc06f032
     Tools • Dart 2.9.2
    
     > dart --version
     Dart SDK version: 2.9.2 (stable) (Wed Aug 26 12:44:28 2020 +0200) on "macos_x64"
    
     > which flutter
     /Users/cj/soft/flutter/bin/flutter
     > which dart
     /Users/cj/soft/flutter/bin/dart
    
  3. 诊断依赖项

     > flutter doctor
     Doctor summary (to see all details, run flutter doctor -v):
     [✓] Flutter (Channel stable, 1.20.4, on Mac OS X 10.11.2 15C50, locale zh-Hans)
     [✗] Android toolchain - develop for Android devices
         ✗ Unable to locate Android SDK.
           Install Android Studio from: https://developer.android.com/studio/index.html
           On first launch it will assist you in installing the Android SDK components.
           (or visit https://flutter.dev/docs/get-started/install/macos#android-setup for detailed instructions).
           If the Android SDK has been installed to a custom location, set ANDROID_SDK_ROOT to that location.
           You may also want to add it to your PATH environment variable.
    
     [!] Xcode - develop for iOS and macOS (Xcode 7.2)
         ✗ Flutter requires a minimum Xcode version of 11.0.0.
           Download the latest version or update via the Mac App Store.
         ✗ CocoaPods not installed.
             CocoaPods is used to retrieve the iOS and macOS platform side's plugin code that responds to your plugin usage on the Dart side.
             Without CocoaPods, plugins will not work on iOS or macOS.
             For more info, see https://flutter.dev/platform-plugins
           To install:
             sudo gem install cocoapods
     [!] Android Studio (not installed)
     [!] IntelliJ IDEA Community Edition (version 2017.3.4)
         ✗ Flutter plugin not installed; this adds Flutter specific functionality.
         ✗ Dart plugin not installed; this adds Dart specific functionality.
     [!] Connected device
         ! No devices available
    
     ! Doctor found issues in 5 categories.
    
  4. 安装更新 Xcode => XCode ( with IOS Simulator )

  5. 安装 Android Studio => Android Studio ( AVD Manager -> Android Simulator )

  6. 安装 VS Code

  7. 各个IDE安装Flutter & Dart插件

模拟器

# 列出模拟器
> flutter emulator
3 available emulators:

apple_ios_simulator • iOS Simulator   • Apple  • ios
Pixel_API_28        • Pixel API 28    • Google • android
Pixel_XL_API_30     • Pixel XL API 30 • Google • android

To run an emulator, run 'flutter emulators --launch <emulator id>'.
To create a new emulator, run 'flutter emulators --create [--name xyz]'.

You can find more information on managing emulators at the links below:
  https://developer.android.com/studio/run/managing-avds
  https://developer.android.com/studio/command-line/avdmanager

> flutter emulator --launch

Hello World

  1. IOS => Xcode -> Open Developer Tool -> Simulator 或者 cmd: open -a Simulator
     > open -a Simulator
     > flutter create first_app
     > cd first_app
     > flutter run
    
  2. Android => 创建Android模拟器 Android Studio -> Tools -> AVD Manager -> Create Virtual Device

Sample: main.dart

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Center(
        child: Text(
          "Hello World",
          textDirection: textDirection.ltr,
          style: TextStyle(fontSize: 30, color: Colors.orange),
        ),
    );
  }
}

运行打包

flutter run --target=src/app.dart
flutter build apk --target=src/app.dart

第三方库

第三方库

pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  dio: ^3.0.10
> cd first_app
> flutter pub get

Flutter 组件树

https://zhuanlan.zhihu.com/p/151964543

flutter生命周期 https://www.jianshu.com/p/7e8dff26f81a

  • StatelessWidget
  • StatefulWidget

StatelessWidget

/// for example: StatelessWidget
class ProductItemWidget extends StatelessWidget {
  final String title;
  final String subTitle;
  final String imageUrl;

  const ProductItemWidget(this.title, this.subTitle, this.imageUrl);

  @override
  Widget build(BuildContext context) {
    return Container(
        padding: EdgeInsets.all(10),
        margin: EdgeInsets.all(10),
        decoration: BoxDecoration(border: Border.all(width: 2)),
        child: Column(
          children: [
            Text(
              title,
              style: TextStyle(fontSize: 20),
            ),
            Text(subTitle, style: TextStyle(fontSize: 16)),
            SizedBox(
              height: 10,
            ),
            Image.network(imageUrl),
          ],
        ));
  }
}

StatefulWidget

import 'package:flutter/material.dart';

/// for test: StatefulWidget 生命周期
/*
第一次初始化
flutter: StatefulWidgetLcDemo constructor()
flutter: StatefulWidgetLcDemo createState()
flutter: _StatefulWidgetLcDemoState constructor()
flutter: _StatefulWidgetLcDemoState initState()
flutter: _StatefulWidgetLcDemoState didChangeDependencies()
flutter: _StatefulWidgetLcDemoState build(ctx)

保存-热加载更新
flutter: StatefulWidgetLcDemo constructor()
flutter: _StatefulWidgetLcDemoState didUpdateWidget(oldWidget)
flutter: _StatefulWidgetLcDemoState build(ctx)

press-setState
flutter: _StatefulWidgetLcDemoState setState() before
flutter: _StatefulWidgetLcDemoState setState() do
flutter: _StatefulWidgetLcDemoState setState() after
flutter: _StatefulWidgetLcDemoState build(ctx)
*/

class StatefulWidgetLcDemo extends StatefulWidget {
  StatefulWidgetLcDemo() {
    print("StatefulWidgetLcDemo constructor()");
  }

  @override
  _StatefulWidgetLcDemoState createState() {
    print("StatefulWidgetLcDemo createState()");
    return _StatefulWidgetLcDemoState();
  }
}

class _StatefulWidgetLcDemoState extends State<StatefulWidgetLcDemo> {
  int count = 0;

  _StatefulWidgetLcDemoState() {
    print("_StatefulWidgetLcDemoState constructor()");
  }

  @override
  void initState() {
    super.initState();
    print("_StatefulWidgetLcDemoState initState()");
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    print("_StatefulWidgetLcDemoState didChangeDependencies()");
  }

  @override
  void didUpdateWidget(StatefulWidgetLcDemo oldWidget) {
    super.didUpdateWidget(oldWidget);
    print("_StatefulWidgetLcDemoState didUpdateWidget(oldWidget)");
  }

  @override
  Widget build(BuildContext context) {
    print("_StatefulWidgetLcDemoState build(ctx)");
    return Container(
      child: RaisedButton(
        child: Text("Test StatefulWidget Lifecycle - Count: $count"),
        onPressed: () {
          print("_StatefulWidgetLcDemoState setState() before");
          setState(() {
            count++;
            print("_StatefulWidgetLcDemoState setState() do");
          });
          print("_StatefulWidgetLcDemoState setState() after");
        },
      ),
    );
  }
}

Scaffold

App页面框架

  • drawer 左侧滑出
  • 主屏幕
    • appBar:
      • leading,title,actions
      • flexibleSpace
      • bottom
    • body
    • bottomSheet
    • persistentFooterButtons
    • BottomNavigationBar
  • endDrawer 右侧滑出

容器和布局

容器:包含单个Widget 布局:包含多个Widget

容器

万能容器 Container

  • 绘制顺序
    • transform
    • decoration
    • child
    • foregroundDecoration
  • 属性
    • alignment 容器内child的对齐方式
    • padding,margin: EdgeInsetsGeometry
    • width,height
    • constraints: BoxConstrants 容器大小限制 eg: BoxConstrants.expand() 占满全屏
    • color 容器背景色 (注:不能和decoration同时使用)
    • decoration : Decoration 容器背景装饰
      • color
      • border: Border.all(...) 往外扩展,即占用外边距的宽度
      • borderRadius: BorderRadius.circle() 边框圆角
      • boxShadow: [] 阴影效果
      • gradient: Gradient 渐变效果(LinearGradient,RadiaGradient,SweepGradient),会覆盖color
      • image: DecorationImage(image)
      • shape: BoxShape.circle
    • foregroundDecoration : Decoration 容器前景装饰,绘制在child智商
    • transform: Matrix4 容器变化
    • child: Widget
  • 说明
    • 本身是一个盒子模型
    • 未设置约束和宽高
      • 无child,Container占满全屏
      • 有child
        • 设置了alignment,Container占满全屏
        • 未设置了alignment,Container同child一般大
    • Decoration
      • BoxDecoration
      • ShapeDecoration 通常用于单独为四条边绘制不同效果,特别:shape属性同BoxDecoration不同,类型为ShapeBorder
        • Border 绘制四边
        • UnderlineInputBorder 绘制底边线
        • RoundedRectangleBorder 绘制矩形边框
        • CircleBorder 绘制圆形边框
        • StadiumBorder 绘制竖向椭圆边框
        • BeveledRectangleBorder 绘制八角边框
      • UnderlineTabIndicator
    • EdgeInsetsGeometry
      • EdgeInsets 不支持国际化: .all(),.symmetric(),.fromLTRB(),.only()
      • EdgeInsetsDirectional 支持国际化(左右切换)

基础布局

  • 弹性 Flex
    • direction: Axis 主轴方向 Axis.horizontal ,Axis.vertical
    • mainAxisAlignment,crossAxisAlignment 主轴/交叉轴上的对齐方式
    • mainAxisSize: 主轴应占用多大空间 MainAxisSize.min,MainAxisSize.max
    • textDirection,verticalDirection : TextDirection 子组件布局顺序
    • textBaseline 基线
    • children: List
  • 线性 Row, Column (继承自Flex)
  • 流式 Wrap 自动换行(像水一样自动流过去,换行)
    • direction: Axis
    • alignment,crossAxisAlignment 主轴/交叉轴上的对齐方式
    • runAlignment 纵轴对齐方式
    • runSpacing: double 纵轴间距(默认0.0)
    • textDirection,verticalDirection
    • children: List
  • 层叠 Stack 重叠组件(子组件列表后米的重置在前面,超过渲染区可剪切掉)

辅助布局

  • Padding 边距布局
    • padding
    • child
  • Center 水平垂直居中布局
    • child
  • SizedBox 固定宽高布局
    • width,height
    • child
  • AspectRatio 宽高比布局
  • FractionallySizedBox 百分比布局
  • Card 卡片布局

高级布局

  • ListView 可滚动列表(特定化的Column,支持垂直和水平滚动)
    • ListView()
    • ListView.builder()
    • ListView.separated() 可添加分割线
    • ListView custom()
  • GridView 可滚动的二维网格列表 (二维列表,内容超过渲染区时将自动滚动)
    • GrideView()
    • GrideView.count 交叉轴上为固定个数的网格(必设属性:crossAxisCount)
    • GrideView.extend 交叉轴上最大可容纳的网格(必设属性:maxCrossAxisExtent 一个网格的最大宽度)
    • GrideView.builder
    • GrideView.custom
  • Table/TableRow 表格 (类似线性布局)
  • IndexStack 栈索引 (继承自Stack,用于显示第index个child,其他child不可见,默认显示index为0的元素)

Sample: GridView

_buildContent(context,value){
    return Container(
      height: StationTheme.stationHeight,
      child: GridView.builder(
        padding: EdgeInsets.all(8),
        scrollDirection: Axis.horizontal,
        itemCount: value.stationItems.length,
        itemBuilder: (context,index){
          return InkWell(
            child:_buildStation(value.stationItems[index]),
            onTap: ()=>Navigator.of(context)
              .pushNamed(GlobalRoutes.Route_Detail,arguments: {'item':value.stationItems[index]}),
          );
        },
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2, 
          crossAxisSpacing: 8, 
          mainAxisSpacing: 8,
          childAspectRatio:0.6
        ),
      )
    );
}

基础控件

  • Text
    • data: String
    • style: TextStyle
    • textAlign: TextAlign
    • textDirection: TextDirection
    • softWrap: bool 是否自动换行
    • overflow: TextOverflow 溢出处理 (clip,fade,ellipsis,visible)
    • textScaleFator: double 字体缩放系数
    • maxLines: int 最多可显示行数,溢出的按overflow属性规则处理
    • textSpan: TextSpan 需使用Text.rich方法创建
  • Image
    • 构造:
      • Image(...)
      • Image.asset 加载资源目录中的图片
      • Image.network 加载网络图片 = Image(image:NetworkImage(src))
      • Image.file 加载本地图片文件
      • Image.memory 加载Uint8List资源图片
    • 属性:
      • image: ImageProvider
      • width/height: double
      • fit: BoxFit 填充模式
      • color
      • colorBlendMode: BlendMode 混色处理
      • alignment
      • repeat: ImageRepeat
      • centerSlice: Rect 点9的8个方向拉伸处理
      • gaplessPlayback: bool 当ImageProvider发生变化,true则保留图片知道新图出现,否则中间显示空
  • Icon
  • SelectableText 可选文本
  • TextField 文本输入框
  • Button
    • RaisedButton 凸起按钮
    • FlatButton 扁平按钮
    • IconButton 图标按钮
    • OutlineButton 线框按钮
  • Radio 单选框
  • Checkbox 多选框
  • Chip 碎片(类似tag)
  • Switch 开/关按钮
  • Slider 滑块(类似进度条)
  • Placeholder 占位

事件与通知

Flutter手势系统分为两个层级

  • 下层:识别原始的指针事件,描述指针的位置和移动
    • 指针事件:
      • PointerDownEvent
      • PointerMoveEvent
      • PointerUpEvent
      • PointerCancelEvent
    • 当指针按下时,事件指派给最内层的组件,后续指针事件以冒泡方式向上传递给到根组件的所有途径组件
    • 无法取消或停止事件冒泡传递
    • 可使用Listener监听指针事件,不过应尽量使用更高层的手势
  • 上层:识别手势,即语义动作,由一个活多个指针事件组成
    • 手势事件:
      • 点击(Tap)
      • 拖动(Drag)
      • 缩放(Scale)
    • 生命周期内会发送一个或多个事件,使用GestureDetector来监听手势事件
    • GestureDetector通过检查出不为null的手势事件处理器来获悉需要识别的手势

GestureDetector 手势检测,有7种类型事件:

  • 单击事件(Tap): onTapDown,onTapUp,onTap,onTapCancel
  • 双击事件(Double Tap): onDoubleTap
  • 长按事件(Long Press): onLongPress,onLongPressMoveUpdate,onLongPressUp,onLongPressEnd
  • 垂直滑动(Vertical Drap): onVerticalDrapDown,onVerticalDrapStart,onVerticalDrapUpdate,onVerticalDrapEnd,onVerticalDrapCancel
  • 水平滑动(Horizontal Drap): onHorizontalDragStart,onHorizontalDrapUpdate,onHorizontalDragEnd
  • 指针事件(Pan): onPanDown,onPanStart,onPanUpdate,onPanEnd,onPanCacel
  • 缩放事件(Scale) onScaleStart,onScaleUpdate,onScaleEnd

InkWell 具有水波纹效果(溅墨效果)的点击事件控件 注: 当InkWell的子控件设置了背景色,是看不到溅墨效果的,需进行特殊处理(用Material & Ink 包裹) 用InkWell包裹Image时,也是看不到溅墨效果的,建议使用 Ink.Image控件

本地资源

静态化本地资源 assets,fonts

pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  flutter_svg: ^0.19.1                    # svg
  flutter_staggered_grid_view: "^0.3.2"   # 瀑布流插件

flutter:
    assets:
        - assets/images/
        - assets/icons/
    fonts:
        - family: Nunito
          fonts:
            - asset: assets/fonts/Nunito/Nunito-Regular.ttf
            - asset: assets/fonts/Nunito/Nunito-SemiBold.ttf
              weight: 600
            - asset: assets/fonts/Nunito/Nunito-Bold.ttf
              weight: 700

路由

Navigator 导航器

  • 按照堆栈方式管理子组件
  • 内部使用Overlay组件管理多个页面,位于最上层的页面可见
  • 通过调整Overlay里页面顺序来切换页面
  • 页面Page也称为ScreenRoute
  • WidgetsAppMaterialApp里会自动创建一个导航器,组件里可使用Navigator.of(context)来获取其祖先组件里的导航器
  1. 进入新页和返回

    • 使用MaterialApp的home属性制定首页,该页位于导航器堆栈的最底层
    • 使用Navigator.push方法进入某个页面(传递一个MaterialPageRoute对象)
    • 使用Navigator.pop方法返回上一页
    • 如果页面使用了Scaffold,则导航条上会自动出现返回按钮
  2. 跨页面传递数据

    • 可通过页面组件的构造函数传递数据
    • Navigator.push 返回Future对象,会在Navigator.pop(value)退出页面时完成,Future得到pop的值
    • 可先创建命名路由,后使用Navigator.pushNamed进入指定名字的路由
  3. 导航器嵌套

    • 应用内所有导航器为树状结构,有一个根root导航器
    • 使用WillPopScope来使内层导航器响应Android实体返回键

Navigator & Router

  1. MaterialApp.routes 路由
     initialRoute:'/',
     routes:{ 
         '/': (context) => NavigatorDemo(), 
         '/home': (context) => Home(),
         '/about': (context) => MyPage(title: "About"),
     }
    
  2. Navigator.push 进入
     onPressed: () {
       // Navigator.of(context).push(
       //  MaterialPageRoute(
       //    builder: (BuildContext context) => MyPage(title: 'About'))
       //);
       Navigator.pushNamed(context, '/about');
     },
    
  3. Navigator.pop 返回上一页
     onPressed: () {
           Navigator.pop(context);
     },
    

静态路由&动态路由 https://www.jianshu.com/p/66fa8c85cdc6

动态路由框架 Fluro

  1. pubspec.yaml

     dependencies:
       flutter:
         sdk: flutter
       flutter_localizations:
         sdk: flutter
       # 动态路由
       fluro: 1.7.7
    
  2. routes.dart:

    
     import 'package:fluro/fluro.dart';
     import 'package:flutter/material.dart';
     import 'package:third_app/components/main_body.dart';
     import 'package:third_app/pages/detail/detail.dart';
    
     /*
     https://dart-pub.mirrors.sjtug.sjtu.edu.cn/packages/fluro
     https://zhuanlan.zhihu.com/p/107383867
     https://segmentfault.com/a/1190000021488577
     */
    
     Handler homePageHandler = Handler(
       handlerFunc: (context, params) =>  MyMainBodyPageItem(Colors.grey,title: Text("Home Page"),),
     );
    
     Handler detailPageHandler = Handler(
       handlerFunc: (context, params){
         String detailId = params['id'].first;
         Map args = context.settings.arguments;
         debugPrint("detailPageHandler get detailId:$detailId,args:$args");
         return DetailPage(detailId: detailId,args: args);
       },
     );
    
     Handler notFoundHandler = Handler(
       handlerFunc: (context, parameters){
         return Container(
           alignment: Alignment.center,
           child: Text("出错啦。。。"),
         );
       },
     );
    
     class Routes{
       static String homePage = '/home';
       static String detailPage = '/details/:id';
    
       static void configRoutes(FluroRouter router) {
         router..define(homePage,handler:homePageHandler)
               ..define(detailPage, handler: detailPageHandler)
               ..notFoundHandler=notFoundHandler;
       }
     }
    
  3. global.dart

     import 'package:fluro/fluro.dart';
     class Global{
       static FluroRouter router;
     }
    
  4. main.dart

     void main() {
       final router = FluroRouter();
       Routes.configRoutes(router);
       Global.router=router;
    
       runApp(MyApp());
     }
    
     class MyApp extends StatelessWidget {
       @override
       Widget build(BuildContext context) {
         return MaterialApp(
           title: 'Hi Dear',
           home: MyFrame(),
           // routes:
           onGenerateRoute: Global.router.generator,
         );
       }
     }
    
  5. Page A -> Page B

     Navigator.pushNamed(
         context, 
         Routes.detailPage,
         arguments: {'title':items[index]['title']}
     ).then((value)=>debugPrint("Get returnData: $value"));
    
     Global.router.navigateTo(
         context, 
         items[index]['path'],
         transition: TransitionType.material,
         routeSettings: RouteSettings(
           arguments:{'title':items[index]['title']}
         ),
     ).then((value) => debugPrint("Get returnData: $value"));
    
  6. Page B return to Page A

     onPressed: () {
       // Global.router.pop(context, args);
       Navigator.pop(context,args);
     },
    

动态路由框架 auto_route

  1. pubspec.yaml

     dependencies:
       flutter:
         sdk: flutter
       flutter_localizations:
         sdk: flutter
       # 动态路由
       auto_route: ^0.6.9
     dev_dependencies:
       flutter_test:
         sdk: flutter
       # 路由生成
       auto_route_generator: ^0.6.9
       build_runner:
    
  2. auto_routes/auto_routes.dart

     import 'package:auto_route/auto_route_annotations.dart';
     import 'package:third_app/auto_routes/auth_grard.dart';
     import 'package:third_app/pages/detail/detail.dart';
     import 'package:third_app/pages/frame/frame.dart';
     import 'package:third_app/pages/home/home.dart';
    
     @MaterialAutoRouter(
       routes: <AutoRoute>[
         MaterialRoute(path: '/',page: MyFrame,initial: true),
         MaterialRoute(path:'/home',page: HomePage),
         MaterialRoute(path:'/details/:id',page: DetailPage,guards: [AuthGuard]),
       ],
     )  //CustomAutoRoute(..config)
     class $AppRouter {
     }
    
  3. auto_routes/auth_guard.dart

     import 'package:auto_route/auto_route.dart';
    
     class AuthGuard extends RouteGuard{
       @override
       Future<bool> canNavigate(ExtendedNavigatorState<RouterBase> navigator, String routeName, Object arguments) async {
         print("This is AuthGuard canNavigate: $routeName,$arguments");
         return true;
       }
     }
    
  4. cmd to generate auto_routes/auto_routes.gr.dart

     flutter packages pub run build_runner build
    
  5. main.dart

     void main() {
       runApp(MyApp());
     }
    
     class MyApp extends StatelessWidget {
       @override
       Widget build(BuildContext context) {
         return MaterialApp(
           title: 'Hi Dear',
           home: MyFrame(),
           // 注册路由 (auto_flutter) - Without ExtendedNavigator will lose support for RouteGuards and auto-nested navigation handling
           // onGenerateRoute: AppRouter(),
           // 注册路由 (auto_flutter)
           builder: ExtendedNavigator<AppRouter>(
             // initialRoute: Routes.myFrame,
             router: AppRouter(),
             guards:[AuthGuard()]
           ),
         );
       }
     }
    
  6. Page A -> Page B

     ExtendedNavigator.of(context).push(
         items[index]['path'],
         arguments:DetailPageArguments(detailId:'$index',args:{'title':items[index]['title']} ),
       ).then((value) => debugPrint("Get returnData: $value"));
    
  7. Page B return to Page A

     ExtendedNavigator.of(context).pop(args);
    

导航栏组件

顶部导航栏

AppBar

return Scaffold(
    backgroundColor: Colors.grey[300],
    appBar: AppBar(
      leading: IconButton(
        icon:Icon(Icons.menu),
        tooltip: 'Navigation',
        onPressed: ()=>debugPrint('Navigation button is pressed.'),
      ),
      actions: [
        IconButton(
          icon:Icon(Icons.search),
          tooltip: 'Search',
          onPressed: ()=>debugPrint('Search button is pressed.'),
        )
      ],
      title: Text("Second App")
    ),
    body: null
  );

底部导航栏

  • BottomNavigationBar
  • BottomAppBar

BottomNavigationBar

import 'package:flutter/material.dart';

class BottomNavigationBarDemo extends StatefulWidget {
  BottomNavigationBarDemo({Key key}) : super(key: key);

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

class _BottomNavigationBarDemoState extends State<BottomNavigationBarDemo> {
  int _currentIndex = 0;

  void _onTapHandler(int index) {
    setState(()=>_currentIndex=index);
  }

  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
        // 底部导航栏
        type: BottomNavigationBarType.fixed, // item大于等于4个时需设置
        fixedColor: Colors.black,
        currentIndex: _currentIndex,
        onTap: _onTapHandler,
        items: [
          BottomNavigationBarItem(icon: Icon(Icons.explore), title: Text("Explore")),
          BottomNavigationBarItem(icon: Icon(Icons.history), title: Text("History")),
          BottomNavigationBarItem(icon: Icon(Icons.list), title: Text("List")),
          BottomNavigationBarItem(icon: Icon(Icons.person), title: Text("My")),
        ]);
  }
}

BottomAppBar

  • Example 1 :

      return BottomAppBar(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            IconButton(icon: Icon(Icons.home),onPressed: null,),
            IconButton(icon: Icon(Icons.list),onPressed: null,),
            IconButton(icon: Icon(Icons.person),onPressed: null,),
          ],
        )
      );
    
  • Example 2 :

      return BottomAppBar(
        child: Expanded(
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children:[
              Expanded(child:IconButton(icon: Icon(Icons.home),onPressed: null,)),
              Expanded(child:IconButton(icon: Icon(Icons.list_alt_rounded),onPressed: null,)),
              Expanded(child:IconButton(icon: Icon(Icons.person),onPressed: null,)),
              Expanded(child:IconButton(icon: Icon(Icons.person),onPressed: null,)),
              Expanded(child:IconButton(icon: Icon(Icons.person),onPressed: null,)),
              Expanded(child:IconButton(icon: Icon(Icons.person),onPressed: null,)),
              Expanded(child:IconButton(icon: Icon(Icons.person),onPressed: null,)),
              Expanded(child:IconButton(icon: Icon(Icons.person),onPressed: null,)),
              Expanded(child:IconButton(icon: Icon(Icons.person),onPressed: null,)),
              Expanded(child:IconButton(icon: Icon(Icons.ac_unit),onPressed: null,)),
            ]
          ),
        ),
      );
    
  • Example 3:

      return BottomAppBar(
        child: Container(
          padding: EdgeInsets.symmetric(vertical: 8),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            crossAxisAlignment: CrossAxisAlignment.end,
            children: [
              InkWell(
                onTap: ()=>_onTapHandler(0),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Icon(Icons.home,color: _currentIndex==0?Colors.red:Colors.black,),
                    Text("首页",style: TextStyle(color: _currentIndex==0?Colors.red:Colors.black),)
                  ],
                ),
              ),
              InkWell(
                onTap: ()=>_onTapHandler(1),
                child:Container(
                  child:Text("Do",style: TextStyle(color:Colors.white),),
                  padding: EdgeInsets.all(12),
                  decoration: BoxDecoration(
                    borderRadius: BorderRadius.circular(35),
                    color: _currentIndex==1?Colors.red:Colors.grey,
                  ),
                )
              ),
              InkWell(
                onTap: ()=>_onTapHandler(2),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Icon(Icons.person,color: _currentIndex==2?Colors.red:Colors.black,),
                    Text("我的",style: TextStyle(color: _currentIndex==2?Colors.red:Colors.black),)
                  ],
                ),
              ),
            ],
          ),
        )
      );
    

页面切换保持状态

https://juejin.cn/post/6844903660816695309

Flutter BottomNavigationBar切换页面被重置问题(保存状态) https://www.jianshu.com/p/87e545b889cd

Flutter底部tab切换保持页面状态的2种方法(转) https://www.jianshu.com/p/b7dd54f0bcbd?utm_campaign=haruki https://www.jianshu.com/p/369f00a40cc2

切换后页面状态的保持 AutomaticKeepAliveClientMixin

https://blog.csdn.net/niceyoo/article/details/92855534 https://blog.csdn.net/u010842313/article/details/105554390

Flutter实现页面切换后保持原页面状态的3种方法 https://www.jb51.net/article/157680.htm https://my.oschina.net/u/4581368/blog/4372254

  • 使用 IndexedStack, eg: BottomNavigationBar/BottomAppBar & IndexedStack
    • 缺点在于第一次加载时便实例化了所有的子页面State
  • AutomaticKeepAliveClientMixin
    • 对于未使用的页面状态不会进行实例化,减小了应用初始化时的开销
    • 先决条件:
      • 使用的页面必须是 StatefulWidget,如果是 StatelessWidget 是没办法办法使用的。
      • 其实只有两个前置组件才能保持页面状态:PageView 和 IndexedStack。
      • 重写 wantKeepAlive 方法,如果不重写也是实现不了的。
  • 扩展:如何在列表中的item不被摧毁?
    • Flutter提供了一个KeepAlive()小部件,它可以保持项目处于活动状态,否则可能会被摧毁。在列表中,元素默认包装在AutomaticKeepAlive小部件中。
    • 可以通过将addAutomaticKeepAlives字段设置为false来禁用AutomaticKeepAlives。这在元素不需要保持活动或KeepAlive的自定义实现的情况下非常有用。

Sample: IndexedStack

class IndexBody extends StatelessWidget {
  final int currentIndex;
  final List<Widget> items = [
    HomePage(),
    DoPage(),
    MyPage(),
  ];
  IndexBody({Key key,this.currentIndex=0}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    debugPrint("build IndexBody");
    return IndexedStack(
      index: currentIndex,
      children: items,
    );
  }
}

Sample: AutomaticKeepAliveClientMixin

class ProjectAnnounceView extends StatefulWidget {
  ProjectAnnounceView({Key key}) : super(key: key);
  @override
  _ProjectAnnounceViewState createState() => _ProjectAnnounceViewState();
}

class _ProjectAnnounceViewState extends State<ProjectAnnounceView> with AutomaticKeepAliveClientMixin{

  @override
  // TODO: implement wantKeepAlive
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    return _buildAnnouncementList(getDataOfAnnouncements());
  }

  Widget _buildAnnouncementList(announcementList){
    return Container(
      margin: EdgeInsets.only(top:10),
      child: ListView.builder(
        itemCount: announcementList.length+1,
        itemBuilder: (_,index){
          if(index==announcementList.length)
            return Container(
              margin: EdgeInsets.all(20),
              alignment: Alignment.center,
              child: Text("——  End  ——",style: TextStyle(color: Colors.grey),),
            );
          return _buildItem(announcementList[index]);
        },
      )
    );
  }

}

标签栏

TabBar & TabView & TabController https://www.cnblogs.com/joe235/p/11213861.html

Flutter TabBar、TabBarView、 TabController 实现 Tab 标签菜单布局 http://www.ptbird.cn/flutter-tab-tabcontroller.html

TabController

  • 创建有两种形式
    • 使用系统的DefaultTabController
    • 自己定义一个TabController实现SingleTickerProviderStateMixin
  • 一般放入有状态控件中使用,以适应标签页数量和内容有动态变化的场景
  • 如果标签页在APP中是静态固定的格局,则可以在无状态控件中加入简易版的DefaultTabController以提高运行效率,毕竟无状态控件要比有状态控件更省资源,运行效率更快。

DefaultTabController

class Home extends StatelessWidget {
  const Home({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // TabController & TabBar(tabs) & TabBarView
    return DefaultTabController(
        length: 5,
        child: Scaffold(
          backgroundColor: Colors.grey[300],
          floatingActionButton: FloatingActionButton(
            child: Icon(Icons.arrow_back),
            onPressed: () {
              Navigator.pop(context);
            },
          ),
          appBar: AppBar(
              elevation: 0, 
              actions: [
                IconButton(
                  icon: Icon(Icons.search),
                  tooltip: 'Search',
                  onPressed: () => debugPrint('Search button is pressed.'),
                )
              ],
              bottom: TabBar(
                tabs: [
                  Tab(icon: Icon(Icons.local_florist)),
                  Tab(icon: Icon(Icons.change_history)),
                  Tab(icon: Icon(Icons.directions_bike)),
                  Tab(icon: Icon(Icons.view_quilt)),
                  Tab(icon: Icon(Icons.view_agenda)),
                ],
                unselectedLabelColor: Colors.black38,
                indicatorColor: Colors.black54,
                indicatorSize: TabBarIndicatorSize.label,
                indicatorWeight: 1.0,
              ),
              title: Text("Second App")),
          body: TabBarView(
            children: [
              Icon(Icons.local_florist, size: 128, color: Colors.black12),
              Icon(Icons.change_history, size: 128, color: Colors.black12),
              Icon(Icons.directions_bike, size: 128, color: Colors.black12),
              Icon(Icons.view_quilt, size: 128, color: Colors.black12),
              Icon(Icons.view_agenda, size: 128, color: Colors.black12),
            ],
          ),
          bottomNavigationBar: BottomNavigationBarDemo(),
        ));
  }
}

TabController

class ChannelFmIndex extends StatefulWidget {
  ChannelFmIndex({Key key}) : super(key: key);
  @override
  _ChannelFmIndexState createState() => _ChannelFmIndexState();
}

class _ChannelFmIndexState extends State<ChannelFmIndex>  with SingleTickerProviderStateMixin {

  TabController _tabController;

  @override
  void initState() { 
    super.initState();
    _tabController = TabController(length: 3, vsync: this); // 创建 TabController
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // appBar: _buildAppBar(context),
      appBar: _buildHeader(),
      body: _buildTabView(context),
    );
  }

  _buildHeader(){
    return AppBar(
      elevation: 0,
      automaticallyImplyLeading: false,
      titleSpacing: 0,
      toolbarHeight: 160,
      title: _buildHeadCard(),
      bottom: PreferredSize(
        preferredSize: Size.fromHeight(50),
        child: _buildHeadTabBar(),
      ),
    );
  }

  _buildHeadTabBar(){
    return Container(
      decoration: BoxDecoration(
        color: Colors.white,
        border: Border.symmetric(
          horizontal: BorderSide(color: Colors.grey[50],width: 8)
        )
      ),
      child: TabBar(
        controller: _tabController,     // 配置 TabController
        indicatorColor: Colors.black87,
        indicatorSize: TabBarIndicatorSize.label,
        indicatorWeight: 1.0,
        tabs: [
          Tab(child: Text("最新"),),
          Tab(child: Text("动态"),),
          Tab(child: Text("简介"),),
        ],
      ),
    );
  }

  _buildTabView(BuildContext context){
    return TabBarView(
      controller: _tabController,   // 配置 TabController
      children: <Widget>[ 
        _buildTabOfLatest(context),
        _buildTabOfActivity(context),
        _buildTabOfIntro()
      ],
    );
  }

  _buildTabOfLatest(BuildContext context){
    // ...
  }

  _buildTabOfActivity(context){
    // ...
  }

  _buildTabOfIntro(){
    // ...
  }

}

Banner组件

第三方组件: flutter_swiper

https://www.jianshu.com/p/9bdfc5a00877 https://zhuanlan.zhihu.com/p/88790923

  1. pubspec.yaml

     dependencies:
       flutter:
         sdk: flutter
       # 轮播图
       flutter_swiper: ^1.1.6
    
  2. home_banner.dart

     import 'package:flutter/material.dart';
     import 'package:flutter_swiper/flutter_swiper.dart';
    
     /*
     Ref Doc:
     https://www.cnblogs.com/joe235/p/11251710.html
     https://zhuanlan.zhihu.com/p/88790923
     https://segmentfault.com/a/1190000021488577
     */
    
     class HomeBanner extends StatelessWidget {
       final List items;
    
       HomeBanner({Key key, this.items})
           : assert(items != null && items.length != 0),
             super(key: key);
    
       @override
       Widget build(BuildContext context) {
         return Container(
           height: 200,
           child: Swiper(
             itemCount: items.length,
             itemBuilder: (context, index) {
               // return InkWell(
               //   child: Image.network(items[index]['image'], fit: BoxFit.fill),
               //   onTap: (){
               //     debugPrint('banner click to path: ${items[index]['path']}');
               //   },
               // );
               return Image.network(items[index]['image'], fit: BoxFit.fill);
             },
             pagination: new SwiperPagination(
               // builder: SwiperPagination.fraction
               // builder: RectSwiperPaginationBuilder(
               //   color: Colors.black,
               //   size: Size(20,10)
               // )
             ),
             autoplay: true,
             autoplayDelay:3000,
             onTap: (index){
               debugPrint("banner onTap:$index ${items[index]['path']}");
             }
             // viewportFraction: 0.8,
             // scale: 0.9,
           ),
         );
       }
     }
    
  3. home.dart

     Widget _buildHomeBanner(){
         List items=[
           {
             'image':'https://ss1.bdstatic.com/70cFuXSh_Q1YnxGkpoWK1HF6hhy/it/u=1603365312,3218205429&fm=26&gp=0.jpg',
             'path':'/details/a',
             'title':'AAA'
           },
           {
             'image':'https://ss2.bdstatic.com/70cFvnSh_Q1YnxGkpoWK1HF6hhy/it/u=2005235653,1742582269&fm=26&gp=0.jpg',
             'path':'/details/b',
             'title':'BBB'
           },
           {
             'image':'https://ss2.bdstatic.com/70cFvnSh_Q1YnxGkpoWK1HF6hhy/it/u=1355153719,3297569375&fm=26&gp=0.jpg',
             'path':'/details/c',
             'title':'CCC'
           },
         ];
         return HomeBanner(items: items);
       }
    

刷新组件

RefreshIndicator

下拉刷新 https://www.cnblogs.com/darecy/archive/2020/05/10/12863080.html

_buildRefreshIndicator(String name,Widget child){
    return RefreshIndicator(
      onRefresh: () async {
        debugPrint("+++ $name onRefresh! +++");
      },
      child: child
    );
}

flutter_easyrefresh

第三方组件

下拉刷新以及上拉加载

  1. pubspec.yaml

     dependencies:
       flutter:
         sdk: flutter
       # 下拉刷新以及上拉加载
       flutter_easyrefresh: ^2.1.6
    
  2. home.dart

       Widget _buildHomeBody(){
         /*
         https://github.com/xuelongqy/flutter_easyrefresh/blob/v2/README.md
         */
         return EasyRefresh(
           child:  _buildHomeBodyList(),
           header: ClassicalHeader(
             refreshText:'下拉刷新',
             refreshReadyText:'准备刷新',
             refreshingText:'刷新获取中',
             refreshedText:'刷新完成',
             refreshFailedText: '刷新失败',
             infoText: '更新时间 %T',
             completeDuration: Duration(milliseconds: 300),
             textColor: Colors.black54,
             infoColor: Colors.black54,
             showInfo:false
           ),
           footer: ClassicalFooter(
             loadText:'上滑加载',
             loadReadyText: '加载中',
             loadingText:'加载中',
             loadedText:'加载完成',
             loadFailedText:'加载失败',
             noMoreText: '我也是有底线的',
             infoText: '更新时间 %T',
             textColor: Colors.black54,
             showInfo: false
           ),
           onRefresh: () async{
             debugPrint("HomePage onRefresh");
           },
           onLoad: () async{
             debugPrint("HomePage onLoad");
           },
         );
       }
    
       Widget _buildHomeBodyList(){
         return ListView(
             children: [
               _buildHomeBanner(),
             ],
         );
       }
    

滚动控件(Scroll)

Flutter 滚动控件篇-->滚动监听及控制(ScrollController) https://www.mk2048.com/blog/blog_h1ck21icak0hj.html

Flutter 滚动监听及实战appBar滚动渐变 https://www.jianshu.com/p/b0b1c6308674

ScrollController

  • offset:可滚动组件当前的滚动位置。
  • jumpTo(double offset) 跳转到指定位置,offset 为滚动偏移量。
  • animateTo(double offset,@required Duration duration,@required Curve curve)jumpTo(double offset) 一样,不同的是 animateTo 跳转时会执行一个动画,需要传入执行动画需要的时间和动画曲线。

ScrollPosition

  • 用来保存可滚动组件的滚动位置的
  • 一个 ScrollController 对象可能会被多个可滚动的组件使用
  • ScrollController 会为每一个滚动组件创建一个 ScrollPosition 对象来存储位置信息
  • ScrollPosition 中存储的是在 ScrollController 的 positions 属性里面,他是一个 List<ScrollPosition> 数组
  • controller.positions.elementAt(0).pixels
  • ScrollPosition 有两个常用方法:分别是 animateTo()jumpTo(),他们才是真正控制跳转到滚动位置的方法,在 ScrollController 中这两个同名方法,内部最终都会调用 ScrollPosition 这两个方法

ScrollController控制原理

ScrollController方法createScrollPosition

  • 当 ScrollController 和可滚动组件关联时,可滚动组件首先会调 ScrollController 的 createScrollPosition 方法来创建一个ScrollPosition来存储滚动位置信息。
  • 在滚动组件调用 createScrollPosition 方法之后,接着会调用 void attach(ScrollPosition position) 方法来将创建好的 ScrollPosition 信息添加到 positions 属性中,这一步称为“注册位置”,只有注册后animateTo()jumpTo()才可以被调用。
  • 最后当可滚动组件被销毁时,会调用 void detach(ScrollPosition position) 方法,将其 ScrollPosition 对象从 ScrollController 的positions 属性中移除,这一步称为“注销位置”,注销后 animateTo() 和 jumpTo() 将不能再被调用。

ScrollNotification

在接收到滚动事件时,参数类型为ScrollNotification

  • 包括一个metrics属性,类型是ScrollMetrics,该属性包含当前ViewPort及滚动位置等信息:
    • pixels:当前滚动位置。
    • maxScrollExtent:最大可滚动长度。
    • extentBefore:滑出ViewPort顶部的长度;此示例中相当于顶部滑出屏幕上方的列表长度。
    • extentInside:ViewPort内部长度;此示例中屏幕显示的列表部分的长度。
    • extentAfter:列表中未滑入ViewPort部分的长度;此示例中列表底部未显示到屏幕范围部分的长度。
    • atEdge:是否滑到了可滚动组件的边界(此示例中相当于列表顶或底部)

NotificationListener

  • 通知冒泡
    • Flutter Widget 树中子 Widget可以通过发送通知(Notification)与父(包括祖先) Widget 进行通信,父级组件可以通过 NotificationListener 组件来监听自己关注的通知,这种通信方式类似于 Web 开发中浏览器的事件冒泡,在 Flutter 中就沿用了“冒泡”这个术语,称为通知冒泡
  • 滚动通知
    • Flutter 中很多地方使用了通知,如可滚动组件(Scrollable Widget)滑动时就会分发滚动通知(ScrollNotification),而 Scrollbar 正是通过监听 ScrollNotification 来确定滚动条位置的
      switch (notification.runtimeType){
      case ScrollStartNotification: print("开始滚动"); break;
      case ScrollUpdateNotification: print("正在滚动"); break;
      case ScrollEndNotification: print("滚动停止"); break;
      case OverscrollNotification: print("滚动到边界"); break;
      }
      
  • onNotification 回调为通知处理回调,他的返回值时布尔类型(bool),当返回值为 true 时,阻止冒泡,其父级 Widget 将再也收不到该通知;当返回值为 false 时继续向上冒泡通知。

ScrollController VS. NotificationListener

  1. ScrollController 可以控制滚动控件的滚动,而 NotificationListener 是不可以的。
  2. 通过 NotificationListener 可以在从可滚动组件到widget树根之间任意位置都能监听,而ScrollController只能和具体的可滚动组件关联后才可以。
  3. 收到滚动事件后获得的信息不同;NotificationListener在收到滚动事件时,通知中会携带当前滚动位置和ViewPort的一些信息,而ScrollController只能获取当前滚动位置

ScrollPhysics

https://zhuanlan.zhihu.com/p/84716922

确定可滚动控件的物理特性

  • BouncingScrollPhysics :允许滚动超出边界,但之后内容会反弹回来。
  • ClampingScrollPhysics : 防止滚动超出边界,夹住 。
  • AlwaysScrollableScrollPhysics :始终响应用户的滚动。
  • NeverScrollableScrollPhysics :不响应用户的滚动。

Sample:

CustomScrollView(
    // ... 
    physics: const BouncingScrollPhysics()
);

ListView.builder(
     // ... 
    physics: const AlwaysScrollableScrollPhysics()
);


GridView.count(
     // ... 
    physics: NeverScrollableScrollPhysics()
);

应用:两个滚动列表嵌套,设置NeverScrollableScrollPhysics让内部滚动失效,依赖外部的滚动

_buildItems(value){
    return ListView.builder(
      itemCount: value.activityItems.length+1,
      shrinkWrap: true, // 父视图的大小跟随子组件的内容大小 for solve: Error: Flutter Horizontal viewport was given unbounded height.width. (或固定父容器高度. 或用Expanded/Flexible包裹,把剩余空间全部占掉)
      physics: new NeverScrollableScrollPhysics(),
      itemBuilder:  (ctx,index){
        if(index==value.activityItems.length){
          return _buildEndInfo(value);
        }
        return _buildActivity(value.activityItems[index]);
      }
    );
  }

应用:ListView滑动到底部自动加载更多

ScrollController _scrollController = new ScrollController();

@override
void initState() {
    debugPrint("-- HomeMovie initState --");
    // TODO: implement initState
    super.initState();
    _scrollController.addListener(() {
      // debugPrint("_scrollController:${_scrollController.position.pixels}");
      if(_scrollController.position.pixels==_scrollController.position.maxScrollExtent){
        debugPrint("-- HomeMovie Trigger load --");
        widget.homeMovieState.load();
      }
    });
}

@override
void dispose() {
    debugPrint("-- HomeMovie dispose --");
    _scrollController.dispose();
    // TODO: implement dispose
    super.dispose();
}
/// ListView.builder( controller: _scrollController, itemBuilder: (ctx,index){...},...);

应用:滚动到指定位置

Flutter 滚动距离来设置TabBar的位置,点击TabBar滚动的到指定的位置 https://blog.csdn.net/yujunlong3919/article/details/105107195

flutter滚动到列表指定元素 https://chentaoqian.com/?p=613

  • 方案一:使用第三方组件scrollable_positioned_list
  • 方案二:使用 Scrollable.ensureVisible(context) ,该方式不仅能达到效果,还适用于各种尺寸的widget。但这方案也有个问题,就是不能跳转到不可见的组件(non-visible)

Sample:

  • 需要被跳转的组件,设置key为GlobalKey(),并在外部存储该key。
  • 调用Scrollable.ensureVisible(context),传入的context是上一步key的currentContext。
class ScrollView extends StatelessWidget {
  final dataKey = new GlobalKey();

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      primary: true,
      appBar: new AppBar(
        title: const Text('Home'),
      ),
      body: new SingleChildScrollView(
        child: new Column(
          children: [
            new SizedBox(height: 160.0, width: double.infinity, child: new Card()),
            new SizedBox(height: 160.0, width: double.infinity, child: new Card()),
            new SizedBox(height: 160.0, width: double.infinity, child: new Card()),
            // destination
            new Card(
              key: dataKey,
              child: new Text("data\n\n\n\n\n\ndata"),
            )
          ],
        ),
      ),
      bottomNavigationBar: new RaisedButton(
        onPressed: () => Scrollable.ensureVisible(dataKey.currentContext),
        child: new Text("Scroll to data"),
      ),
    );
  }
}

ScrollView

https://www.jianshu.com/p/cf8e92f76bdb https://blog.csdn.net/yechaoa/article/details/90701321

CustomScrollView

_buildSliverBody(context) {
    return CustomScrollView(
      slivers: [
        _buildSliverAppBar(),

        _buildSliverPanding(),
        _buildSliverFillRemaining(),
        _buildSliverFillViewport(),

         _buildSliverGrid(),
        _buildSliverList(),
      ],
    );
  }

SliverAppBar

_buildSliverAppBar() {
    return SliverAppBar(
        title: Text("标题"),
        expandedHeight: 180.0,
        floating: false,
        pinned: true,
        snap: false,
        flexibleSpace: new FlexibleSpaceBar(
          title: new Text("标题标题标题"),
          centerTitle: true,
          collapseMode: CollapseMode.pin,
        ),
        onStretchTrigger:()async{ print("onStretchTrigger");return; }
    );

    return SliverAppBar(
      floating: false,
      pinned: true,
      expandedHeight: 180,
      flexibleSpace: FlexibleSpaceBar(
        title: Text(
          "Silver Demo",
          style: TextStyle(letterSpacing: 3, fontWeight: FontWeight.w400),
        ),
        background: Image.network(
            "https://ss0.bdstatic.com/70cFvHSh_Q1YnxGkpoWK1HF6hhy/it/u=1549239440,280119214&fm=26&gp=0.jpg",
            fit: BoxFit.cover),
      ),
    );
  }

SliverList

_buildSliverList() {
    return SliverFixedExtentList(
      itemExtent: 40.0,
      delegate: SliverChildBuilderDelegate(
        (BuildContext context, int index) {
          return Card(
            child: Container(
              alignment: Alignment.center,
              color: Colors.primaries[(index % 18)],
              child: Text(''),
            ),
          );
        },
      ),
    );
  }

SliverGrid

_buildSliverGrid() {
    return SliverGrid(
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 3,
        childAspectRatio: 2.0,
        mainAxisSpacing: 10,
        crossAxisSpacing: 10,
      ),
      ///子Item构建器
      delegate: new SliverChildBuilderDelegate(
        (BuildContext context, num index) {
          ///每一个子Item的样式
          return Container(
            color: Colors.blue,
            child: Text("grid $index"),
          );
        },
        ///子Item的个数
        childCount: 30,
      ),
    );
  }

SliverToBoxAdapter

在CustomScrollView 中是只能使用Sliver系的控件,如果在CustomScrollView 中想要嵌套其他非 Sliver 系就必须要使用SliverToBoxAdapter包装一下

_buildSliverPanding() {
    return SliverPadding(
        padding: EdgeInsets.all(8),
        sliver: SliverToBoxAdapter(
          child: Container(
            height: 40,
            color: Colors.grey,
            child: Text("SliverPadding & SliverToBoxAdapter"),
          ),
        ));
  }

SliverFillRemaining

 _buildSliverFillRemaining() {
    return SliverFillRemaining(
      child: Container(
        height: 40,
        color: Colors.lightBlue,
        child: Text("SliverFillRemaining"),
      ),
    );
  }

SliverFillViewport

_buildSliverFillViewport() {
    return SliverFillViewport(
        viewportFraction: 1.0,
        delegate: SliverChildBuilderDelegate(
            (_, index) => Container(
                margin: EdgeInsets.all(8),
                child: Text('Item $index'),
                alignment: Alignment.center,
                color: Colors.indigoAccent),
            childCount: 10));
  }

NestedScrollView

https://segmentfault.com/a/1190000022575678

_buidBody() {
    return NestedScrollView(
      headerSliverBuilder: (_,innerBoxIsScrolled){
        return [
          SliverToBoxAdapter(child: _buildPlayPanel(),),
          SliverAppBar(
            automaticallyImplyLeading: false,
            elevation: 0,
            floating: true,
            pinned: true,
            title: _buildTabBar()
          ),
        ];
      }, 
      body: _buildTabView()
    );
  }

NestedScrollView 滚动问题

Flutter 里 NestedScrollView body 嵌套滚动的问题 https://www.v2ex.com/t/655844 http://codingdict.com/questions/97543

在子组件里控制父级组件的滚动 => 但“会导致头部滚动折叠的很快”,不推荐!

_scrollController = ScrollController();

_scrollController.addListener((){
  var innerPos      = _scrollController.position.pixels;
  var maxOuterPos   = widget.parentController.position.maxScrollExtent;
  var currentOutPos = widget.parentController.position.pixels;

  if(innerPos >= 0 && currentOutPos < maxOuterPos) {
    //widget.parentController.position.jumpTo(innerPos+currentOutPos);
    widget.parentController.position.animateTo(innerPos+currentOuterPos, 
      duration: Duration(seconds:1), curve: Curves.easeOut);

  }else{
    var currenParentPos = innerPos + currentOutPos;
    widget.parentController.position.jumpTo(currenParentPos);
  }
});

widget.parentController.addListener((){
  var currentOutPos = widget.parentController.position.pixels;
  if(currentOutPos <= 0) {
    _scrollController.position.jumpTo(0);
  }
});

异常处理

Flutter Exception降到万分之几的秘密 https://zhuanlan.zhihu.com/p/53443293

https://blog.csdn.net/wangfeijn/article/details/90033008 https://segmentfault.com/a/1190000022280728 https://ducafecat.tech/2020/06/05/flutter-project/flutter-project-news-12-error-sentry/

捕捉异常

  • 同步异常捕捉 => 通过try/catch/finally即可
  • 异步异常捕捉 => 通过runZonedGuarded(...)方法,指定一个代码执行的环境空间

异常:

  • Dart异常
  • Flutter异常

异常处理:

  • try/catch/finally: catch sync dart error.
  • runZonedGuarded: catch uncatched dart error.
  • FlutterError.onError = (FlutterErrorDetails details) async {};: catch flutter error when working in release mode.
  • ErrorWidget.builder=(FlutterErrorDetails details){}; : a widget show flutter error when working in debug mode.

Sample:

  1. main.dart:

     import 'dart:async';
    
     import 'package:five_demo/pages/app/app_page.dart';
     import 'package:five_demo/utils/log_utils.dart';
     import 'package:flutter/material.dart';
     import 'global/global.dart';
    
     // void main() {
     //   runApp(MyApp());
     // }
    
     void main() async {
       // catch uncatched dart error
       runZonedGuarded(() async {
         Global.init().then((value) {
           // runApp(Global.wrapGlobalProviders(MyApp()));
           runApp(AppPage());
         });
       }, (error,stack)=>LogUtils.error("$error \n $stack"));
     }
    
  2. log_utils.dart:

     import 'dart:async';
    
     import 'package:flutter/material.dart';
    
     class LogUtils{
    
       static info(Object object){
         print("=== INFO === : $object");
       }
    
       static error(Object object){
         print("=================== ERROR Begin");
         print(object);
         print("=================== ERROR End");
       }
    
       // 是否开发环境
       static bool get isInDebugMode {
         bool isRelease = bool.fromEnvironment("dart.vm.product");
         return !isRelease;
       }
    
       /// Note: debug mode doesn't work, work in release mode !
       static setFlutterErrorHandlerOnRelease(){
         FlutterError.onError = (FlutterErrorDetails details) async {
           print("=================== CAUGHT FLUTTER ERROR");
           if (isInDebugMode == true) {
             FlutterError.dumpErrorToConsole(details);
           } else {
             Zone.current.handleUncaughtError(details.exception, details.stack);
           }
         };
       }
    
       /// Note: only show in debug mode ,won't show in release mode !
       static setFlutterErrorWidgetOnDebug(){
         ErrorWidget.builder=(FlutterErrorDetails details){
           print("=================== BUILD ERROR WIDGET");
           print(details.toString());
           return SingleChildScrollView(
             // decoration: BoxDecoration(
             //   image: DecorationImage(
             //     image: new AssetImage("assets/images/default/empty.png"),
             //   )
             // ),
             child: Column(
               mainAxisAlignment: MainAxisAlignment.spaceAround,
               children:[
                 Text("Exception!",style: TextStyle(color: Colors.red,fontSize: 26),),
                 // Image.network(
                 //   'https://dss2.bdstatic.com/70cFvnSh_Q1YnxGkpoWK1HF6hhy/it/u=1671659209,570778771&fm=26&gp=0.jpg'),
                 Text("${details.exception}",style: TextStyle(color:Colors.red,fontSize: 18),textDirection: TextDirection.ltr,)  
               ]
             ),
           );
         };
       }
     }
    
  3. global.dart

     class Global{
    
       static Future init() async {
    
         LogUtils.info("[Global] init() start");
    
         WidgetsFlutterBinding.ensureInitialized();
    
         LogUtils.info("[Global] set FlutterErrorHander");
         LogUtils.setFlutterErrorWidgetOnDebug();
         LogUtils.setFlutterErrorHandlerOnRelease();
    
         // ...
       }
     }
    

Flutter项目IOS真机部署及问题

flutter build ios --release flutter run --release

Xcode:

https://www.jianshu.com/p/69e1efc2fc55 http://www.cocoachina.com/articles/475845 https://www.codingsky.com/doc/flutter/ios-release-build.html

  1. 在Xcode中, 在你的工程目录中的ios文件夹下打开Runner.xcworkspace.
  2. 要查看您的应用程序的设置,请在Xcode项目导航器中选择Runner项目。然后,在主视图边栏中,选择Runnertarget
  3. 选择 General 选项卡.

在 Identity 部分:

  • Display Name: 要在主屏幕和其他地方显示的应用程序的名称
  • Bundle Identifier: 您在iTunes Connect上注册的App ID.

在 Signing 部分:

  • Automatically manage signing: Xcode是否应该自动管理应用程序签名和生成。默认设置为true,对大多数应用程序来说应该足够了。对于更复杂的场景,请参阅Code Signing Guide。
  • Team: 选择与您注册的Apple Developer帐户关联的团队。如果需要,请选择Add Account...,然后更新此设置

TextField & Keyboard

TextField

Flutter 基础组件:输入框和表单 https://www.cnblogs.com/parzulpan/p/12066691.html

Flutter TextField详解 https://blog.csdn.net/yechaoa/article/details/90906689

Flutter 实现一个登录界面 http://www.cocoachina.com/articles/29529?filter=rec

Flutter监听TextField焦点事件,点击与清除焦点 https://www.uedbox.com/post/65066/

Flutter Form、TextFormField及表单验证、表单输入框聚焦 http://www.ptbird.cn/flutter-form-textformfield.html

键盘弹起/隐藏问题

Flutter底部弹出TextField评论输入框并且自适应高度 https://www.jianshu.com/p/09ae2b6995e7?utm_campaign=haruki

flutter中关于软键盘弹起导致的问题 https://www.jianshu.com/p/4dab8a87f28b

  1. 当布局高度写死时,例如设置为屏幕高度,这时候键盘弹起页面上会出现布局overflow的提示
  2. 软键盘弹起后遮挡输入框 => 原因:在flutter中,键盘弹起时系统会缩小Scaffold的高度并重建

解决问题1中overflow提示的两种办法:

  1. 把Scaffold的resizeToAvoidBottomInset属性设置为false,这样在键盘弹出时将不会resize
  2. 把写死的高度改为 原高度 - MediaQuery.of(context).viewInsets.bottom,键盘弹出时布局将重建,而这个MediaQuery.of(context).viewInsets.bottom变量在键盘弹出前是0,键盘弹起后的就是键盘的高度

解决问题2的办法:

  • 将输入框放进可滚动的Widget中即可,当输入框获取焦点后,系统会自动将它滑动到可视区域

flutter TextField 输入框被软键盘挡住的解决方案 https://www.cnblogs.com/tianmiaogongzuoshi/p/11181782.html

页面元素的最外层肯定得嵌套一层SingleChildScrollViewSingleChildScrollView 元素内部不能和 Expanded 的flex 直接填充,会冲突)

body: Container(
         //SingleChildScrollView 的父级元素得有高度  最外层Container默认 填充全部
        child: SingleChildScrollView(
        ........
    )

flutter中如何监听键盘弹出关闭 https://segmentfault.com/a/1190000022495736

Flutter showModalBottomSheet & Textfield 制作底部评论框并解决bug https://juejin.cn/post/6844903846645334023

Flutter 弹出键盘认识 https://juejin.cn/post/6844903749362647048

TextField, 全局点击空白处隐藏键盘 https://my.oschina.net/u/4082303/blog/4543122?utm_source=osc_group_android 为 TextField 添加 focusNode,点击空白处时使 TextField 失去焦点

FocusScope.of(context).requestFocus(new FocusNode());

class DismissKeyboardDemo extends StatelessWidget {
  final FocusNode focusNode = FocusNode();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: GestureDetector(
        onTap: () {
          focusNode.unfocus();
        },
        child: Container(
          color: Colors.transparent,
          alignment: Alignment.center,
          child: TextField(
            focusNode: focusNode,
          ),
        ),
      ),
    );
  }
}

当 App 中有多个页面多个 TextField 时,此方式会增加大量重复的代码,因此全局添加点击空白处的监听:

onTap: () {
    FocusScopeNode currentFocus = FocusScope.of(context);
    if (!currentFocus.hasPrimaryFocus &&
        currentFocus.focusedChild != null) {
      FocusManager.instance.primaryFocus.unfocus();
    }
},

也可以使用如下方式隐藏键盘:

SystemChannels.textInput.invokeMethod('TextInput.hide');

状态管理

MVC、MVP、BloC、Redux四种架构在Flutter上的尝试 https://www.jianshu.com/p/ba3414457419

Flutter State Management状态管理全面分析 https://www.jianshu.com/p/9334b8f68004

  • 构建 StatefulWidget -> setState 自身组件动态维护
  • 包裹 ProviderInheritedWidget)-> 与其他组件交互
  • 使用 Bloc (Stream) -> 与多个监听组件同步信息

Bloc

https://www.jianshu.com/p/4d5e712594b4 https://www.jianshu.com/p/e31e8268d2cd

pubspec.yaml

dependencies:
  # bloc
  flutter_bloc: ^6.1.1
  1. counter_page.dart

     import 'package:flutter/material.dart';
     import 'package:flutter_bloc/flutter_bloc.dart';
     import 'package:third_app/pages/counter/counter_event.dart';
    
     import 'counter_bloc.dart';
    
     class CounterPage extends StatelessWidget {
       CounterPage({Key key}) : super(key: key);
       @override
       Widget build(BuildContext context) {
         return Container(
             child: BlocProvider<CounterBloc>(
                 create: (context) => CounterBloc(0),
                 // child: BlocBuilder<CounterBloc, int>(
                 //   builder: (context, state) =>
                 //       _buildContent(context.watch<CounterBloc>(), state),
                 // )
                 child: BlocListener<CounterBloc,int>(
                   listener:(context,state)=>print("state:$state"),
                   child:BlocBuilder<CounterBloc, int>(
                       builder: (context, state) =>
                           _buildContent(context.watch<CounterBloc>(),state),
                     )
                 ),
               )
             );
       }
       _buildContent(CounterBloc bloc, int state) {
         return Row(
           mainAxisAlignment: MainAxisAlignment.spaceEvenly,
           children: [
             Text("Count:$state"),
             OutlineButton(
                 onPressed: () => bloc.add(CounterEvent.increment),
                 child: Text("Add")),
             OutlineButton(
                 onPressed: () => bloc.add(CounterEvent.decrement),
                 child: Text("Sub")),
           ],
         );
       }
     }
    
  2. counter_bloc.dart

     import 'package:flutter_bloc/flutter_bloc.dart';
     import 'package:third_app/pages/counter/counter_event.dart';
    
     class CounterBloc extends Bloc<CounterEvent,int>{
    
       CounterBloc(int initialState) : super(initialState);
    
       @override
       Stream<int> mapEventToState(CounterEvent event) async * {
         switch(event){
           case CounterEvent.decrement:
             yield state-1;
             break;
           case CounterEvent.increment:
             yield state+1;
             break;
           default:
             throw Exception('oops');
         }
       }
     }
    
  3. counter_event.dart

     enum CounterEvent { 
       increment, 
       decrement 
     }
    

Provider

Provider组件

pubspec.yaml

dependencies:
  # 状态管理
  provider: ^4.3.2+2

https://www.jianshu.com/p/a87ebd2d3296 http://www.cainiaoxueyuan.com/xcx/13599.html https://www.cnblogs.com/crazycode2/p/11407967.html https://www.jianshu.com/p/bf2f33b2b5ef https://cloud.tencent.com/developer/article/1485323

  • 注入/创建: Provider.value / Provider.create => 推荐create(在销毁时自动调用ChangeNotifier中的dispose()方法释放一些资源)
  • 获取:context.read / context.watch
    • read 在Provider的build method / update callback 中使用,不会导致重构(等于 Provider.of<T>(this, listen: false);
    • watch 会导致重构
  • 获取:provider.of(context) 所在子widget不管是否是const都被重建后刷新数据 => 将会把调用了该方法的context作为听众,并在 notifyListeners 的时候通知其刷新。
  • 监听改变:组件被Consumer,Select 包裹,监听到T改变,会重新构建 => 极大地缩小你的控件刷新范围(可使用Selector达到更精细地控制,eg: 在list的长度发生改变时才会重新渲染,其内部元素改变时并不会触发重绘)
    • Consumer做了什么: 从先祖获取Provider然后传递给builder出的组件,本来代代相承的传家宝直接通过Consumer隔代传送
    • Consumer 使用了 Builder模式,收到更新通知就会通过 builder 重新构建
    • Consumer 的 builder 实际上就是一个 Function,它接收三个参数 (BuildContext context, T model, Widget child)
      • context: context 就是 build 方法传进来的 BuildContext
      • T: 就是获取到的最近一个祖先节点中的数据模型。
      • child:它用来构建那些与 Model 无关的部分,在多次运行 builder 中,child 不会进行重建。
    • Consumer 代表了它要获取哪一个祖先中的 Model
    • Consumer 的内部实现
        @override
          Widget build(BuildContext context) {
            return builder(
              context,
              Provider.of<T>(context),
              child,
            );
          }
      
  • Provider 最基础的provider,它会获取一个值并将它暴露出来
  • ListenableProvider 用来暴露可监听的对象,该provider将会监听对象的改变以便及时更新组件状态
  • ChangeNotifierProvider
  • ListerableProvider 依托于ChangeNotifier的一个实现,它将会在需要的时候自动调用ChangeNotifier.dispose方法
  • ValueListenableProvider 监听一个可被监听的值,并且只暴露ValueListenable.value方法
  • StreamProvider 监听一个流,并且暴露出其最近发送的值
  • FutureProvider 接受一个Future作为参数,在这个Future完成的时候更新依赖
  • ProxyProvider 能够将不同provider中的多个值整合成一个对象,并将其发送给外层provider,当所依赖的多个provider中的任意一个发生变化时,这个新的对象都会更新

Consumer

使用目的:

  1. 当没有BuildContext时可以使用Consumer

     @override // ERROR:ProviderNotFoundError 因为该context中并没有Provider
     Widget build(BuildContext context) {
       return ChangeNotifierProvider(
         builder: (_) => Foo(),
         child: Text(Provider.of<Foo>(context).value),
       );
     }
    
     @override // OK 
     Widget build(BuildContext context) {
       return ChangeNotifierProvider(
         builder: (_) => Foo(),
         child: Consumer<Foo>(
           builder: (_, foo, __) => Text(foo.value),
         },
       );
     }
    
  2. 它通过更细粒度的重构来帮助性能优化。

     class RedBox extends StatelessWidget {
       @override
       Widget build(BuildContext context) {
         print("---------RedBox---------build---------");
         return Container(
           color: Colors.red,
           width: 150,
           height: 150,
           alignment: Alignment.center,
           child: Consumer<CountState>(builder: (ctx,state,child){
             print("---------RedBox----Consumer-----build---------");
             return Text("Red:${state.count}",
                 style: TextStyle(fontSize: 20),);
           }),
         );
       }
     }
    

Consumer 源码

class Consumer<T> extends SingleChildStatelessWidget {
  /// {@template provider.consumer.constructor}
  /// Consumes a [Provider<T>]
  /// {@endtemplate}
  Consumer({
    Key key,
    @required this.builder,
    Widget child,
  })  : assert(builder != null),
        super(key: key, child: child);

  /// {@template provider.consumer.builder}
  /// Build a widget tree based on the value from a [Provider<T>].
  ///
  /// Must not be `null`.
  /// {@endtemplate}
  final Widget Function(BuildContext context, T value, Widget child) builder;

  @override
  Widget buildWithChild(BuildContext context, Widget child) {
    return builder(
      context,
      Provider.of<T>(context),
      child,
    );
  }
}

注:传入的context是谁的BuildContext? 每个Widget都有属于自己的元素Element,在该Element进行mount的时候回将自身化作美丽的天使(Context)传入组件或State的build方法中来供你使用

FutureBuilder & StreamBuilder

https://book.flutterchina.club/chapter7/futurebuilder_and_streambuilder.html

FutureBuilder({
  this.future,
  this.initialData,
  @required this.builder,       // Function (BuildContext context, AsyncSnapshot snapshot) snapshot 包含当前异步任务的状态信息及结果信息
})
StreamBuilder({
  Key key,
  this.initialData,
  Stream<T> stream,
  @required this.builder,
})

snapshot.hasError判断异步任务是否有错误 snapshot.connectionState 获取异步任务的状态信息

enum ConnectionState {
  /// 当前没有异步任务,比如[FutureBuilder]的[future]为null时
  none,
  /// 异步任务处于等待状态
  waiting,
  /// Stream处于激活状态(流上已经有数据传递了),对于FutureBuilder没有该状态。
  active,
  /// 异步任务已经终止.
  done,
}

MVVM

Flutter 实践 MVVM https://cloud.tencent.com/developer/article/1372224

  • Model: MovieItem
  • View: MovieView
  • ViewModel: MovieState

应用

获取某个控件的坐标

https://blog.csdn.net/baidu_34120295/article/details/86495861

1.首先先需要对控件进行渲染

初始化GlobalKey :GlobalKey anchorKey = GlobalKey();

2.在需要测量的控件的下面添加key:

child: Text("点击弹出悬浮窗",
  style: TextStyle(fontSize: 20),
  key: anchorKey
),

3.获取控件的坐标:

RenderBox renderBox = anchorKey.currentContext.findRenderObject();
var offset =  renderBox.localToGlobal(Offset.zero);

控件的横坐标:offset.dx

控件的纵坐标:offset.dy

如果想获得控件正下方的坐标:

RenderBox renderBox = anchorKey.currentContext.findRenderObject();
var offset =  renderBox.localToGlobal(Offset(0.0, renderBox.size.height));

控件下方的横坐标:offset.dx

控件下方的纵坐标:offset.dy

隐藏和显示widget

https://www.cnblogs.com/pjl43/p/9615685.html

通常情况下,显示有四种情况:

1.(visible)显示 2.(invisible)隐藏:这种隐藏是指在屏幕中占据空间,只是没有显示。这种情况出现场景如:用带有背景色的Container Widget去包含一个不可见的Image,当从网络中加载图片后才让它显示,这是为了避免图片显示后让页面布局改变发生跳动。 3.(Offscreen)超出屏幕,同样占据空间 4.(Gone)消失:这种是指Widget没有被rendered,不存在于wedget tree中

透明渐变 AppBar

https://blog.csdn.net/u012109585/article/details/108127209

Flutter之自定义AppBar并实现滑动渐变 https://blog.csdn.net/u013600907/article/details/101456290 https://www.jianshu.com/p/6fe2e74d35bf

用到了NotificationListener这个widget,借助这个widget可以监听滚动的高度。appBar则使用自定义widget实现,给外层嵌套一个opacity组件,通过滚动监听高度变化然后改变appBar透明度即可。

scrollNotification.depth 的值 0 表示其子组件(只监听子组件,不监听孙组件); scrollNotification is ScrollUpdateNotification 来判断组件是否已更新,ScrollUpdateNotification 是 notifications 的生命周期一种情况,分别有一下几种:

  • ScrollStartNotification 组件开始滚动
  • ScrollUpdateNotification 组件位置已经发生改变
  • ScrollEndNotification 组件停止滚动
  • UserScrollNotification 不清楚
NotificationListener(
  onNotification: (scrollNotification) {
  if (scrollNotification is ScrollUpdateNotification && scrollNotification.depth==0) {
       _onScroll(scrollNotification.metrics.pixels);
    }
}

判断条件里的ScrollUpdateNotification是指widget组件位置发生改变才会执行相应的逻辑

_onScroll (offset) {
    double alpha = offset / APPBAE_SCROLL_OFFSET; // APPBAE_SCROLL_OFFSET为appBar高度
    if (alpha < 0) {
      // alpha = 0;
      return ;
    } else if (alpha > 1) {
      // alpha = 1;
      return ;
    }
    setState(() {
      alphaAppBar = alpha;
    });
  }

ScrollController / NotificationListener https://www.jianshu.com/p/b0b1c6308674

=> 内容如果过多的话,你这种写法会造成页面的严重卡顿?

LayoutBuilder

通过LayoutBuilder组件可以获取父组件的约束尺寸 eg: 根据组件的大小确认组件的外观,比如竖屏的时候上下展示,横屏的时候左右展示,

Flutter如何检查Sliver AppBar是否展开或折叠? https://www.javaroad.cn/questions/81420

_buildSliverToBoxAdapter(){
    return SliverToBoxAdapter(
      child:Container(
        height: 30,         // 当设置父组件的宽高大于100时显示蓝色,小于100时显示红色。
        child: _buildLayoutBuilder()
      ),
    );
  }
  _buildLayoutBuilder(){
    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
        var color = Colors.red;
        debugPrint("$constraints");
        if (constraints.maxHeight > 100) {
          color = Colors.blue;
        }
        return Container(
          height: 30,
          width: 50,
          color: color,
        );
      },
    );
  }

Json处理

  1. json_serializable: ^3.5.0
  2. person.dart

     import 'package:json_annotation/json_annotation.dart';
    
     part "person.g.dart";
    
     @JsonSerializable(nullable: false)
     class Person {
       final String firstName;
       final String lastName;
       final DateTime dateOfBirth;
       Person({this.firstName, this.lastName, this.dateOfBirth});
       factory Person.fromJson(Map<String, dynamic> json) => _$PersonFromJson(json);
       Map<String, dynamic> toJson() => _$PersonToJson(this);
     }
    
  3. cmd: flutter packages pub run build_runner build => generate person.g.dart

     import 'package:json_annotation/json_annotation.dart';
     part "person.g.dart";
    
     @JsonSerializable(nullable: false)
     class Person {
    
       final String firstName;
       final String lastName;
       final DateTime dateOfBirth;
    
       Person({this.firstName, this.lastName, this.dateOfBirth});
    
       factory Person.fromJson(Map<String, dynamic> json) => _$PersonFromJson(json);
    
       Map<String, dynamic> toJson() => _$PersonToJson(this);
     }
    

或使用 json to code 工具(根据json生成dart code): https://app.quicktype.io/

或使用vscode 插件 Paste JSON as Code

ios不受信任的应用开发者如何设置

https://jingyan.baidu.com/article/574c52196f55486c8d9dc106.html

  1. 打开苹果手机,点击“设置”,进入设置页面;
  2. 进入设置页面后,滑动手机屏幕找到“通用”,点击进入“通用”页面;
  3. 进入通用页面后,滑动手机找到“描述文件与设备管理”,点击进入描述文件与设备管理页面;
  4. 进入描述文件与设备管理页面后,看到“企业级应用”,然后点击进入企业级应用下面的按钮;

综合Demo

启动初始化设置

  1. lib/main.dart:

     import 'dart:async';
    
     import 'package:five_demo/pages/app/app_page.dart';
     import 'package:five_demo/utils/log_utils.dart';
     import 'package:flutter/material.dart';
     import 'global/global.dart';
    
     // void main() {
     //   runApp(MyApp());
     // }
    
     void main() async {
       // catch uncatched dart error
       runZonedGuarded(() async {
         Global.init().then((value) {
           // runApp(Global.wrapGlobalProviders(MyApp()));
           runApp(AppPage());
         });
       }, (error,stack)=>LogUtils.error("$error \n $stack"));
     }
    
  2. lib/global/global.dart

     import 'dart:io';
     import 'package:five_demo/entities/platform_info.dart';
     import 'package:five_demo/global/global_configs.dart';
     import 'package:five_demo/utils/log_utils.dart';
     import 'package:five_demo/utils/storage_utils.dart';
     import 'package:flutter/material.dart';
     import 'package:flutter/services.dart';
    
     class Global{
    
       static bool isFirstOpen;
       static PlatformInfo platformInfo;
    
       // static LoginUserInfo loginUserInfo;
       // static CheckInInfo checkInInfo;
    
       static Future init() async {
    
         LogUtils.info("[Global] init() start");
    
         WidgetsFlutterBinding.ensureInitialized();
    
         LogUtils.info("[Global] set FlutterErrorHander");
         LogUtils.setFlutterErrorWidgetOnDebug();
         LogUtils.setFlutterErrorHandlerOnRelease();
    
         LogUtils.info("[Global] storage.init() start");
         var storage = StorageUtils();
         await storage.init();
         LogUtils.info("[Global] storage.init() end");
    
         /// isFirstOpen?
         isFirstOpen = storage.getBool(GlobalConfigs.Storage_App_First_Open,defalutValue:true);
         LogUtils.info("[Global] isFirstOpen: $isFirstOpen");
         /// do after Welcome Page Loaded: 
         // if(isFirstOpen){    
         //   storage.setBool(GlobalConfigs.Storage_App_First_Open, false);
         // }
    
         // /// loginUser? => autoLogin
         // Map<String,dynamic> loginUserMap = storage.getMap(GlobalConfigs.Storage_App_Login_User,defalutValue: null);
         // if(loginUserMap!=null){
         //   loginUserInfo=LoginUserInfo.fromMap(loginUserMap);
         //   /// Do auto login and update storage
         //   LogUtils.info("[Global] Trigger autoLogin");
         //   // ...
         // }
         // LogUtils.info("[Global] LoginUserInfo: $loginUserInfo");
    
         /// platformInfo
         platformInfo = PlatformInfo(
           operatingSystem: Platform.operatingSystem,
           operatingSystemVersion: Platform.operatingSystemVersion,
           version: Platform.version
         );
         // android 状态栏为透明的沉浸
         if (Platform.isAndroid) {
           SystemUiOverlayStyle systemUiOverlayStyle =
               SystemUiOverlayStyle(statusBarColor: Colors.transparent);
           SystemChrome.setSystemUIOverlayStyle(systemUiOverlayStyle);
         }
         LogUtils.info("[Global] PlatformInfo: $platformInfo");
    
         // ...
         // await Future.delayed(Duration(seconds: 1));
    
         LogUtils.info("[Global] init() end");
       }
    
     }
    

SharedPreferences本地存储

  1. pubspec.yaml:

     dependencies:
         # 本地存储
         shared_preferences: ^0.5.12+4
    
  2. storage_utils.dart:

     import 'dart:convert';
    
     import 'package:shared_preferences/shared_preferences.dart';
    
     class StorageUtils{
    
       static final StorageUtils _instance = new StorageUtils._internal();
       factory StorageUtils() => _instance;    // 工厂构造
       StorageUtils._internal();               // 命名构造
       static SharedPreferences _prefs;
    
       Future<void> init() async {
         if (_prefs == null) {
           _prefs = await SharedPreferences.getInstance();
         }
       }
    
       Future<bool> setMap(String key, Map<String,dynamic> map) async {
         String jsonStr = jsonEncode(map);
         return _prefs.setString(key, jsonStr);
       }
    
       Map<String,dynamic> getMap(String key,{Map<String,dynamic> defalutValue}) {
         String jsonStr = _prefs.getString(key);
         return jsonStr == null ? defalutValue : jsonDecode(jsonStr);
       }
    
       Future<bool> setBool(String key, bool val) {
         return _prefs.setBool(key, val);
       }
    
       bool getBool(String key,{bool defalutValue=false}) {
         bool val = _prefs.getBool(key);
         return val == null ? defalutValue : val;
       }
    
       Future<bool> remove(String key) {
         return _prefs.remove(key);
       }
     }
    
  3. 使用(global.dart)

     import 'dart:io';
     import 'package:five_demo/utils/storage_utils.dart';
    
     class Global{
    
       static bool isFirstOpen;
       // static LoginUserInfo loginUserInfo;
    
       static Future init() async {
    
         LogUtils.info("[Global] init() start");
         WidgetsFlutterBinding.ensureInitialized();
    
         var storage = StorageUtils();
         await storage.init();
         LogUtils.info("[Global] storage.init() end");
    
         /// isFirstOpen?
         isFirstOpen = storage.getBool(GlobalConfigs.Storage_App_First_Open,defalutValue:true);
         LogUtils.info("[Global] isFirstOpen: $isFirstOpen");
         /// do after Welcome Page Loaded: 
         // if(isFirstOpen){    
         //   storage.setBool(GlobalConfigs.Storage_App_First_Open, false);
         // }
    
         // /// loginUser? => autoLogin
         // Map<String,dynamic> loginUserMap = storage.getMap(GlobalConfigs.Storage_App_Login_User,defalutValue: null);
         // if(loginUserMap!=null){
         //   loginUserInfo=LoginUserInfo.fromMap(loginUserMap);
         //   /// Do auto login and update storage
         //   LogUtils.info("[Global] Trigger autoLogin");
         //   // ...
         // }
         // LogUtils.info("[Global] LoginUserInfo: $loginUserInfo");
    
         // ...
         LogUtils.info("[Global] init() end");
       }
     }
    

全局状态控制

  • 是否第一次使用此app: Global.isFirstOpen?WelcomePage():IndexPage()
  • 是否全局黑灰蒙布: isGreyFilter 使用Provider状态管理 (MVVM模式)

pubspec.yaml

dependencies:
    # 状态管理
    provider: ^4.3.2+2
  1. app_page.dart

     import 'package:five_demo/global/global.dart';
     import 'package:five_demo/global/global_routes.dart';
     import 'package:five_demo/global/global_themes.dart';
     import 'package:five_demo/pages/pages.dart';
     import 'package:five_demo/utils/log_utils.dart';
     import 'package:flutter/material.dart';
     import 'package:provider/provider.dart';
    
     import 'app_state.dart';
    
     class AppPage extends StatefulWidget {
    
       final AppState appState = AppState.getInstance();
    
       final Map args;
    
       AppPage({Key key,this.args}) : super(key: key);
       @override
       _AppPageState createState() => _AppPageState();
     }
    
     class _AppPageState extends State<AppPage> {
    
       @override
       void initState() {
         debugPrint("+++ AppPage.initState() +++");
         super.initState();
       }
    
       @override
       void dispose() {
         debugPrint("+++ AppPage.dispose() +++");
         super.dispose();
       }
    
       @override
       Widget build(BuildContext context) {
    
         return MultiProvider(
           providers: [
             ChangeNotifierProvider<AppState>.value(
               value: widget.appState,
              ),
           ],
           child: _selectIsGrey(),
         );
       }
    
       _selectIsGrey(){
         return Selector<AppState,bool>(
           selector: (_,appState)=>appState.isGreyFilter,
           builder: (_,value,child){
             return value? ColorFiltered(
               colorFilter: ColorFilter.mode(Colors.white, BlendMode.color),
               child: _buildMaterialApp()
             ):_buildMaterialApp();
           }
         );
       }
    
       _buildMaterialApp(){
         return MaterialApp(
           title: 'Hey,Dear!',
           theme: GlobalThemes.mainThemeData,
           debugShowCheckedModeBanner:LogUtils.isInDebugMode,
           onGenerateRoute: GlobalRoutes.router.generator,
           home: Global.isFirstOpen?WelcomePage():IndexPage(),
         );
       }
    
     }
    
  2. app_state.dart

     import 'package:flutter/material.dart';
     import 'package:provider/provider.dart';
    
     class AppState with ChangeNotifier{
    
       static final AppState _instance = AppState._internal();
    
       factory AppState.getInstance(){
         debugPrint("++++ 1. AppState.getInstance() +++");
         return _instance;
       }
    
       AppState._internal(){
         debugPrint("++++ 2. AppState._internal() +++");
       }
    
       @override
       dispose(){
         debugPrint("++++ 3. AppState.dispose() +++");
         super.dispose();
       }
    
       bool _isGreyFilter=false;
    
       /// isGreyFilter 灰色滤镜
       get isGreyFilter {
         return  _isGreyFilter;
       }
       set isGreyFilter(String value) {
         isGreyFilter = value;
         notifyListeners();
       }
       switchGrayFilter(){
         _isGreyFilter=!_isGreyFilter;
         notifyListeners();
       }
    
     }
    
  3. global_themes.dart

     class GlobalThemes{
    
       static ThemeData mainThemeData= ThemeData(
         primaryColor: Colors.white,
         visualDensity: VisualDensity.adaptivePlatformDensity,
         // 去除水波纹
         highlightColor: Colors.transparent,
         splashColor:Colors.transparent,
       );
     }
    
  4. welcome_page.dart

     import 'package:five_demo/global/global_configs.dart';
     import 'package:five_demo/global/global_routes.dart';
     import 'package:five_demo/utils/storage_utils.dart';
     import 'package:flutter/material.dart';
    
     class WelcomePage extends StatelessWidget {
       final Map args;
       WelcomePage({Key key,this.args}) : super(key: key);
       @override
       Widget build(BuildContext context) {
         return Scaffold(
           body: Container(
             width: double.infinity,
             decoration: BoxDecoration(
               image: DecorationImage(
                 image: new AssetImage("assets/images/default/记录.png"),
               )
             ),
             child: Column(
               mainAxisAlignment: MainAxisAlignment.spaceAround,
               children:[
                 _buildTitle(context),
                 // Image.network("https://ss0.bdstatic.com/70cFuHSh_Q1YnxGkpoWK1HF6hhy/it/u=3103964819,2015218737&fm=26&gp=0.jpg"),
                 // Image.asset("assets/images/default/记录.png"),
                 _buildNextBtn(context)
               ]
             )
           ),
         );
       }
    
       _buildTitle(context){
         return Text("Welcome",style: Theme.of(context).textTheme.headline4);
       }
    
       _buildNextBtn(context){
         return IconButton(
           icon: Icon(Icons.arrow_forward),
           onPressed: (){
             StorageUtils().setBool(GlobalConfigs.Storage_App_First_Open, false);
             // Navigator.of(context).pushNamed(GlobalRoutes.Route_Index);
             Navigator.of(context).pushNamedAndRemoveUntil(GlobalRoutes.Route_Index, (route) => false);
           }
         );
       }
    
     }
    

路由

使用Fluro动态路由框架

pubspec.yaml

dependencies:
    # 动态路由 fluro
    fluro: 1.7.7
  1. global_routes.dart

     import 'package:five_demo/pages/do/do_page.dart';
     import 'package:fluro/fluro.dart';
     import 'package:five_demo/pages/pages.dart';
    
     class GlobalRoutes{
    
       // Index,Welcome,Home,Do,My,Detail,Login,Error
    
       static const String Route_App = "/";
       static const String Route_Index = "/index";
       static const String Route_Welcome = "/welcome";
       static const String Route_Home = "/home";
       static const String Route_Do = "/do";
       static const String Route_My="/my";
       static const String Route_Detail ="/detail/:id";
       static const String Route_Login = "/login";
       static const String Route_Mall="/mall";
       static const String Route_Error = "/error";
    
       static const List<String> routePaths=[
         Route_App,
         Route_Index,
         Route_Welcome,
         Route_Home,
         Route_Do,
         Route_My,
         Route_Detail,
         Route_Login,
         Route_Error
       ];
    
       static FluroRouter router = init();
    
       static init(){
         FluroRouter router = FluroRouter();
         routePaths.forEach((element) {
           router.define(
             element, 
             handler: buildHandler(element),
             transitionType: TransitionType.native
           );
         });
         router.notFoundHandler=buildHandler(Route_Error);
         return router;
       }
    
       static buildHandler(String path){
         return Handler(
           handlerFunc: (context,params)=>getRoutePageWidget(path, context.settings.arguments)
         );
       }
    
         static getRoutePageWidget(String path,Map args){
         // Index,Welcome,Home,Do,My,Detail,Login,Error
         switch(path){
           case Route_App:
             return AppPage(args:args);
           case Route_Index:
             return IndexPage(args: args);
           case Route_Welcome:
             return WelcomePage(args:args);
           case Route_Home:
             return HomePage(args: args);
           case Route_Do:
             return DoPage(args:args);
           case Route_My:
             return MyPage(args:args);
           case Route_Detail:
             return DetailPage(args:args);
           case Route_Login:
             return LoginPage(args:args);
           case Route_Mall:
             return MallPage(args:args);
           default:
             return ErrorPage(args:args);
         }
       }
    
     }
    
  2. app_page.dart

     class AppPage extends StatefulWidget {
    
       final AppState appState = AppState.getInstance();
    
       final Map args;
       AppPage({Key key,this.args}) : super(key: key);
       @override
       _AppPageState createState() => _AppPageState();
     }
    
     class _AppPageState extends State<AppPage> {
    
       @override
       void initState() {
         super.initState();
       }
    
       @override
       Widget build(BuildContext context) {
         // ...
         // _buildMaterialApp();
       }
    
       _buildMaterialApp(){
         return MaterialApp(
           title: 'Hey,Dear!',
           theme: GlobalThemes.mainThemeData,
           debugShowCheckedModeBanner:LogUtils.isInDebugMode,
           onGenerateRoute: GlobalRoutes.router.generator,           // route!
           home: Global.isFirstOpen?WelcomePage():IndexPage(),
         );
       }
     }
    
  3. 使用:

     // welcome_page.dart:
     onPressed: (){
         StorageUtils().setBool(GlobalConfigs.Storage_App_First_Open, false);
         // Navigator.of(context).pushNamed(GlobalRoutes.Route_Index);
         Navigator.of(context).pushNamedAndRemoveUntil(GlobalRoutes.Route_Index, (route) => false);
     }
    
     // banner_view.dart
     onTap: (index){
       debugPrint("banner onTap:$index ${value.bannerItems[index].targetPath}");
       Navigator.pushNamed(
         ctx, 
         value.bannerItems[index].targetPath,
         arguments:{'title':value.bannerItems[index].title}
       );
     }
    
     // channel_view.dart
     onTap: () => Navigator
                 .of(context)
                 .pushNamed(
                   GlobalRoutes.Route_Detail,
                   arguments: {'item':e,'targetType':e.targetType,'targetPath':e.targetPath}
                 )
    
     // channel_fm_index.dart
     onTap: (){
       Navigator.pushNamed(
         context,
         GlobalRoutes.Route_Detail,
         arguments: {'item':item,'targetPath':item['targetPath'],'targetType':item['targetType']} 
       );
     }
    
     // pop
     onPressed: () => Navigator.of(context).pop()
     onPressed: () => Navigator.pop(context);
     onPressed: () => Navigator.pop(context,controller.text);
    

底部导航

使用 BottomAppBar & IndexedStack & Provider(MVVM)

pubspec.yaml

dependencies:
  # 状态管理
  provider: ^4.3.2+2
  1. index_page.dart

     class IndexPage extends StatefulWidget {
    
       final IndexState indexState = IndexState.getInstance();
       final Map args;
       IndexPage({Key key,this.args}) : super(key: key);
       @override
       _IndexPageState createState() => _IndexPageState();
     }
    
     class _IndexPageState extends State<IndexPage> {
    
       @override
       void initState() {
         debugPrint("+++ IndexPage.initState() +++");
         super.initState();
       }
    
       @override
       void dispose() {
         debugPrint("+++ IndexPage.dispose() +++");
         super.dispose();
       }
    
       @override
       Widget build(BuildContext context) {
    
         ScreenUtil.init(
           context,
           designSize:GlobalConfigs.ScreenDesignSize,
           allowFontScaling:true);
    
         return MultiProvider(
           providers: [
             ChangeNotifierProvider<IndexState>.value(value: widget.indexState),
           ],
           child: Scaffold(
             /// header
             // appBar: wds.buildAppBar("Index"),
             /// body
             body: _selectCurrentIndex(_bodyBuilder),
             /// bottom 
             bottomNavigationBar: BottomAppBar(
               child: Column(
                 mainAxisSize: MainAxisSize.min,
                 children: [
                   _selectCurrentIndex(_bottomNavBuilder),
                 ],
               ),
             ),
             /// end
           ),
         );
       }
    
       _selectCurrentIndex(builder){
        return Selector<IndexState,int>(
           selector: (_,indexState)=>indexState.currentIndex,
           builder:builder
         );
       }
    
       Widget _bodyBuilder(BuildContext context,int value,Widget child){
         return IndexedStack(
           index: value,
           children: IndexState.items.map((e) => e.page).toList(),
         );
       }
    
       Widget _bottomNavBuilder(BuildContext context,int value,Widget child){
         return Container(
           padding: GlobalThemes.containerPadding,
           child: Row(
             mainAxisAlignment: MainAxisAlignment.spaceAround,
             crossAxisAlignment: CrossAxisAlignment.end,
             children: IndexState.items.map(
               (e) => InkWell(
                 child:_buildBottomNavItem(e,value),
                 onTap: ()=>_onTapHandler(e.index),
               )
             ).toList()
           ),
         );
       }
    
       _onTapHandler(index){
         if (widget.indexState.currentIndex != index) {
           debugPrint("$index");
           widget.indexState.currentIndex = index;
         }
       }
    
       Widget _buildBottomNavItem(IndexPageItem item,int currentIndex) {
         // debugPrint("_buildBottomMenuItem $index active: $active ");
         bool active = currentIndex==item.index;
         if (item.iconData == null) {
           return Container(
             padding: GlobalThemes.containerCirclePadding,
             decoration: IndexThemes.getBottomBarCircleDecoration(active),
             child: Text(
               item.label, 
               style: IndexThemes.getBottomBarCircleTextStyle(active)
             ),
           );
         } 
         return Theme(
           data: ThemeData(
             iconTheme: IndexThemes.getBottomBarIconThemeData(active)
           ),
           child:Column(
             mainAxisSize: MainAxisSize.min,
             children: [
               Icon(active?item.activeIconData:item.iconData),
               Text(
                 item.label,
                 style: IndexThemes.getBottomBarLabelTextStyle(active)
               )
             ],
           )
         );
       }
     }
    
  2. index_state.dart

     class IndexPageItem{
       int index;
       IconData iconData;
       IconData activeIconData;
       String label;
       Widget page;
       IndexPageItem({this.index,this.iconData,this.activeIconData,this.label,this.page});
     }
    
     class IndexState with ChangeNotifier{
    
       static List<IndexPageItem> items=[
         IndexPageItem(
           index: 0,
           iconData:Icons.home_outlined,
           activeIconData: Icons.home,
           label:"首页",
           page:HomePage(args:{'title':'首页'})
         ),
         IndexPageItem(
           index: 1,
           // iconData:Icons.list,
           label:"Do",
           page:DoPage(args:{'title':'Do'})
         ),
         IndexPageItem(
           index: 2,
           iconData:Icons.person_outline,
           activeIconData: Icons.person,
           label:"我的",
           page:MyPage(args:{'title':'我的'})
         ),
       ];
    
       int _currentIndex = 0;
    
       static final IndexState _instance = IndexState._internal();
       factory IndexState.getInstance() => _instance;
    
       // IndexState._internal() {
       //   _currentIndex = 0;
       // }
       IndexState._internal();
    
       /// currentIndex
       get currentIndex {
         return _currentIndex;
       }
       set currentIndex(int value) {
         _currentIndex = value;
         notifyListeners();
       }
     }
    

Login

登录注册方式:

  • 用户名(手机号) & 验证码(60s发送到手机)
  • 第三方授权

Flutter 中“倒计时”的那些事儿 https://blog.csdn.net/weixin_45189747/article/details/103370289

Flutter倒计时/计时器的实现 https://zhuanlan.zhihu.com/p/61970955

Login

pubspec.yaml

dependencies:
  # 提示框
  fluttertoast: ^7.1.6
  1. login_page.dart

     import 'dart:async';
    
     import 'package:five_demo/global/global_values.dart';
     import 'package:five_demo/widgets/widgets.dart';
     import 'package:flutter/material.dart';
     import 'package:flutter/services.dart';
     import 'package:fluttertoast/fluttertoast.dart';
    
     class LoginPage extends StatefulWidget {
       final Map args;
       LoginPage({Key key,this.args}) : super(key: key);
       @override
       _LoginPageState createState() => _LoginPageState();
     }
    
     class _LoginPageState extends State<LoginPage> {
       String phoneNo;
       String verificationCode;
       GlobalKey<FormState> formGlobalKey = GlobalKey<FormState>();
       final TextEditingController _phoneNoController = TextEditingController();
       final TextEditingController _codeController = TextEditingController();
    
       Timer _timer;
       int _timeCount = 60;
    
       startTimer(){
         _timeCount=60;
         _timer = Timer.periodic(Duration(seconds: 1), (Timer timer){
           if(_timeCount<=0){
             debugPrint("倒计时结束");
             _timer.cancel();
             setState((){
               _timer=null;
             });
           }else{
             setState((){
               _timeCount-=1;
             });
           }
         });
       }
    
       getCodeText(){
         if(_timer!=null && _timeCount>=0)
           return "${_timeCount}s";
         return "获取验证码";
       }
    
       @override
       void dispose() {
         super.dispose();
         if (_timer != null) {
           debugPrint("销毁啦");
           _timer.cancel();
           _timer=null;
         }
       }
    
       @override
       Widget build(BuildContext context) {
         return Scaffold(
           resizeToAvoidBottomInset: false,
           appBar: _buildAppBar(),
           body: GestureDetector(
             onTap: (){
               debugPrint("gesture onTap");
               FocusScope.of(context).requestFocus(new FocusNode());
               // FocusScopeNode currentFocus = FocusScope.of(context);
               // if (!currentFocus.hasPrimaryFocus &&
               //     currentFocus.focusedChild != null) {
               //   FocusManager.instance.primaryFocus.unfocus();
               // }
             },
             child: Container(
               color: Colors.grey[50],
               child: Column(
                 mainAxisAlignment: MainAxisAlignment.start,
                 children: [
                   _buildHeader(),
                   _buildForm(),
                   SizedBox(height: 100,),
                   _buildThirdPart(),
                 ],
               ),
             ),
           ),
           bottomNavigationBar: _buildPolicy(),
         );
       }
    
       _buildAppBar(){
         return AppBar(
           backgroundColor: Colors.grey[50],
           elevation: 0.0,
           toolbarHeight: 40,
         );
       }
    
       _buildHeader(){
         return Container(
           padding: EdgeInsets.only(top:10,bottom: 30),
           child: Text("Hey,Dear",style: TextStyle(fontSize: 22,fontWeight: FontWeight.w500,color: Colors.deepOrange)),
         );
       }
    
       _buildForm(){
         return Container(
           padding: EdgeInsets.symmetric(horizontal: 30),
           // height: 300,
           child: Form(
             key: formGlobalKey,
             child: Column(
               // padding: EdgeInsets.all(20),
               children: [
                 _buildPhoneNoField(),
                 _buildCodeField(),
                 _buildSubmit(),
    
               ],
             ),
           ),
         );
       }
    
       _buildPhoneNoField(){
         return Container(
           height: 45,
           padding: EdgeInsets.symmetric(horizontal: 10),
           margin: EdgeInsets.only(top: 10),
           decoration: BoxDecoration(
             // borderRadius: BorderRadius.circular(10),
             // border: Border.all(color: Colors.grey,),
             border: Border(bottom: BorderSide(color: Colors.grey)),
           ),
           child: Row(
             crossAxisAlignment: CrossAxisAlignment.center,
             children: [
               Text(" + 86  |",style: TextStyle(fontSize: 14),),
               Expanded(
                 child:TextFormField(
                   controller: _phoneNoController,
                   decoration: InputDecoration(
                     hintText: "手机号码",
                     hintStyle: TextStyle(fontSize: 14,color: Colors.grey),
                     isDense: true,
                     contentPadding: EdgeInsets.only(left: 10,top: 5,bottom: 0,right: 10),
                     border: const OutlineInputBorder(
                       gapPadding: 0,
                       borderSide: BorderSide(width: 0,style: BorderStyle.none,),
                     ),
                     suffix: InkWell(
                       onTap: (){
                         _phoneNoController.clear();
                       },
                       child: Icon(Icons.clear,size: 14,),
                     )
                   ),
                   keyboardType: TextInputType.phone,
                   inputFormatters: [
                     FilteringTextInputFormatter.digitsOnly,
                     LengthLimitingTextInputFormatter(11)
                   ],
                   // onSaved: (value){
                   //   debugPrint("phoneNo onSaved");
                   //   this.phoneNo=value;
                   // },
               )),
               // IconButton(icon: Icon(Icons.clear,size: 16,), onPressed: (){})
             ],
           ),
         );
       }
    
       _buildCodeField(){
         return Container(
           height: 45,
           padding: EdgeInsets.only(left: 10),
           margin: EdgeInsets.only(top: 10),
           decoration: BoxDecoration(
             // borderRadius: BorderRadius.circular(10),
             // border: Border.all(color: Colors.grey,),
             border: Border(bottom: BorderSide(color: Colors.grey)),
           ),
           child: Row(
             crossAxisAlignment: CrossAxisAlignment.center,
             children: [
               Expanded(
                 child:TextFormField(
                   controller: _codeController,
                   decoration: InputDecoration(
                     hintText: "验证码",
                     hintStyle: TextStyle(fontSize: 14,color: Colors.grey),
                     isDense: true,
                     contentPadding: EdgeInsets.only(left: 10,top: 5,bottom: 0,right: 10),
                     border: const OutlineInputBorder(
                       gapPadding: 0,
                       borderSide: BorderSide(width: 0,style: BorderStyle.none,),
                     ),
                   ),
                   keyboardType: TextInputType.phone,
                   inputFormatters: [
                     FilteringTextInputFormatter.digitsOnly,
                     LengthLimitingTextInputFormatter(8),
                   ],
                   // onSaved: (value){
                   //   debugPrint("verificationCode onSaved");
                   //   this.verificationCode=value;
                   // },
               )),
               _buildCodeBtn()
             ],
           ),
         );
       }
    
       _buildCodeBtn(){
         return Container(
           // height: 35,
           // decoration: BoxDecoration(
           //   borderRadius: BorderRadius.circular(20),
           //   color: Colors.grey[200],
           // ),
           child: FlatButton(
             onPressed: (){
               if(_phoneNoController.text==null || _phoneNoController.text.length!=11){
                 Fluttertoast.showToast(
                   msg:"请先输入手机号!",
                   toastLength: Toast.LENGTH_SHORT,
                   gravity: ToastGravity.CENTER,
                   timeInSecForIosWeb: 1,
                   backgroundColor: Colors.grey[100],
                   fontSize: 14,
                   textColor: Colors.deepOrange[300]
                 );
                 return;
               }
               if(_timer==null)
                 startTimer();
               else
                 debugPrint("等等");
             },
             child: Text(getCodeText(),style: TextStyle(color: Colors.deepOrange[300],fontSize: 14),),
           ),
         );
       }
    
       _buildSubmit(){
         return Container(
           width: double.infinity,
           margin: EdgeInsets.only(top: 20),
           child: RaisedButton(
             child: Text("登 录", style: TextStyle(color: Colors.white,fontSize: 16)),
             color: Colors.deepOrange.withOpacity(0.8),
             elevation: 0,
             shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
             onPressed: () {
               debugPrint("Submit Clicked");
               // formGlobalKey.currentState.save();
               // debugPrint("Submit get username: $username | password: $password");
               bool result = formGlobalKey.currentState.validate();
               debugPrint("validate result: $result");
               if (result) {
                 formGlobalKey.currentState.save();
                 debugPrint("Submit get phoneNo: $phoneNo | verificationCode: $verificationCode");
                 debugPrint("Submit controller phoneNo: ${_phoneNoController.text} | verificationCode: ${_codeController.text}");
               }
             },
           ),
         );
       }
    
       _buildPolicy(){
         return Container(
           padding: EdgeInsets.symmetric(vertical: 10),
           child: Text.rich(TextSpan(
             style: TextStyle(fontSize: 12,color: Colors.grey),
             children: [
               TextSpan(text:"首次登录会自动创建账号,且代表同意"),
               TextSpan(text: "《用户服务协议》《隐私政策》",style: TextStyle(color: Colors.deepOrange)),
             ]
           ))
         );
       }
    
       /* --------------------- */
    
       _buildThirdPart(){
         return Container(
           padding: EdgeInsets.symmetric(horizontal: 15),
           child: Column(
             children: [
               Text("使用第三方账号登录",style: TextStyle(color: Colors.grey),),
               SizedBox(height: 15,),
               Row(
                 mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                 children: [
                   IconButton(icon: Icon(Iconfont.wechat,color: Colors.green,size:36), onPressed: (){}),
                   IconButton(icon: Icon(Iconfont.qq, color: Colors.blue,size:36), onPressed: (){}),
                   IconButton(icon: Icon(Iconfont.weibo, color: Colors.red,size:36), onPressed: (){}),
                 ],
               )
             ],
           ),
         );
       }
     }
    

Banner

  • 使用第三方库 flitter_swiper
  • MVVM:
    • V(View): banner_view.dart
    • VM(ViewModel): banner_state.dart => HttpRequest(dio)
    • M(Model): banner_item.art

Banner

pubspec.yaml

dependencies:
  # 轮播图
  flutter_swiper: ^1.1.6
  # 状态管理
  provider: ^4.3.2+2
  # http - dio
  dio: ^3.0.10
  dio_log : ^1.3.3

index_page -> home_page: View(banner_view),VM(banner_state),M(banner_item)

  1. index_page.dart

     class IndexPage extends StatefulWidget {
       final IndexState indexState = IndexState.getInstance();
       final BannerState bannerState = BannerState.getInstance();
       final ActivityState activityState = ActivityState.getInstance();
       // ...
     }
    
     class _IndexPageState extends State<IndexPage> {
         // ...
         @override
         Widget build(BuildContext context) {
             return MultiProvider(
               providers: [
                 ChangeNotifierProvider<IndexState>.value(value: widget.indexState),
                 ChangeNotifierProvider<BannerState>.value(value: widget.bannerState),
                 ChangeNotifierProvider<ActivityState>.value(value: widget.activityState),
               ],
               child: Scaffold(
                 body: _selectCurrentIndex(_bodyBuilder),
                 // ...
               ),
             );
         }
     }
    
  2. home_page.dart

     class HomePage extends StatefulWidget {
       final Map args;
       HomePage({Key key, this.args}) : super(key: key);
       @override
       _HomePageState createState() => _HomePageState();
     }
    
     class _HomePageState extends State<HomePage> {
       @override
       Widget build(BuildContext context) {
         return Scaffold(
           appBar: wds.buildAppBar(widget.args != null ? widget.args['title'] ?? '' : ''),
           // body: _buildBody(context),
           body: _buildWrapRefresh(context)
         );
       }
    
       _onRefresh(BuildContext context) {
         context.read<CheckInState>().doCheckIn();
         context.read<BannerState>().load();
         context.read<ActivityState>().reload();
         // ... 
       }
    
       _buildWrapRefresh(BuildContext ctx) {
         return RefreshIndicator(
             onRefresh: () async {
               debugPrint("+++ HomePage onRefresh! +++");
               _onRefresh(ctx);
             },
             child: _buildBody(ctx));
       }
    
       _buildBody(context) {
         return ListView(
           controller: Provider.of<ActivityState>(context).scrollController,
           children: [
             Consumer<CheckInState>(
               builder: (_, value, child) => CheckInView(checkInState: value),
             ),
             Consumer<BannerState>(builder: (_, value, child) => BannerView(bannerState: value,)),
             Consumer<ActivityState>(builder: (_, value, child) => ActivityView()),
             // ...
           ],
         );
       }
     }
    
  3. banner_view.dart

     import 'package:five_demo/pages/home/state/banner_state.dart';
     import 'package:five_demo/widgets/widgets.dart';
     import 'package:flutter/material.dart';
     import 'package:flutter_swiper/flutter_swiper.dart';
     import 'package:provider/provider.dart';
    
     class BannerTheme{
       static Color activePaginationColor = Colors.lightBlue[100];
       static double bannerHeight=150;
     }
    
     class BannerView extends StatelessWidget {
    
       final BannerState bannerState;
       BannerView({Key key,this.bannerState}) : super(key: key);
    
       @override
       Widget build(BuildContext context) {
         debugPrint("--- BannerView Build ---");
         // var value = Provider.of<BannerState>(context);
         return _buildBody(context, bannerState);
       }
    
       _buildBody(ctx,value){
         if(value.isLoading){
           return buildLoading(height:BannerTheme.bannerHeight);
         }
         if(value.bannerItems==null || value.bannerItems.length==0){
           return buildEmpty();
         }
         return _buildContent(ctx,value);
       }
    
       _buildContent(ctx,value){
         return Container(
           height: BannerTheme.bannerHeight,
           child: Swiper(
             itemCount: value.bannerItems.length,
             itemBuilder: (context, index) {
               return Image.network(value.bannerItems[index].imageUrl, fit: BoxFit.cover);
             },
             pagination: SwiperPagination(
               builder: DotSwiperPaginationBuilder(activeColor: BannerTheme.activePaginationColor)
             ),
             autoplay: true,
             autoplayDelay:3000,
             onTap: (index){
               debugPrint("banner onTap:$index ${value.bannerItems[index].targetPath}");
               Navigator.pushNamed(
                 ctx, 
                 value.bannerItems[index].targetPath,
                 arguments:{'title':value.bannerItems[index].title}
               );
             }
           )
         );
       }
    
     }
    
  4. banner_state.dart

     import 'package:five_demo/entities/banner_item.dart';
     import 'package:five_demo/entities/response_entity.dart';
     import 'package:flutter/material.dart';
    
     class BannerState with ChangeNotifier{
       List<BannerItem> bannerItems;
       bool isLoading;
    
       static final BannerState _instance = BannerState._internal();
       factory BannerState.getInstance() => _instance;
    
       BannerState._internal(){
         load();
       }
    
       @override
       dispose(){
         debugPrint("-- BannerState dispose --");
         bannerItems=null;
         super.dispose();
       }
    
         load() async {
    
         // HttpUtils.get(HttpConfig.HomeBannerRequestPath)
         // .then((response){
         //   if(response.statusCode==200){
         //     var data = ResponseEntity<List<Map<String,dynamic>>>.fromMap(response.data['data']);
         //     debugPrint("${data.toJson()}");
    
         //     bannerItems=data.result?.map((item) => BannerItem.fromMap(item));
         //     notifyListeners();
         //   }
         // });
    
         Map<String,dynamic> responseData={
           'success':false,
           'result':[
               {
               'id':'1',
               'imageUrl':'https://ss2.bdstatic.com/70cFvnSh_Q1YnxGkpoWK1HF6hhy/it/u=2386581431,1215568659&fm=26&gp=0.jpg',
               'title':'bannerPic1',
               'subtitle':'this is banner1 picture',
               'targetPath':'/detail/a',
               'seqNo':'1'
             },
             {
               'id':'2',
               'imageUrl':'https://ss0.bdstatic.com/70cFuHSh_Q1YnxGkpoWK1HF6hhy/it/u=35517157,3504012963&fm=26&gp=0.jpg',
               'title':'bannerPic2',
               'subtitle':'this is banner2 picture',
               'targetPath':'/detail/b',
               'seqNo':'2'
             },
             {
               'id':'3',
               'imageUrl':'https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=1123133791,1776909836&fm=26&gp=0.jpg',
               'title':'bannerPic3',
               'subtitle':'this is banner3 picture',
               'targetPath':'/detail/c',
               'seqNo':'3'
             },
           ]
         };
    
         isLoading=true;
         notifyListeners();
    
         await Future.delayed(Duration(seconds: 2));
    
         var data = ResponseEntity<List<Map>>.fromMap(responseData);
         bannerItems=data.result.map(
           (item){
             // debugPrint("$item");
             return BannerItem.fromMap(item);
           }
         ).toList();
    
         isLoading=false;
         notifyListeners();
       }
    
     }
    
  5. banner_item.dart

     class BannerItem{
       String id;
       String imageUrl;
       String title;
       String subtitle;
       String targetPath;
       Map<String,dynamic> args;
       int seqNo;
    
       BannerItem.fromMap(Map<String, dynamic> map){
         id=map['id'];
         imageUrl=map['imageUrl'];
         title=map['title'];
         subtitle=map['subtitle'];
         targetPath=map['targetPath'];
         args=map['args'];
         seqNo=int.parse(map['seqNo']??"0");
       }
     }
    
  6. http_utils.dart

     import 'package:dio/dio.dart';
     import 'package:dio_log/interceptor/dio_log_interceptor.dart';
    
     class HttpUtils {
       Dio dio;
       static final HttpUtils _instance = new HttpUtils._internal();
       factory HttpUtils() => _instance; // 工厂构造
    
       HttpUtils._internal() {
         BaseOptions options = new BaseOptions(
             contentType: 'application/json; charset=utf-8',
             headers: {'Authorization': null});
         dio = Dio(options);
    
         // Interceptor interceptor = InterceptorsWrapper(
         //   onRequest: (RequestOptions options) => options,
         //   onResponse: (Response response) => response,
         //   onError: (DioError error) => error,
         // );
         // _dio.interceptors.add(interceptor);
         dio.interceptors.add(DioLogInterceptor()); // showDebugBtn(context);
       }
    
       static String getPath(String path,Map<String,dynamic> args){
         // print("input path:$path");
         Pattern pattern = RegExp(r':([^/|?|:]+)|([*])');
         String result = path.replaceAllMapped(
           pattern, 
           (match){
             print("match group: ${match.group(0)}");
             print("match group: ${match.group(1)}");
             return "${args[match.group(1)]??match.group(0)}";
           }
         );
         // print(result);
         return result;
       }
    
       Future get(
         String path, {
         Map<String, dynamic> queryParameters,
         Options options,
         CancelToken cancelToken,
         ProgressCallback onReceiveProgress,
       }) async {
    
       return dio.get(path, 
           queryParameters: queryParameters,
           options:options,
           cancelToken:cancelToken,
           onReceiveProgress:onReceiveProgress
         );
       }
       // get dio => _dio;
     }
    

ListView 下滑加载更多

index_page -> home_page : View(activity_view),VM(activity_state),M(activity_item & link_item)

Activities

  1. index_page.dart

     class IndexPage extends StatefulWidget {
       final IndexState indexState = IndexState.getInstance();
       final BannerState bannerState = BannerState.getInstance();
       final ActivityState activityState = ActivityState.getInstance();
       // ...
     }
    
     class _IndexPageState extends State<IndexPage> {
         // ...
         @override
         Widget build(BuildContext context) {
             return MultiProvider(
               providers: [
                 ChangeNotifierProvider<IndexState>.value(value: widget.indexState),
                 ChangeNotifierProvider<BannerState>.value(value: widget.bannerState),
                 ChangeNotifierProvider<ActivityState>.value(value: widget.activityState),
                 // ...
               ],
               child: Scaffold(
                 body: _selectCurrentIndex(_bodyBuilder),
                 // ...
               ),
             );
         }
     }
    
  2. home_page.dart

     class HomePage extends StatefulWidget {
       final Map args;
       HomePage({Key key, this.args}) : super(key: key);
       @override
       _HomePageState createState() => _HomePageState();
     }
    
     class _HomePageState extends State<HomePage> {
       @override
       Widget build(BuildContext context) {
         return Scaffold(
           appBar: wds.buildAppBar(widget.args != null ? widget.args['title'] ?? '' : ''),
           // body: _buildBody(context),
           body: _buildWrapRefresh(context)
         );
       }
    
       _onRefresh(BuildContext context) {
         context.read<CheckInState>().doCheckIn();
         context.read<BannerState>().load();
         context.read<ActivityState>().reload();
         // ... 
       }
    
       _buildWrapRefresh(BuildContext ctx) {
         return RefreshIndicator(
             onRefresh: () async {
               debugPrint("+++ HomePage onRefresh! +++");
               _onRefresh(ctx);
             },
             child: _buildBody(ctx));
       }
    
       _buildBody(context) {
         return ListView(
           controller: Provider.of<ActivityState>(context).scrollController,
           children: [
             Consumer<CheckInState>(
               builder: (_, value, child) => CheckInView(checkInState: value),
             ),
             Consumer<BannerState>(builder: (_, value, child) => BannerView(bannerState: value,)),
             Consumer<ActivityState>(builder: (_, value, child) => ActivityView()),
             // ...
           ],
         );
       }
     }
    
  3. activity_view.dart

     import 'package:five_demo/pages/home/state/activity_state.dart';
     import 'package:flutter/material.dart';
     import 'package:provider/provider.dart';
    
     class ActivityTheme{
       // ...
     }
    
     class ActivityView extends StatelessWidget {
       ActivityView({Key key}) : super(key: key);
       @override
       Widget build(BuildContext context) {
         var value = Provider.of<ActivityState>(context);
         return _buildBody(context, value);
       }
    
       _buildBody(context,value){
         // if(value.isLoading){
         //   return buildMsg("Loading...");
         // }
         // if(value.activityItems==null || value.activityItems.length==0){
         //   return buildEmpty();
         // }
         return _buildContent(context,value);
       }
    
       _buildContent(context,value){
         return Container(
           margin: EdgeInsets.all(8),
           padding: EdgeInsets.all(8),
           // color: Colors.lightBlue[50],
           child: Column(
             children: [
               _buildHeader(),
               SizedBox(height: 10),
               _buildItems(value),
             ],
           )
         );
       }
    
       _buildHeader(){
         // ...
       }
    
       _buildItems(value){
         return ListView.builder(
           itemCount: value.activityItems.length+1,
           shrinkWrap: true,
           physics: new NeverScrollableScrollPhysics(),
           itemBuilder:  (ctx,index){
             if(index==value.activityItems.length){
               return _buildEndInfo(value);
             }
             return _buildActivity(value.activityItems[index]);
           }
         );
       }
    
       _buildActivity(item){
         // ...
       }
    
       _buildEndInfo(value){
         var msg="";
         if((value.activityItems==null || value.activityItems.length==0)){
           if(value.isLoading)
             msg="More Loading ... ";
           else
             msg="No Data!";
         }else{
           if(value.isLoading)
             msg="More Loading ... ";
           else{
             msg="End!";
           }
         }
         return Container(
           padding: EdgeInsets.all(10),
           alignment: Alignment.center,
           child: Text(msg,style: TextStyle(fontSize: 14,color: Colors.grey),)
         );
       } 
     }
    
  4. activity_state.dart

     import 'package:five_demo/entities/activity_item.dart';
     import 'package:five_demo/entities/response_entity.dart';
     import 'package:flutter/material.dart';
    
     class ActivityState with ChangeNotifier{
       List<ActivityItem> activityItems;
       bool isLoading;
       int start=0;
       int limit=10;
    
       ScrollController scrollController;
    
       static final ActivityState _instance = ActivityState._internal();
       factory ActivityState.getInstance() => _instance;
    
       ActivityState._internal(){
         scrollController=new ScrollController();
         scrollController.addListener(() {
           // debugPrint("_scrollController:${_scrollController.position.pixels}");
           if(scrollController.position.pixels==scrollController.position.maxScrollExtent){
             debugPrint("-- ActivityState ScrollController Trigger load --");
             load();
           }
         });
         activityItems=[];
    
         load();
       }
    
       @override
       dispose(){
         debugPrint("-- ActivityState dispose --");
         activityItems=null;
         super.dispose();
       }
    
       reload(){
         start=0;
         limit=10;
         activityItems.clear();
         load();
       }
    
       load() async {
         /**
           String id;
           String avatar;
           String postUser;
           String postTag;
           String postTime;
           String postFrom;
           String postContent;
           String postLink;
           int likes;
           int comments;
           String targetPath;
          */
         isLoading=true;
         notifyListeners();
    
          Map<String,dynamic> responseData={
           'success':false,
           'result':[
             {
               'id':'1',
               'avatar':'https://ss2.bdstatic.com/70cFvnSh_Q1YnxGkpoWK1HF6hhy/it/u=2102119556,3949762144&fm=26&gp=0.jpg',
               'postUser':'小甜甜',
               'postTag':'官方',
               'postFrom':'Hey!Dear',
               'postTime':'2020-12-17',
               'postContent':'喜洋洋与灰太狼的日常更新啦',
               'postLink':{
                 'cover':'https://ss0.bdstatic.com/70cFvHSh_Q1YnxGkpoWK1HF6hhy/it/u=1668316143,3125260297&fm=26&gp=0.jpg',
                 'title':'我在这里,我在这里',
                 'subtitle':'快来呀快来呀',
                 'targetPath':'/detail/link/1',
                 'linkType':'html'
               },
               'targetPath':'/detail/activity/1',
               'likes':93840,
               'comments':3
             },
            {
               'id':'2',
               'avatar':'https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=3224388312,1750026865&fm=26&gp=0.jpg',
               'postUser':'肉嘟嘟',
               'postTag':'官方',
               'postFrom':'Hey!Dear',
               'postTime':'2020-12-18',
               'postContent':'健身啦健身啦',
               'postLink':{
                 'cover':'https://ss2.bdstatic.com/70cFvnSh_Q1YnxGkpoWK1HF6hhy/it/u=1832065145,3723585217&fm=26&gp=0.jpg',
                 'title':'全民运动节,礼品等你拿',
                 'targetPath':'/detail/link/2',
                 'linkType':'html'
               },
               'targetPath':'/detail/activity/2',
               'likes':938,
               'comments':0
             },
             {
               'id':'3',
               'avatar':'https://ss1.bdstatic.com/70cFuXSh_Q1YnxGkpoWK1HF6hhy/it/u=1715123681,2767202506&fm=26&gp=0.jpg',
               'postUser':'酷哥',
               'postTag':'官方',
               'postFrom':'Hey!Dear',
               'postTime':'2020-12-18',
               'postContent':'Rap! Rap! 跟着我的步伐',
               'postLink':{
                 'cover':'https://ss0.bdstatic.com/70cFvHSh_Q1YnxGkpoWK1HF6hhy/it/u=1996869834,1902662910&fm=26&gp=0.jpg',
                 'title':'嘿,你敢来么',
                 'targetPath':'/detail/link/3',
                 'linkType':'html'
               },
               'targetPath':'/detail/activity/3',
               'likes':99,
               'comments':9
             },
           ],
         };
    
         isLoading=true;
         notifyListeners();
    
         await Future.delayed(Duration(seconds: 2));
    
         var data = ResponseEntity<List<Map>>.fromMap(responseData);
    
         activityItems.addAll(
           data.result.map(
             (item){
               // debugPrint("$item");
               return ActivityItem.fromMap(item);
             }
           ).toList()
         );
    
         start+=limit;
    
         isLoading=false;
         notifyListeners();
    
       }
     }
    
  5. activity_item.dart

     import 'package:five_demo/entities/link_item.dart';
    
     class ActivityItem{
       String id;
       String avatar;
       String postUser;
       String postTag;
       String postTime;
       String postFrom;
       String postContent;
       LinkItem postLink;
       int likes;
       int comments;
       String targetPath;
    
       ActivityItem.fromMap(Map map){
         id=map['id'];
         avatar=map['avatar'];
         postUser=map['postUser'];
         postTag=map['postTag'];
         postFrom=map['postFrom'];
         postTime=map['postTime'];
         postContent=map['postContent'];
         postLink=map['postLink']!=null?LinkItem.fromMap(map['postLink']):null;
         likes=map['likes'];
         comments=map['comments'];
         targetPath=map['targetPath'];
       }
    
       Map toJson()=>{
         'id':id,
         'avatar':avatar,
         'postUser':postUser,
         'postTag':postTag,
         'postFrom':postFrom,
         'postTime':postTime,
         'postContent':postContent,
         'postLink':postLink,
         'likes':likes,
         'comments':comments,
         'targetPath':targetPath
       };
    
       @override
       String toString() {
         return toJson().toString();
       }
     }
    
  6. link_item.dart

     class LinkItem{
       String cover;
       String title;
       String subtitle;
       String targetPath;
       String linkType;
    
       LinkItem.fromMap(Map map){
         cover=map['cover'];
         title=map['title'];
         subtitle=map['subtitle'];
         targetPath=map['targetPath'];
         linkType=map['linkType'];
       }
     }
    

Provider

do_page

  • View: doModuleView
    • VM: doModuleState ( Module: List doModules)
    • VM: doInfoState ( Module: Map doInfoMap)
  • View: doRecomendView

Do Page

  1. do_page.dart

     class DoPage extends StatelessWidget {
    
       final Map args;
       DoPage({Key key,this.args}) : super(key: key);
    
       @override
       Widget build(BuildContext context) {
         return MultiProvider(
           providers: [
             ChangeNotifierProvider<DoModuleState>(create: (_)=> DoModuleState.getInstance()),
             ChangeNotifierProvider<DoInfoState>(create:(_)=>DoInfoState.getInstance()),
             ChangeNotifierProvider<DoRecommendState>(create:(_)=>DoRecommendState.getInstance()),
           ],
           builder: (_,child){
             return RefreshIndicator(
               onRefresh: () async {
                 debugPrint("+++ DoPage onRefresh! +++");
                 _onRefresh(_);
               },
               child: child
             );
           },
          child: Scaffold(
           //  appBar: buildAppBar("Do"),
            appBar: _buildDoBar(),
            body: ListView(
              children: [
                 Consumer<DoModuleState>(builder: (_,value,child)=>DoModuleView(),),
                 Consumer<DoRecommendState>(builder: (_,value,child)=>DoRecommendView(),),
              ],
            ),
          ),
         );
       }
    
       _onRefresh(BuildContext context) {
         Provider.of<DoModuleState>(context,listen: false).load();
         Provider.of<DoInfoState>(context,listen: false).load();
         Provider.of<DoRecommendState>(context,listen: false).load();
       }
    
       _buildDoBar(){
         // ...
       }
     }
    
  2. do_module_view.dart

     class DoModuleView extends StatelessWidget {
       DoModuleView({Key key}) : super(key: key);
       @override
       Widget build(BuildContext context) {
         var value = Provider.of<DoModuleState>(context);
         return _buildBody(context, value);
       }
    
       _buildBody(BuildContext context,DoModuleState value){
         if(value.isLoading){
           return buildMsg("Loading...");
         }
         if(value.doModules==null || value.doModules.length==0){
           return buildEmpty();
         }
         return _buildContent(context,value);
       }
    
       _buildContent(BuildContext context,DoModuleState value){
         return Container(
           child: Column(
             crossAxisAlignment: CrossAxisAlignment.start,
             children: value.doModules.map<Widget>(
               (e) =>_buildModule(context,e)
             ).toList(),
           ),
         );
       }
    
       _buildModule(BuildContext context,DoModule module){
         return Container(
           child: Column(
             crossAxisAlignment: CrossAxisAlignment.start,
             children: [
               _buildModuleHeader(context,module),
               _buildModuleProjects(context,module.children)
             ],
           ),
         );
       }
    
       _buildModuleHeader(BuildContext context,DoModule module){
         // ...
       }
    
       _buildModuleProjects(BuildContext context,List<DoProject> items){
         return buildPanel(
           Column(
             crossAxisAlignment: CrossAxisAlignment.start,
             children: ListTile.divideTiles(
               context: context,
               tiles: items.map<Widget>((e){
                 return Column(
                   children: [
                     _buildProjectContent(e),
                     _buildProjectItems(context,e.children),
                   ],
                 );
               })
             ).toList(),
           )
         );
       }
    
       _buildProjectContent(DoProject project){
         return Selector<DoInfoState,DoInfoItem>(
           selector: (_,value){
             if(value.doInfoMap!=null)
               return value.doInfoMap[project.id];
             return null;
           },
           builder: (context, info, child) {
             // debugPrint("build project ${project.id} cause info change");
             return ListTile(
               dense: true,
               contentPadding: EdgeInsets.all(0),
               leading: _buildProjectLeading(project,info),
               title: Text(project.title,style: project.level==2?DoModuleViewTheme.level2Title:DoModuleViewTheme.level1Title),
               subtitle: _buildProjectSubtitle(info),
               trailing: _buildProjectTrailing(project,info),
               onTap: (){
                 debugPrint("do Project onTap: ${project.id} ${project.title}");
                 Navigator.pushNamed(
                   context,
                   GlobalRoutes.Route_Detail,arguments: {'item':project,'info':info,'targetPath':project.targetPath,'targetType':project.targetType} 
                 );
                 context.read<DoInfoState>()?.reset(project.id);
               },
             );
           },
         );
       }
    
       _buildProjectItems(BuildContext context,List<DoProject> items){
         if(items==null || items.length==0)
           return Container();
         return Container(
           child: Column(
             crossAxisAlignment: CrossAxisAlignment.start,
             mainAxisAlignment: MainAxisAlignment.start,
             children:  items.map<Widget>((e){
                 return Container(
                   child: _buildProjectContent(e)
                 );
               }).toList(),
           ),
         );
       }
    
     }
    

Tab

  • TabController & TabBar(Tab) & TabBarView
  • AppBar (PreferredSize)
  • PopupMenuButton

Channel FM Index

channel_fm_index.dart:

class ChannelFmIndex extends StatefulWidget {
  ChannelFmIndex({Key key}) : super(key: key);
  @override
  _ChannelFmIndexState createState() => _ChannelFmIndexState();
}

class _ChannelFmIndexState extends State<ChannelFmIndex>  with SingleTickerProviderStateMixin {

  TabController _tabController;

  @override
  void initState() { 
    super.initState();
    _tabController = TabController(length: 3, vsync: this);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: _buildHeader(),
      body: _buildTabView(context),
      floatingActionButton: _buildPopupMenu(),
    );
  }

  _buildPopupMenu(){
    return PopupMenuButton(
      offset: Offset(0,-110),
      icon: Icon(Icons.menu,color: Colors.black,),
      itemBuilder: (BuildContext context) => <PopupMenuEntry>[
          const PopupMenuItem(
            child: FlatButton(onPressed: null, child: Text("@ 客服"))
          ),
          PopupMenuItem(
            child: FlatButton.icon(
              onPressed: null, 
              icon: Icon(Icons.edit,size: 16,), 
              label: Text("投稿")
            )
          ),
        ]
    );
  }

  _buildHeader(){
    return AppBar(
      elevation: 0,
      automaticallyImplyLeading: false,
      titleSpacing: 0,
      toolbarHeight: 160,
      title: _buildHeadCard(),
      bottom: PreferredSize(
        preferredSize: Size.fromHeight(50),
        child: _buildHeadTabBar(),
      ),
    );
  }

  _buildHeadCard(){
    return Container(
      child: Row(
        children: [
          // ...
        ],
      ),
    );
  }

 /* ------------------------------------ */

  _buildHeadTabBar(){
    return Container(
      decoration: BoxDecoration(
        color: Colors.white,
        border: Border.symmetric(
          horizontal: BorderSide(color: Colors.grey[50],width: 8)
        )
      ),
      child: TabBar(
        controller: _tabController,
        indicatorColor: Colors.black87,
        indicatorSize: TabBarIndicatorSize.label,
        indicatorWeight: 1.0,
        tabs: [
          Tab(child: Text("最新"),),
          Tab(child: Text("动态"),),
          Tab(child: Text("简介"),),
        ],
      ),
    );
  }

  _buildTabView(BuildContext context){
    return TabBarView(
      controller: _tabController,
      children: <Widget>[
        _buildRefreshIndicator("Latest",_buildTabOfLatest(context)),
        _buildRefreshIndicator("Activity",_buildTabOfActivity(context)),
        _buildRefreshIndicator("Intro",_buildTabOfIntro()),
      ],
    );
  }

  /* ------------------------------------ */

  _buildRefreshIndicator(String name,Widget child){
    return RefreshIndicator(
      onRefresh: () async {
        debugPrint("+++ $name onRefresh! +++");
      },
      child: child
    );
  }

  _buildTabOfLatest(BuildContext context){
    // ...
  }

  _buildTabOfActivity(context){
    // ...
  }

  _buildTabOfIntro(){
   // ... 
  }

}

Radio Play

https://pub.dev/packages/audioplayers https://dengxiaolong.com/article/2019/07/how-to-play-audioplaxyers-in-flutter.html https://www.jianshu.com/p/288f869690f0 音效库 https://www.tukuppt.com/yinxiao/m101/zonghe_0_0_0_0_0_0_3.html

Channel FM Media

pubspec.yaml

dependencies:
  # 音频播放
  audioplayers: ^0.17.2

channel_media_index.dart:

class ChannelMediaIndex extends StatefulWidget {
  ChannelMediaIndex({Key key}) : super(key: key);
  @override
  _ChannelMediaIndexState createState() => _ChannelMediaIndexState();
}

class _ChannelMediaIndexState extends State<ChannelMediaIndex> with SingleTickerProviderStateMixin {
  TabController _tabController;
  AudioPlayer _audioPlayer;
  var _playingItem={};
  Duration maxDuration;
  Duration currentDuration;
  AudioPlayerState playerState;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 3, vsync: this);
    _initAudioPlayer();
  }

  _initAudioPlayer() {
    _audioPlayer = new AudioPlayer();

    _audioPlayer.onDurationChanged.listen((Duration d) {
      debugPrint('Max duration: $d');
      if(mounted)
        setState(() => maxDuration = d);
    });

    // 位置事件: 此事件更新音频的当前播放位置
    _audioPlayer.onAudioPositionChanged.listen((Duration p) {
      // debugPrint('Current position: ${p.inMinutes}:${p.inSeconds}');
      if(mounted)
        setState(() => currentDuration = p);
    });

    // 状态事件: 此事件返回当前播放器状态。你可以用它来显示播放器是否在播放,或停止,或暂停
    _audioPlayer.onPlayerStateChanged.listen((AudioPlayerState s) {
      debugPrint('Current player state: $s');
      if(mounted)
        setState(() => playerState = s);
    });

    // 完成状态: 当音频结束播放时调用此事件(当使用pause或者stop方法中断播放时不会产生该事件)
    _audioPlayer.onPlayerCompletion.listen((event) {
      debugPrint('Current player completed');
      // setState(() { position = duration;});
    });

    // 错误事件: 当在本地代码中抛出意外错误时
    _audioPlayer.onPlayerError.listen((msg) {
      print('audioPlayer error : $msg');
      // setState(() {
      //   playerState = PlayerState.stopped;
      //   duration = Duration(seconds: 0);
      //   position = Duration(seconds: 0);
      // });
    });
  }

  play(String resource, {bool isLocal = false}) async {
    int result = await _audioPlayer.play(resource, isLocal: isLocal);
    if (result == 1) {
      debugPrint('play success');
    } else {
      debugPrint('play failed');
    }
  }

  stop() async {
    int result = await _audioPlayer.stop();
    if (result == 1) {
      debugPrint('stop success');
    } else {
      debugPrint('stop failed');
    }
  }

  pause() async {
    int result = await _audioPlayer.pause();
    if (result == 1) {
      // success
      debugPrint('pause success');
    } else {
      debugPrint('pause failed');
    }
  }

  resume() async {
    int result = await _audioPlayer.resume();
    if (result == 1) {
      debugPrint('resume success');
    } else {
      debugPrint('resume failed');
    }
  }

  seek(startMilliseconds) async {
    int result =
        await _audioPlayer.seek(new Duration(milliseconds: startMilliseconds));
    if (result == 1) {
      debugPrint('seek: go to $startMilliseconds success');
      // await audioPlayer.resume();
    } else {
      debugPrint('seek: go to $startMilliseconds failed');
    }
  }

  @override
  void deactivate() async {
    debugPrint('channel_media_index deactivate!');
    int result = await _audioPlayer.release();
    if (result == 1) {
      debugPrint('audioPlayer release success');
    } else {
      debugPrint('audioPlayerrelease failed');
    }
    super.deactivate();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: _buildAppBar("FM:风姿物语 - 2020.08"),
      body: _buidBody(),
    );
  }

  _buidBody() {
    return NestedScrollView(
      headerSliverBuilder: (_,innerBoxIsScrolled){
        return [
          SliverToBoxAdapter(child: _buildPlayPanel(),),
          SliverAppBar(
            automaticallyImplyLeading: false,
            elevation: 0,
            floating: true,
            pinned: true,
            title: _buildTabBar()
          ),
        ];
      }, 
      body: _buildTabView()
    );
  }

  _buildPlayPanel() {
    return Container(
      height: 330,
      decoration: BoxDecoration(
        gradient: RadialGradient(
          radius: 1,
          colors: [Colors.brown,Colors.brown[300],Colors.brown[100]]
        ),
      ),
      child: Column(
        children: [
          SizedBox(height: 20,),
          Expanded(child:_buildPlayCovers()),
          SizedBox(height: 10,),
          Text(_playingItem['title']!=null?_playingItem['title']:"",style: TextStyle(color: Colors.white.withOpacity(0.7),fontWeight: FontWeight.bold,fontSize: 16),),
          SizedBox(height: 10,),
          _buildPlayCtls()
        ],
      ),
    );
  }

  _buildPlayCovers(){
    var covers=[
      "https://ss1.bdstatic.com/70cFuXSh_Q1YnxGkpoWK1HF6hhy/it/u=2826031711,1618078016&fm=26&gp=0.jpg",
      "https://ss1.bdstatic.com/70cFvXSh_Q1YnxGkpoWK1HF6hhy/it/u=1822980246,1716056569&fm=26&gp=0.jpg",
      "https://ss1.bdstatic.com/70cFuXSh_Q1YnxGkpoWK1HF6hhy/it/u=3155864268,1264666725&fm=26&gp=0.jpg",
      "https://ss1.bdstatic.com/70cFvXSh_Q1YnxGkpoWK1HF6hhy/it/u=1899644059,569818638&fm=26&gp=0.jpg"
    ];
    return Swiper(
      itemCount: covers.length,
      itemBuilder: (_,index){
        return Container(
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(8),
            image: DecorationImage(
              image: NetworkImage(covers[index]),
              fit: BoxFit.fill,
            ),
            // color: Colors.grey,
          ),
        );
      },
      autoplay: false,
      viewportFraction: 0.8,
      scale: 0.9,
    );
  }

  _buildPlayCtls(){
    return Container(
      child: Column(children: [
        Container(
          padding: EdgeInsets.symmetric(horizontal: 10),
          child: LinearProgressIndicator(
            value: (maxDuration!=null && currentDuration!=null)? currentDuration.inSeconds/maxDuration.inSeconds : 0,
            backgroundColor: Colors.white.withOpacity(0.2),
            valueColor: AlwaysStoppedAnimation<Color>(Colors.white.withOpacity(0.7)),
            minHeight:2,
          ),
        ),
        Container(
          padding: EdgeInsets.only(left: 10,right: 10,top: 5),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Text(currentDuration!=null?"${currentDuration.inMinutes}:${currentDuration.inSeconds.remainder(60)}":"",style: TextStyle(fontSize: 12,color: Colors.white.withOpacity(0.5)),),
              Text(maxDuration!=null?"${maxDuration.inMinutes}:${maxDuration.inSeconds.remainder(60)}":"",style: TextStyle(fontSize: 12,color: Colors.white.withOpacity(0.5)),),
            ],
          ),
        ),
        Container(
          // color: Colors.grey,
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              InkWell(
                child:Icon(Icons.fast_rewind,color: Colors.white.withOpacity(0.5)),
                onTap: (){},
              ),
              InkWell(
                child: Icon(AudioPlayerState.PLAYING==playerState?Icons.play_arrow:Icons.pause,size: 34,color: Colors.white),
                onTap: (){
                  if(AudioPlayerState.PLAYING==playerState)
                    pause();
                  else if(_playingItem!=null && _playingItem['resource']!=null)
                    play(_playingItem['resource']);
                },
              ),
              Icon(Icons.fast_forward,color: Colors.white.withOpacity(0.5)),
            ],
          ),
        ),
        SizedBox(height: 8,)
      ],)
    );
  }

  /* ----------------- */

  _buildTabBar() {
    return Container(
      decoration: BoxDecoration(
          color: Colors.white,
          border: Border.symmetric(
              horizontal: BorderSide(color: Colors.grey[50], width: 8))),
      child: TabBar(
        controller: _tabController,
        indicatorColor: Colors.black87,
        indicatorSize: TabBarIndicatorSize.label,
        indicatorWeight: 1.0,
        tabs: [
          Tab(
            child: Text("播放列表"),
          ), // 音频,点击 -> 当前页切换播放内容 Rebuild (同一路由)
          Tab(
            child: Text("花絮周边"),
          ), // 动态,图文/影片,评论留言 -> 底部弹框,关闭
          Tab(
            child: Text("简介"),
          )
        ],
      ),
    );
  }

  _buildTabView() {
    return TabBarView(
      controller: _tabController,
      children: <Widget>[
        _buildTab1(),
        _buildTab2(),
        _buildTab3(),
      ],
    );
  }

  /* --------------------- */

  getDataOfPlayList() {
    return [
      {
        'id': '01',
        'title': '风中有朵雨做的云',
        'duration': '2:30',
        'subtitle': '收录于专辑《听说》',
        'resource':'https://img.tukuppt.com/newpreview_music/08/98/97/5c88d1231eeb570304.mp3'
      },
      {
        'id': '02',
        'title': '雨一直下',
        'duration': '5:30',
        'subtitle': '收录于专辑《听说》',
        'resource': 'https://img.tukuppt.com/newpreview_music/09/00/75/5c894afe4de5f1618.mp3'
      },
      {
        'id': '03',
        'title': '恶作剧幽默搞怪',
        'duration': '02:03',
        'subtitle': '猜猜是什么话题',
        // 'resource':'https://img.tukuppt.com/newpreview_music/09/00/44/5c8926ae5fdb676071.mp3',
        'resource':'https://img.tukuppt.com/newpreview_music/09/00/75/5c894afdc4c1578647.mp3'
      },
      {'id': '04', 'title': '舒缓音乐', 'duration': '12:30','resource':'https://img.tukuppt.com/newpreview_music/09/00/76/5c894c2e6ad5854338.mp3'},
      {'id': '05', 'title': '海边悠扬的海浪拍打声和海鸥鸣叫声', 'duration': '3:30', 'subtitle': 'amazing!','resource':'https://img.tukuppt.com/newpreview_music/09/00/40/5c8921fb5b7a586734.mp3'},
    ];
  }

  _buildTab1() {
    return Container(
      child: ListView(children: [
        _buildPlayList(getDataOfPlayList()),
        // ...
      ])
    );
  }

  _buildPlayList(playList) {
    return Container(
      color: Colors.white,
      padding: EdgeInsets.all(10),
      child: Column(
        children: ListTile.divideTiles(
            context: context,
            tiles: playList.map<Widget>((e) {
              bool isActive = (_playingItem!=null && _playingItem['id']==e['id']);
              return InkWell(
                child: Container(
                  padding: EdgeInsets.all(8),
                  child: Row(
                    children: [
                      Icon(Icons.play_circle_outline,color: isActive?Colors.deepOrange:Colors.grey,),
                      SizedBox(width:10),
                      Expanded(
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text("${e['title']}"),
                            e['subtitle'] != null
                                ? Text(
                                    "${e['subtitle']}",
                                    style: TextStyle(
                                        color: Colors.grey, fontSize: 12),
                                  )
                                : SizedBox()
                          ],
                        ),
                      ),
                      Text(
                        "${e['duration']}",
                        style: TextStyle(color: Colors.grey, fontSize: 12),
                      )
                    ],
                  ),
                ),
                onTap: (){
                  if(e['resource']==null)
                    return;
                  if(mounted){
                    setState(() {
                      _playingItem=e;
                    });
                    play(e['resource']);
                  } 
                },
              );
            })).toList(),
      ),
    );
  }

}

setState导致的内存泄漏

Flutter中setState导致的内存泄漏——setState() called after dispose()

https://blog.csdn.net/qq_26287435/article/details/89674247 错误原因是异步消息未返回,所以在setState方法之前调用mouted属性进行判断即可。具体示例如下:

if(mounted){
    setState(() {
      _listData.addAll(list);
    }
}

Video Player

视频库 http://www.cg002.com/

Flutter 中使用 video_player 播放视频 http://www.ptbird.cn/flutter-video.html

Flutter视频播放、Flutter VideoPlayer 视频播放组件精要 https://blog.csdn.net/zl18603543572/article/details/111327310

pubspec.yaml

dependencies:
  # 视频播放
  video_player: ^1.0.1
  1. video_play_widget.dart:

     import 'package:flutter/material.dart';
     import 'package:video_player/video_player.dart';
    
     class VideoPlayWidget extends StatefulWidget {
    
       final String url;
       VideoPlayWidget({Key key,this.url}) : super(key: key);
       @override
       _VideoPlayWidgetState createState() => _VideoPlayWidgetState();
    
     }
    
     class _VideoPlayWidgetState extends State<VideoPlayWidget> {
    
       VideoPlayerController  _playerController ;
       @override
       void initState() {
         super.initState();
         _playerController = VideoPlayerController.network(widget.url);
         _playerController.initialize()
          //异步执行完的回调
          ..whenComplete(() {
            //刷新页面
            setState(() {});
          });
       }
    
       @override
       void dispose() {
         _playerController.dispose();
         super.dispose();
       }
    
       @override
       Widget build(BuildContext context) {
         return GestureDetector(
           onTap: () {
             debugPrint("pop");
             Navigator.pop(context);
           },
           child: Container(
             color: Colors.black,
             alignment: Alignment.center,
             child: _buildPlayer(),
           ),
         );
       }
    
       _buildPlayer(){
         return AspectRatio(
           //设置视频的大小 宽高比。长宽比表示为宽高比。例如,16:9宽高比的值为16.0/9.0
           aspectRatio: _playerController.value.aspectRatio,
           //播放视频的组件
           child: VideoPlayer(_playerController),
         );
       }
     }
    
  2. 调用:

     Navigator.push(context,
       PopupRouteWidget(child: VideoPlayWidget(
         url: entry.value['link']
       ))
     );
    

Photo Preview

Channel Media Photo Channel Media Photo

pubspec.yaml

dependencies:
  # 弹出放大缩小图片
  photo_view: ^0.10.3

photo_preview_widget.dart:

import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';

class PhotoPreviewWidget extends StatefulWidget {

  final int initialIndex;
  final List photoList;
  final PageController pageController;

  PhotoPreviewWidget({Key key,this.initialIndex,this.photoList}) : pageController = PageController(initialPage: initialIndex),super(key: key);

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

class _PhotoPreviewWidgetState extends State<PhotoPreviewWidget> {

  int _currentIndex;

  @override
  void initState() {
    _currentIndex = widget.initialIndex;
    super.initState();
  }

  void onPageChanged(int index) {
    setState(() {
      _currentIndex = index;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.black,
      child: Stack(
        children: [
          GestureDetector(
            onTap: () => Navigator.pop(context),
            child: _buildPhotoPreview(),
          ),
          Align(
            alignment: Alignment.bottomCenter,
            child: _buildPagination(),
          )
        ],
      ),
    );
  }

  _buildPhotoPreview(){
    return Container(
      child: PhotoViewGallery.builder(
        scrollPhysics: const BouncingScrollPhysics(),
        onPageChanged: onPageChanged,
        itemCount: widget.photoList.length,
        pageController: widget.pageController,
        loadingBuilder: (context, event) => Center(
          child: Container(
            width: 20.0,
            height: 20.0,
            child: CircularProgressIndicator(
              value: event == null
                  ? 0
                  : event.cumulativeBytesLoaded / event.expectedTotalBytes,
            ),
          ),
        ),
        builder: (BuildContext context, int index) {
          return PhotoViewGalleryPageOptions(
            imageProvider: NetworkImage(widget.photoList[index]['cover']),
            minScale: PhotoViewComputedScale.contained * 0.6,
            maxScale: PhotoViewComputedScale.covered * 1.1,
            initialScale: PhotoViewComputedScale.contained,
          );
        },
      ),
    );
  }

  _buildPagination(){
    return Container(
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: List.generate(
          widget.photoList.length,
          (i) => Container(
            margin: EdgeInsets.all(5),
            child: CircleAvatar(
              radius: 4,
              backgroundColor: _currentIndex == i ? Colors.white.withOpacity(0.9) : Colors.white.withOpacity(0.5),
            ),
          ),
        ).toList(),
      ),
    );
  }

}

底部评论

Channel Media Comment Channel Media Comment

  1. 点击触发展示底部评论框 (channel_media_index.dart / project_activity_view.dart)

     FlatButton.icon(
       icon:Icon(Icons.comment,size:16,color:Colors.grey),
       label: Text("评论",style:TextStyle(fontSize:12,color:Colors.grey)),
       onPressed: (){
         Navigator.push(context,
           PopupRouteWidget(child: InputBottomWidget(
             onEditingCompleteText: (text){
                 print('点击发送 ---$text');
             },
           ))
         );
       }),
    
     FlatButton(
       onPressed: (){
         Navigator.push(context,
             PopupRouteWidget(child: InputBottomWidget(
               draftText: activity['draftText'],
               onEditingCompleteText: (text){
                   print('点击发送 ---$text');
               },
             ))
         ).then((value){
           print("input draft: $value --> cache local");
           activity['draftText']=value;
         });
       },
       child: Text("评论")
     );
    
     FlatButton(
       onPressed: (){
         Navigator.push(context,
             PopupRouteWidget(child: InputBottomWidget(
                   hintText: "回复 ${reply['from']}",
                   onEditingCompleteText: (text){print('点击发送 ---$text');},
             ))
         );
       },
       child: Text("回复")
     );
    
  2. 底部评论框 input_bottom_widget.dart (点击其他处/输入框回车发送 => 关闭):

     import 'package:flutter/material.dart';
    
     class InputBottomWidget extends StatelessWidget {
    
       final String hintText;
       final String draftText;
       final ValueChanged onEditingCompleteText;   // typedef ValueChanged<T> = void Function(T value);
    
       final TextEditingController controller = TextEditingController();
    
       InputBottomWidget({
         Key key,
         this.onEditingCompleteText,
         this.hintText="评论",
         this.draftText
       }) : super(key: key) {
         controller.text=draftText;
       }
    
       @override
       Widget build(BuildContext context) {
         return Scaffold(
           backgroundColor: Colors.transparent,
           body: Column(
             children: <Widget>[
               Expanded(  // 蒙板
                   child: GestureDetector(
                     child: Container(
                       color: Colors.transparent,  
                     ),
                     onTap: () {
                       Navigator.pop(context,controller.text);   // 关闭此页
                     },
                   )
               ),
               Container(
                   color: Colors.grey[100],
                   padding: EdgeInsets.symmetric(vertical: 8,horizontal: 16),
                   child:  _buildInputTextField(context)
               )
             ],
           ),
         );
       }
    
       _buildInputTextField(context){
         return Container(
           decoration: BoxDecoration(
               color: Colors.white
           ),
           child: TextField(
             decoration: InputDecoration(
               hintText: hintText,
               isDense: true,
               contentPadding: EdgeInsets.only(left: 10,top: 5,bottom: 5,right: 10),
               border: const OutlineInputBorder(
                 gapPadding: 0,
                 borderSide: BorderSide(width: 0,style: BorderStyle.none,),
               ),
             ),
             controller: controller,
             autofocus: true,
             style: TextStyle(fontSize: 16),
             //设置键盘按钮为发送
             textInputAction: TextInputAction.send,
             keyboardType: TextInputType.multiline,
             minLines: 1,
             maxLines: 5,
             onEditingComplete: (){
               //点击发送调用
               print('onEditingComplete');
               onEditingCompleteText(controller.text);
               Navigator.pop(context);
             },
           ),
         );
       }
     }
    
  3. 弹出消失动画 popup_route_widget.dart:

     import 'package:flutter/material.dart';
    
     class PopupRouteWidget extends PopupRoute{
       final Duration _duration = Duration(milliseconds: 300);
       Widget child;
    
       PopupRouteWidget({@required this.child});
    
       @override
       Color get barrierColor => null;
    
       @override
       bool get barrierDismissible => true;
    
       @override
       String get barrierLabel => null;
    
       @override
       Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
         // return child;
         return FadeTransition(
           opacity: animation,
           child: child,
         );
       }
    
       @override
       Duration get transitionDuration => _duration;
     }
    

瀑布流

Channel Daily Comment

pubspec.yaml

dependencies:
  # 瀑布流
  flutter_staggered_grid_view: ^0.3.2

channel_daily_index.dart:

_buildNoteItems(){

    var noteList=[
      {'id':'1','cover':'https://ss2.bdstatic.com/70cFvnSh_Q1YnxGkpoWK1HF6hhy/it/u=2248624002,2108517552&fm=26&gp=0.jpg','title':'采茶之旅','postBy':'砍柴人','avatar':'https://ss0.bdstatic.com/70cFvHSh_Q1YnxGkpoWK1HF6hhy/it/u=3397053345,592981929&fm=26&gp=0.jpg','likes':5},
      {'id':'2','cover':'https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=3008435233,972423020&fm=26&gp=0.jpg','title':'断桥边','postBy':'砍柴人','avatar':'https://ss0.bdstatic.com/70cFvHSh_Q1YnxGkpoWK1HF6hhy/it/u=3397053345,592981929&fm=26&gp=0.jpg','likes':0},
      {'id':'3','cover':'https://ss1.bdstatic.com/70cFuXSh_Q1YnxGkpoWK1HF6hhy/it/u=2126854169,3585890470&fm=26&gp=0.jpg','title':'亲子厨房','postBy':'砍柴人','avatar':'https://ss0.bdstatic.com/70cFvHSh_Q1YnxGkpoWK1HF6hhy/it/u=3397053345,592981929&fm=26&gp=0.jpg','likes':15},
      {'id':'4','cover':'https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=2487663409,412721828&fm=26&gp=0.jpg','title':'墙壁大作战','postBy':'砍柴人','avatar':'https://ss0.bdstatic.com/70cFvHSh_Q1YnxGkpoWK1HF6hhy/it/u=3397053345,592981929&fm=26&gp=0.jpg','likes':35},
      {'id':'5','cover':'https://ss1.bdstatic.com/70cFuXSh_Q1YnxGkpoWK1HF6hhy/it/u=3647241271,3241631924&fm=26&gp=0.jpg','title':'灰色灰色','postBy':'砍柴人','avatar':'https://ss0.bdstatic.com/70cFvHSh_Q1YnxGkpoWK1HF6hhy/it/u=3397053345,592981929&fm=26&gp=0.jpg','likes':45},
    ];

    return Container(
      padding: EdgeInsets.symmetric(horizontal: 10),
      child: StaggeredGridView.countBuilder(
        shrinkWrap: true,
        physics: NeverScrollableScrollPhysics(),
        crossAxisCount: 4,
        itemCount: noteList.length,
        itemBuilder: (BuildContext context, int index) => _buildNoteContent(index, noteList[index]),
        staggeredTileBuilder: (int index) =>
            new StaggeredTile.count(2, index.isEven ? 2:2.5),
        mainAxisSpacing: 8,
        crossAxisSpacing: 8,
      ),
    );
  }

  _buildNoteContent(index,item){
    // return Container(
    //   color: Colors.white,
    //   child: Center(
    //     child: CircleAvatar(
    //       backgroundColor: Colors.grey[100],
    //       child: Text('$index'),
    //     ),
    //   )
    // );

    return Container(
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(5),
        color: Colors.white,
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Expanded(child: 
            ClipRRect(
              borderRadius: BorderRadius.circular(5),
              child: Image.network(item['cover'],fit: BoxFit.cover,)
            )
          ),
          SizedBox(height: 5,),
          Text(item['title'],style: TextStyle(fontWeight: FontWeight.w500),),
          SizedBox(height: 5,),
          Row(
            children: [
              CircleAvatar(radius: 10,backgroundImage: NetworkImage(item['avatar']),),
              SizedBox(width: 5,),
              Expanded(child:Text(item['postBy'])),
              Icon(Icons.favorite_outline,size: 15,color: Colors.grey,),
              SizedBox(width:5,),
              Text("${item['likes']}")
            ],
          ),
          SizedBox(height: 5,),
        ],
      ),
    );
  }

Chat 聊天框

project_chat_view.dart:

class ProjectChatView extends StatefulWidget {

  final FocusNode focusNode;

  ProjectChatView({Key key,this.focusNode}) : super(key: key);
  @override
  _ProjectChatViewState createState() => _ProjectChatViewState();
}

class _ProjectChatViewState extends State<ProjectChatView> with AutomaticKeepAliveClientMixin {

  @override
  bool get wantKeepAlive => true;

  ScrollController _scrollController = ScrollController() ;
  final TextEditingController _inputController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _buildChatList(getDataOfChatMsgs()),
      bottomNavigationBar:BottomAppBar(
        child:  _buildInputPanel(),
      )
    );
  }

  _buildInputPanel(){
    return Container(
      padding: EdgeInsets.all(8),
      decoration: BoxDecoration(
        color: Colors.grey[200],
      ),
      child: Row(children: [
        Expanded(child: _buildInputTextField()),
        SizedBox(width: 5,),
        Icon(Icons.insert_emoticon),
        SizedBox(width: 5,),
        InkWell(
          child:Icon(Icons.add_circle_outline),
          onTap: (){
            // _chatScrollController.jumpTo(_chatScrollController.position.maxScrollExtent);
            // Future.delayed(Duration(milliseconds: 100), (){
            //   _scrollController.jumpTo(_scrollController.position.maxScrollExtent);
            // });
          },
        ),
      ],)
      // child: TextField(),
    );
  }

  _buildInputTextField(){
    return TextField(
      decoration: InputDecoration(
        isDense: true,
        hintText: '@ 管理员',
        hintStyle: TextStyle(color: Colors.grey),
        border: InputBorder.none,
        filled: true,
        fillColor: Colors.white,
      ),
      keyboardType: TextInputType.multiline,
      textInputAction: TextInputAction.send,
      minLines: 1,
      maxLines: 5,
      focusNode: widget.focusNode,
      controller: _inputController,
      onSubmitted: (value) {
        debugPrint('submit: $value');
        _inputController.clear();
      },
      onTap: (){
        debugPrint("tap");
        Future.delayed(Duration(milliseconds: 100), (){
          _scrollController.jumpTo(_scrollController.position.maxScrollExtent);
          // _scrollController.animateTo(
          //   _scrollController.position.maxScrollExtent,
          //   duration: Duration(milliseconds: 500), curve: Curves.ease);
        });
      },
    );
  }

}

WebView

https://pub.flutter-io.cn/packages/webview_flutter/example

Flutter WebView与JS交互简易指南 https://blog.csdn.net/weixin_33739541/article/details/89571639

Flutter使用JsBridge方式处理Webview与H5通信的方法 https://www.yht7.com/news/60783

Flutter 采坑之旅--webview_flutter https://www.jianshu.com/p/611840cc2797

pubspec.yaml

dependencies:
  webview_flutter: ^1.0.7
@override
Widget build(BuildContext context) {
    return Scaffold(
      appBar: _buildAppBar(),
      floatingActionButtonLocation: FloatingActionButtonLocation.miniEndFloat,
      body: SafeArea(
        child: _buildWebview(),
      ),
    );
}

_buildWebview(){
    return Container(
      height: _webViewHeight,
      child: WebView(
        initialUrl: widget.initialUrl,
        javascriptMode: JavascriptMode.unrestricted,
        onWebViewCreated: (WebViewController webViewController) {
          _controller.complete(webViewController);
        },
        onPageStarted: (String url) {
          debugPrint('Page started loading: $url');
        },
        onPageFinished: (String url) {
          debugPrint('Page finished loading: $url');
          _getWebViewHeight();
          // setState(() {
          //   // _isPageFinished = true;
          // });
        },
        gestureNavigationEnabled: true,
        javascriptChannels: <JavascriptChannel>[
          _invokeJavascriptChannel(context),
        ].toSet(),
        //拦截页面url
        navigationDelegate: (NavigationRequest request) {
          if (request.url.startsWith('alipays:') ||
              request.url.startsWith('weixin:')) {
            // _openPay(context, request.url);
            return NavigationDecision.prevent;
          }
          return NavigationDecision.navigate;
        },
      ),
    );
}

// 注册js回调
JavascriptChannel _invokeJavascriptChannel(BuildContext context) {
    return JavascriptChannel(
      name: 'Invoke',
      onMessageReceived: (JavascriptMessage message) {
        debugPrint("${message.message}");
        var webHeight = double.parse(message.message);
        if (webHeight != null) {
          setState(() {
            _webViewHeight = webHeight;
          });
        }
      }
    );
}

// 获取页面高度
_getWebViewHeight() async {
    WebViewController webviewController = await _controller.future;
    final String temp = await webviewController.getTitle();
    debugPrint('title:' + temp);

    await webviewController.evaluateJavascript('Invoke.postMessage(document.documentElement.scrollHeight)');
}

调用 (channel_media_index.dart)

WebviewWidget(initialUrl: "https://juejin.cn/post/6844903908087693319",)

Html

html内容加载

https://pub.flutter-io.cn/packages/flutter_html https://cloud.tencent.com/developer/article/1502142 https://www.jianshu.com/p/8ea17b06e541

pubspec.yaml

dependencies:
  # html
  flutter_html: ^1.0.0
_buildRecommendContent(String targetPath){
    return Container(
      child: Html(
        data: '''
        <ul>
          <li>
            <div>
              <img src="https://ss1.bdstatic.com/70cFvXSh_Q1YnxGkpoWK1HF6hhy/it/u=30699094,3647522649&fm=26&gp=0.jpg"/>
            </div>
            <div style="color:green;border:5px solid red;">
              Content
            </div>
          </li>
        </ul>
        ''',
        style: {
          "img":Style(
            width: 100
          ),
          "div":Style(
            display: Display.INLINE
          )
        },
      ),
    );
  }

More Reference