Flutter 支持图片以及特殊文字的输入框(二)实现过程

extended_text_field 相关文章

上一篇关于extended_text_field的文章主要介绍下用法,这篇文章介绍下,实现的过程。

image
image

过程

文字中插入图片

关于怎么在文字里面加入图片,在这篇文章里面我就不再介绍了,有兴趣的同学可以先看一下Extended Text,原理是一毛一样的。

键盘与输入框的关联

我写的好多组件都是对官方组件的扩展,所以对官方源码一定要读懂,知道它是做什么用的,才能在这个基础上扩展自己的功能。

image

除了工具类,其他都是从官方那边copy过来,然后进行修改的。

我们先打开extended_editable_text.dart

image

可以看到它是继承这个TextInputClient的,而TextInputClient是一个抽象类,而TextInputConnection是键盘的通信的关键先生,它将键盘的动作反馈给TextInputClient,我们顺便来看看它的实现。

class TextInputConnection {
  TextInputConnection._(this._client)
    : assert(_client != null),
      _id = _nextId++;

  static int _nextId = 1;
  final int _id;

  final TextInputClient _client;

  /// Whether this connection is currently interacting with the text input control.
  bool get attached => _clientHandler._currentConnection == this;

  /// Requests that the text input control become visible.
  void show() {
    assert(attached);
    SystemChannels.textInput.invokeMethod<void>('TextInput.show');
  }

  /// Requests that the text input control change its internal state to match the given state.
  void setEditingState(TextEditingValue value) {
    assert(attached);
    SystemChannels.textInput.invokeMethod<void>(
      'TextInput.setEditingState',
      value.toJSON(),
    );
  }

  /// Stop interacting with the text input control.
  ///
  /// After calling this method, the text input control might disappear if no
  /// other client attaches to it within this animation frame.
  void close() {
    if (attached) {
      SystemChannels.textInput.invokeMethod<void>('TextInput.clearClient');
      _clientHandler
        .._currentConnection = null
        .._scheduleHide();
    }
    assert(!attached);
  }
}

可以看到3里面的几个方法都有调用
SystemChannels.textInput.invokeMethod

这种代码是不是很熟悉,methodchannel,用过的人都知道,可以跟原生进行交互,那么就很简单了。

text field会在点击的时候获得焦点,并且打开键盘的链接,这样就可以接受到键盘的响应,那么原生反馈Flutter是在哪里呢,是在_TextInputClientHandler _clientHandler这个里面.
我们也看看_TextInputClientHandler里面的代码

class _TextInputClientHandler {
  _TextInputClientHandler() {
    SystemChannels.textInput.setMethodCallHandler(_handleTextInputInvocation);
  }

  TextInputConnection _currentConnection;

  Future<dynamic> _handleTextInputInvocation(MethodCall methodCall) async {
    if (_currentConnection == null)
      return;
    final String method = methodCall.method;
    final List<dynamic> args = methodCall.arguments;
    final int client = args[0];
    // The incoming message was for a different client.
    if (client != _currentConnection._id)
      return;
    switch (method) {
      case 'TextInputClient.updateEditingState':
        _currentConnection._client.updateEditingValue(TextEditingValue.fromJSON(args[1]));
        break;
      case 'TextInputClient.performAction':
        _currentConnection._client.performAction(_toTextInputAction(args[1]));
        break;
      case 'TextInputClient.updateFloatingCursor':
        _currentConnection._client.updateFloatingCursor(_toTextPoint(_toTextCursorAction(args[1]), args[2]));
        break;
      default:
        throw MissingPluginException();
    }
  }

  bool _hidePending = false;

  void _scheduleHide() {
    if (_hidePending)
      return;
    _hidePending = true;

    // Schedule a deferred task that hides the text input. If someone else
    // shows the keyboard during this update cycle, then the task will do
    // nothing.
    scheduleMicrotask(() {
      _hidePending = false;
      if (_currentConnection == null)
        SystemChannels.textInput.invokeMethod<void>('TextInput.hide');
    });
  }
}

final _TextInputClientHandler _clientHandler = _TextInputClientHandler();

又是跟methodchannel一毛一样,可以监听原生的回调,其实啊,SystemChannels.textInput就是一个methodchannel

image

从上面代码我们看到。如果进行了键盘输入,那么原生会通知flutter去updateEditingValue,并且把这个时候的数值转递过来

case 'TextInputClient.updateEditingState':
        _currentConnection._client.updateEditingValue(TextEditingValue.fromJSON(args[1]));
        break;

这个值是结构是TextEditingValue,它包括了文本,光标(选中)位置,以及composing(我的理解是,比如中文输入的时候是字母,然后下面有下划线,只有当输入完毕选择的时候才会显示成中文)

  /// The current text being edited.
  final String text;

  /// The range of text that is currently selected.
  final TextSelection selection;

  /// The range of text that is still being composed.
  final TextRange composing;

现在我们知道flutter的输入框跟键盘是怎么进行交互的了,总结一下,

  • 键盘通过TextInputConnection,执行3个方法传递变化给输入框
  /// Requests that this client update its editing state to the given value.
  void updateEditingValue(TextEditingValue value);

  /// Requests that this client perform the given action.
  void performAction(TextInputAction action);

  /// Updates the floating cursor position and state.
  void updateFloatingCursor(RawFloatingCursorPoint point);
  • 输入框通过TextInputConnection,也可以把TextEditingValue传递给键盘,
  /// Requests that the text input control change its internal state to match the given state.
  void setEditingState(TextEditingValue value)
  
   /// Requests that the text input control become visible.
  void show() 
  
  /// Stop interacting with the text input control.
  ///
  /// After calling this method, the text input control might disappear if no
  /// other client attaches to it within this animation frame.
  void close()

接下来我们移动到buildTextSpan 方法

  /// Builds [TextSpan] from current editing value.
  ///
  /// By default makes text in composing range appear as underlined.
  /// Descendants can override this method to customize appearance of text.
  TextSpan buildTextSpan(BuildContext context)

可以看到这里是将TextEditingValue转换为了TextSpan,那么我们的机会是不是就来了,我们可以在这里通过SpecialTextSpanBuilder,把TextEditingValue的值转换为我们想要的特殊的TextSpan.

TextSpan buildTextSpan(BuildContext context) {
    if (!widget.obscureText && _value.composing.isValid) {
      final TextStyle composingStyle = widget.style.merge(
        const TextStyle(decoration: TextDecoration.underline),
      );
      var beforeText = _value.composing.textBefore(_value.text);
      var insideText = _value.composing.textInside(_value.text);
      var afterText = _value.composing.textAfter(_value.text);

      if (supportSpecialText) {
        var before = widget.specialTextSpanBuilder
            .build(beforeText, textStyle: widget.style);
        var after = widget.specialTextSpanBuilder
            .build(afterText, textStyle: widget.style);

        List<TextSpan> children = List<TextSpan>();

        if (before != null && before.children != null) {
          _createImageConfiguration(<TextSpan>[before], context);
          before.children.forEach((sp) {
            children.add(sp);
          });
        } else {
          children.add(TextSpan(text: beforeText));
        }

        children.add(TextSpan(
          style: composingStyle,
          text: insideText,
        ));

        if (after != null && after.children != null) {
          _createImageConfiguration(<TextSpan>[after], context);
          after.children.forEach((sp) {
            children.add(sp);
          });
        } else {
          children.add(TextSpan(text: afterText));
        }

        return TextSpan(style: widget.style, children: children);
      }

      return TextSpan(style: widget.style, children: <TextSpan>[
        TextSpan(text: beforeText),
        TextSpan(
          style: composingStyle,
          text: insideText,
        ),
        TextSpan(text: afterText),
      ]);
    }

    String text = _value.text;
    if (widget.obscureText) {
      text = RenderEditable.obscuringCharacter * text.length;
      final int o =
          _obscureShowCharTicksPending > 0 ? _obscureLatestCharIndex : null;
      if (o != null && o >= 0 && o < text.length)
        text = text.replaceRange(o, o + 1, _value.text.substring(o, o + 1));
    }

    if (supportSpecialText) {
      var specialTextSpan =
          widget.specialTextSpanBuilder?.build(text, textStyle: widget.style);
      if (specialTextSpan != null) {
        _createImageConfiguration(<TextSpan>[specialTextSpan], context);
        return specialTextSpan;
      }
    }

    return TextSpan(style: widget.style, text: text);
  }

根据官方的源码,我对各种情况进行了处理,并且通过SpecialTextSpanBuilder将文本转换了我们想要的TextSpan,为绘制做好准备。

绘制过程

拿到TextSpan,那么下一步,我们就要准备去绘制文字了,我们去看看
extended_render_editable.dart

大概看了下源码,就感觉跟extended text 里面的extended_render_paragraph差别不大,区别是输入框增加了对光标,以及选中背景的绘制。

那么套路都是一样,找到_paintContents方法,我们将在这里绘制图片以及一些特殊文本。

  • 源码的绘制顺序是 选中背景,光标,文本(当然根据平台不同,光标和文本顺序也不同),

  • 修改之后 绘制顺序为 选中背景,特殊文本(图片等),光标,文本(当然根据平台不同,光标和文本顺序也不同)

移动到_paintSpecialText方法中,跟Extended Text一样,支持图片和自定义背景2种特殊文本,区别只是我只遍历children,不会再到children的children里面去找特殊文本了

void _paintSpecialText(PaintingContext context, Offset offset) {
    if (!handleSpecialText) return;

    final Canvas canvas = context.canvas;

    canvas.save();

    ///move to extended text
    canvas.translate(offset.dx, offset.dy);

    ///we have move the canvas, so rect top left should be (0,0)
    final Rect rect = Offset(0.0, 0.0) & size;
    _paintSpecialTextChildren(text.children, canvas, rect);
    canvas.restore();
  }

  void _paintSpecialTextChildren(
      List<TextSpan> textSpans, Canvas canvas, Rect rect,
      {int textOffset: 0}) {
    if (textSpans == null) return;

    for (TextSpan ts in textSpans) {
      Offset topLeftOffset = getOffsetForCaret(
        TextPosition(offset: textOffset),
        rect,
      );
      //skip invalid or overflow
      if (topLeftOffset == null ||
          (textOffset != 0 && topLeftOffset == Offset.zero)) {
        return;
      }

      if (ts is ImageSpan) {
        ///imageSpanTransparentPlaceholder \u200B has no width, and we define image width by
        ///use letterSpacing,so the actual top-left offset of image should be subtract letterSpacing(width)/2.0
        Offset imageSpanOffset = topLeftOffset -
            Offset(getImageSpanCorrectPosition(ts, textDirection), 0.0);

        if (!ts.paint(canvas, imageSpanOffset)) {
          //image not ready
          ts.resolveImage(
              listener: (ImageInfo imageInfo, bool synchronousCall) {
            if (synchronousCall)
              ts.paint(canvas, imageSpanOffset);
            else {
              if (owner == null || !owner.debugDoingPaint) {
                markNeedsPaint();
              }
            }
          });
        }
      } else if (ts is BackgroundTextSpan) {
        var painter = ts.layout(_textPainter);
        Rect textRect = topLeftOffset & painter.size;
        Offset endOffset;
        if (textRect.right > rect.right) {
          int endTextOffset = textOffset + ts.toPlainText().length;
          endOffset = _findEndOffset(rect, endTextOffset);
        }

        ts.paint(canvas, topLeftOffset, rect,
            endOffset: endOffset, wholeTextPainter: _textPainter);
      }
//      else if (ts.children != null) {
//        _paintSpecialTextChildren(ts.children, canvas, rect,
//            textOffset: textOffset);
// 
     }
      textOffset += ts.toPlainText().length;
    }
  }

光标以及交互的处理

我们处理了关联,绘制,最后我们需要处理光标以及交互。

我们把眼光移动到extended_text_selection.dart

ExtendedTextSelectionOverlay 跟它的名字一样,它是OverlayEntry,主要是负责显示那个 比如(copy,paste,select all)这种菜单的。

眼光再次移动到 extended_text_field.dart

这个里面定义很多交互,它们有的用来移动光标,有的用来选中文本,有的用来选中整个word。

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,
        ),
      ),
image

关键的点来了,因为我们把文本转换为了特殊TextSpan,导致其实绘制的文字跟实际文本是不一样的,比如对于图片,之前它是"[1]"文本,但在绘制的时候它其实只是"",一个空的占位符号。

再详细点的例子就是,比如我点击在一个表情的后面,对于TextPainter来说,它告诉你的位置1,但是对于真实文本来说,它的位置应该是3.

我们使用的真实值以及键盘的值是用TextEditingValue 来保存的,而我们绘画文本是用TextSpan以及TextPainter来进行计算的,所以我们需要给他们2者之间来一个转换,让我们把目光移动到extended_text_field_utils.dart

在这个里面,我写了双方进行转换的方法,他们是以下方法

TextPosition convertTextInputPostionToTextPainterPostion(
    TextSpan text, TextPosition textPosition)
    
TextSelection convertTextInputSelectionToTextPainterSelection(
    TextSpan text, TextSelection selection)

TextPosition convertTextPainterPostionToTextInputPostion(
    TextSpan text, TextPosition textPosition)
    
TextSelection convertTextPainterSelectionToTextInputSelection(
    TextSpan text, TextSelection selection)

其实道理很简单,就是双方文字的差异就是这个光标表示方法的差异,就像上面的例子,"[1]" 和 ""之间差距是2,这就会导致它们表示的光标位置差距也是2,根据这个原理我们就可以把它们进行互相的转换了。

感兴趣的同学可以去看看代码,如果有更优化的解放,请告诉我一下,谢谢。

其他的坑

  • 图片光标以及选中背景的位置问题

因为ImageSpan的做法是使用\u200B(ZERO WIDTH SPACE,就是宽带为0的空白),而使用letterSpacing当作宽度,所以通过
TextPainter计算出来的位置,是在letterSpacing的中间,图片绘画的地方应该要向前移动width / 2.0。也就是说如果光标在图片前,要向前移动width / 2.0。如果光标在图片之后,要向后移动width / 2.0。
对于选中背景也是同样的道理。

// zmt
    double imageTextSpanWidth = 0.0;
    Offset imageSpanEndCaretOffset;
    if (handleSpecialText) {
      var textSpan = text.getSpanForPosition(textPosition);
      if (textSpan != null) {
        if (textSpan is ImageSpan) {
          if (textInputPosition.offset >= textSpan.start &&
              textInputPosition.offset < textSpan.end) {
            imageTextSpanWidth -=
                getImageSpanCorrectPosition(textSpan, textDirection);
          } else if (textInputPosition.offset == textSpan.end) {
            ///_textPainter.getOffsetForCaret is not right.
            imageSpanEndCaretOffset = _textPainter.getOffsetForCaret(
                  TextPosition(
                      offset: textPosition.offset - 1,
                      affinity: textPosition.affinity),
                  effectiveOffset & size,
                ) +
                Offset(
                    getImageSpanCorrectPosition(textSpan, textDirection), 0.0);
          }
        }
      } else {
        //handle image text span is last one, textPainter will get wrong offset
        //last one
        textSpan = text.children?.last;
        if (textSpan != null && textSpan is ImageSpan) {
          imageSpanEndCaretOffset = _textPainter.getOffsetForCaret(
                TextPosition(
                    offset: textPosition.offset - 1,
                    affinity: textPosition.affinity),
                effectiveOffset & size,
              ) +
              Offset(getImageSpanCorrectPosition(textSpan, textDirection), 0.0);
        }
      }
    }

    final Offset caretOffset = (imageSpanEndCaretOffset ??
            _textPainter.getOffsetForCaret(textPosition, _caretPrototype) +
                Offset(imageTextSpanWidth, 0.0)) +
        effectiveOffset;
  • 特殊文本输入时候的光标修正

因为支持手动输入也要转换特殊文本,所以存在这种情况。

image

我先输入了[],再把光标移动到中间,输入1,这个时候会转换为表情1,但是光标没有停留在表情之后,如果你这个时候再输入,它就会在1后面增加。对于这种情况,我们要做一下处理。

///correct caret Offset
///make sure caret is not in image span
TextEditingValue correctCaretOffset(TextEditingValue value, TextSpan textSpan,
    TextInputConnection textInputConnection) {
  if (value.selection.isValid && value.selection.isCollapsed) {
    int caretOffset = value.selection.extentOffset;
    var imageSpans = textSpan.children.where((x) => x is ImageSpan);
    //correct caret Offset
    //make sure caret is not in image span
    for (ImageSpan ts in imageSpans) {
      if (caretOffset > ts.start && caretOffset < ts.end) {
        //move caretOffset to end
        caretOffset = ts.end;
        break;
      }
    }

    ///tell textInput caretOffset is changed.
    if (caretOffset != value.selection.baseOffset) {
      value = value.copyWith(
          selection: value.selection
              .copyWith(baseOffset: caretOffset, extentOffset: caretOffset));
      textInputConnection?.setEditingState(value);
    }
  }
  return value;
}

当光标位置处于表情文字中间的时候,我们把光标移动到表情的后面去,并且通知键盘,光标位置变化了。这样我们再继续输入的时候,就没有问题了。

  • getFullHeightForCaret api在低版本不支持

TextPainter的getFullHeightForCaret 在低版本上面不支持,如果你是适合的版本建议打开下面的注释,这样光标的高度会更舒服。

    ///zmt
    ///1.5.7
    ///under lower version of flutter, getFullHeightForCaret is not support
    ///
    // Override the height to take the full height of the glyph at the TextPosition
    // when not on iOS. iOS has special handling that creates a taller caret.
    // TODO(garyq): See the TODO for _getCaretPrototype.
//    if (defaultTargetPlatform != TargetPlatform.iOS &&
//        _textPainter.getFullHeightForCaret(textPosition, _caretPrototype) !=
//            null) {
//      caretRect = Rect.fromLTWH(
//        caretRect.left,
//        // Offset by _kCaretHeightOffset to counteract the same value added in
//        // _getCaretPrototype. This prevents this from scaling poorly for small
//        // font sizes.
//        caretRect.top - _kCaretHeightOffset,
//        caretRect.width,
//        _textPainter.getFullHeightForCaret(textPosition, _caretPrototype),
//      );
//    }

广告时间

当这5个都介绍完毕的时候,我们就讲的差不多了,为了方便大家查看我修改的地方,你只需要搜索 zmt ,就能快速找到我为支持扩展功能而添加的代码了。

image

最后放上 extended_text_field,如果你有什么不明白或者对这个方案有什么改进的地方,请告诉我,欢迎加入Flutter Candies,一起生产可爱的Flutter 小糖果(QQ群:181398081)

最最后放上Flutter Candies全家桶,真香。

custom flutter candies(widgets) for you to easily build flutter app, enjoy it.

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

推荐阅读更多精彩内容