时间:2022-08-06 22:21:01 | 来源:网站运营
时间:2022-08-06 22:21:01 来源:网站运营
Flutter
搭建微信首页的功能,详细讲述该功能实现过程中所运用到的技术,以及遇到问题后如何解决的心得体会。该功能虽然粗看时看似简单,但是细作时发现其功能逻辑复杂,内部细节处理较高,当然其中涵盖了Flutter
中大部分知识点,笔者相信初学者通过实现该功能后,定会对所学的Flutter
知识的掌握上更上一层楼
。Flutter
的过程中有所帮助,当然笔者必将知无不言、言无不尽
,梳理实战过程之问题,总结解决问题之方案,让尔等知其然,知其所以然
。望能抛玉引砖,摆渡众生,如有纰漏,还望斧正。Stack + Positioned 布局
Transform.translate(平移)
、Transform.scale(放大)
、Opacity(设置子部件透明度)
滚动监听及控制
动画组件使用(AnimatedPositioned、AnimatedOpacity、ScaleTransition)
状态管理Provider
监听键盘弹起
通过GlobalKey 获取某个 Widget 的尺寸
侧滑删除
的功能,主要利用 flutter_slidable 插件来实现的,其具体实现过程以及细节处理的心得体会,与笔者前面写过的 Flutter 玩转微信——通讯录 文章中详细说明如何实现联系人侧滑删除
的功能类似,这里笔者就不再一一赘述。有兴趣的同学,还请自行移步。Flutter
必备的知识点,所以笔者会详述其实现过程中遇到的坑以及填坑的方法。内容页
、导航栏
、三个点
、小程序
都会层叠展示,所以整个微信页面这里采取的是 Stack + Positioned
布局方案,关于UI构建的细节,大家参看源码即可,这里就不再赘述,具体伪代码如下:/// 构建子部件Widget _buildChildWidget() { return Container( constraints: BoxConstraints.expand(), color: Style.pBackgroundColor, child: Stack( overflow: Overflow.visible, // 注意层叠顺序,她不像 Web 中有 z-index 的概念 children: <Widget>[ // 导航栏 // 内容页 // 三个点部件 // 小程序 // 菜单 ], ), );}
特别注意:Stack
中子部件(Positioned
)添加顺序,最后面添加的在最上面,她不像 Web
中的样式有z-index
的概念。 下拉显示小程序
和上拉隐藏小程序
两个过程的逻辑处理,当然这才是一个真正的闭环,有显示就会有隐藏。这里笔者就只拿以 下拉逻辑
为例,详细讲解其中的逻辑分析和细节处理。上拉逻辑
大家可以反推即可。导航栏
的顶部会随着手指下拉而向下偏移(offset)
,偏移距离等于下拉距离。 临界点① = 60
时,出现一个小球
逐渐放大,放大系数(scale) = 0
,当 偏移量 > 临界点①
时,scale 会逐渐变大
;反之,scale = 0
。 临界点② = 90
时,此过程中,小球
会放大到最大值(scale = 2
)。即offset:临界点① --> 临界点②
,scale: 0 --> 2
。继 临界点③ = 130
时,此过程中,小球
会生成两个小球,一个小球逐渐左平移到最大值,一个小球逐渐右平移到最大值,其本身也缩放到原始值(scale = 1
)。 临界点④ = 180
时,此过程中,三个球
的透明度(opacity
)从 1.0 --> 0.2
变化,以及小程序模块透明度(opacity
)从0 --> 0.5
变化且自身缩放比例(scale
)为(scale = 0.4
)。 6. 继续下拉 offset > 临界点④
时,三个小球
的透明度恒等于0.2
,以及小程序模块透明度恒等于0.5
且自身缩放比例(scale
)恒为(scale = 0.4
)。拖拽
状态,也就是手指没有离开屏幕。那么手指离开屏幕后,有会发生什么状况呢,请听笔者一一道来。offset
是否大于 临界点② = 90
, 若大于,则显示小程序模块,反之,则隐藏小程序模块。0.5 --> 1
且缩放比例由0.4 --> 1
、底部导航栏隐藏。滚动偏移量(offset)
,那么我们必须得监听列表的滚动
,从而根据偏移量来完成整个UI逻辑。关于滚动监听,大家可以参看 滚动监听及控制 这篇文章。 // 方案一_controller.addListener(() { // 获取偏移量 final offset = _controller.offset; // 处理 _handlerOffset(offset);});// 方案二NotificationListener( onNotification: (ScrollNotification notification) { // 正在刷新 do nothing... if (_isRefreshing || _isAnimating) { return false; } // offset final offset = notification.metrics.pixels; if (notification is ScrollStartNotification) { if (notification.dragDetails != null) { _focus = true; } } else if (notification is ScrollUpdateNotification) { // 能否进入刷新状态 final bool canRefresh = offset <= 0.0 ? (-1 * offset >= _topDistance ? true : false) : false; if (_focusState && notification.dragDetails == null) { _focus = false; // 手指释放的瞬间 _isRefreshing = canRefresh; } } else if (notification is ScrollEndNotification) { if (_focusState) { _focus = false; } } // 处理 _handlerOffset(offset); return false;},
通过NotificationListener监听滚动事件
和通过ScrollController
有两个主要的不同:NotificationListener
可以在从可滚动组件到widget
树根之间任意位置都能监听。而ScrollController
只能和具体的可滚动组件关联后才可以。NotificationListener
在收到滚动事件时,通知中会携带当前滚动位置和ViewPort
的一些信息,而ScrollController
只能获取当前滚动位置。NotificationListener监听滚动事件
的另一个重要原因是:监听手指是否处于拖拽状态
,即notification.dragDetails != null
。从而明确用户手指离开屏幕的瞬间时,得到此时的偏移量,以此来决定小程序模块
的显示与否。_offset
的变化而变化即可,偏移量处理如下:// 处理偏移逻辑void _handlerOffset(double offset) { // 计算 if (offset <= 0.0) { _offset = offset * -1; } else if (_offset != 0.0) { _offset = 0.0; } // 这里需要 if (_isRefreshing && !_isAnimating) { // 刷新且非动画状态 // 正在动画 _isAnimating = true; // 动画时间 _duration = 300; // 最终停留的位置 _offset = ScreenUtil.screenHeightDp - kToolbarHeight - ScreenUtil.statusBarHeight; // 隐藏掉底部的TabBar Provider.of<TabBarProvider>(context, listen: false).setHidden(true); setState(() {}); return; } _duration = 0; // 非刷新且非动画状态 if (!_isAnimating) { setState(() {}); }}
因为考虑到UI布局依赖于_offset
的变化而变化,这里必须强调的是下拉过程中的两种状态: - 拖拽状态(手指未离开屏幕) - 非拖拽状态(手指离开屏幕)拖拽状态
下时UI,导航栏的顶部回跟随_offset
的变化发生偏移,其无非是修改Positioned
的top
属性即可,伪代码如下:Positioned( top: _offset, //...)
当结束 拖拽状态
下时UI,即:如果手指释放的瞬间,_offset
大于 临界点,则 导航栏
,内容页
...等部件会丝滑的过渡
到底部,这里想必大家一定清楚了,要想实现丝滑过渡
这个功能,一定离不开动画
的加持。那么这种状态下,若依然延用修改Positioned
的top
属性方法就会在这个过程中显得生硬
,所以这里采用Flutter
自带的动画组件 AnimatedPositioned
来代替 Positioned
。 伪代码如下:AnimatedPositioned( top: _offset, duration: Duration(milliseconds: 300), //...)
AnimatedPositioned
虽然轻而易举的实现了非拖拽状态
下时 导航栏
丝滑过渡到底部的功能,但是若处于拖拽状态
下时,用AnimatedPositioned
就会导致导航栏
很Q弹,比较差强人意。为了兼顾这两种状态,笔者采用的是控制AnimatedPositioned
的duration
属性来实现的,即:拖拽时,_duration=0
;释放且大于临界点时,_duration=300
。伪代码如下:AnimatedPositioned( top: _offset, duration: Duration(milliseconds:(_isRefreshing ? 300 : 0)), //...)
当然,笔者认为下拉过程中比较有趣的功能点就是:三个小球逻辑
。当然结合上面的功能分析,其实实现也比较简单,主要用到Opacity 、Transform.translate、Transform.scale
组件,且其使用比较高频,大家很有必要掌握,这里笔者给出关键代码逻辑,大家一看便知:// 阶段I临界点final double stage1Distance = 60;// 阶段II临界点final double stage2Distance = 90;// 阶段III临界点final double stage3Distance = 130;// 阶段IV临界点final double stage4Distance = 180;final top = (offset + 44 + 10 - 6) * 0.5;// 中间点相关double scale = 0.0;double opacityC = 0;// 右边点相关double translateR = 0.0;double opacityR = 0;// 右边点相关double translateL = 0.0;double opacityL = 0;final cOffset = (offset <= stage4Distance) ? offset : stage4Distance;if (offset > stage3Distance) { // 第四阶段 1 - 0.2 final step = 0.8 / (stage4Distance - stage3Distance); double opacity = 1 - step * (cOffset - stage3Distance); if (opacity < 0.2) { opacity = 0.2; } // 中间点阶段III: 保持scale 为1 opacityC = opacity; scale = 1; // 右边点阶段III: 平移到最右侧 opacityR = opacity; translateR = 16; // 左边点阶段III: 平移到最左侧 opacityL = opacity; translateL = -16;} else if (offset > stage2Distance) { final delta = stage3Distance - stage2Distance; final deltaOffset = offset - stage2Distance; // 中间点阶段II: 中间点缩小:2 -> 1 final stepC = 1 / delta; opacityC = 1; scale = 2 - stepC * deltaOffset; // 右边点阶段II: 慢慢平移 0 -> 16 final stepR = 16.0 / delta; opacityR = 1; translateR = stepR * deltaOffset; // 左边点阶段II: 慢慢平移 0 -> -16 final stepL = -16.0 / delta; opacityL = 1; translateL = stepL * deltaOffset;} else if (offset > stage1Distance) { final delta = stage2Distance - stage1Distance; final deltaOffset = offset - stage1Distance; // 中间点阶段I: 中间点放大:0 -> 2 final step = 2 / delta; opacityC = 1; scale = 0 + step * deltaOffset;}
小程序模块,在下拉过程中,只需要控制其透明度opacity
,以及内容页的缩放scale
系数即可,以及上拉过程中,控制好其透明度opacity
即可,总体来说,So Easy ~,当然整个过程也是都需要考虑手指的 拖拽状态
,也就是需要加动画,如:透明度动画、缩放动画。对此这里用到的对应的动画组件如下, - AnimatedOpacity
替代 Opacity
,增加透明度动画 - ScaleTransition
替代 Transform.scale
,增加缩放动画小程序模块
笔者觉得比较细节的地方,就是UI布局
上了。因为要实现上拉滑动,且小程序内容页也支持上下拉。所以就涉及到嵌套滑动
,即ListView
嵌套ListView
。因为最外层的上拉滑动,能促使导航栏、内容页
向上偏移,所以最外层的ListView
的 maxScrollExtent:最大可滚动长度
的处理是比较细节的。也就是理想情况下,手指从屏幕最底部向上拖拽到屏幕最顶部,正好能使导航栏
的最顶部到达屏幕的顶部即可,那么maxScrollExtent = 2 * 屏幕的高度 - 状态栏的高度 - 导航栏的高度
,且如果小程序内容页高度已知(假设:480)。那么最外层的ListView
不仅要嵌套一个ListView(高度480)
,而且要嵌套一个空(占位)部件(SizedBox
),且空部件的高度为:占位部件高度 = 2 * 屏幕的高度 - 状态栏的高度 - 导航栏的高度 - 480;
当然上拉和下拉类似,无非也是监听滚动,处理滚动的偏移量,上拉的偏移量的处理代码如下:/// 处理小程序滚动事件void _handleAppletOnScroll(double offset, bool dragging) { if (dragging) { _isAnimating = false; // 去掉动画 _duration = 0; // 计算高度 _offset = ScreenUtil.screenHeightDp - kToolbarHeight - ScreenUtil.statusBarHeight - offset; // Fixed Bug: 如果是dragging 状态下 已经为0.0 ;然后 非dragging 也为 0.0 ,这样会导致 即使 setState(() {}); 也没有卵用 // 最小值为 0.001 _offset = max(0.0001, _offset); setState(() {}); return; } if (!_isAppletRefreshing && !_isAnimating) { // 开始动画 _duration = 300; // 计算高度 _offset = 0.0; _isAppletRefreshing = true; _isAnimating = true; setState(() {}); }}
小模块内容页,也有个比较新颖的小功能:就是默认每次进来小程序模块是隐藏搜索框
的,只有当用户下拉一丢丢,手指释放时,会自动看到搜索框
,且用户上拉一丢丢,手指释放时,也会自动隐藏搜索框
的。实现这一功能主要涉及到两个知识点:监听滚动
和 控制滚动
。其中监听滚动
肯定已经耳熟能详了,控制滚动
有两个常用API如下: jumpTo(double offset)
animateTo(double offset,...)
return NotificationListener( onNotification: (ScrollNotification notification) { if (notification is ScrollStartNotification) { if (notification.dragDetails != null) { // 记录起始拖拽 _startOffsetY = notification.metrics.pixels; } } else if (notification is ScrollEndNotification) { final offset = notification.metrics.pixels; if (_startOffsetY != null && offset != 0.0 && offset < ScreenUtil().setHeight(60.0 * 3)) { // 如果小于 60 再去判断是 下拉 还是 上拉 if ((offset - _startOffsetY) < 0) { // 下拉 Future.delayed( Duration(milliseconds: 10), () async { _controllerContent.animateTo(.0, duration: Duration(milliseconds: 200), curve: Curves.ease); }, ); } else { // 上拉 // Fixed Bug : 记得延迟一丢丢,不然会报错 Why? Future.delayed( Duration(milliseconds: 10), () async { _controllerContent.animateTo(ScreenUtil().setHeight(60.0 * 3), duration: Duration(milliseconds: 200), curve: Curves.ease); }, ); } } // 这里设置为null _startOffsetY = null; } return true; // 阻止冒泡 }, child: ListView()}
但是如果我们在结束滚动的一瞬间,调用 jumpTo(double offset) 或 animateTo(double offset,...)
其实是不起作用的,只有延迟一丢丢时间,再去控制其滚动才行,这里笔者也是懵逼好久,还望有缘人解答一下哈(评论即可)~。tabBar
;上拉释放时,需要显示底部tabBar
。这里就要用到状态管理
的功能。 这里主要笔者借助 provider 来实现的。关键代码如下:/// 用于控制TabBar 的显示和隐藏class TabBarProvider with ChangeNotifier { // 显示or隐藏 bool _hidden = false; bool get hidden => _hidden; void setHidden(bool hidden) { _hidden = hidden; notifyListeners(); }}// UI层return Consumer<TabBarProvider>( builder: (context, tabBarProvider, _) { return Scaffold( appBar: null, body: list[_currentIndex], // iOS bottomNavigationBar: tabBarProvider.hidden ? null : CupertinoTabBar( items: myTabs, onTap: _itemTapped, currentIndex: _currentIndex, activeColor: Style.pTintColor, inactiveColor: Color(0xFF191919), ), ); },);// 下拉释放时,隐藏Provider.of<TabBarProvider>(context, listen: false).setHidden(true);// 上拉释放时,显示Provider.of<TabBarProvider>(context, listen: false).setHidden(false);
至此!下拉显示小程序的功能点也就是以上这些了,当然一些UI搭建和逻辑处理还是比较复杂的,只要你思维缜密,逻辑清晰,也就没什么难得了。透明度动画AnimatedOpacity
和缩放动画ScaleTransition
组件。@overridevoid initState() { super.initState(); // 配置动画 _controller = new AnimationController( vsync: this, duration: Duration(milliseconds: 200)); _animation = new CurvedAnimation(parent: _controller, curve: Curves.easeInOut); // 监听动画 _controller.addStatusListener((AnimationStatus status) { // 到达结束状态时 要回滚到开始状态 if (status == AnimationStatus.completed) { // 正向结束, 重置到当前 _controller.reset(); setState(() {}); } });}@overrideWidget build(BuildContext context) { if (widget.show) { // 只有显示后 才需要缩放动画 _shouldAnimate = true; _scaleBegin = _scaleEnd = 1.0; } else { _scaleBegin = 1.0; _scaleEnd = 0.5; // 处于开始阶段 且 需要动画 if (_controller.isDismissed && _shouldAnimate) { _shouldAnimate = false; _controller.forward(); } else { _scaleEnd = 1.0; } } // Fixed Bug: offstage 必须要等缩放动画结束后才去设置为 true, 否则 休想看到缩放动画 return Offstage( offstage: !widget.show && _controller.isDismissed, child: InkWell()}
结合 代码,特别要注意的是,隐藏菜单时,要加个判断逻辑,只有当显示过菜单以及动画状态正处于开始状态时,才去进行缩放动画,且动画完成后需要重置到初始状态,以便下次继续缩放。当然,一定要等缩放动画结束后,方可隐藏整个菜单(蒙版+内容
),否则是看不到缩放动画的,因为蒙版会比内容先隐藏。Flutter
的路上,很多知识都不够全面,导致其实现过程中还是遇到了些许坑,这里笔者一一详尽,所需知识点如下:通过GlobalKey 获取某个 Widget 的尺寸
AnimatedPositioned 实现平移动画
监听键盘的高度变化
微信首页
搜索框, 搜索
和 取消
按钮同时向左平移,并且AppBar
和 Search
页同时向上移动,键盘弹出;微信内容页
和 底部TabBar
隐藏,搜索页面
展示,按住说话
按钮跟随键盘弹出而弹出。 搜索页的取消按钮
, 搜索
和 取消
按钮同时向右平移,并且AppBar
和 Search
页同时向下移动,键盘收起;微信内容页
和 底部TabBar
显示,搜索页面
隐藏,清掉搜索内容,按住说话
按钮跟随键盘收起而收起。搜索框(SearchBar)
和搜索页(SearchContent)
的搭建,虽整体不难,但细节满满。因为考虑平移(左移、右移)动画
和监听键盘高度变化而变化的UI
,所以整体内部widget
布局都是采用Stack + Positioned/AnimatedPositioned
来构建的,当然道路千万条,实现第一条。这里以SearchBar
为例,其内部的子部件(widget
)布局,伪代码如下:Stack( children: <Widget>[ // 白色背景框 AnimatedPositioned(), // 输入框 Positioned(), // 搜索 按钮 AnimatedPositioned(), // 取消按钮 AnimatedPositioned() ])
搜索
居中实现。虽然UI实现居中可能比较简单,比如: Stack
的alignment: AlignmentDirectional.center,
和 Row
的mainAxisAlignment: MainAxisAlignment.center,
,以及 Container
的alignment: AlignmentDirectional.center,
等.... 但是需要考虑到动画的加入以及动画丝滑的效果,就不得不采用Stack
布局的形式了,以及采用Stack
的alignment: AlignmentDirectional.center,
来达到居中,且AnimatedPositioned
的left
和right
必须设置null
,不然是无法居中的,伪代码如下:Stack( alignment: AlignmentDirectional.center, children: <Widget>[ // 搜索 按钮 AnimatedPositioned( child: ` 搜索`, left : null, top: 0, bottom: 0 ), ])
虽然上述确实实现了 搜索
居中,且不费吹灰之力。如果点击 搜索
按钮,假设此时是编辑模式,即 _isEdit = true;
,此时 搜索
按钮需要加入左移动画,即AnimatedPositioned
的left : 0
,同学们可能会非常轻松的写出如下代码:Stack( alignment: AlignmentDirectional.center, children: <Widget>[ // 搜索 按钮 AnimatedPositioned( child: ` 搜索`, left : _isEdit? 0 : null, top: 0, bottom: 0 ), ])
当然,上述代码逻辑确实是稳如藏獒,但是一旦运行后,你就会一脸懵逼,因为点击 搜索
按钮, 搜索
按钮会嗖
的一下到达左侧,丝毫没有理想情况下的左移的丝滑度。侧面验证 理想34D(很丰满),现实对A(很骨感)
的道理。 其实原因就是: AnimatedPositioned 的 left 是从 null --> 0 过渡的,若 left 有值过渡到 0 是有动画的。
AnimatedPositioned 的 left
在非编辑(_isEdit = false
)的场景下必须的有值 ,且为了保证 搜索
居中,则left必须满足:left = (屏幕的宽度 - 搜索的宽度) * 0.5
,所以首要任务是获取 搜索
按钮的尺寸,这里采用GlobalKey
来获取,关于GlobalKey
的使用,大家请自行百度。 伪代码如下/// 用于获取文字高度GlobalKey _textKey = new GlobalKey();/// 搜索图标距离左侧的距离double _searchIconLeft = 0;@overrideWidget build(BuildContext context) { // 方案一: 先算出 SearchCube 的宽度,再去计算其位置 left ,虽然能实现,但是初次显示时会跳动 widgetUtil.asyncPrepare(context, true, (Rect rect) { final RenderBox box = _textKey.currentContext.findRenderObject(); final Size size = box.size; setState(() { _searchIconLeft = (rect.width - 16.0 - size.width) * .5; }); print('渲染完成 ${rect.size} $size ${size.width} $_searchIconLeft'); }); return Stack( alignment: AlignmentDirectional.center, children: <Widget>[ / / 搜索 按钮 AnimatedPositioned( child: ` 搜索`, left : _isEdit? 0 : _searchIconLeft, top: 0, bottom: 0 ), ] ) ;}
上面完美实现了 搜索
按钮居中,且左移动画纵享丝滑,但是在首次初始化的时候,会有跳动的Bug,原因就是_searchIconLeft
初始化为0,导致在widgetUtil.asyncPrepare()
计算出来_searchIconLeft
,会有个_searchIconLeft 由 0 过渡到 大于0
的动画,导致了跳动的Bug,解决方法:初始状态下,left 为 null ,等渲染完成后,再去设置 left 为 _searchIconLeft ,且渲染完成后再去显示 搜索 按钮
,终极伪代码如下/// 用于获取文字高度GlobalKey _textKey = new GlobalKey();/// 搜索图标距离左侧的距离double _searchIconLeft = 0;/// 是否已经渲染好bool _isPrepared = false;@overrideWidget build(BuildContext context) { // 方案一: 先算出 SearchCube 的宽度,再去计算其位置 left ,虽然能实现,但是初次显示时会跳动 widgetUtil.asyncPrepare(context, true, (Rect rect) { final RenderBox box = _textKey.currentContext.findRenderObject(); final Size size = box.size; setState(() { _isPrepared = true; _searchIconLeft = (rect.width - 16.0 - size.width) * .5; }); print('渲染完成 ${rect.size} $size ${size.width} $_searchIconLeft'); }); return Stack( alignment: AlignmentDirectional.center, children: <Widget>[ / / 搜索 按钮 AnimatedPositioned( child: Offstage( offstage: !_isPrepared, child: ` 搜索`, ), left : _isEdit? 0 : (_isPrepared ? _searchIconLeft : null), top: 0, bottom: 0 ), ] ) ;}
键盘高度监听。这个虽然看似简单,但确实是笔者在实现过程中耗时最久的模块,首先纵观全网,键盘监听高度的方法都是如下实现,伪代码如下: class _HomePageState extends State<HomePage> with WidgetsBindingObserver { @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeMetrics() { super.didChangeMetrics(); // Fixed Bug : bottomNavigationBar 的子页面无法监听到键盘高度变化, so 没办法只能再此监听了 WidgetsBinding.instance.addPostFrameCallback((_) { final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; }); }}
秉承着前人栽树,后人乘凉
的原则,以为代码一复制,则功能已实现,考虑到只有搜索页(SearchContent)
需要监听,所以兴致勃勃的把上述代码复制进去了,结果 didChangeMetrics
中获取的MediaQuery.of(context).viewInsets.bottom;
的值一直是 0
,代码完全没问题,当结果却是有问题,真是百撕不得骑姐,结果发现,微信页、联系人页 都监听不到,后来笔者大胆猜想,是否bottomNavigationBar 的子页面无法监听到键盘高度变化
,后面笔者把代码拷贝到 Homepage
页就行了,期间过程真是欲哭无泪... 只好利用Provider
来记录HomePage
的键盘高度变化,从而修改 搜索页(SearchContent)
的UI变化,伪代码如下:@overridevoid didChangeMetrics() { super.didChangeMetrics(); // Fixed Bug : bottomNavigationBar 的子页面无法监听到键盘高度变化, so 没办法只能再此监听了 WidgetsBinding.instance.addPostFrameCallback((_) { final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; Provider.of<KeyboardProvider>(context, listen: false) .setKeyboardHeight(keyboardHeight); });}
消息的侧滑删除
、下拉显示小程序
、点击导航栏 + 按钮,弹出菜单栏
等功能。其中通过对功能点的逐步剖析和逻辑处理,笔者相信大家在各个功能点的代码实现上应该能得心应手了。flutter
组件库; 同时学会了列表的监听滚动
和控制滚动
等知识点,掌握了不同的监听或控制滚动
的方案,以及对Flutter
中的状态管理的实现有了一定的了解等...Flutter
产生学习的动力和乐趣。关键词: