Flutter TolyUI 框架#08 | 聊聊输入框

2,093 阅读11分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!


《Flutter TolyUI 框架》系列前言:

TolyUI张风捷特烈 打造的 Fluter 全平台应用开发 UI 框架。具备 全平台组件化源码开放响应式 四大特点。可以帮助开发者迅速构建具有响应式全平台应用软件:

开源地址: github.com/TolyFx/toly…

image.png

该系列将详细介绍 TolyUI 框架使用方式、框架开发过程中的技术知识、设计理念、难题解决等。
输入框作为应用程序最最最重要的 交互手段 ,没有之一。它可以接收用户的输入,向应用程序提供数据。比如输入用户名、密码、搜索关键字、验证码、投资金额、文字聊天等。可以说输入框在应用程序中的价值无可替代 (除非未来有什么颠覆性的交互革命)。


一、Flutter 内置的输入框

对于 Flutter 组件来说,输入框所涉及的源码可以说是框架中最复杂的部分。官方每次版本更新,都会或多或少涉及输入框方面的变化。另外 Flutter 作为 全平台 的应用开发框架,平台间的适应性也让输入框变得复杂,比如 Material 、Cupertino 风格。 还有对于桌面端、Web 而言鼠标、键盘的快捷键操作,让输入框的复杂性大大增加。本文将梳理 Flutter 内置输入框的 TextField 组件界面布局特征,从而为进一步封装输入框提供坚实的基础。


1. TextField 基本效果

下面分别是桌面端(Windows) 和移动端 (Android) 输入框 TextField 组件的表现样式。其中:

  • 桌面端由于有键盘设备,不需要弹出软键盘。在鼠标在输入框上时,鼠标指针会变成输入图标,而且边线有加深的示意。在输入框聚焦后,边线颜色变为主题色。
  • 移动端一般没有外接键盘设备,所以需要弹出 系统软键盘。输入框聚焦后,边线颜色同样变为主题色。

01.gif

代码也非常简单,在 Scaffold 组件的 body 中放置一个 TextField 组件即可:

Scaffold(
  backgroundColor: Colors.white,
  appBar: AppBar(
    backgroundColor: const Color(0xfff7f8fa),
    title: Text(widget.title),
  ),
  body: const TextField(),
);

下面是输入长字符串的效果:

02.gif


2. TextField 的布局特性

一个组件的布局特性可以从 界面表现布局尺寸内部约束 等方面分析。首先直观上来看:

TextField 默认会在 水平方向上 尽可能延伸。
TextField 默认高度只有一行,超出区域后,可以支持 水平滚动


对于组件 布局尺寸 的特征,可以通过 Flutter DevTools 的布局分析器 Flutter Inspector 来分析。在 《Flutter 调试工具篇 | 壹 - 使用 Flutter Inspector 分析界面》 一文中,介绍过分析器的使用方式。

image.png

从分析器中可以看出输入框的高度是 48, 但当选择文字部分时,可以发现。真正可编辑的区域 EditableText 高度是 24 :

image.png


3. 什么影响着 TextField 的高度

从组件详情树中可以找到尺寸突变的原因,如下所示:InputDecorator 组件对应的渲染对象尺寸为 360*48;而其下子节点的尺寸是 360*24。也就是说,InputDecorator 组件会影响输入框的高度值。

image.png


二、单行时 TextField 的尺寸特性

很多人可能为 TextField 的尺寸困扰,本节将详细探讨一些配置参数对 TextField 尺寸的影响,如下所示:

03.gif


1. TextField#style#fontSize

首先,对输入框尺寸影响最明显的当属文字样式中的 style 属性。随着字号变大,可编辑文字尺寸也会变大。这就会导致 TextField 尺寸增加。如下 fontSize 为 50,可以看出总高度和可编辑文字高度差为 99-75 = 24:

TextField 整体尺寸可编辑文字尺寸
image.pngimage.png

当字号减小到 16 ,两者高度之差为 48-24 = 24。可以看出,当文字变大时 InputDecorator 的作用下,会将可编辑文字外部加上边距:

TextField 整体尺寸可编辑文字尺寸
image.pngimage.png

2. TextField#decoration#isDense

isDense 表示是否是紧凑的,在 TextField#decoration 中,可以通过 InputDecoration 对象设置。

04.gif

关闭 isDense 时,尺寸: 458.7*48

image.png

开启 isDense 时,尺寸: 458.7*40

image.png

所以 isDense 的作用,可以使可编辑区域的高度边距减少,达到紧凑的效果。从而会影响输入框的高度。


3. TextField#decoration#isCollapsed

如下所示,边线装饰的 isCollapsed 属性会完全去除默认的边距:

05.gif

使默认情况下 TextField 的高度和可编辑区域的高度一致:

image.png


4.TextField#decoration#contentPadding

contentPadding 也是输入装饰 InputDecoration 的一部分,它是 EdgeInsetsGeometry 类型,表示可编辑文字,在输入框中左上右下的边距。但在实际使用时,它可能没那么 "听话"
如下所示,默认情况下,将 Padding 置为左右 24,上 0 下 80,可以看出它并没有按我们的心意距离上面 0 ,下面 80 。而是保持居中。

image.png

TextField 的实际大小是 458*96 ,水平方向的边距没有问题。竖直方向的高度差是 96-24 = 72

image.png


当开启 isCollapsd 时,就会按照 contentPadding 的边距来影响 TextField 的尺寸,不再强制居中,如下所示:

image.png

仔细看一下,此时 TextField 的高度是 96。 这么来算 96-24 = 72 ,距离下方并没有达到 80

image.png


当边距全为 0 时,可以看出可编辑区域高度是 16

image.png

当有充足的边距时,会将可编辑区域尺寸增加到 24

image.png

上面设置下边距为 80,但实际输入框尺寸是 96,且可编辑区 24 。这 8 逻辑像素,被用于可编辑区尺寸的生长。


三、多行时 TextField 的尺寸特性

默认情况下 TextField 只支持一行,我们可以通过一些配置来实现多行输入甚至是输入区域。如下所示,我们在 PlayGround 中增加 Lines 的输入框来控制输入框最大和最小的行数:

06.gif


1. 最小行数: minLines

minLinesmaxLines 用于控制输入的最小最大行数。在无输入时,mixLines 可以让可编辑区域的高度占指定行数倍的文本高度:

image.png

TextField(
  style: style,
  controller: _ctrl,
  minLines: _minLine,
  maxLines: _maxLine,
  decoration: InputDecoration(
      isDense: _isDense,
      contentPadding: _padding,
      isCollapsed: _isCollapsed,
      hintText: '请输入...',
      border: OutlineInputBorder()),
),

上面调试分析时知道,当前字体 16 号的文字一行占 24 逻辑像素。所以这里将 minLines 指定为 3 。可编辑区的高度将变为 24*3 = 72:

image.png


2. 最大行数: maxLines

当输入了大量的文本时,不可能让所有的文字全部展示。maxLines 可以控制展示的最大行数。当超过最大行数时,在可编辑区域会拥有 竖直方向 滚动的特点。可以对比一下,默认情况下,只能展示一行文本,超出区域时会拥有 水平方向 滚动的特点。

image.png

这就是 minLinesmaxLines 的作用,它是开启输入框多行展示,竖直滚动的钥匙。


3. 自动延展:expand

当为输入框施加一个紧约束,比如 SizedBox、Expandend 延展时。TextField 的高度被迫为指定高度,但是可编辑区则仍纹丝不动:

TextFile区域可编辑区域
image.pngimage.png

有很多场景,我们需要可编辑区域自适应高度,比如企业微信、笔记软件编辑区:

image.png

此时可以通过 expand 属性,使可编辑区自动伸展。⚠️ 注意,此属性为 true 时,maxLines 和minLines 需要为 null

image.png

默认情况下可编辑区会居中,可以通过打开 isCollapsed 关闭默认的布局行为,然后通过调节 contentPadding 达到期望的可编辑区域的效果:

07.gif

到这里,输入框的尺寸方便的布局特性就介绍的差不多了。


四、从示例聊聊输入框的界面表现

接下来,我将通过几个实际的小例子,介绍一下输入框的界面表现。以此介绍 TextField 装饰的各个属性对应的表现效果。


1. 搜索输入框

首先来看一个非常常见的搜索框,如下是仿照网盘 搜索 的样式。其中:

  • 左侧有搜索图标;默认提示 搜索
  • 在未激活时,搜索框呈灰色,无边线;激活后,填充白色,蓝色边线
  • 输入框有一定的圆角。

08.gif


  • 想让输入框有填充色,可以使用 filled + fillColor 属性;
  • 图标前缀可以使用 prefixIconprefixIconColor属性;
  • 边线和激活边线可以使用 borderfocusedBorder 属性;

这里强调一点,prefixIcon 图标默认最小宽高约束是 40*40; 如果你原本的输入框高度小于 40,那么会被成大而影响布局效果:

image.png

我们可以通过 prefixIconConstraints 来控制前缀的约束:比如下面将其改为 32*32:

 prefixIconConstraints: BoxConstraints(minWidth: 32, minHeight: 32),

image.png


由于需求中激活时,填充色需要变为白色,所以需要通过 FocusNode 来监听激活状态,更新填充色。全部代码如下所示。其中 constraints 可以调节输入框的布局约束信息,也就是最大最小宽高:

class SearchInput extends StatefulWidget {
  const SearchInput({super.key});

  @override
  State<SearchInput> createState() => _SearchInputState();
}

class _SearchInputState extends State<SearchInput> {
  final FocusNode _focusNode = FocusNode();
  Color _fillColor = const Color(0xffe5e6e8);

  @override
  void initState() {
    super.initState();
    _focusNode.addListener(_onFocusChange);
  }

  void _onFocusChange() {
    setState(() {
      _fillColor = _focusNode.hasFocus ? Colors.white : const Color(0xffe5e6e8);
    });
  }

  @override
  void dispose() {
    _focusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      focusNode: _focusNode,
      style: const TextStyle(height: 1),
      decoration: InputDecoration(
        isDense: true,
        constraints: const BoxConstraints(maxWidth: 200),
        filled: true,
        fillColor: _fillColor,
        prefixIconConstraints: const BoxConstraints(minWidth: 32, minHeight: 32),
        hintText: '搜索',
        hintStyle: const TextStyle(color: Color(0xff8d9195)),
        prefixIcon: const Icon(Icons.search, size: 18),
        prefixIconColor: const Color(0xff63676d),
        focusedBorder: OutlineInputBorder(
          borderRadius: BorderRadius.circular(4),
          borderSide: const BorderSide(color: Color(0xff267ef0)),
        ),
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(4),
          borderSide: BorderSide.none,
        ),
      ),
    );
  }
}

2. 综合性输入框

下面的小案例,将囊括 InputDecoration 中的各种小饰品,其中:

  • label 系列, 未激活时展示在中间,激活时在边线左上角;此处为 Account
  • hint 系列,虽然文字为空时,虽然内容使用。 此处为 Input...
  • prefixIcon 系列,横在左侧的组件;此处为 用户图标
  • prefix 系列,输入框激活时会在 prefixIcon 后展示;此处为 User::
  • suffixIcon 系列,恒在右侧的组件;此处为 电话图标
  • suffix 系列,输入框激活时会在 suffixIcon 前展示;此处为 ::call
  • helper 系列,底部的帮助文字。此处为 Help me?
  • error 系列,底部的错误文字。此处为 This is an Test Error!

09.gif

当 errorText 非空时,helperText 会被替代,展示错误文字。这里测试时,点击小电话主动切换 _error 的状态。errorBorder 用于控制错误时的非激活边线;focusedErrorBorder 用于控制错误时的激活时边线,全部代码如下所示:

class SearchInputV2 extends StatefulWidget {
  const SearchInputV2({super.key});

  @override
  State<SearchInputV2> createState() => _SearchInputV2State();
}

class _SearchInputV2State extends State<SearchInputV2> {
  FocusNode _focusNode = FocusNode();
  Color _fillColor = Color(0xffe5e6e8); // 默认填充色

  @override
  void initState() {
    super.initState();
    _focusNode.addListener(_onFocusChange);
  }

  void _onFocusChange() {
    setState(() {
      _fillColor = _focusNode.hasFocus ? Colors.white : Color(0xffe5e6e8);
    });
  }

  bool _error = false;

  @override
  void dispose() {
    _focusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      focusNode: _focusNode,
      style: TextStyle(height: 1),
      decoration: InputDecoration(
        isDense: true,
        constraints: BoxConstraints(maxWidth: 240),
        filled: true,
        contentPadding: EdgeInsets.symmetric(vertical: 8, horizontal: 12),
        fillColor: _fillColor,
        prefixIconConstraints: BoxConstraints(minWidth: 32, minHeight: 32),
        prefixIcon: Icon(Icons.supervised_user_circle, size: 18),
        prefixIconColor: const Color(0xff63676d),
        prefixText: "User:: ",
        prefixStyle: TextStyle(color: Colors.grey, fontSize: 14),
        suffixText: '::call',
        suffixStyle: TextStyle(color: Colors.grey, fontSize: 14),
        suffixIcon: GestureDetector(onTap: _toggleError, child: Icon(Icons.call)),
        suffixIconConstraints: BoxConstraints(minWidth: 32, minHeight: 32),
        focusColor: Colors.white,
        suffixIconColor: Colors.blue,
        labelStyle: TextStyle(color: Colors.grey),
        hintText: 'Input...',
        hintStyle: TextStyle(color: Colors.grey),
        labelText: 'Account',
        helperText: 'Help me?',
        helperStyle: TextStyle(
            color: Colors.blue, decoration: TextDecoration.underline, decorationColor: Colors.blue),
        errorText: _error ? 'This is an Test Error!' : null,
        focusedBorder: OutlineInputBorder(
          borderRadius: BorderRadius.circular(4),
          borderSide: BorderSide(color: Color(0xff267ef0)),
        ),
        errorBorder: OutlineInputBorder(
          borderRadius: BorderRadius.circular(4),
          borderSide: BorderSide(color: Colors.redAccent),
        ),
        focusedErrorBorder: OutlineInputBorder(
          borderRadius: BorderRadius.circular(4),
          borderSide: BorderSide(color: Colors.redAccent),
        ),
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(4),
          borderSide: BorderSide.none,
        ),
      ),
    );
  }

  void _toggleError() {
    setState(() {
      _error = !_error;
    });
  }
}

3. 输入区域

最后来模拟一下企业微信的输入区,如下所示。我们将在指定高度区域,内让输入框自动伸展。

10.gif


首先,简单地划分一下区域,当前视图呈 左右结构,右侧呈上中下分布。其中主角在最下方,封装为 ImInputPanel 组件单独维护:

image.png

class ImPanel extends StatelessWidget {
  const ImPanel({super.key});

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Container(width: 240, color: const Color(0xfff0f0f1)),
        const VerticalDivider(width: 1, color: Color(0xffdee0e2)),
        Expanded(
          child: ColoredBox(
            color: const Color(0xfff6f6f7),
            child: Column(
              children: [
                Container(height: 56),
                const Divider(height: 1, color: Color(0xffdee0e2)),
                Expanded(child: Container()),
                const ImInputPanel()
              ],
            ),
          ),
        ),
      ],
    );
  }
}

输入面板本身也是一个上中下结构,上下区域是输入框的工具条。中间的输入框使用 Expanded 组件延展剩余高度:

image.png

输入框在构造时将 expand 置为 true ,并且需要将 maxLines 置为 null; cursor 系列的属性可以配置输入框中的光标:

image.png

class ImInputPanel extends StatelessWidget {
  const ImInputPanel({super.key});

  Widget _buildInputPanel() {
    return const TextField(
      style: TextStyle(fontSize: 14),
      cursorWidth: 1,
      expands: true,
      maxLines: null,
      decoration: InputDecoration(
        isCollapsed: true,
        border: InputBorder.none,
        contentPadding: EdgeInsets.symmetric(horizontal: 12),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 240,
      margin: const EdgeInsets.only(left: 20, right: 20, bottom: 20),
      decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
      child: Column(
        children: [
          const TopBar(),
          Expanded(child: _buildInputPanel()),
          const BottomBar(),
        ],
      ),
    );
  }
}

到这里,TextFiled 组件的基础使用上介绍完毕了,但它的视图表现比较局限。TolyUI将基于已有的输入框,提供更多的输入相关的通用组件或者自定义装饰效果。让开发者可以迅速构建心仪的效果。敬请期待~