Evil Mouth's Blog

Fun Flutter Widget

August 14, 2020

本文介绍日常开发中比较实用的 Flutter Widget

Placeholder 占位图

常用在异步加载请求时先使用 Placeholder 代替显示,请求结束后替换

const Placeholder({
  Key key,
  this.color = const Color(0xFF455A64), // BlueGrey 700
  this.strokeWidth = 2.0,
  this.fallbackWidth = 400.0,
  this.fallbackHeight = 400.0,
})

Form 表单

例如登陆页面,利用 Form Widget 将用户名和密码两个输入框包起来,通过 Key 进行统一校验,还自带返回按键监听

const Form({
  Key key,
   this.child,
  this.autovalidate = false, // 自动校验
  this.onWillPop, // 返回按键监听
  this.onChanged, // 表单内容变化
})

在 Form 里使用的输入框目前只提供 TextFormField 和 DropdownButtonFormField,两者都继承 FormField,可自行扩展

Form(
  key: _formKey, // 通过key拿到FormState
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: <Widget>[
      TextFormField(
        decoration: const InputDecoration(
          hintText: 'Enter your email',
        ),
        validator: (value) {
          if (value.isEmpty) {
            return 'Please enter your email';
          }
          return null; // 表示无错误
        },
        onSaved: (value) {
          _formEmail = value;
        },
      ),
      //TODO 密码输入框同理
      Padding(
        padding: const EdgeInsets.symmetric(vertical: 16.0),
        child: RaisedButton(
          onPressed: () {
            // Validate will return true if the form ivalid, or false if
            // the form is invalid.
            if (_formKey.currentState.validate()) {
              // Process data.
              _formKey.currentState.save(); // 触发FormField#onSaved回调
            }
          },
          child: Text('Submit'),
        ),
      ),
    ],
  ),
)

FormField 为何要被包在 Form 里面

Form 是个 StatefuleWidget,state 是 FormState,FormState 提供了一些操作 FormField 的方法,比如 validate、save。如何获取呢,原因在 FormField 的 build()方法内向上获取到 Form,并将自己注册给 Form


Widget build(BuildContext context) {
  Form.of(context)?._register(this); // 将自己注册给Form
  return widget.builder(this);
}

Form.of 是什么,这就要讲到 Flutter 的 InheritedWidget,InheritedWidget 是一个方便子 Widget 获取父 Widget 提供的属性的 Widget。Theme.of、Local.of 都是基于 InheritedWidget

FutureBuilder 支持异步加载的 Widget

一般如何实现异步加载动态更新布局,自然是通过 setState 改变状态。FutureBuilder 就是这么一个封装的 Widget,应用在比较单一、一次性的场景

const FutureBuilder({
  Key key,
  this.future, // 异步任务
  this.initialData, // 初始数据,不是必要
   this.builder, // Widget构建
})
FutureBuilder<String>(
  future: Future.delayed(
      Duration(seconds: 2), () => 'this is data from net'),
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.done) {
      if (snapshot.hasError) {
        return Text("Error: ${snapshot.error}"); // 加载失败
      } else {
        return Text("Contents: ${snapshot.data}"); // 加载成功
      }
    } else {
      return CircularProgressIndicator(); // 加载中
    }
  },
)

builder 会回调多次,原因就是 snapshot 是一个状态,会改变多次

WillPopScope 监听 Android 返回按钮

Android 才需要的返回按键监听,例如应用在双击返回键退出应用等操作

上面介绍的 Form 也有一个 onWillPop 属性,其实就是包了一层 WillPopScope

const WillPopScope({
  Key key,
   this.child,
   this.onWillPop, // Future<bool> Function()
})

下面的例子通过 WillPopScope 包裹 AlertDialog 来实现一个无法通过返回按键取消的弹窗,只需要 onWillPop 返回 flase 即表示屏蔽返回

showDialog(
  context: context,
  barrierDismissible: false,
  builder: (context) {
    return WillPopScope(
      onWillPop: () async => false,
      child: AlertDialog(
        title: Text('弹窗标题'),
        content: Text('只能通过关闭按钮关闭'),
        actions: <Widget>[
          FlatButton(
            onPressed: () {
              Navigator.of(context).pop();
            },
            child: Text('关闭'),
          ),
        ],
      ),
    );
  },
)

Overlay 实现悬浮按钮

Flutter 的路由实际就是用 Overlay 实现

可以理解为 Flutter 的世界只有一个 Activity,不同的 Page 其实就是 Fragment 堆叠

所以在 Flutter 实现全局悬浮按钮简直不要太简单

Overlay 跟着 Navigator 一起被创建,所以通过直接 Overlay.of 取得 OverlayState

下面的例子展示如何悬浮一个全局按钮


void initState() {
  super.initState();
  Future.delayed(Duration(seconds: 0)).then((value) {
    Overlay.of(context).insert(floatingView); // 插入
  });
}

final floatingView = OverlayEntry(builder: (ctx) { // OverlayEntry
  return FloatingView();
});

// 悬浮按钮
class FloatingView extends StatelessWidget {
  
  Widget build(BuildContext context) {
    double size = 50;
    double left = MediaQuery.of(context).size.width / 4 - size / 2;
    double top = MediaQuery.of(context).size.height / 1.5 - size / 2;
    return ChangeNotifierProvider(
      create: (context) => _PositionProvider(size, left, top),
      child: _PositionObserver(
        child: _PositionConsumer(),
      ),
    );
  }
}

如果需要移除,floatingView.remove()

利用 insert 和 remove 即可实现 Flutter 层面的 Toast

AbsorbPointer 快速禁止屏幕点击

通过 AbsorbPointer 包裹住 RaisedButton,即使 RaisedButton 声明了点击事件,但是 AbsorbPointer 也声明了 absorbing: true,所有 RaisedButton 是点不了的

AbsorbPointer(
  absorbing: true, // 是否吸收触摸事件
  child: RaisedButton(onPressed: () {}),
)

Navigator 其实也包裹了一层 AbsorbPointer,在路由跳转过程,会吸收所有屏幕触摸事件,让跳转完成

Dismissible 列表 Item 滑动移除

用来实现滑动删除场景

ListView.builder(
  itemCount: _funItems.length,
  itemBuilder: (context, i) {
    return Dismissible(
      key: ValueKey(_funItems[i]),
      child: Container(
        color: Colors.green,
        child: ListTile(
          title: Text(_funItems[i]),
        ),
      ),
      background: Container( // 右滑背景
        color: Colors.blue,
      ),
      secondaryBackground: Container( // 左滑背景
        color: Colors.red,
      ),
      onDismissed: (decoration) {
        setState(() {
          _funItems.removeAt(i);
        });
      },
    );
  },
)

Hero 图片过渡效果

在 Android 21 以上,可以通过给 View 设置一个 flag 来让页面跳转时两个 View 直接有个过渡效果,通常用于浏览大图。在 Flutter 也是类似

const Hero({
  Key key,
   this.tag, // String 标记
  this.createRectTween, // 过渡动画,默认为MaterialRectArcTween
  this.flightShuttleBuilder, // 允许过渡中不使用child
  this.placeholderBuilder, // 占位
  this.transitionOnUserGestures = false, // iOS滑动返回时是否也过渡
   this.child,
})
Hero(
  tag: 'hero',
  child: Image.asset('images/ic_launcher.png'),
)

只需要将 Image 包裹在 Hero 中并提供一个 tag,在第二个页面同样标记,剩下的就交给 Flutter 帮我们实现过渡

FractionallySizedBox 屏幕百分比

通常用在某些 Widget 需要撑满屏幕

const FractionallySizedBox({
  Key key,
  this.alignment = Alignment.center,
  this.widthFactor, // 0-1 1表示撑满
  this.heightFactor,
  Widget child,
})

SizeBox 控制 Widget 大小

字面意思,向父 Widget 申请一定大小的空间

SizeBox(width: 10, height: 10, child: null)
  • 充当两个 Widget 直接的 Margin
  • 控制 Widget 大小

Transform 改变 Widget 形态

与 Android 的作用一致,可以对 Widget 进行形态上的改变,比如伪 3D 旋转

Transform(
  alignment: Alignment.topRight,
  transform: Matrix4.skewY(0.3)..rotateZ(-3.1415926 / 12.0),
  child: Container(
    padding: const EdgeInsets.all(8.0),
    color: const Color(0xFFE8581C),
    child: const Text('Apartment for rent!'),
  ),
)

IndexStack 快速切换 Widget

例如某个 Widget 拥有很多种状态,每个状态不同的 Icon,通过 IndexStack 先将所有 Icon 提供,通过改变 index 快速切换 Icon

IndexedStack(
  index: _indexStackIndex,
  children: <Widget>[
    Icon(Icons.star),
    Icon(Icons.account_box),
    Icon(Icons.cake),
  ],
)

IndexedStack 继承 Stack,并重写了其渲染部分,只渲染 index 命中的 Icon

Wrap 流式布局

Wrap 基本与 Row/Column 一致,不同的一点是当 children 超出范围的话,Wrap 会自动将超出的 chilren 换行

下面的例子中第三第四个 Chip 后被 layout 在第二行

Wrap(
  spacing: 8.0, // gap between adjacent chips
  runSpacing: 4.0, // gap between lines
  children: <Widget>[
    Chip(
      avatar: CircleAvatar(
          backgroundColor: Colors.blue.shade900, child: Text('AH')),
      label: Text('Hamilton'),
    ),
    Chip(
      avatar: CircleAvatar(
          backgroundColor: Colors.blue.shade900, child: Text('ML')),
      label: Text('Lafayette'),
    ),
    Chip(
      avatar: CircleAvatar(
          backgroundColor: Colors.blue.shade900, child: Text('HM')),
      label: Text('Mulligan'),
    ),
    Chip(
      avatar: CircleAvatar(
          backgroundColor: Colors.blue.shade900, child: Text('JL')),
      label: Text('Laurens'),
    ),
  ],
)

PageView Android 下的 ViewPager

估计是开发中最常用到的控件了,具体效果可以看 Demo

在 Flutter 中,通过 PageView+BottomNavigationBar 实现

body: PageView( // 类似ViewPager
  physics: NeverScrollableScrollPhysics(), // 禁止左右滑动
  controller: _controller,
  children: <Widget>[ // 类似Fragment
    _DemoHomePageView(),
    _DemoHomeMyPageView(),
  ],
  onPageChanged: (index) {
    setState(() {
      _selectedIndex = index; // 改变_selectedIndex是为了更新BottomNavigationBar的选中状态
    });
  },
)

bottomNavigationBar: BottomNavigationBar(
  items: const <BottomNavigationBarItem>[
    BottomNavigationBarItem(
      icon: Icon(Icons.home),
      title: Text('Home'),
    ),
    BottomNavigationBarItem(
      icon: Icon(Icons.person),
      title: Text('My'),
    ),
  ],
  currentIndex: _selectedIndex,
  selectedItemColor: Colors.amber[800],
  onTap: (index) {
    setState(() {
      _selectedIndex = index;
    });
    _controller.jumpToPage(index); // 类似ViewPager.setCurrentPosition
  },
)

PageView 是默认不保活的,可以将_DemoHomePageView 混入 AutomaticKeepAliveClientMixin 并保证 build 方法调用 super

混入 mixin 是什么,可以看 dart 语法,不同于 java 单继承,dart 的 mixin 可以扩展属性和方法

Texture 相机

像相机这种需要实时传输大量图像数据的,Flutter 提供了 Texture,通过映射共享数据的工具

const Texture({
  Key key,
   this.textureId, // 通过该id获得共享的图像数据
  this.filterQuality = FilterQuality.low,
})

下面是一个相机例子

class _TextureBodyState extends State<TextureBody> {
  List<CameraDescription> cameras; // 可用的相机集合
  CameraController cameraController; // 相机控制中枢

  bool get isInit => (cameraController?.value?.isInitialized == true);

  /// 生成一个文件路径
  Future<String> get tempFilePath async {
    final cacheDir = await getTemporaryDirectory();
    final dirPath = '${cacheDir.path}/Picture';
    await Directory(dirPath).create(recursive: true);
    return '$dirPath/${DateTime.now().millisecondsSinceEpoch.toString()}.jpg';
  }

  String latestPicturePath;

  
  void initState() {
    super.initState();
    _fetchCameras();
  }

  _fetchCameras() async {
    cameras = await availableCameras();
    if (cameras.isNotEmpty) {
      cameraController = CameraController(cameras[0], ResolutionPreset.medium);
      await cameraController.initialize();
      if (mounted) {
        setState(() {});
      }
    }
  }

  
  void dispose() {
    cameraController?.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    if (!isInit) {
      return Container(
        child: Center(
          child: CircularProgressIndicator(),
        ),
      );
    }
    return Container(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          AspectRatio(
            aspectRatio: cameraController.value.aspectRatio,
            child: CameraPreview(cameraController),
          ),
          Expanded(
            child: Row(
              children: [
                Expanded(
                  child: Center(
                    child: latestPicturePath != null
                        ? SizedBox(
                            width: 80,
                            height: 160,
                            child:
                                Image.file(File(latestPicturePath)).preview(),
                          )
                        : null,
                  ),
                ),
                Expanded(
                  child: Center(
                    child: IconButton(
                      icon: Icon(Icons.camera_alt),
                      onPressed: isInit
                          ? () async {
                              if (cameraController.value.isTakingPicture) {
                                return;
                              }
                              final filePath = await tempFilePath;
                              await cameraController.takePicture(filePath); // 拍照
                              if (mounted) {
                                setState(() {
                                  latestPicturePath = filePath;
                                });
                              }
                            }
                          : null,
                    ),
                  ),
                ),
                Spacer(),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

PlatformView WebView

像 WebView 这种极其复杂的原生控件,用 Flutter 重新写不太现实,所以 Flutter 也提供这样一种 Widget,将原生控件直接嵌入 Flutter 的 Widget 树。

官方已经提供 WebView 库帮我们代理了原生 WebView

webview_flutter: ^0.3.22+1
WebView(
    initialUrl: widget.url,
    javascriptMode: JavascriptMode.unrestricted, // 支持js
    onWebViewCreated: (webViewController) {
      _controller.complete(webViewController);
    },
    javascriptChannels: <JavascriptChannel>[
      _toasterJavascriptChannel(context), // js通信
    ].toSet(),
    onPageStarted: (String url) {
      print('Page started loading: $url');
    },
    onPageFinished: (String url) {
      print('Page finished loading: $url');
    },
  ),
)

— Evil Mouth