flutter笔记8:实战聊天页面嵌入交互动画和IOS风格适配

熟悉了flutter的各种控件和相互嵌套的代码结构后,可以再加深一点难度:加入动画特效。

虽然flutter的内置Metarial控件已经封装好了符合其设计语言的动画特效,使开发者节约了不少视觉处理上的精力,比如点击或长按listTile控件时自带水波纹动画、页面切换时切入向上或向下的动画、列表上拉或下拉到尽头有回弹波纹等。flutter也提供了用户可自定义的动画处理方案,使产品交互更加生动亲切、富有情趣。

Flutter中封装了包含有值和状态(如forward向前播放,reverse向后播放,completed完成播放和dismissed释放退出)的Animation对象。把Animation对象附加到控件中或直接监听动画对象属性, Flutter会根据对Animation对象属性的变化,修改控件的呈现效果并重新构建控件树。

这次,敲一个APP的聊天页面,试试加入Animation后的效果,再尝试APP根据运行的操作系统进行风格适配。

第一步 构建一个聊天界面

先创建一个新项目:

flutter create chatPage

进入main.dart,贴入如下代码:

import 'package:flutter/material.dart';
//程序入口
void main() {
  runApp(new FriendlychatApp());
}

const String _name = "CYC";    //聊天帐号昵称

class FriendlychatApp extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
    return new MaterialApp(        //创建一个MaterialApp控件对象,其下可塞入支持Material设计语言特性的控件
      title: "Friendlychat",
      home: new ChatScreen(),    //主页面为用户自定义ChatScreen控件
    );
  }
}

//单条聊天信息控件
class ChatMessage extends StatelessWidget {
  ChatMessage({this.text});
  final String text;
  @override
  Widget build(BuildContext context) {
    return new Container(
      margin: const EdgeInsets.symmetric(vertical: 10.0),
      child: new Row(                                   //聊天记录的头像和文本信息横向排列
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          new Container(
            margin: const EdgeInsets.only(right: 16.0),
            child: new CircleAvatar(child: new Text(_name[0])),      //显示头像圆圈
          ),
          new Column(                                    //单条消息记录,昵称和消息内容垂直排列
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              new Text(_name, style: Theme.of(context).textTheme.subhead),    //昵称
              new Container(
                margin: const EdgeInsets.only(top: 5.0),
                child: new Text(text),                    //消息文字
              ),
            ],
          ),
        ],
      ),
    );
  }
}

//聊天主页面ChatScreen控件定义为一个有状态控件
class ChatScreen extends StatefulWidget {
  @override
  State createState() => new ChatScreenState();   //ChatScreenState作为控制ChatScreen控件状态的子类
}

//ChatScreenState状态中实现聊天内容的动态更新
class ChatScreenState extends State<ChatScreen> {
  final List<ChatMessage> _messages = <ChatMessage>[];    //存放聊天记录的数组,数组类型为无状态控件ChatMessage
  final TextEditingController _textController = new TextEditingController();    //聊天窗口的文本输入控件

  //定义发送文本事件的处理函数
  void _handleSubmitted(String text) {
    _textController.clear();        //清空输入框
    ChatMessage message = new ChatMessage(    //定义新的消息记录控件对象
      text: text,
    );
    //状态变更,向聊天记录中插入新记录
    setState(() {
      _messages.insert(0, message);      //插入新的消息记录
    });
  }

  //定义文本输入框控件
  Widget _buildTextComposer() {
   return new Container(
       margin: const EdgeInsets.symmetric(horizontal: 8.0),  
       child: new Row(                    //文本输入和发送按钮都在同一行,使用Row控件包裹实现
          children: <Widget>[
            new Flexible(                    
              child: new TextField( 
                controller: _textController,              //载入文本输入控件
                onSubmitted: _handleSubmitted, 
                decoration: new InputDecoration.collapsed(hintText: "Send a message"),      //输入框中默认提示文字
              ),
            ),
            new Container(
              margin: new EdgeInsets.symmetric(horizontal: 4.0),
              child: new IconButton(            //发送按钮
                icon: new Icon(Icons.send),    //发送按钮图标
                onPressed: () => _handleSubmitted(_textController.text)),      //触发发送消息事件执行的函数_handleSubmitted
           ),
         ]
       )
    );
  }
  //定义整个聊天窗口的页面元素布局
  Widget build(BuildContext context) {
    return new Scaffold(              //页面脚手架
      appBar: new AppBar(title: new Text("Friendlychat")),      //页面标题
      body: new Column(             //Column使消息记录和消息输入框垂直排列
        children: <Widget>[
        new Flexible(                     //子控件可柔性填充,如果下方弹出输入框,使消息记录列表可适当缩小高度
          child: new ListView.builder(        //可滚动显示的消息列表
            padding: new EdgeInsets.all(8.0),
            reverse: true,                  //反转排序,列表信息从下至上排列
            itemBuilder: (_, int index) => _messages[index],    //插入聊天信息控件
            itemCount: _messages.length,
          )
        ),
        new Divider(height: 1.0),      //聊天记录和输入框之间的分隔
        new Container(
          decoration: new BoxDecoration(
            color: Theme.of(context).cardColor),
          child: _buildTextComposer(),        //页面下方的文本输入控件
        ),
       ]
     ),
   );
  }
}

运行上面的代码,可以看到这个聊天窗口已经生成,并且可以实现文本输入和发送了:


没有加入动画的聊天窗口

如上图标注的控件,最终通过放置在状态对象ChatScreenState控件中的Scaffold脚手架完成安置,小伙伴可以输入一些文本,点击发送按钮试试ListView控件发生的变化。

当发送按钮IconButton触发onPressed事件后调用_handleSubmitted函数,在_handleSubmitted中执行了setState()方法,此时flutter根据setState()中的变量_messages变更重新渲染_messages对象,然后大家就可以看到消息记录框ListView中底部新增了一行消息。

由于ListView中的每一行都是瞬间添加完成,没有过度动画,使交互显得非常生硬,因此向ListView中的每个Item的加入添加动画效果,提升一下交互体验。

第二步 消息记录加入动效

  • 改造ChatScreen控件

要让主页面ChatScreen支持动效,要在它的定义中附加mixin类型的对象TickerProviderStateMixin

class ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin { // modified
  final List<ChatMessage> _messages = <ChatMessage>[];
  final TextEditingController _textController = new TextEditingController();
  ...
  }
  • 向ChatMessage中植入动画控制器控制动画效果

class ChatMessage extends StatelessWidget {
  ChatMessage({this.text, this.animationController});        //new 加入动画控制器对象
  final String text;
  final AnimationController animationController;
  @override
  Widget build(BuildContext context) {
    return new SizeTransition(             //new  用SizeTransition动效控件包裹整个控件,定义从小变大的动画效果
      sizeFactor: new CurvedAnimation(                              //new  CurvedAnimation定义动画播放的时间曲线
        parent: animationController, curve: Curves.easeOut),      //new  指定曲线类型
      axisAlignment: 0.0,                                           //new  对齐
      child: new Container(                                    //modified  Container控件被包裹到SizeTransition中
        margin: const EdgeInsets.symmetric(vertical: 10.0),
          child: new Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
            new Container(
              margin: const EdgeInsets.only(right: 16.0),
              child: new CircleAvatar(child: new Text(_name[0])),
            ),
            new Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
              new Text(_name, style: Theme.of(context).textTheme.subhead),
              new Container(
                margin: const EdgeInsets.only(top: 5.0),
                child: new Text(text),
              ),
            ],
          ),
        ],
      ),
    )                                                           //new
  );
}
}
  • 修改_handleSubmitted()处理函数

由于ChatMessage对象的构造函数中添加了动画控制器对象animationController,因此创建新ChatMessage对象时也需要加入animationController的定义:

void _handleSubmitted(String text) {
  _textController.clear();
  ChatMessage message = new ChatMessage(
    text: text,
    animationController: new AnimationController(                  //new
      duration: new Duration(milliseconds: 700),                   //new  动画持续时间
      vsync: this,                                                 //new  TickerProviderStateMixin内默认的属性和参数
    ),                                                             //new
  );                                                               //new
  setState(() {
    _messages.insert(0, message);
  });
  message.animationController.forward();                           //new  启动动画
}
  • 释放控件

由于附加了动效的控件比较耗费内存,当不需要用到此页面时最好释放掉这些控件,Flutter会在复杂页面中自动调用dispose()释放冗余的对象,玩家可以通过重写dispose()指定页面中需要释放的内容,当然由于本案例只有这一个页面,因此Flutter不会自动执行到dispose()

@override
void dispose() {                                                   //new
  for (ChatMessage message in _messages)                           //new  遍历_messages数组
    message.animationController.dispose();                         //new  释放动效
  super.dispose();                                                 //new
} 

按上面的代码改造完后,用R而不是r重启一下APP,可以把之前没有加入动效的ChatMessage对象清除掉,使整体显示效果更和谐。这时候试试点击发送按钮后的效果吧~

  • 可以通过调整在_handleSubmittedAnimationController对象的Duration函数参数值(单位:毫秒),改变动效持续时间。

  • 可通过改变CurvedAnimation对象的curve参数值,改变动效时间曲线(和CSS的贝塞尔曲线类似),参数值可参考Curves

  • 可以尝试使用FadeTransition替代SizeTransition,试试动画效果如何

实现了消息列表的滑动,但是这个聊天窗口还有很多问题,比如输入框的文本只能横向增加不会自动换行,可以空字符发送消息等,接下来就修复这些交互上的BUG,顺便再复习下setState()的用法。

第三步 优化交互

  • 杜绝发送空字符

TextField控件中的文本正在被编辑时,会触发onChanged事件,我们通过这个事件检查文本框中是否有字符串,如果没有则点击发送按钮失效,如果有则可以发送消息。

class ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
  final List<ChatMessage> _messages = <ChatMessage>[];
  final TextEditingController _textController = new TextEditingController();
  bool _isComposing = false;                                      //new  到ChatScreenState对象中定义一个标志位
  ...
}

向文本输入控件_buildTextComposer中加入这个标志位的控制:

Widget _buildTextComposer() {
  return new IconTheme(
    data: new IconThemeData(color: Theme.of(context).accentColor),
    child: new Container(
      margin: const EdgeInsets.symmetric(horizontal: 8.0),
      child: new Row(
        children: <Widget>[
          new Flexible(
            child: new TextField(
              controller: _textController,
              onChanged: (String text) {          //new  通过onChanged事件更新_isComposing 标志位的值
                setState(() {                     //new  调用setState函数重新渲染受到_isComposing变量影响的IconButton控件
                  _isComposing = text.length > 0; //new  如果文本输入框中的字符串长度大于0则允许发送消息
                });                               //new
              },                                  //new
              onSubmitted: _handleSubmitted,
              decoration:
                  new InputDecoration.collapsed(hintText: "Send a message"),
            ),
          ),
          new Container(
            margin: new EdgeInsets.symmetric(horizontal: 4.0),
            child: new IconButton(
              icon: new Icon(Icons.send),
              onPressed: _isComposing
                  ? () => _handleSubmitted(_textController.text)    //modified
                  : null,                             //modified  当没有为onPressed绑定处理函数时,IconButton默认为禁用状态
            ),
          ),
        ],
      ),
    ),
  );
}

当点击发送按钮后,重置标志位为false:

void _handleSubmitted(String text) {
  _textController.clear();
  setState(() {                                                    //new  你们懂的
    _isComposing = false;                                          //new  重置_isComposing 值
  });                                                              //new
  ChatMessage message = new ChatMessage(
    text: text,
    animationController: new AnimationController(
      duration: new Duration(milliseconds: 700),
      vsync: this,
    ),
  );
  setState(() {
    _messages.insert(0, message);
  });
  message.animationController.forward();
}

这时候热更新一下,再发送一条消息试试:


禁止发送空文本
  • 自动换行

当发送的文本消息超出一行时,会看到以下效果:


无法自动换行

遇到这种情况,使用Expanded控件包裹一下ChatMessage的消息内容区域即可:

...

new Expanded(                                               //new  Expanded控件
  child: new Column(                                   //modified  Column被Expanded包裹起来,使其内部文本可自动换行
    crossAxisAlignment: CrossAxisAlignment.start,
    children: <Widget>[
      new Text(_name, style: Theme.of(context).textTheme.subhead),
      new Container(
        margin: const EdgeInsets.only(top: 5.0),
        child: new Text(text),
      ),
    ],
  ),
),                                                          //new

...

第四步 IOS和安卓风格适配

flutter虽然可以一套代码生成安卓和IOS的APP,但是这两者有着各自的设计语言:Material和Cupertino。因此为了让APP能够更好的融合进对应的系统设计语言,我们要对页面中的控件进行一些处理。

Cupertino和Material的设计风格
  • 引入IOS控件库:

前面已经引入Material.dart控件库,但还缺少了IOS的Cupertino控件库,因此在main.dart的头部中引入:

import 'package:flutter/cupertino.dart'; 
  • 定义Material和Cupertino的主题风格

Material为默认主题,当检测到APP运行在IOS时使用Cupertino主题,因此在最外部定义操作系统各自适配的主题风格:

final ThemeData kIOSTheme = new ThemeData(    //Cupertino主题风格
  primarySwatch: Colors.orange,
  primaryColor: Colors.grey[100],
  primaryColorBrightness: Brightness.light,
);

final ThemeData kDefaultTheme = new ThemeData(    //默认的Material主题风格
  primarySwatch: Colors.purple,
  accentColor: Colors.orangeAccent[400],
);
  • 根据运行的操作系统判断对应的主题:

首先要引入一个用于识别操作系统的工具库,其内的defaultTargetPlatform值可帮助我们识别操作系统:

import 'package:flutter/foundation.dart';

到程序的入口控件FriendlychatApp中应用对应的操作系统主题:

class FriendlychatApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: "Friendlychat",
      theme: defaultTargetPlatform == TargetPlatform.iOS         //newdefaultTargetPlatform用于识别操作系统
        ? kIOSTheme                                              //new
        : kDefaultTheme,                                         //new
      home: new ChatScreen(),
    );
  }
}
  • 页面标题的风格适配

页面顶部显示Friendlychat的标题栏的下方,在IOS的Cupertino设计语言中没有阴影,与下面的应用主体通过一条灰色的线分隔开,而Material则通过标题栏下方的阴影达到这一效果,因此将两种特性应用到代码中:

   // Modify the build() method of the ChatScreenState class.
   Widget build(BuildContext context) {
      return new Scaffold(
        appBar: new AppBar(
          title: new Text("Friendlychat"),
          elevation: Theme.of(context).platform == TargetPlatform.iOS ? 0.0 : 4.0),    //new  适配IOS的扁平化无阴影效果
          body: new Container(                    //modified    使用Container控件,方便加入主题风格装饰
            child: new Column(                      //modified
              children: <Widget>[
                new Flexible(
                  child: new ListView.builder(
                    padding: new EdgeInsets.all(8.0),
                    reverse: true,
                    itemBuilder: (_, int index) => _messages[index],
                    itemCount: _messages.length,
                  ),
                ),
              new Divider(height: 1.0),
              new Container(
              decoration: new BoxDecoration(color: Theme.of(context).cardColor),
              child: _buildTextComposer(),
            ),
          ],
        ),
          decoration: Theme.of(context).platform == TargetPlatform.iOS //new    加入主题风格
        ? new BoxDecoration(                                     //new
            border: new Border(                                  //new  为适应IOS加入边框特性
              top: new BorderSide(color: Colors.grey[200]),      //new  顶部加入灰色边框
            ),                                                   //new
          )                                                      //new
        : null),                                                 //modified  
      );
    }
  • 发送按钮的风格适配

发送按钮在APP遇到IOS时,使用Cupertino风格的按钮:

// Modify the _buildTextComposer method.

new Container(
   margin: new EdgeInsets.symmetric(horizontal: 4.0),
   child: Theme.of(context).platform == TargetPlatform.iOS ?  //modified
   new CupertinoButton(                              //new  使用Cupertino控件库的CupertinoButton控件作为IOS端的发送按钮
     child: new Text("Send"),                         //new
     onPressed: _isComposing                                  //new
         ? () =>  _handleSubmitted(_textController.text)      //new
         : null,) :                                           //new
   new IconButton(                                            //modified
       icon: new Icon(Icons.send),
       onPressed: _isComposing ?
           () =>  _handleSubmitted(_textController.text) : null,
       )
   ),

获取最终代码请点击此处

总结一下,为控件加入动画效果,就是把控件用动画控件包裹起来实现目的。动画控件有很多种,今天只选用了一个大小变化的控件SizeTransition作为示例,由于每种动画控件内部的属性不同,都需要单独配置,大家可参考官网了解这些动画控件的详情。

除此之外为了适应不同操作系统的设计语言,用到了IOS的控件库和操作系统识别的控件库,这是跨平台开发中常用的功能。

好啦,flutter的入门笔记到本篇就结束了,今后将更新flutter的进阶篇和实战,由于近期工作任务较重,加上日常还有跟前端大神狐神学习golang的任务,以后的更新会比较慢,因此也欢迎大家到我的Flutter圈子中投稿,分享自己的成果,把这个专题热度搞起来,赶上谷歌这次跨平台的小火车,也可以加入flutter 中文社区(官方QQ群:338252156)共同成长,谢谢大家~

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

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,066评论 4 62
  • 用到的组件 1、通过CocoaPods安装 2、第三方类库安装 3、第三方服务 友盟社会化分享组件 友盟用户反馈 ...
    SunnyLeong阅读 14,607评论 1 180
  • 单位经常邀请专家来讲课,由于采取强制措施,每个人必须签到,否则按考勤不到扣钱云云。这种被强迫的感觉让人很不舒服,所...
    雒渭阅读 195评论 2 1
  • 昨天看朋友的觉察日记,写到陪孩子时的各种念头,看着时就觉得是我的心声,今天上午孩子带着自行车出门,出门前孩子还是希...
    静静心晴阅读 164评论 0 1
  • 过渡 transition-property:none | all | propertytransition-du...
    greenteaObject阅读 236评论 0 6