Flutter源码解析-TextField (1)

说明

本文源码基于flutter 1.7.8
之前我们分析完文本,现在来分析一下输入框

使用

关于怎么使用,这里不做过多的介绍了
推荐看一下:Flutter TextField详解
介绍的还是蛮详细的

问题

  1. 值的监听和变化是怎么实现的
  2. 限制输入字数的效果是怎么实现的
  3. 长按出现的复制、粘贴提示是怎么实现的
  4. 光标呼吸是如何实现的

分析

由简单的开始分析,逐步深入


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,因为还有很多细节部分并没有介绍,这里主要是能明白一些功能的一个大致的流程。
关于聚焦问题就留到下一篇分析了

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