美文网首页Flutter圈子Flutter教程网Flutter中文社区
Flutter 支持图片以及特殊文字的输入框(二)实现过程

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

作者: 法的空间 | 来源:发表于2019-05-10 09:05 被阅读13次

    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.

    相关文章

      网友评论

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

        本文链接:https://www.haomeiwen.com/subject/vemtoqtx.html