Flutter 玩转微信——微信首页
概述
-
这篇文章主要介绍的是如何利用
Flutter
搭建微信首页的功能,详细讲述该功能实现过程中所运用到的技术,以及遇到问题后如何解决的心得体会。该功能虽然粗看时看似简单,但是细作时发现其功能逻辑复杂,内部细节处理较高,当然其中涵盖了Flutter
中大部分知识点,笔者相信初学者通过实现该功能后,定会对所学的Flutter
知识的掌握上更上一层楼
。 -
笔者此次主要实现了微信首页的以下几个功能点:
- 消息的侧滑删除
- 下拉显示小程序
- 点击导航栏 + 按钮,弹出菜单栏
-
笔者希望初学者通过实现上面👆的功能点,能够在学习
Flutter
的过程中有所帮助,当然笔者必将知无不言、言无不尽
,梳理实战过程之问题,总结解决问题之方案,让尔等知其然,知其所以然
。望能抛玉引砖,摆渡众生,如有纰漏,还望斧正。 -
源码地址:flutter_wechat
效果图
GIF |
微信页 |
---|---|
mainframe_page.gif | mainframe_page_1.png |
菜单栏 |
小程序 |
mainframe_page_2.png | mainframe_page_3.png |
知识储备
Stack + Positioned 布局
-
Transform.translate(平移)
、Transform.scale(放大)
、Opacity(设置子部件透明度)
滚动监听及控制
动画组件使用(AnimatedPositioned、AnimatedOpacity、ScaleTransition)
状态管理Provider
功能
一、消息的侧滑删除
侧滑删除
的功能,主要利用 flutter_slidable 插件来实现的,其具体实现过程以及细节处理的心得体会,与笔者前面写过的 Flutter 玩转微信——通讯录 文章中详细说明如何实现联系人侧滑删除
的功能类似,这里笔者就不再一一赘述。有兴趣的同学,还请自行移步。
二、下拉显示小程序
下拉显示小程序,以及显示后上拉隐藏小程序的功能,个人认为在实现过程是比较复杂的,涵盖大部分Flutter
必备的知识点,所以笔者会详述其实现过程中遇到的坑以及填坑的方法。
- UI搭建
由于考虑到下拉过程中,内容页
、导航栏
、三个点
、小程序
都会层叠展示,所以整个微信页面这里采取的是 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
的概念。
- 功能分析
大家可以对比你手机上的微信首页,下拉显示小程序的功能上其实涵盖了,下拉显示小程序
和上拉隐藏小程序
两个过程的逻辑处理,当然这才是一个真正的闭环,有显示就会有隐藏。这里笔者就只拿以 下拉逻辑
为例,详细讲解其中的逻辑分析和细节处理。上拉逻辑
大家可以反推即可。
❗️下拉逻辑
1、手指下拉内容页整个过程中,导航栏
的顶部会随着手指下拉而向下偏移(offset)
,偏移距离等于下拉距离。
2、继续下拉到 临界点① = 60
时,出现一个小球
逐渐放大,放大系数(scale) = 0
,当 偏移量 > 临界点①
时,scale 会逐渐变大
;反之,scale = 0
。
3、继续下拉到 临界点② = 90
时,此过程中,小球
会放大到最大值(scale = 2
)。即offset:临界点① --> 临界点②
,scale: 0 --> 2
。
4、继续下拉到 临界点③ = 130
时,此过程中,小球
会生成两个小球,一个小球逐渐左平移到最大值,一个小球逐渐右平移到最大值,其本身也缩放到原始值(scale = 1
)。
5、继续下拉到 临界点④ = 180
时,此过程中,三个球
的透明度(opacity
)从 1.0 --> 0.2
变化,以及小程序模块透明度(opacity
)从0 --> 0.5
变化且自身缩放比例(scale
)为(scale = 0.4
)。
6、继续下拉 offset > 临界点④
时,三个小球
的透明度恒等于0.2
,以及小程序模块透明度恒等于0.5
且自身缩放比例(scale
)恒为(scale = 0.4
)。
注意: 以上👆过程都是用户手指都是处于拖拽
状态,也就是手指没有离开屏幕。那么手指离开屏幕后,有会发生什么状况呢,请听笔者一一道来。
7、手指释放的一瞬间,判断下拉偏移量offset
是否大于 临界点② = 90
, 若大于,则显示小程序模块,反之,则隐藏小程序模块。
8、显示小程序的过程中,导航栏的底部偏移到屏幕的底部、内容页的顶部平移到屏幕的底部,小程序的透明度由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
组件。
-
功能分析
1、 点击导航栏+按钮,菜单渐渐显示(透明度动画)。
2、 显示菜单栏后,点页面空白处,菜单渐渐向右上角缩放隐藏(透明度动画+缩放动画) -
功能实现
关键代码如下:
@override
void 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(() {});
}
});
}
@override
Widget 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
组件库; 同时学会了列表的监听滚动
和控制滚动
等知识点,掌握了不同的监听或控制滚动
的方案,以及对Flutter
中的状态管理的实现有了一定的了解等...
最后,本文的核心还是想培养大家在写任意一个功能之前,先做一些功能或逻辑分析,理清思路,确定好实现方案,再去编写代码,磨刀不负砍柴工。同时也希望大家在完成此功能后,对Flutter
产生学习的动力和乐趣。
期待
- 文章若对您有些许帮助,请给个喜欢❤️,毕竟码字不易;若对您没啥帮助,请给点建议💗,切记学无止境。
- 针对文章所述内容,阅读期间任何疑问;请在文章底部评论指出,我会火速解决和修正问题。
- GitHub地址:https://github.com/CoderMikeHe
- 源码地址:flutter_wechat