header-bg.jpg
Flutter 开发踩坑记录(持续更新)
发表于 2020-02-05 01:47
|
分类于 Dart
|
评论次数 2
|
阅读次数 8964

flutter.png

Flutter 太好学了!BUG 真的太少了! issues 只有 5000 多!也就那么亿点!简单得我都枯了!毕竟每次遇到问题,👴🏻 都是直接去找群里的法佬、低调、Alex 等几位大佬(🐶管理,此处小声哔哔)来解决,只要有大佬在,问题也就不大。虽然法佬经常说要学会看源码,但道理大家其实都懂,看源码也就图一乐,真正有 BUG 还是得找法佬。

不多哔哔,单写一篇文章,先记录它一手。本文记录 👴🏻 在 Flutter 开发中遇到的一些 BUG(as design),避免遗忘,如果正在看文章的你也遇到了,那咱们可以握个手。

容器宽高相关问题

Container 设置宽高不生效

一般是由于父级容器的 constraints 属性引起的,在 Flutter 中,子组件的大小会被父组件的 constraints 属性限制,例如

ConstrainedBox(
  constraints: BoxConstraints(
    minWidth: 100.0, // 最小宽度为 100 像素
    minHeight: 50.0 // 最小高度为 50 像素
  ),
  child: Container(
    height: 5.0,// 高度为 5 逻辑像素
    child: redBox 
  ),
)

上面的代码中,Container 组件设置高度为 5 像素,是无法生效的,因为父级容器已经设置了最小高度为 50 像素,所以 Container 组件的最终高度将会是 50 像素。

当然,这肯定不是我们想要的效果,我们就想让 Container 组件的最终高度是 5 像素怎么办?其实很简单,可以使用 UnconstraindBox 解除父级容器的 constraints 属性对子组件大小的限制。例如:

ConstrainedBox(
  constraints: BoxConstraints(
    minWidth: 100.0, // 最小宽度为 100 像素
    minHeight: 50.0 // 最小高度为 50 像素
  ),
  child: UnconstraintsBox(
    child: Container(
      height: 5.0, 
      child: redBox 
    ),
  ),
)

UnconstrainedBox 允许其子组件按照其自身的大小绘制,我们会很少直接使用此组件,除非对于 Material 自带的一些组件,如 Appbar 的 icon 使用了固定大小,利用该组件可以解除限制,一般情况下,我们在组件外面套一层布局类组件就可以解决需求,例如以下组件:

Row()
Column()
Align()
Center()
Flex()
Wrap()
Flow()
Stack()

SignleChildScrollView 不满一屏高度时无法撑满全屏

其实和上面这个问题是相似的,可以使用布局类组件解决,或者用如下方式:

Container(
  alignment: Alignment.topLeft,
  child: SingleChildScrollView(),
),

如果你看过 Container 的源码你会发现其实设置 alignment 属性,和用 Align 组件是一回事,源码也是使用 Align 组件,这就是个语法糖,仅此而已。

说到语法糖,其实 Center 组件也是 Align 组件的语法糖,当你不给 Align 传递任何参数时,使用 Center() 和使用 Align() 是一模一样的效果,我的习惯是不管什么情况,都是只用Align 组件。

Container 设置 borderRadius 不生效

设置 borderRadius 有两种做法,第一种使用 Container 等组件自带的 borderRadius 属性,第二种是,直接用 ClipRRect 等 clip 组件对容器进行裁剪,第二种比第一种更加暴力、消耗性能,但更有效。

例如给 TabView 的容器设置 borderRadius,你会发现无法生效,而使用 ClipRRect 则可以解决,我的理解是 ClipRRect 会直接裁剪成圆角形状,而 BorderRadius 的圆角外的弧形范围是透明的,类似 css 中的 display:noneopaticy:0 的区别,实际是什么情况,我也没有去细究,复制粘贴、能跑就行。

元素显示层级问题

可以认为 Flutter 中 widget 布局的层级关系是递进的,例如 child 的层级比父 Widget 层级更高, ColumnRow 等组件的 children 中同级 widget,谁在后面谁的层级就更高,和 Stackchildren 的层级关系相同。

显示隐藏的几种做法

第一种,利用 IndexedStack 组件控制层级,上面也提到过,子组件谁在后面谁的层级就高,Flutter 中虽然没有 z-index 这一说法,但其实原理和 css 的 z-index 是类似的,index 越大,层级越高,当然这里的 IndexedStackindex 属性是用来控制当前显示的某一个 children,只能显示一个。该方法常用于 APP 首页切换底部导航。

第二种,利用 IgnorePointerOpacity 组件组合隐藏 widget,可以使用 AnimationOpacity 组件达到以前 JQuery 中常用的 fadeIn 效果。

第三种,利用 PositionedTransform.translate 移动到屏幕外,需要显示时再移动回来,这种做法非常适合动画切换,例如视频进度条等效果。

第四种,利用 Offstage 组件,前三种都是利用视觉效果将元素隐藏起来,其实在布局上并未发生改变,而此组件就是类似于 css 中的 display:none,直接让元素在布局中隐藏,不会在布局上继续占用空间。

最后一种,在 build 方法中提前判断,不符合条件直接不渲染,或者返回空 box,这就类似于 HTML 中删除 dom 元素,我人没了,还显示个🔨,这是最恐怖的。

GestureDetector 设置 onTap 不生效

Listener 默认的 behaviorHitTestBehavior.deferToChild

如果 Listener 的子组件是一个 Container,这个 Container 不设置 decoration 的情况下,即透明背景色、无边框,则点击 Container 时,无法触发 down、up 等事件。

同理,GestureDetector 是对 Listener 的封装,无法触发 onTap 等事件也是必然的,那么解决办法也很简单,有以下两种解决办法:

1. 给 Container 设置 decoration
2. 将 behavior 属性设置为 opaque 或 translucent

调用 setState 或 markNeedsBuild 后报错

第一种报错

setState() or markNeedsBuild() called during build

遇到此提示,一般解决思路都是利用 addPostFrameCallback 来解决,例如:

WidgetsBinding.instance.addPostFrameCallback((_){
    _model.setOpacity(opacity);
});

第二种报错

setState() called after dispose()

一般定时器在 app 返回桌面后仍在调用 setState 或 页面 pop 销毁后异步任务才完成,此时调用了 setState 必然会出现该提示,那么解决办法也很简单,判断生命周期再执行重构逻辑。

if (!mounted) return;
setState(() {
  // do somthing
});

动态更改 TabBar 的长度后 setState 报错

其实这个问题肯定是由于使用了 SingleTickerProviderStateMixin 造成的,解决方案有两种。

第一种是使用 DefaultTabController 来解决,这个方案比较适合大佬造轮子,因为需要自己写 TabBar 的切换效果,非常之麻烦。

第二种方案就是我目前正在使用的,非常简单,只需要将 SingleTickerProviderStateMixin 替换为 TickerProviderStateMixin 即可,相关代码如下:

class EntryPage extends StatefulWidget {
  
  createState() => _EntryPageState();
}

class _EntryPageState extends State<EntryPage> with TickerProviderStateMixin {

  TabController tabController;

  final tabs = <DanceSort>[
    DanceSort.fromJson({"id": -1, "name": "推荐"}),
    DanceSort.fromJson({"id": 0, "name": "关注"}),
  ];

  Future<void> getTabBar() async {
    final danceSorts = await EntryApi.getDanceSorts();
    if (danceSorts == null) return;
    tabs.addAll(danceSorts);
    tabController.dispose();
    tabController = TabController(length: tabs.length, vsync: this);
    setState(() {});
  }

  
  initState() {
    getTabBar();
    tabController = TabController(length: tabs.length, vsync: this);
    super.initState();
  }

  
  dispose() {
    tabController.dispose();
    super.dispose();
  }

  get tabBar => TabBar(
    controller: tabController,
    tabs: tabs.map<Tab>((v) => Tab(text: v.name)).toList(),
  );

  get tabBarView => TabBarView(
    controller: tabController,
    children: tabs.map<RecommendList>((v) => RecommendList(v.id)).toList(),
  );

  
  Widget build(BuildContext context) {
    return FloatingScrollView(
      tabBar: tabBar,
      tabBarView: tabBarView,
    );
  }
}

initState 时,先初始化本地默认的 tab,通过 Api 请求到服务端的 tab 数据后,再将原 tabController 销毁,生成一个新的 tabController,由于使用的是 TickerProviderStateMixin,所以并不会因为 Single 而报错。

为了便于理解,这个例子使用的 setState 来重新构建布局,其实完全可以使用 Provider 进行优化,我的项目也是全部使用 Provider 来进行管理的,利用 Selector 将构建范围缩小至最小,能很大地改善重构布局时的性能问题,例如上面 tabBar 部分可以换成:

get tabBar => Selector<HomeModel, TabController>(
  selector: (context, model) => model.tabController,
  builder: (context, controller, _) => TabBar(
    controller: controller,
    tabs: model.tabs.map<Tab>((v) => Tab(text: v.name)).toList(),
  ),
);

键盘相关问题

键盘弹出后将布局顶起来了,而不是遮住布局

解决办法:在 scafold 里设置 resizeToAvoidBottomInset: false,键盘会遮住布局,而不是顶起布局。

就想让键盘顶起布局,布局却溢出了怎么办?

溢出肯定是因为没有键盘时,整体高度没有一屏高,键盘出现了,却超出了一屏的高度。解决办法很简单,首先将布局使用 SingleChildScrolleView 之类的滚动组件包裹住,将布局改变可为滚动的,这样键盘弹出后布局就不会溢出了。

接着可以使用 WidgetsBindingObserver 类来监听键盘弹起事件,每次弹起键盘出触发 didChangeMetrics 钩子,在该钩子里执行逻辑即可,例如将 SingleChildScrolleView 的当前位置调整至最底部,相关代码如下:

import 'package:flutter/material.dart';

class Demo extends StatefulWidget {
  
  createState() => _DemoState();
}

class _DemoState extends State<Demo> with WidgetsBindingObserver {

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

  FocusNode _phoneFocusNode = FocusNode();
  FocusScopeNode _focusScopeNode;

  get _phoneTextFiled => TextField(
    controller: _phoneController,
    focusNode: _phoneFocusNode,
    keyboardType: TextInputType.phone,
    maxLength: 11,
    decoration: InputDecoration(
      hintText: '请输入手机号',
      border: InputBorder.none,
      counterText: '',
    ),
  );

  void handlePostFrame() {
    if (!_phoneFocusNode.hasFocus) {
      print('requestFocus');
      _focusScopeNode.requestFocus(_phoneFocusNode);
    }
    print('jumpTo');
    _scrollController.jumpTo(_scrollController.position.maxScrollExtent);
  }

  
  void initState() {
    WidgetsBinding.instance.addObserver(this);
    super.initState();
  }

  
  void didChangeMetrics() {
    WidgetsBinding.instance.addPostFrameCallback(handlePostFrame);
    super.didChangeMetrics();
  }

  
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }
}

键盘弹起和收回会引起页面重新build

我的项目中有一个接近 1 万行代码的视频详情页,全部使用 Provider 进行状态管理,如果键盘弹起回收触发 build,就可能出现一些奇怪的 BUG,比如当前的滚动组件在屏幕中的位置发生变化。

我的解决方案是利用 showBottomSheet 方法,页面中展示的 TextField 上盖一层透明遮罩,使用户无法点击,而点击遮罩时,则触发 showBottomSheet, push 进一个新的路由,弹起键盘,却不会引起重新 build,收起键盘时,则会 pop 回页面,其实视觉上一直都保持在同一页面中,和普通的弹起键盘没区别,并且性能也非常棒,相关代码如下:

  get textField => TextField(
    autofocus: true,
    cursorColor: currentTheme.hoverColor,
    cursorWidth: 1.0,
    textInputAction: TextInputAction.done,
    style: TextStyle(
      color: currentTheme.primaryColorLight,
      fontSize: setSp(32),
    ),
    decoration: InputDecoration(
      hintText: '发一句友善的评论来见证当下吧',
      hintStyle: TextStyle(fontSize: setSp(28)),
      contentPadding: EdgeInsets.symmetric(horizontal: setWidth(31)),
      filled: true,
      fillColor: currentTheme.primaryColorDark,
      border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(setWidth(30)),
          borderSide: BorderSide.none
      ),
    ),
    onSubmitted: (value) {},
  );

  Widget buildTextFieldPage(BuildContext context) {
    return SizedBox.expand(
      child: Stack(
        alignment: Alignment.bottomLeft,
        children: <Widget>[
          Positioned.fill(
            child: GestureDetector(
              behavior: HitTestBehavior.opaque,
              onTap: () => Navigator.pop(context),
              child: Container(color: Colors.black.withOpacity(.5)),
            ),
          ),
          buildInput(),
        ],
      ),
    );
  }

  buildInput({hasTextField = true}) {
    Widget child;

    child = hasTextField
        ? Container(
            decoration: BoxDecoration(
              color: currentTheme.backgroundColor,
              borderRadius: BorderRadius.circular(setWidth(31)),
            ),
            child: textField,
          )
        : GestureDetector(
            onTap: () {
              showBottomSheet(
                context: context,
                backgroundColor: Colors.transparent,
                builder: buildTextFieldPage,
              );
            },
            child: Container(
              decoration: BoxDecoration(
                color: currentTheme.backgroundColor,
                borderRadius: BorderRadius.circular(setWidth(31)),
              ),
            ),
          );

    return Container(
      height: setWidth(103),
      padding: EdgeInsets.symmetric(
        vertical: setWidth(20),
        horizontal: setWidth(25),
      ),
      decoration: BoxDecoration(
        border: Border(top: commentDivider),
        color: currentTheme.primaryColor,
      ),
      child: Row(
        children: <Widget>[
          Expanded(child: child),
          Container(
            width: setWidth(66),
            padding: EdgeInsets.only(left: setWidth(25)),
            alignment: Alignment.center,
            child: Icon(
              IcoMoon.send,
              color: currentTheme.hoverColor.withOpacity(.5),
              size: setWidth(42),
            ),
          ),
        ],
      ),
    );
  }

相关效果如下:

input.gif

路由 push pop 常见需求

例如浏览记录中有如下 4 个页面,当前页面为 d

a->b->c->d

在当前页面使用 Navigator.popUtil(context, ModalRoute.withName('a')),可以直接返回至 a 页面,并销毁 bc 页面。

在当前页面使用 Navigator.pushNamedAndRemoveUntil(context, 'e', (route) => false),可以进入 e 页面之前,销毁所有历史记录,即 e 页面变成第一页,e 页面里无法继续 pop 返回上一页。

Mac 环境 build 时的错误

提示如下:

Automatically assigning platform iOS with version 9.0 on target Runner because no platform was specified. Please specify a platform for this target in your Podfile.

解决办法是:删除 pod 文件中 platform前的 #

因为没有做过原生开发,所以对于这种 build 问题真的是一脸茫然,最开始遇到过几次类似错误,我通过网上搜索答案、群里问大佬来解决,非常之麻烦。所以后来我在 Mac 环境 build 产生错误时,都是直接重建项目,把逻辑代码复制进新项目里,再重新 build 就不会发生各种乱七八糟看不懂的错误了,效率也快。

pageView、ListView 等滚动组件切换页面返回后的高度位置被改变了

解决办法:给滚动组件加上 key 属性,例如: key: PageStorageKey(1)

其实一般的 ListView 还无法满足我们日常开发中各种花式的需求,推荐使用法佬的 NestedScrollView

法佬已经给我们解决了很多奇怪的 bug,还要什么自行车?

如何监听 app 返回桌面事件

我需要当 app 返回桌面时暂停视频的播放,从桌面返回 app 后再继续播放,解决方案如下:

class _DemoState extends State<Demo> with WidgetsBindingObserver {
  
  void didChangeAppLifecycleState(AppLifecycleState state) {
    print('app lifecycle state: $state');
    if (state == AppLifecycleState.inactive) {
      _playerModel.pausePlayer();
    } else if (state == AppLifecycleState.resumed) {
      if (_homeModel.isFindPage) _playerModel.startPlayer();
    }
    super.didChangeAppLifecycleState(state);
  }
}

WidgetsBindingObserver 这个类我经常使用,例如监听键盘弹起事件也会用到这个类。

TextField 设置 border 不生效

TextField 的 border 有如下 3 种,需要针对性地设置,只设置一个是无法生效的:

decoration: InputDecoration(border enabledBorder focusBorder)

ps:设置 maxLength 属性后,decoration 里需要设置 counterText: '',否则默认会附带一个统计字数的样式。

Dio 小技巧

使用 Dio 进行 HTTP 请求时,请求头 content-type 的默认值是

application/json; charset=utf-8

如果返回头的 content-type

application/json

Dio 将自动解析返回 json 数据为 Dart 相应的数据类型,而不需要手动地调用 jsonDecode 方法,所以客户端、服务端的统一使用 application/json 作为 content-type,他好我也好。

Android 打包后无法进行网络请求

在我第一次使用 Flutter 打包项目时遇到了这个问题,最后发现是没有网络请求的权限,类似的,储存读取本地文件时可能也会有类似问题,这种问题设置权限就可以解决了。

android/app/src/profile/AndroidManifest.xml

以及 android/app/src/main/AndroidManifest.xml 两个文件的 manifest 标签内添加如下子标签即可:

<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />

对于类中的属性和方法的定义规范的一些建议

全屏相关设置

强制竖屏:

void initState() {
  SystemChrome.setPreferredOrientations([
    DeviceOrientation.portraitUp,
    DeviceOrientation.portraitDown
  ]);
  super.initState();
}

强制横屏:

initState() {
  SystemChrome.setPreferredOrientations([
    DeviceOrientation.landscapeLeft,
    DeviceOrientation.landscapeRight
  ]);
  super.initState();
}

Transform 3D 转换

推荐使用 Transform 组件来完成动画效果,例如 Transform.translateTransform.scale 可以完成宽高、位置的变化, Transform.rotate 可以完成旋转角度的变化。

Transform.rotateRotateBox 都可以完成旋转功能,下面讲讲他们的区别:

使用 RotateBox 渲染 widget 是在 layout 阶段,渲染完毕后就会占用实际位置,而 Transform 组件则是在 layout 之后的绘制阶段, Transform 只是一个视觉效果,实际所占空间大小是 transform 变化之前所占用的空间大小,所以重新渲染 Transform.rotate 组件比重新渲染 RotateBox 开销更小。

Flutter 的 Transform 组件的这个特性和 CSS 的 transform 属性非常相似,都可以用来提升动画性能。

不过做视频全屏功能时,可以用 IndexedStack + RotateBox 替代 push 一个横屏的路由的做法,RotateBox 它会使容器填充全屏,而 IndexedStack 可以控制是否显示全屏,这里如果使用 Transform 则无法填充全屏,因为容器的宽高在 layout 时就已经确定了,所以只能使用 RotateBox

视频镜像翻转

我在项目中不仅使用 RotatedBox 完成视频全屏功能,还利用了 Transform 来完成镜像翻转功能,写法如下:

Selector<VideoModel, bool>(
  selector: (context, model) => model.isMirror,
    builder: (context, isMirror, child) => Transform(
      alignment: Alignment.center,
      transform: Matrix4.identity()..setEntry(3, 2, 0.006)..rotateY(isMirror ? math.pi : 0),
      child: child,
    ),
    child: FijkView(
    player: model.player,
    color: Colors.black,
    panelBuilder: (player, context, size, pos) => emptyBox,
  ),
)

原理很简单,FijkView 是 fijkplayer 提供的视频容器,我将视频容器以中心位置为圆心,沿 Y 轴做一个 180 度的旋转,即可满足需求。

setEntry 用于设置透视,否则将无法看到 Y 轴及 X 轴的立体转换效果

rotateY 则与 css 中的 rotateY 是相同含义,即沿 Y 轴旋转。在 css 中可以设置 transform: rotateY(180deg) 来达到相同的效果。

状态栏相关设置

隐藏状态栏:

import 'package:flutter/services.dart';

void toggleFullscreen() {
  _isFullscreen = !_isFullscreen;
  _isFullscreen
      ? SystemChrome.setEnabledSystemUIOverlays([])
      : SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
}

改变状态栏颜色,则需要使用插件:flutter_statusbarcolor,下面是用法示例:

// 改变状态栏背景颜色,默认改变为透明
Future<void> changeStatusColor({Color color: Colors.transparent}) async {
  try {
    await FlutterStatusbarcolor.setStatusBarColor(
      color,
      animate: true,
    );
    FlutterStatusbarcolor.setStatusBarWhiteForeground(true);
    FlutterStatusbarcolor.setNavigationBarWhiteForeground(true);
  } on PlatformException catch (e) {
    debugPrint(e.toString());
  }
}

下面介绍一个用法,我的 home 页使用 indexStack 组件包含了 4 个 tab 页,每次更改 tab 会改变 currentHomeTab 的值,但不会触发重新 build,而由于路由 pushpop 又会触发重新 build,所以如果需要当进入 home 页的 发现 tab 页 时改变为黑色状态栏,则可以用下面这种做法:

// 在发现页的 build 方法里进行判断

Widget build(BuildContext context) {
  if (ModalRoute.of(context).isCurrent && currentHomeTab == '发现') {
    changeStatusColor(color: Colors.black);
  }
}

fijkplayer 秒开、进度跳转等优化

fijkplayer 默认情况下,进度跳转、播放可能会有性能问题,针对这些问题,可以进行以下优化:

_player.setDataSource(_video.src);
await _player.applyOptions(
    FijkOption()
      ..setFormatOption('flush_packets', 1)
      ..setFormatOption('analyzemaxduration', 100)
      ..setFormatOption('analyzeduration', 1)
      ..setCodecOption('skip_loop_filter', 48)
      ..setPlayerOption('start-on-prepared', 1)
      ..setPlayerOption('packet-buffering', 0)
      ..setPlayerOption('framedrop', 1)
      ..setPlayerOption('enable-accurate-seek', 1)
      ..setPlayerOption('find_stream_info', 0)
      ..setPlayerOption('render-wait-start', 1)
);
await _player.prepareAsync();

参考链接:

IjkPlayer 起播速度优化

IjkPlayer 播放器秒开优化以及常用 Option 设置

LayoutBuilder 相关的实践

如何实现微信朋友圈、哔哩哔哩评论的多行文本收起、展开功能

我写了下面这个工具类,简单、好用得我都枯了,原理是利用先 LayoutBuilder 判断是否超出指定的行数,如果超出则返回 Column,如果未超出则返回原 widget

import 'package:flutter/material.dart';

class ExpandableText extends StatefulWidget {
  final String text;
  final int maxLines;
  final TextStyle style;
  final bool expand;
  final TextStyle markerStyle;
  final String atName;

  const ExpandableText(this.text, {
    Key key,
    this.maxLines,
    this.style,
    this.markerStyle,
    this.expand = false,
    this.atName = '',
  }) : super(key: key);

  
  createState() => _ExpandableTextState();

}

class _ExpandableTextState extends State<ExpandableText> {

  bool expand;
  TextStyle style;
  int maxLines;

  
  void initState() {
    expand = widget.expand;
    style = widget.style;
    maxLines = widget.maxLines;
    super.initState();
  }

  Widget buildOrdinaryText() {
    final text = widget.text;
    return LayoutBuilder(builder: (_, size) {
      final tp = TextPainter(
        text: TextSpan(text: text, style: style),
        maxLines: maxLines,
        textDirection: TextDirection.ltr,
      );
      tp.layout(maxWidth: size.maxWidth);

      if (!tp.didExceedMaxLines) return Text(text, style: style);

      return Builder(
        builder: (context) => Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(text, maxLines: expand ? null : widget.maxLines, style: style),
            GestureDetector(
              onTap: () {
                expand = !expand;
                (context as Element).markNeedsBuild();
              },
              child: Text(
                expand ? '收起' : '展开',
                style: widget.markerStyle,
              ),
            ),
          ],
        ),
      );
    });
  }

  Widget buildAtText() {
    return LayoutBuilder(builder: (_, size) {
      final tp = TextPainter(
        text: TextSpan(text: '回复 @${widget.text}:', style: style),
        maxLines: maxLines,
        textDirection: TextDirection.ltr,
      );
      tp.layout(maxWidth: size.maxWidth);

      if (!tp.didExceedMaxLines) return Text.rich(
        TextSpan(
          children: [
            TextSpan(text: '回复 '),
            TextSpan(text: '@${widget.atName}', style: widget.markerStyle),
            TextSpan(text: ':${widget.text}'),
          ],
        ),
        style: style,
      );

      return Builder(
        builder: (context) => Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text.rich(
              TextSpan(
                children: [
                  TextSpan(text: '回复 '),
                  TextSpan(text: '@${widget.atName}', style: widget.markerStyle),
                  TextSpan(text: ':${widget.text}'),
                ],
              ),
              maxLines: expand ? null : widget.maxLines,
              style: style,
            ),
            GestureDetector(
              onTap: () {
                expand = !expand;
                (context as Element).markNeedsBuild();
              },
              child: Text(
                expand ? '收起' : '展开',
                style: widget.markerStyle,
              ),
            ),
          ],
        ),
      );
    });
  }

  
  build(context) => widget.atName == '' ? buildOrdinaryText() : buildAtText();
}

调用方法如下:

Container(
  padding: EdgeInsets.only(top: setWidth(6), bottom: setWidth(11)),
  alignment: Alignment.centerLeft,
  child: ExpandableText(
    reply.content,
    maxLines: 4,
    style: commentTextStyle,
    markerStyle: commentMarkerStyle,
    atName: reply.isDirect > 0 ? '' : reply.pNickname,
  ),
),

相关效果如下:

extendable.gif

监听父级 widget 的实际宽高信息

LayoutBuilder 的作用非常大,可以用它来监听某个widget的宽高信息,我在项目中遇到了 一个需求,需要根据某个 widget 的高度来弹出 BottomSheet,而这个 widget 的高度是可以滑动改变的,那么 LayoutBuilder 就派上用场了,做法如下:

需要监听的 widgetBody() 组件,给 Body() 组件套上一个 Stack

get body => Stack(
  children: <Widget>[
    Body(),
    BodyLayout(model),
  ],
);

然后用 BodyLayout 组件来监听:

import 'package:flutter/material.dart';

import 'package:vhiphop/provider/video/video_model.dart';

class BodyLayout extends StatelessWidget {

  final VideoModel model;
  BodyLayout(this.model);

  
  Widget build(BuildContext context) {
    return LayoutBuilder(builder: (_, BoxConstraints constraints) {
      model.bottomSheetDy = constraints.maxHeight;
      return emptyBox;
    });
  }
}

Body() 组件高度发生变化时,会触发 LayoutBuilderbuilder 回调函数,在此函数中将高度信息传递给 model ,那么每次弹出 BottomSheet 之前,我就可以从 model 中拿到高度,以设置 BottomSheet 的高度。

底部弹出动画的两种实现方式

这种动画在 App 中是很常见的效果,例如 App 分享功能,点击分享按钮后,会从页面底部弹出分享组件。

第一种,利用 showModalBottomSheet,相关实现代码如下:

  void showShareBottomSheet() {
    showModalBottomSheet(
      elevation: 0,
      backgroundColor: currentTheme.highlightColor,
      context: context,
      builder: (context) => Container(
        width: Screens.width,
        decoration: BoxDecoration(color: currentTheme.primaryColor),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Container(
              alignment: Alignment.bottomLeft,
              height: setWidth(59),
              padding: EdgeInsets.only(left: setWidth(42)),
              child: Text(
                '分享',
                style: TextStyle(
                  fontSize: setSp(32),
                  color: currentTheme.highlightColor,
                ),
              ),
            ),
            Container(
              height: setWidth(206),
              padding: EdgeInsets.only(top: setWidth(33), left: setWidth(33)),
              alignment: Alignment.topLeft,
              decoration: BoxDecoration(
                border: Border(
                  bottom: BorderSide(
                    width: setWidth(.7),
                    color: currentTheme.dividerColor,
                  ),
                ),
              ),
              child: Row(
                children: <Widget>[
                  shareIconOfQQ,
                  shareIconOfQQZone,
                  shareIconOfWeChat,
                  shareIconOfWeChatMoments,
                  shareIconOfMicroBlog,
                ],
              ),
            ),
            Container(
              height: setWidth(206),
              padding: EdgeInsets.only(top: setWidth(33), left: setWidth(33)),
              alignment: Alignment.topLeft,
              child: Row(
                children: <Widget>[
                  shareIconOfLink,
                ],
              ),
            ),
            GestureDetector(
              onTap: () {
                Navigator.pop(context);
              },
              child: Container(
                width: Screens.width,
                height: setWidth(125),
                alignment: Alignment.center,
                decoration: BoxDecoration(
                  border: Border(
                    top: BorderSide(
                      width: setWidth(10),
                      color: currentTheme.backgroundColor,
                    ),
                  ),
                ),
                child: Text(
                  '取消',
                  style: TextStyle(
                    fontSize: setSp(36),
                    color: currentTheme.highlightColor,
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

使用 translate 实现

我在项目中使用 showBottomSheet 时发现动画有点卡顿,可能是测试手机不行,只花了 1000 大洋,但咱是个倔强穷人,非要找一种性能更好的方式,那就是 translate 了。

这种方法比 showBottomSheet 动画性能更高,在我 1000 大洋的测试机 debug 模式下都非常的丝滑流畅,但是代码实现更复杂一点,并且需要依赖 Provider 来更新,我比较喜欢这种方式。

整个页面都使用 Stack 构建,而 bottomSheet 与遮罩 box 则使用 Positioned 定位至页面底部

get body => Stack(
  children: <Widget>[
    page,
    Positioned(
      left: 0,
      bottom: 0,
      right: 0,
      child: bottomSheetBox,
    ),
    Positioned(
      left: 0,
      top: 0,
      right: 0,
      bottom: shareBottomSheetHeight,
      child: bottomSheetBoxMask,
    ),
  ],
);

接着使用我定义的一个工具类,名字叫 AnimatedTranslateBox,我发现 Animated 家族有各种动画组件,比如 AnimatedPaddingAnimatedPositioned 等等,唯独没有 Translate,不知道官方是什么意思,可能他们觉得 Positioned 来调整位置就够用了叭,可是 translate 动画性能更高,它不香吗?没关系,咱自己造了一个,代码如下:

import 'package:flutter/material.dart';

class AnimatedTranslateBox extends StatefulWidget {
  AnimatedTranslateBox({
    Key key,
    this.dx,
    this.dy,
    this.child,
    this.curve = Curves.linear,
    this.duration = const Duration(milliseconds: 200),
    this.reverseDuration,
  });

  final double dx;
  final double dy;
  final Widget child;
  final Duration duration;
  final Curve curve;
  final Duration reverseDuration;

  
  createState() => _AnimatedTranslateBoxState();
}

class _AnimatedTranslateBoxState extends State<AnimatedTranslateBox>
    with SingleTickerProviderStateMixin {

  AnimationController controller;
  Animation<double> animation;
  Tween<double> tween;

  void _updateCurve() {
    animation = widget.curve == null
      ? controller
      : CurvedAnimation(parent: controller, curve: widget.curve);
  }

  
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: widget.duration,
      reverseDuration: widget.reverseDuration,
      vsync: this,
    );
    tween = Tween<double>(begin: widget.dx ?? widget.dy);
    _updateCurve();
  }

  
  void didUpdateWidget(AnimatedTranslateBox oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.curve != oldWidget.curve) _updateCurve();
    controller
      ..duration = widget.duration
      ..reverseDuration = widget.reverseDuration;
    if ((widget.dx ?? widget.dy) != (tween.end ?? tween.begin)) {
      tween
        ..begin = tween.evaluate(animation)
        ..end = widget.dx ?? widget.dy;
      controller
        ..value = 0.0
        ..forward();
    }
  }

  
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  
  build(context) => AnimatedBuilder(
    animation: animation,
    builder: (context, child) => widget.dx == null
        ? Transform.translate(
            offset: Offset(0, tween.animate(animation).value),
            child: child,
          )
        : Transform.translate(
            offset: Offset(tween.animate(animation).value, 0),
            child: child,
          ),
    child: widget.child,
  );
}

调用很简单,使用 Selector 依赖 model 中的布尔值,用于控制显示隐藏:

get bottomSheetBox => Selector<VideoModel, bool>(
  selector: (context, model) => model.showBottomSheet,
  builder: (context, show, child) => AnimatedOpacity(
    opacity: show ? 1 : 0,
    curve: show ? Curves.easeOut : Curves.easeIn,
    duration: bottomSheetDuration,
    child: AnimatedTranslateBox(
      dy: show ? 0 : bottomSheetHeight,
      curve: show ? Curves.easeOut : Curves.easeIn,
      duration: bottomSheetDuration,
      child: child,
    ),
  ),
  child: Container(
    height: bottomSheetHeight,
    child: bottomSheet,
  ),
);

每当 dx 或 dy 的值发生改变,AnimatedTranslateBox 的 child 就会根据 dx 或 dy 的值进行 y 轴 或 x 轴的移动动画。

相关的效果如下:

bottom_sheet.gif

Provider 调用问题

我发现如果在 MaterialApp 下全局挂载了 Provider ,则在 Home 页初始化完成前,是无法使用的,例如:

class MyApp extends StatelessWidget {

  final _userModel = UserModel();
  final _homeModel = HomeModel();

  Widget build(BuildContext context) {
    return OKToast(
      dismissOtherOnShow: true,
      child: MultiProvider(
        providers: [
          ChangeNotifierProvider.value(value: _userModel),
          ChangeNotifierProvider.value(value: _homeModel),
        ],
        child: Selector<ThemeModel, ThemeData>(
          selector: (context, model) => model.theme,
          builder: (context, theme, child) => MaterialApp(
            navigatorKey: Constants.navigatorKey,
            debugShowCheckedModeBanner: false,
            theme: theme,
            initialRoute: '/',
            routes: {
              '/': (context) => HomePage(),
            },
          ),
        ),
      ),
    );
  }
}

上面的代码声明了 MultiProvider,如果在首页做如下调用:


initState() {
  _model = Provider.of<HomeModel>(context);
  _userModel = Provider.of<UserModel>(context);
  super.initState();
}

则会报错:

I/flutter ( 8380): ══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
I/flutter ( 8380): The following assertion was thrown building Builder:
I/flutter ( 8380): dependOnInheritedWidgetOfExactType<_DefaultInheritedProviderScope<HomeModel>>() or
I/flutter ( 8380): dependOnInheritedElement() was called before _HomePageState.initState() completed.
I/flutter ( 8380): When an inherited widget changes, for example if the value of Theme.of() changes, its dependent
I/flutter ( 8380): widgets are rebuilt. If the dependent widget's reference to the inherited widget is in a constructor
I/flutter ( 8380): or an initState() method, then the rebuilt dependent widget will not reflect the changes in the
I/flutter ( 8380): inherited widget.
I/flutter ( 8380): Typically references to inherited widgets should occur in widget build() methods. Alternatively,
I/flutter ( 8380): initialization based on inherited widgets can be placed in the didChangeDependencies method, which
I/flutter ( 8380): is called after initState and whenever the dependencies change thereafter.

提示 initState 必须调用完成,才能使用 Provider.of 来获取祖先节点的 model,非要使用怎么办?办法也很简单, of 方法有一个属性值 listen,默认值为 true,将此值设置为 false 则不会建立与 Provider 的依赖关系,其实我在 Provider 的手册中也发现,建议在 initState 方法中调用 of 时,将 listen 设置为 false


initState() {
  _userModel = Provider.of<UserModel>(context, listen: false);
  _model = Provider.of<HomeModel>(context, listen: false);
  super.initState();
}

如何实现网易云音乐、QQ音乐播放页面的背景图片模糊效果

分析一下,其实这种效果特别简单,首先放大背景图片,其次对图片进行高斯模糊,直接上代码:

import 'package:flutter/material.dart';
import 'dart:ui';

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

class MyApp extends StatelessWidget {
  final image = Image.asset(
    'assets/images/test.jpg',
    fit: BoxFit.cover,
    width: 200,
    height: 200,
  );
  
  get blurImage => ClipRRect(
    child: Stack(
      children: <Widget>[
        Transform.scale(
          scale: 1.5,
          child: image,
        ),
        BackdropFilter(
          filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
          child: Container(
            width: 200,
            height: 200,
            alignment: Alignment.center,
            color: Colors.black.withOpacity(.3),
            child: Text(
              '1 个内容',
              style: TextStyle(
                fontSize: 24,
                color: Colors.white,
              ),
            ),
          ),
        ),
      ],
    ),
);
  
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo app',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: Scaffold(
        appBar: AppBar(title: Text('blur image demo')),
        body: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                Container(
                  margin: EdgeInsets.only(bottom: 30),
                  child: image,
                ),
                blurImage,
              ],
            ),
          ],
        )
      ),
    );
  }
}

这个效果其实没什么难度,主要的知识点在于 BackdropFilter 组件默认的模糊效果是全屏的,必须使用 ClipRRect 进行裁剪,而且 Transform 的几个命名构造函数,如 Transform.translate 带来的效果是在绘制阶段发生的,会超出 widget 实际占用的空间,也需要使用 ClipRRect 进行裁剪,最后的效果图如下:

blur_img.jpg

发布评论
评论
共计 2条评论
最新评论
2021-05-11 17:10:0811[浙江省杭州市网友]
yi
0
0
回复
2020-02-26 10:16:39Alex[广西壮族自治区梧州市网友]
可以可以,就是有些点其实还不是那么准确,不过够用就好
0
0
回复