Flutter 支持圖片以及特殊文字的輸入框(二)實現過程

extended_text_field 相關文章git

上一篇關於extended_text_field的文章主要介紹下用法,這篇文章介紹下,實現的過程。github

過程

文字中插入圖片

關於怎麼在文字裏面加入圖片,在這篇文章裏面我就再也不介紹了,有興趣的同窗能夠先看一下Extended Text,原理是一毛同樣的。ide

鍵盤與輸入框的關聯

我寫的好多組件都是對官方組件的擴展,因此對官方源碼必定要讀懂,知道它是作什麼用的,才能在這個基礎上擴展本身的功能。svg

除了工具類,其餘都是從官方那邊copy過來,而後進行修改的。

咱們先打開extended_editable_text.dart

能夠看到它是繼承這個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去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,
        ),
      ),
複製代碼

關鍵的點來了,由於咱們把文本轉換爲了特殊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;
複製代碼
  • 特殊文本輸入時候的光標修正

由於支持手動輸入也要轉換特殊文本,因此存在這種狀況。

我先輸入了[],再把光標移動到中間,輸入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 ,就能快速找到我爲支持擴展功能而添加的代碼了。

最後放上 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

pub package

extended_image

pub package

extended_text

pub package

extended_text_field

pub package

pull_to_refresh_notification

pub package

loading_more_list

pub package

extended_tabs

pub package

http_client_helper

pub package

相關文章
相關標籤/搜索