说明
本文源码基于flutter 1.7.8
之前我们分析完文本,现在来分析一下输入框
使用
关于怎么使用,这里不做过多的介绍了
推荐看一下:Flutter TextField详解
介绍的还是蛮详细的
问题
- 值的监听和变化是怎么实现的
- 限制输入字数的效果是怎么实现的
- 长按出现的复制、粘贴提示是怎么实现的
- 光标呼吸是如何实现的
分析
由简单的开始分析,逐步深入
1. 问题一:值的监听和变化是怎么实现的
我们通常通过以下方式来获取textField里输入的值
//将这个controller传递给textField
final controller = TextEditingController();
controller.addListener(() {
//获取输入的值
print('input ${controller.text}');
});
那么TextEditingController到底是什么?
class TextEditingController extends ValueNotifier<TextEditingValue> {
//将TextEditingValue传给父类
TextEditingController({ String text })
: super(text == null ? TextEditingValue.empty : TextEditingValue(text: text));
TextEditingController.fromValue(TextEditingValue value)
: super(value ?? TextEditingValue.empty);
String get text => value.text;
set text(String newText) {
value = value.copyWith(
text: newText,
selection: const TextSelection.collapsed(offset: -1),
composing: TextRange.empty,
);
}
TextSelection get selection => value.selection;
set selection(TextSelection newSelection) {
if (newSelection.start > text.length || newSelection.end > text.length)
throw FlutterError('invalid text selection: $newSelection');
value = value.copyWith(selection: newSelection, composing: TextRange.empty);
}
void clear() {
value = TextEditingValue.empty;
}
void clearComposing() {
value = value.copyWith(composing: TextRange.empty);
}
}
@immutable
class TextEditingValue {
const TextEditingValue({
this.text = '',
this.selection = const TextSelection.collapsed(offset: -1),
this.composing = TextRange.empty,
}) : assert(text != null),
assert(selection != null),
assert(composing != null);
...
//当前输入的文本
final String text;
//选择文本组,用于确认光标的位置
final TextSelection selection;
//这个值实际根本没用上
final TextRange composing;
static const TextEditingValue empty = TextEditingValue();
...
}
到这里其实很明朗了,既然能监听肯定是一个观察者模式了
class ValueNotifier<T> extends ChangeNotifier implements ValueListenable<T> {
ValueNotifier(this._value);
@override
T get value => _value;
T _value;
//一旦赋值,就通知所有的监听器,值已经发生变化
set value(T newValue) {
if (_value == newValue)
return;
_value = newValue;
notifyListeners();
}
@override
String toString() => '${describeIdentity(this)}($value)';
}
class ChangeNotifier implements Listenable {
ObserverList<VoidCallback> _listeners = ObserverList<VoidCallback>();
...
//添加单个监听回调方法
@override
void addListener(VoidCallback listener) {
assert(_debugAssertNotDisposed());
_listeners.add(listener);
}
//移出单个监听回调方法
@override
void removeListener(VoidCallback listener) {
assert(_debugAssertNotDisposed());
_listeners.remove(listener);
}
//监听器数组置空
@mustCallSuper
void dispose() {
assert(_debugAssertNotDisposed());
_listeners = null;
}
@protected
@visibleForTesting
void notifyListeners() {
assert(_debugAssertNotDisposed());
if (_listeners != null) {
final List<VoidCallback> localListeners = List<VoidCallback>.from(_listeners);
//遍历循环,调用回调方法
for (VoidCallback listener in localListeners) {
try {
if (_listeners.contains(listener))
listener();
} catch (exception, stack) {
//这个就是常见的出现bug的红色框
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'foundation library',
context: ErrorDescription('while dispatching notifications for $runtimeType'),
informationCollector: () sync* {
yield DiagnosticsProperty<ChangeNotifier>(
'The $runtimeType sending notification was',
this,
style: DiagnosticsTreeStyle.errorProperty,
);
},
));
}
}
}
}
}
总结来说值的变化就是通过观察者模式来作用的
下面我们看下一个重要的类,这个类影响到后续的光标显示
@immutable
class TextSelection extends TextRange {
const TextSelection({
@required this.baseOffset,
@required this.extentOffset,
this.affinity = TextAffinity.downstream,
this.isDirectional = false,
}) : super(
start: baseOffset < extentOffset ? baseOffset : extentOffset,
end: baseOffset < extentOffset ? extentOffset : baseOffset
);
const TextSelection.collapsed({
@required int offset,
this.affinity = TextAffinity.downstream,
}) : baseOffset = offset,
extentOffset = offset,
isDirectional = false,
super.collapsed(offset);
TextSelection.fromPosition(TextPosition position)
: baseOffset = position.offset,
extentOffset = position.offset,
affinity = position.affinity,
isDirectional = false,
super.collapsed(position.offset);
//选择区域的左边开始点,可简单理解为光标位置
//初始为-1,有值的时候,位于光标起始点为0(如: 12,光标在1前面则为0,后面就是1)
final int baseOffset;
//选择区域的结束点 (如: 12345,假如选择234,那么这个值就为4)
final int extentOffset;
final TextAffinity affinity;
final bool isDirectional;
TextPosition get base => TextPosition(offset: baseOffset, affinity: affinity);
TextPosition get extent => TextPosition(offset: extentOffset, affinity: affinity);
...
}
2. 问题二:限制输入字数的效果是怎么实现的
当我们给maxLength服了值后,右下角就有个字数限制显示
来看下textfield的build方法:
@override
Widget build(BuildContext context) {
super.build(context);
final ThemeData themeData = Theme.of(context);
final TextStyle style = themeData.textTheme.subhead.merge(widget.style);
final Brightness keyboardAppearance = widget.keyboardAppearance ?? themeData.primaryColorBrightness;
final TextEditingController controller = _effectiveController;
final FocusNode focusNode = _effectiveFocusNode;
final List<TextInputFormatter> formatters = widget.inputFormatters ?? <TextInputFormatter>[];
//maxLength设置类值后, maxLengthEnforced默认为true
if (widget.maxLength != null && widget.maxLengthEnforced)
//限制长度为设置的值
formatters.add(LengthLimitingTextInputFormatter(widget.maxLength));
bool forcePressEnabled;
TextSelectionControls textSelectionControls;
bool paintCursorAboveText;
bool cursorOpacityAnimates;
Offset cursorOffset;
Color cursorColor = widget.cursorColor;
Radius cursorRadius = widget.cursorRadius;
switch (themeData.platform) {
case TargetPlatform.iOS:
forcePressEnabled = true;
//创建ios风格的复制粘贴工具栏
textSelectionControls = cupertinoTextSelectionControls;
paintCursorAboveText = true;
cursorOpacityAnimates = true;
cursorColor ??= CupertinoTheme.of(context).primaryColor;
cursorRadius ??= const Radius.circular(2.0);
const int _iOSHorizontalOffset = -2;
cursorOffset = Offset(_iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
forcePressEnabled = false;
//创建md风格的复制粘贴工具栏
textSelectionControls = materialTextSelectionControls;
paintCursorAboveText = false;
cursorOpacityAnimates = false;
cursorColor ??= themeData.cursorColor;
break;
}
//使用了EditableText,这个后面会重点介绍
Widget child = RepaintBoundary(
child: EditableText(
key: _editableTextKey,
readOnly: widget.readOnly,
showCursor: widget.showCursor,
showSelectionHandles: _showSelectionHandles,
controller: controller,
focusNode: focusNode,
keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction,
textCapitalization: widget.textCapitalization,
style: style,
strutStyle: widget.strutStyle,
textAlign: widget.textAlign,
textDirection: widget.textDirection,
autofocus: widget.autofocus,
obscureText: widget.obscureText,
autocorrect: widget.autocorrect,
maxLines: widget.maxLines,
minLines: widget.minLines,
expands: widget.expands,
selectionColor: themeData.textSelectionColor,
selectionControls: widget.selectionEnabled ? textSelectionControls : null,
onChanged: widget.onChanged,
onSelectionChanged: _handleSelectionChanged,
onEditingComplete: widget.onEditingComplete,
onSubmitted: widget.onSubmitted,
onSelectionHandleTapped: _handleSelectionHandleTapped,
inputFormatters: formatters,
rendererIgnoresPointer: true,
cursorWidth: widget.cursorWidth,
cursorRadius: cursorRadius,
cursorColor: cursorColor,
cursorOpacityAnimates: cursorOpacityAnimates,
cursorOffset: cursorOffset,
paintCursorAboveText: paintCursorAboveText,
backgroundCursorColor: CupertinoColors.inactiveGray,
scrollPadding: widget.scrollPadding,
keyboardAppearance: keyboardAppearance,
enableInteractiveSelection: widget.enableInteractiveSelection,
dragStartBehavior: widget.dragStartBehavior,
scrollController: widget.scrollController,
scrollPhysics: widget.scrollPhysics,
),
);
if (widget.decoration != null) {
//装饰框聚焦动画
child = AnimatedBuilder(
animation: Listenable.merge(<Listenable>[ focusNode, controller ]),
builder: (BuildContext context, Widget child) {
return InputDecorator(
//添加装饰,这个方法下面就说明
decoration: _getEffectiveDecoration(),
baseStyle: widget.style,
textAlign: widget.textAlign,
textAlignVertical: widget.textAlignVertical,
isHovering: _isHovering,
isFocused: focusNode.hasFocus,
isEmpty: controller.value.text.isEmpty,
expands: widget.expands,
child: child,
);
},
child: child,
);
}
return Semantics(
onTap: () {
if (!_effectiveController.selection.isValid)
_effectiveController.selection = TextSelection.collapsed(offset: _effectiveController.text.length);
_requestKeyboard();
},
child: Listener(
onPointerEnter: _handlePointerEnter,
onPointerExit: _handlePointerExit,
child: IgnorePointer(
ignoring: !(widget.enabled ?? widget.decoration?.enabled ?? true),
//选择区域文本手势
child: TextSelectionGestureDetector(
onTapDown: _handleTapDown,
onForcePressStart: forcePressEnabled ? _handleForcePressStarted : null,
onSingleTapUp: _handleSingleTapUp,
onSingleTapCancel: _handleSingleTapCancel,
onSingleLongTapStart: _handleSingleLongTapStart,
onSingleLongTapMoveUpdate: _handleSingleLongTapMoveUpdate,
onSingleLongTapEnd: _handleSingleLongTapEnd,
onDoubleTapDown: _handleDoubleTapDown,
onDragSelectionStart: _handleMouseDragSelectionStart,
onDragSelectionUpdate: _handleMouseDragSelectionUpdate,
behavior: HitTestBehavior.translucent,
child: child,
),
),
),
);
}
也就是说限制输入长度只要LengthLimitingTextInputFormatter(widget.maxLength)就可以实现了,我们继续追踪下去,会发现formatters传递给了EditableText,然后在下面使用到了formatters
当android的输入法输入字到控件上时,InputConnection就会通过MethodChannel传递到修改后的新值这个方法里
void _formatAndSetValue(TextEditingValue value) {
final bool textChanged = _value?.text != value?.text;
if (textChanged && widget.inputFormatters != null && widget.inputFormatters.isNotEmpty) {
for (TextInputFormatter formatter in widget.inputFormatters)
value = formatter.formatEditUpdate(_value, value);
_value = value;
_updateRemoteEditingValueIfNeeded();
} else {
_value = value;
}
if (textChanged && widget.onChanged != null)
widget.onChanged(value.text);
}
调用LengthLimitingTextInputFormatter的formatEditUpdate进行文本裁剪
class LengthLimitingTextInputFormatter extends TextInputFormatter {
...
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue, // unused.
TextEditingValue newValue,
) {
if (maxLength != null && maxLength > 0 && newValue.text.runes.length > maxLength) {
//修改选择区域,简而言之就是修改光标位置
final TextSelection newSelection = newValue.selection.copyWith(
baseOffset: math.min(newValue.selection.start, maxLength),
extentOffset: math.min(newValue.selection.end, maxLength),
);
final RuneIterator iterator = RuneIterator(newValue.text);
if (iterator.moveNext())
for (int count = 0; count < maxLength; ++count)
if (!iterator.moveNext())
break;
//对输入的值进行字符串裁剪,经过上面的循环,rawIndex就是value的长度或限制输入最大值中最小的一个
final String truncated = newValue.text.substring(0, iterator.rawIndex);
return TextEditingValue(
text: truncated,
selection: newSelection,
composing: TextRange.empty,
);
}
return newValue;
}
}
那么限制字数的绘制是在哪实现的呢?
InputDecoration _getEffectiveDecoration() {
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final ThemeData themeData = Theme.of(context);
final InputDecoration effectiveDecoration = (widget.decoration ?? const InputDecoration())
.applyDefaults(themeData.inputDecorationTheme)
.copyWith(
enabled: widget.enabled,
hintMaxLines: widget.decoration?.hintMaxLines ?? widget.maxLines,
);
//如果你传入了decoration,并且他的counter和counterText都不为空
if (effectiveDecoration.counter != null || effectiveDecoration.counterText != null)
return effectiveDecoration;
//如果你传入了buildCounter,这个计数的控件自己实现
final int currentLength = _effectiveController.value.text.runes.length;
if (effectiveDecoration.counter == null
&& effectiveDecoration.counterText == null
&& widget.buildCounter != null) {
final bool isFocused = _effectiveFocusNode.hasFocus;
counter = Semantics(
container: true,
liveRegion: isFocused,
child: widget.buildCounter(
context,
currentLength: currentLength,
maxLength: widget.maxLength,
isFocused: isFocused,
),
);
return effectiveDecoration.copyWith(counter: counter);
}
//没设置就返回一个无字数统计的装饰
if (widget.maxLength == null)
return effectiveDecoration; // No counter widget
String counterText = '$currentLength';
String semanticCounterText = '';
// 最大长度大于0
if (widget.maxLength > 0) {
//显示的统计数 1/5
counterText += '/${widget.maxLength}';
final int remaining = (widget.maxLength - currentLength).clamp(0, widget.maxLength);
semanticCounterText = localizations.remainingTextFieldCharacterCount(remaining);
if (_effectiveController.value.text.runes.length > widget.maxLength) {
return effectiveDecoration.copyWith(
errorText: effectiveDecoration.errorText ?? '',
counterStyle: effectiveDecoration.errorStyle
?? themeData.textTheme.caption.copyWith(color: themeData.errorColor),
counterText: counterText,
semanticCounterText: semanticCounterText,
);
}
}
return effectiveDecoration.copyWith(
counterText: counterText,
semanticCounterText: semanticCounterText,
);
}
因此,其实对应的有三种方法来解决不显示右下角的统计数字,但却限制最大字数
//textfield限制最大字数,但无字数统计
class TextFieldWithNoCount extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
TextField(
maxLength: 5,
decoration: InputDecoration(
counterText: '',
counter: Container(),
),
),
TextField(
maxLength: 5,
buildCounter: (context,{currentLength,maxLength, isFocused,}){
return Container();
},
),
TextField(
inputFormatters: [LengthLimitingTextInputFormatter(5)],
),
],
);
}
}
图片效果就不展示了(展示了也说明不了什么),感兴趣的自己去试试
3. 问题三: 长按出现的复制、粘贴提示是怎么实现的
工具栏在问题二里我们看到了TextSelectionGestureDetector 手势控件
先看onTapDown中调用的_handleTapDown,这个手势是必然调用的(只要触发了事件)
void _handleTapDown(TapDownDetails details) {
//记录当前点击点
_renderEditable.handleTapDown(details);
_startSplash(details.globalPosition);
final PointerDeviceKind kind = details.kind;
//判断是否显示工具栏,仅限触摸和手写设备
_shouldShowSelectionToolbar =
kind == null ||
kind == PointerDeviceKind.touch ||
kind == PointerDeviceKind.stylus;
}
enum PointerDeviceKind {
/// 触摸
touch,
/// 鼠标点击
mouse,
/// 手写
stylus,
/// 反向手写
invertedStylus,
/// 不识别事件设备
unknown
}
接下来我们看单击事件,也就是移动光标
void _handleSingleTapUp(TapUpDetails details) {
if (widget.selectionEnabled) {
switch (Theme.of(context).platform) {
case TargetPlatform.iOS:
_renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
//因为down事件已经记录了点,所以这里直接修改位置即可
_renderEditable.selectPosition(cause: SelectionChangedCause.tap);
break;
}
}
//申请软键盘
_requestKeyboard();
//动画效果
_confirmCurrentSplash();
if (widget.onTap != null)
widget.onTap();
}
因为down事件已经确认点击位置,当up触发后,确认这是一个单击事件后,直接更新光标到点击位置
之后我们看下长按选择区域的,这里也会出现复制工具栏
void _handleSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) {
if (widget.selectionEnabled) {
switch (Theme.of(context).platform) {
case TargetPlatform.iOS:
_renderEditable.selectPositionAt(
from: details.globalPosition,
cause: SelectionChangedCause.longPress,
);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
//设置一个区域,从坐标from到坐标to
_renderEditable.selectWordsInRange(
from: details.globalPosition - details.offsetFromOrigin,
to: details.globalPosition,
cause: SelectionChangedCause.longPress,
);
break;
}
}
}
void _handleSingleLongTapEnd(LongPressEndDetails details) {
if (widget.selectionEnabled) {
//显示工具栏
if (_shouldShowSelectionToolbar)
_editableTextKey.currentState.showToolbar();
}
}
当长按且左右移动时会出现一个选择区域,松开手后就会显示工具栏
void selectWordsInRange({ @required Offset from, Offset to, @required SelectionChangedCause cause }) {
//摆放文本
_layoutText(constraints.maxWidth);
if (onSelectionChanged != null) {
//绝对坐标转相对坐标,然后计算当前坐标在文本的第几个位置
final TextPosition firstPosition = _textPainter.getPositionForOffset(globalToLocal(from - _paintOffset));
final TextSelection firstWord = _selectWordAtOffset(firstPosition);
final TextSelection lastWord = to == null ?
firstWord : _selectWordAtOffset(_textPainter.getPositionForOffset(globalToLocal(to - _paintOffset)));
_handlePotentialSelectionChange(
//baseOffset和 extentOffset的意思很明显
TextSelection(
baseOffset: firstWord.base.offset,
extentOffset: lastWord.extent.offset,
affinity: firstWord.affinity,
),
cause,
);
}
}
TextSelection _selectWordAtOffset(TextPosition position) {
//根据点返回这个字的TextRange,简单来说就是一个转换
final TextRange word = _textPainter.getWordBoundary(position);
if (position.offset >= word.end)
return TextSelection.fromPosition(position);
return TextSelection(baseOffset: word.start, extentOffset: word.end);
}
确认文本选择的区域其实靠的就是起点坐标和终点坐标
继续看showToolbar显示工具栏的步骤
bool showToolbar() {
if (_selectionOverlay == null || _selectionOverlay.toolbarIsVisible) {
return false;
}
_selectionOverlay.showToolbar();
return true;
}
void showToolbar() {
//_buildToolbar就是整个工具栏的绘制了,比较简单
_toolbar = OverlayEntry(builder: _buildToolbar);
//Overlay是一个层,与stack有关,这里就是讲工具栏加载在输入框之上
Overlay.of(context, debugRequiredFor: debugRequiredFor).insert(_toolbar);
_toolbarController.forward(from: 0.0);
}
我们继续剖析下去,_selectionOverlay.showToolbar()
里_selectionOverlay这个是什么?在_buildToolbar的最后可以找到这个:
return FadeTransition(
opacity: _toolbarOpacity,
child: CompositedTransformFollower(
link: layerLink,
showWhenUnlinked: false,
offset: -editingRegion.topLeft,
child: selectionControls.buildToolbar(
context,
editingRegion,
renderObject.preferredLineHeight,
midpoint,
endpoints,
selectionDelegate,
),
),
);
简而言之就是将绘制交给了selectionControls,那么selectionControls的值是怎么来的呢?
可以在第二个问题的textfield的build方法中找到答案,遗憾的是textfield不能自己选择风格,如果android想用ios风格的工具栏的话就需要重改代码或者直接用EditableText
2个风格实现的原理都差不多,我们直接看md的吧
class _MaterialTextSelectionControls extends TextSelectionControls {
@override
Widget buildToolbar(
BuildContext context,
Rect globalEditableRegion,
double textLineHeight,
Offset position,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
) {
//省略了计算高度部分,工具栏默认显示在输入框的上方,当上方显示的位置不够时就显示在其的下方
...
return ConstrainedBox(
constraints: BoxConstraints.tight(globalEditableRegion.size),
child: CustomSingleChildLayout(
delegate: _TextSelectionToolbarLayout(
MediaQuery.of(context).size,
globalEditableRegion,
preciseMidpoint,
),
//4个方法
child: _TextSelectionToolbar(
handleCut: canCut(delegate) ? () => handleCut(delegate) : null,
handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null,
handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null,
handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null,
),
),
);
}
...
}
_TextSelectionToolbar内的build方法说白了就是一个Row布局内放4个FlatButton控件,着重查看一下TextSelectionToolbar的四个回调方法都做了什么?
abstract class TextSelectionControls {
//省略部分内容
...
bool canCut(TextSelectionDelegate delegate) {
//cutEnabled: 当自己设为readonly为true是,这个值为false,默认为true
//isCollapsed:当selection的start和end相等时返回true
return delegate.cutEnabled && !delegate.textEditingValue.selection.isCollapsed;
}
bool canCopy(TextSelectionDelegate delegate) {
//copyEnabled: 当自己设为readonly为true是,这个值为false,默认为true
return delegate.copyEnabled && !delegate.textEditingValue.selection.isCollapsed;
}
bool canPaste(TextSelectionDelegate delegate) {
// TODO(goderbauer): return false when clipboard is empty, https://github.com/flutter/flutter/issues/11254
return delegate.pasteEnabled;
}
bool canSelectAll(TextSelectionDelegate delegate) {
return delegate.selectAllEnabled && delegate.textEditingValue.text.isNotEmpty && delegate.textEditingValue.selection.isCollapsed;
}
void handleCut(TextSelectionDelegate delegate) {
//举个例子
//文本是: "我是一个好人" , 现在要剪切"一个"
final TextEditingValue value = delegate.textEditingValue;
//往Clipboard添加剪切的数据
Clipboard.setData(ClipboardData(
text: value.selection.textInside(value.text),
));
//text的文本就为:"我是好人",光标则移动到"一个"的前面,即2的位置
delegate.textEditingValue = TextEditingValue(
text: value.selection.textBefore(value.text)
+ value.selection.textAfter(value.text),
selection: TextSelection.collapsed(
offset: value.selection.start
),
);
//这个其实就是更新界面,滚动到光标的位置,_scrollController.jumpTo
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
delegate.hideToolbar();
}
void handleCopy(TextSelectionDelegate delegate) {
final TextEditingValue value = delegate.textEditingValue;
Clipboard.setData(ClipboardData(
text: value.selection.textInside(value.text),
));
//和上面一个,但是文本未发生变化,光标移动到末尾,即"一个"的后面,也就是4的位置
delegate.textEditingValue = TextEditingValue(
text: value.text,
selection: TextSelection.collapsed(offset: value.selection.end),
);
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
delegate.hideToolbar();
}
Future<void> handlePaste(TextSelectionDelegate delegate) async {
final TextEditingValue value = delegate.textEditingValue; // Snapshot the input before using `await`.
//获取Clipboard保存的数据
final ClipboardData data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null) {
//在光标位置插入这个保存的文本,同时光标移动到保存文本的尾部
delegate.textEditingValue = TextEditingValue(
text: value.selection.textBefore(value.text)
+ data.text
+ value.selection.textAfter(value.text),
selection: TextSelection.collapsed(
offset: value.selection.start + data.text.length
),
);
}
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
delegate.hideToolbar();
}
void handleSelectAll(TextSelectionDelegate delegate) {
//文本不变,但是选择区域变为0-文本的末尾,也就是全选
delegate.textEditingValue = TextEditingValue(
text: delegate.textEditingValue.text,
selection: TextSelection(
baseOffset: 0,
extentOffset: delegate.textEditingValue.text.length,
),
);
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
}
}
辅助旋转的小点是怎么绘制的:
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textHeight) {
final Widget handle = SizedBox(
width: _kHandleSize,
height: _kHandleSize,
child: CustomPaint(
painter: _TextSelectionHandlePainter(
color: Theme.of(context).textSelectionHandleColor
),
),
);
switch (type) {
//左的的向右旋转90°
case TextSelectionHandleType.left: // points up-right
return Transform.rotate(
angle: math.pi / 2.0,
child: handle,
);
case TextSelectionHandleType.right: // points up-left
return handle;
//中间的向右旋转45°
case TextSelectionHandleType.collapsed: // points up
return Transform.rotate(
angle: math.pi / 4.0,
child: handle,
);
}
assert(type != null);
return null;
}
至此,工具栏也分析完了。读完了流程,其实我们也能模仿官方,然后像微信那么弄个自己的工具栏,比如说收藏或删除什么的
4. 问题四: 光标呼吸是如何实现的
先说光标呼吸怎么实现,之前我们反复说道了一个控件EditableText,再往其内部的build方法
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
_focusAttachment.reparent();
super.build(context); // See AutomaticKeepAliveClientMixin.
final TextSelectionControls controls = widget.selectionControls;
//这是一个可以滚动的控件
return Scrollable(
excludeFromSemantics: true,
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
controller: _scrollController,
physics: widget.scrollPhysics,
dragStartBehavior: widget.dragStartBehavior,
viewportBuilder: (BuildContext context, ViewportOffset offset) {
return CompositedTransformTarget(
link: _layerLink,
//语义辅助类,导盲语音播报用的
child: Semantics(
onCopy: _semanticsOnCopy(controls),
onCut: _semanticsOnCut(controls),
onPaste: _semanticsOnPaste(controls),
//_Editable这个是个重要的控件,绘制基本就在这发生了
child: _Editable(
key: _editableKey,
textSpan: buildTextSpan(),
value: _value,
cursorColor: _cursorColor,
backgroundCursorColor: widget.backgroundCursorColor,
showCursor: EditableText.debugDeterministicCursor
? ValueNotifier<bool>(widget.showCursor)
: _cursorVisibilityNotifier,
hasFocus: _hasFocus,
maxLines: widget.maxLines,
minLines: widget.minLines,
expands: widget.expands,
strutStyle: widget.strutStyle,
selectionColor: widget.selectionColor,
textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context),
textAlign: widget.textAlign,
textDirection: _textDirection,
locale: widget.locale,
obscureText: widget.obscureText,
autocorrect: widget.autocorrect,
offset: offset,
onSelectionChanged: _handleSelectionChanged,
onCaretChanged: _handleCaretChanged,
rendererIgnoresPointer: widget.rendererIgnoresPointer,
cursorWidth: widget.cursorWidth,
cursorRadius: widget.cursorRadius,
cursorOffset: widget.cursorOffset,
paintCursorAboveText: widget.paintCursorAboveText,
enableInteractiveSelection: widget.enableInteractiveSelection,
textSelectionDelegate: this,
devicePixelRatio: _devicePixelRatio,
),
),
);
},
);
}
深入到_Editable,会发现其是一个LeafRenderObjectWidget,然后在createRenderObject我们可以找到RenderEditable
根据问题,我们从paint方面来分析RenderEditable
class RenderEditable extends RenderBox {
@override
void paint(PaintingContext context, Offset offset) {
_layoutText(constraints.maxWidth);
//超出边界就裁剪
if (_hasVisualOverflow)
context.pushClipRect(needsCompositing, offset, Offset.zero & size, _paintContents);
else
_paintContents(context, offset);
}
void _paintContents(PaintingContext context, Offset offset) {
final Offset effectiveOffset = offset + _paintOffset;
bool showSelection = false;
bool showCaret = false;
if (_selection != null && !_floatingCursorOn) {
if (_selection.isCollapsed && _showCursor.value && cursorColor != null)
showCaret = true;
else if (!_selection.isCollapsed && _selectionColor != null)
showSelection = true;
_updateSelectionExtentsVisibility(effectiveOffset);
}
//绘制选择文本区域
if (showSelection) {
_selectionRects ??= _textPainter.getBoxesForSelection(_selection);
_paintSelection(context.canvas, effectiveOffset);
}
// 光标在文本之上,paintCursorAboveText:ios为true
if (paintCursorAboveText)
_textPainter.paint(context.canvas, effectiveOffset);
//绘制光标
if (showCaret)
_paintCaret(context.canvas, effectiveOffset, _selection.extent);
//文本覆盖光标,paintCursorAboveText: android为false
if (!paintCursorAboveText)
_textPainter.paint(context.canvas, effectiveOffset);
if (_floatingCursorOn) {
if (_resetFloatingCursorAnimationValue == null)
_paintCaret(context.canvas, effectiveOffset, _floatingCursorTextPosition);
_paintFloatingCaret(context.canvas, _floatingCursorOffset);
}
}
}
void _paintCaret(Canvas canvas, Offset effectiveOffset, TextPosition textPosition) {
//改变画笔颜色_cursorColor(光标呼吸主要是通过这个颜色的透明度实现)
final Paint paint = Paint()
..color = _floatingCursorOn ? backgroundCursorColor : _cursorColor;
//省略部分代码
...
caretRect = caretRect.shift(_getPixelPerfectCursorOffset(caretRect));
if (cursorRadius == null) {
//默认矩形光标
canvas.drawRect(caretRect, paint);
} else {
//可以使用自定义的圆角光标
final RRect caretRRect = RRect.fromRectAndRadius(caretRect, cursorRadius);
canvas.drawRRect(caretRRect, paint);
}
if (caretRect != _lastCaretRect) {
_lastCaretRect = caretRect;
if (onCaretChanged != null)
onCaretChanged(caretRect);
}
}
光标呼吸闪烁主要是通过动画,控制_cursorColor改变透明度,通过Timer.periodic循环调用动画控制器
最后说明
你并不能依靠本文来手写一个完整的TextField,因为还有很多细节部分并没有介绍,这里主要是能明白一些功能的一个大致的流程。
关于聚焦问题就留到下一篇分析了
网友评论