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

栏目: IOS · 发布时间: 5年前

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

extended_text_field 相关文章

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

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

过程

文字中插入图片

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

键盘与输入框的关联

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

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

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

我们先打开 extended_editable_text.dart

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

可以看到它是继承这个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

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

从上面代码我们看到。如果进行了键盘输入,那么原生会通知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,
        ),
      ),
复制代码
Flutter 支持图片以及特殊文字的输入框(二)实现过程

关键的点来了,因为我们把文本转换为了特殊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;
复制代码
  • 特殊文本输入时候的光标修正

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

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

我先输入了[],再把光标移动到中间,输入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 ,就能快速找到我为支持扩展功能而添加的代码了。

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

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

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

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

extended_nested_scroll_view

extended_image

extended_text

extended_text_field

pull_to_refresh_notification

loading_more_list

extended_tabs

http_client_helper

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

以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

JavaScript Patterns

JavaScript Patterns

Stoyan Stefanov / O'Reilly Media, Inc. / 2010-09-21 / USD 29.99

What's the best approach for developing an application with JavaScript? This book helps you answer that question with numerous JavaScript coding patterns and best practices. If you're an experienced d......一起来看看 《JavaScript Patterns》 这本书的介绍吧!

JS 压缩/解压工具
JS 压缩/解压工具

在线压缩/解压 JS 代码

在线进制转换器
在线进制转换器

各进制数互转换器

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码