本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
《Flutter TolyUI 框架》系列前言:
TolyUI 是 张风捷特烈 打造的 Fluter 全平台应用开发 UI 框架。具备 全平台、组件化、源码开放、响应式 四大特点。可以帮助开发者迅速构建具有响应式全平台应用软件:
开源地址: github.com/TolyFx/toly…
该系列将详细介绍 TolyUI 框架使用方式、框架开发过程中的技术知识、设计理念、难题解决等。
输入框作为应用程序最最最重要的 交互手段
,没有之一。它可以接收用户的输入,向应用程序提供数据。比如输入用户名、密码、搜索关键字、验证码、投资金额、文字聊天等。可以说输入框在应用程序中的价值无可替代 (除非未来有什么颠覆性的交互革命)。
一、Flutter 内置的输入框
对于 Flutter 组件来说,输入框所涉及的源码可以说是框架中最复杂的部分。官方每次版本更新,都会或多或少涉及输入框方面的变化。另外 Flutter 作为 全平台 的应用开发框架,平台间的适应性也让输入框变得复杂,比如 Material 、Cupertino 风格。 还有对于桌面端、Web 而言鼠标、键盘的快捷键操作,让输入框的复杂性大大增加。本文将梳理 Flutter 内置输入框的 TextField 组件界面布局特征,从而为进一步封装输入框提供坚实的基础。
1. TextField 基本效果
下面分别是桌面端(Windows
) 和移动端 (Android
) 输入框 TextField 组件的表现样式。其中:
- 桌面端由于有键盘设备,不需要弹出软键盘。在鼠标在输入框上时,鼠标指针会变成输入图标,而且边线有加深的示意。在输入框聚焦后,边线颜色变为主题色。
- 移动端一般没有外接键盘设备,所以需要弹出
系统软键盘
。输入框聚焦后,边线颜色同样变为主题色。
代码也非常简单,在 Scaffold
组件的 body 中放置一个 TextField
组件即可:
Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: const Color(0xfff7f8fa),
title: Text(widget.title),
),
body: const TextField(),
);
下面是输入长字符串的效果:
2. TextField 的布局特性
一个组件的布局特性可以从 界面表现、布局尺寸、内部约束 等方面分析。首先直观上来看:
TextField 默认会在
水平方向上
尽可能延伸。
TextField 默认高度只有一行,超出区域后,可以支持水平滚动
。
对于组件 布局尺寸 的特征,可以通过 Flutter DevTools 的布局分析器 Flutter Inspector 来分析。在 《Flutter 调试工具篇 | 壹 - 使用 Flutter Inspector 分析界面》 一文中,介绍过分析器的使用方式。
从分析器中可以看出输入框的高度是 48
, 但当选择文字部分时,可以发现。真正可编辑的区域 EditableText 高度是 24
:
3. 什么影响着 TextField 的高度
从组件详情树中可以找到尺寸突变的原因,如下所示:InputDecorator 组件对应的渲染对象尺寸为 360*48
;而其下子节点的尺寸是 360*24
。也就是说,InputDecorator 组件会影响输入框的高度值。
二、单行时 TextField 的尺寸特性
很多人可能为 TextField 的尺寸困扰,本节将详细探讨一些配置参数对 TextField 尺寸的影响,如下所示:
1. TextField#style#fontSize
首先,对输入框尺寸影响最明显的当属文字样式中的 style
属性。随着字号变大,可编辑文字尺寸也会变大。这就会导致 TextField 尺寸增加。如下 fontSize 为 50,可以看出总高度和可编辑文字高度差为 99-75 = 24
:
TextField 整体尺寸 | 可编辑文字尺寸 |
---|---|
当字号减小到 16 ,两者高度之差为 48-24 = 24
。可以看出,当文字变大时 InputDecorator 的作用下,会将可编辑文字外部加上边距:
TextField 整体尺寸 | 可编辑文字尺寸 |
---|---|
2. TextField#decoration#isDense
isDense 表示是否是紧凑的,在 TextField#decoration 中,可以通过 InputDecoration
对象设置。
关闭 isDense 时,尺寸:
458.7*48
开启 isDense 时,尺寸:
458.7*40
所以 isDense 的作用,可以使可编辑区域的高度边距减少,达到紧凑的效果。从而会影响输入框的高度。
3. TextField#decoration#isCollapsed
如下所示,边线装饰的 isCollapsed 属性会完全去除默认的边距:
使默认情况下 TextField 的高度和可编辑区域的高度一致:
4.TextField#decoration#contentPadding
contentPadding 也是输入装饰 InputDecoration 的一部分,它是 EdgeInsetsGeometry 类型,表示可编辑文字,在输入框中左上右下的边距。但在实际使用时,它可能没那么 "听话"
。
如下所示,默认情况下,将 Padding 置为左右 24,上 0 下 80,可以看出它并没有按我们的心意距离上面 0 ,下面 80 。而是保持居中。
TextField 的实际大小是 458*96
,水平方向的边距没有问题。竖直方向的高度差是 96-24 = 72
:
当开启 isCollapsd 时,就会按照 contentPadding 的边距来影响 TextField 的尺寸,不再强制居中,如下所示:
仔细看一下,此时 TextField 的高度是 96。 这么来算 96-24 = 72 ,距离下方并没有达到 80
当边距全为 0 时,可以看出可编辑区域高度是 16
:
当有充足的边距时,会将可编辑区域尺寸增加到 24
:
上面设置下边距为 80,但实际输入框尺寸是 96,且可编辑区 24 。这 8 逻辑像素,被用于可编辑区尺寸的生长。
三、多行时 TextField 的尺寸特性
默认情况下 TextField 只支持一行,我们可以通过一些配置来实现多行输入甚至是输入区域。如下所示,我们在 PlayGround 中增加 Lines 的输入框来控制输入框最大和最小的行数:
1. 最小行数: minLines
minLines
和 maxLines
用于控制输入的最小最大行数。在无输入时,mixLines 可以让可编辑区域的高度占指定行数倍的文本高度:
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
:
2. 最大行数: maxLines
当输入了大量的文本时,不可能让所有的文字全部展示。maxLines
可以控制展示的最大行数。当超过最大行数时,在可编辑区域会拥有 竖直方向 滚动的特点。可以对比一下,默认情况下,只能展示一行文本,超出区域时会拥有 水平方向 滚动的特点。
这就是 minLines
和 maxLines
的作用,它是开启输入框多行展示,竖直滚动的钥匙。
3. 自动延展:expand
当为输入框施加一个紧约束,比如 SizedBox、Expandend 延展时。TextField 的高度被迫为指定高度,但是可编辑区则仍纹丝不动:
TextFile区域 | 可编辑区域 |
---|---|
有很多场景,我们需要可编辑区域自适应高度,比如企业微信、笔记软件编辑区:
此时可以通过 expand 属性,使可编辑区自动伸展。⚠️ 注意,此属性为 true 时,maxLines 和minLines 需要为 null
默认情况下可编辑区会居中,可以通过打开 isCollapsed
关闭默认的布局行为,然后通过调节 contentPadding
达到期望的可编辑区域的效果:
到这里,输入框的尺寸方便的布局特性就介绍的差不多了。
四、从示例聊聊输入框的界面表现
接下来,我将通过几个实际的小例子,介绍一下输入框的界面表现。以此介绍 TextField 装饰的各个属性对应的表现效果。
1. 搜索输入框
首先来看一个非常常见的搜索框,如下是仿照网盘 搜索 的样式。其中:
- 左侧有搜索图标;默认提示
搜索
- 在未激活时,搜索框呈灰色,无边线;激活后,填充白色,蓝色边线
- 输入框有一定的圆角。
- 想让输入框有填充色,可以使用
filled
+fillColor
属性; - 图标前缀可以使用
prefixIcon
、prefixIconColor
属性; - 边线和激活边线可以使用
border
和focusedBorder
属性;
这里强调一点,prefixIcon
图标默认最小宽高约束是 40*40
; 如果你原本的输入框高度小于 40,那么会被成大而影响布局效果:
我们可以通过 prefixIconConstraints
来控制前缀的约束:比如下面将其改为 32*32:
prefixIconConstraints: BoxConstraints(minWidth: 32, minHeight: 32),
由于需求中激活时,填充色需要变为白色,所以需要通过 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!
当 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. 输入区域
最后来模拟一下企业微信的输入区,如下所示。我们将在指定高度区域,内让输入框自动伸展。
首先,简单地划分一下区域,当前视图呈 左右结构,右侧呈上中下分布。其中主角在最下方,封装为 ImInputPanel 组件单独维护:
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 组件延展剩余高度:
输入框在构造时将 expand 置为 true ,并且需要将 maxLines 置为 null; cursor
系列的属性可以配置输入框中的光标:
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将基于已有的输入框,提供更多的输入相关的通用组件或者自定义装饰效果。让开发者可以迅速构建心仪的效果。敬请期待~