Flutter 玩转微信——微信首页

概述

  • 这篇文章主要介绍的是如何利用Flutter搭建微信首页的功能,详细讲述该功能实现过程中所运用到的技术,以及遇到问题后如何解决的心得体会。该功能虽然粗看时看似简单,但是细作时发现其功能逻辑复杂,内部细节处理较高,当然其中涵盖了Flutter中大部分知识点,笔者相信初学者通过实现该功能后,定会对所学的Flutter知识的掌握上更上一层楼

  • 笔者此次主要实现了微信首页的以下几个功能点:

    • 消息的侧滑删除
    • 下拉显示小程序
    • 点击导航栏 + 按钮,弹出菜单栏
    • 点击搜索框,弹出搜索页
  • 笔者希望初学者通过实现上面👆的功能点,能够在学习Flutter的过程中有所帮助,当然笔者必将知无不言、言无不尽,梳理实战过程之问题,总结解决问题之方案,让尔等知其然,知其所以然。望能抛玉引砖,摆渡众生,如有纰漏,还望斧正。

  • 源码地址:flutter_wechat

效果图

GIF 微信页
mainframe_page.gif
mainframe_page_1.png
菜单栏 小程序
mainframe_page_2.png
mainframe_page_3.png
搜索页
mainframe_page_4.png

知识储备

  • Stack + Positioned 布局
  • Transform.translate(平移)Transform.scale(放大)Opacity(设置子部件透明度)
  • 滚动监听及控制
  • 动画组件使用(AnimatedPositioned、AnimatedOpacity、ScaleTransition)
  • 状态管理Provider
  • 监听键盘弹起
  • 通过GlobalKey 获取某个 Widget 的尺寸

功能

一、消息的侧滑删除

侧滑删除的功能,主要利用 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的变化发生偏移,其无非是修改Positionedtop属性即可,伪代码如下:

Positioned(
    top: _offset,
    //...
)

当结束 拖拽状态 下时UI,即:如果手指释放的瞬间,_offset 大于 临界点,则 导航栏内容页...等部件会丝滑的过渡到底部,这里想必大家一定清楚了,要想实现丝滑过渡这个功能,一定离不开动画的加持。那么这种状态下,若依然延用修改Positionedtop属性方法就会在这个过程中显得生硬,所以这里采用Flutter 自带的动画组件 AnimatedPositioned 来代替 Positioned。 伪代码如下:

AnimatedPositioned(
    top: _offset,
    duration: Duration(milliseconds: 300),
    //...
)

AnimatedPositioned虽然轻而易举的实现了非拖拽状态下时 导航栏 丝滑过渡到底部的功能,但是若处于拖拽状态下时,用AnimatedPositioned就会导致导航栏很Q弹,比较差强人意。为了兼顾这两种状态,笔者采用的是控制AnimatedPositionedduration属性来实现的,即:拖拽时,_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。因为最外层的上拉滑动,能促使导航栏、内容页向上偏移,所以最外层的ListViewmaxScrollExtent:最大可滚动长度的处理是比较细节的。也就是理想情况下,手指从屏幕最底部向上拖拽到屏幕最顶部,正好能使导航栏的最顶部到达屏幕的顶部即可,那么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的路上,很多知识都不够全面,导致其实现过程中还是遇到了些许坑,这里笔者一一详尽,所需知识点如下:

  • 通过GlobalKey 获取某个 Widget 的尺寸
  • AnimatedPositioned 实现平移动画
  • 监听键盘的高度变化
  • 功能分析
    1、点击微信首页搜索框,🔍搜索取消 按钮同时向左平移,并且AppBarSearch页同时向上移动,键盘弹出;微信内容页底部TabBar 隐藏,搜索页面展示,按住说话 按钮跟随键盘弹出而弹出。
    2、点击搜索页的取消按钮🔍搜索取消按钮同时向右平移,并且AppBarSearch页同时向下移动,键盘收起;微信内容页底部TabBar 显示,搜索页面隐藏,清掉搜索内容,按住说话 按钮跟随键盘收起而收起。

  • UI搭建

UI主要包括搜索框(SearchBar)搜索页(SearchContent)的搭建,虽整体不难,但细节满满。因为考虑平移(左移、右移)动画监听键盘高度变化而变化的UI,所以整体内部widget布局都是采用Stack + Positioned/AnimatedPositioned 来构建的,当然道路千万条,实现第一条。这里以SearchBar为例,其内部的子部件(widget)布局,伪代码如下:

Stack(
  children: <Widget>[
      // 白色背景框
      AnimatedPositioned(),
      // 输入框
      Positioned(),
      // 🔍搜索 按钮 
      AnimatedPositioned(),
      // 取消按钮
      AnimatedPositioned()
  ]
)
  • 功能实现

🔍搜索 居中实现。虽然UI实现居中可能比较简单,比如: Stackalignment: AlignmentDirectional.center,RowmainAxisAlignment: MainAxisAlignment.center, ,以及 Containeralignment: AlignmentDirectional.center,等.... 但是需要考虑到动画的加入以及动画丝滑的效果,就不得不采用Stack布局的形式了,以及采用Stackalignment: AlignmentDirectional.center,来达到居中,且AnimatedPositionedleftright必须设置``null,不然是无法居中的,伪代码如下:

Stack(
  alignment: AlignmentDirectional.center,
  children: <Widget>[
      // 🔍搜索 按钮 
      AnimatedPositioned(
         child: `🔍搜索`,
         left : null,
         top: 0,
         bottom: 0
     ),
  ]
)

虽然上述确实实现了🔍搜索 居中,且不费吹灰之力。如果点击🔍搜索 按钮,假设此时是编辑模式,即 _isEdit = true; ,此时🔍搜索 按钮需要加入左移动画,即AnimatedPositionedleft : 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;

@override
Widget 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;

@override
Widget 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变化,伪代码如下:

@override
void 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产生学习的动力和乐趣。

期待

  1. 文章若对您有些许帮助,请给个喜欢❤️,毕竟码字不易;若对您没啥帮助,请给点建议💗,切记学无止境。
  2. 针对文章所述内容,阅读期间任何疑问;请在文章底部评论指出,我会火速解决和修正问题。
  3. GitHub地址:https://github.com/CoderMikeHe
  4. 源码地址:flutter_wechat

主页

GitHub 掘金 CSDN 知乎
点击进入 点击进入 点击进入 点击进入

拓展

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容

  • 长沙修学行分享 长沙第49期初级班分享 自我介绍:朱峻锋,南宁平台 首次初级班时间:2019年11月南宁第49期 ...
    朱队长爱学习阅读 833评论 0 1
  • 我有些不自量力把大师们都集于拙笔下,现在反倒越来越胆怯,由于水平所限,生怕辱没了大师们的尊严和高洁,叫诗友们贻笑大...
    梅姿阅读 2,118评论 40 71
  • 2018年2月8日星期四天气晴 今天一大早又去送小儿子,一下楼就看见车库门开着,这忘事的老毛病又犯了,总是丢三落四...
    卢立松阅读 200评论 0 0
  • 1.第二章 关系模型 关系库是表的集合,一个表是一个实体集,一行是一个实体 列首位为属性,每个属性有一组允许的值,...
    杰米阅读 497评论 0 0