Flutter花式玩轉TextField,寫一個驗證碼輸入框超簡單!

吉原拉麵發表於2018-12-29

GitHub地址:github.com/yumi0629/Fl…

(寫的比較急,程式碼還沒整理好,很凌亂,emmm,果然還是元旦之後再整理吧,→_→)

(本方案暫時只支援數字,不支援英文字母、中文等)

國際慣例先上效果圖:

Flutter花式玩轉TextField,寫一個驗證碼輸入框超簡單!

需求分析

  這個驗證碼輸入框的需求來源近日日群裡有人提出了這麼一個問題:像下面這種的控制元件該怎麼寫?

Flutter花式玩轉TextField,寫一個驗證碼輸入框超簡單!

  乍一看這就是一個TextField,但似乎又有那麼點不太一樣?我冷靜思考了一下,腦子裡有兩套解決方案:

  • 1、複製一份TextField,魔改SDK。
  • 2、用4個輸入框組合;

  這兩種方案,看著就覺得腦殼疼啊。
  先說第一種,點進原始碼我們可以看到TextField的實現鏈式關係為:TextField——>EditableText——>_Editable——>RenderEditable,而主要的繪製都集中在了RenderEditable中的paint()方法:

 @override
  void paint(PaintingContext context, Offset offset) {
    ······
    if (_hasVisualOverflow)
      context.pushClipRect(needsCompositing, offset, Offset.zero & size, _paintContents);
    else
    // 具體繪製內容,包括cursor和文字
      _paintContents(context, offset);
  }
  
void _paintContents(PaintingContext context, Offset offset) {
    ······
    // 繪製cursor
    if (_selection.isCollapsed && _showCursor.value && cursorColor != null) {
      _paintCaret(context.canvas, effectiveOffset);
    } else if (!_selection.isCollapsed && _selectionColor != null) {
        ······
      _paintSelection(context.canvas, effectiveOffset);
    }
    // 繪製文字
    _textPainter.paint(context.canvas, effectiveOffset);
  }

 
複製程式碼

  雖說生命不息,魔改不止,但是這一套魔改下來,emmmm,我選擇拒絕!
  再說第二種,4個控制元件組合,這種方式在佈局上確實會簡單很多,但是,致命的問題在於,要自己處理使用者的手勢輸入,以及cursor的位置移動等等,這個過程是十分複雜的,而且容易出錯。
  眾所周知,我小拉麵是一個懶人,能走對角線的我絕對不拐彎,寫程式碼信奉“曲線救國”原則,怎麼簡單怎麼來,上面兩種方案明顯不適合我。
  那麼怎麼辦呢?我凝視這設計稿,emmm,這果然還是一個TextField嘛,何必搞這麼複雜嘛。你不信的話,我加幾筆給你分析下:

Flutter花式玩轉TextField,寫一個驗證碼輸入框超簡單!
  text有個很重要的屬性letterSpace,用來做數字間距很方便;TextField自帶直線的UnderlineInputBorder,那我們換成虛線的不就行了?虛線的dash值就是字型寬度和letterSpace交替。這個方案,絕對比上面兩個要簡單的多得多得多,嗯,非常適合我。
  好了,那麼我們先來解決第一個問題,測量字型寬度。

測量字型寬度

  字型寬度的測量一直都是一個痛點,因為,它跟textSize肯定不相等,真的不好量啊······字型大小為textSize時,字型寬度並不是下圖中的藍色框,而是紅色框。

Flutter花式玩轉TextField,寫一個驗證碼輸入框超簡單!
  我下面要說的測量方案也只適合數字和英文字母,如果是中文,這個測量值和Flutter實際繪製的寬度還是有差距的。
  我們點進RenderEditable的原始碼可以發現,TextField中字型的繪製最終是通過TextPainter來完成的,而TextPainter的繪製核心則是canvas.drawParagraph(_paragraph, offset);,所以Paragraph就是確定文字位置的最重要的類之一。Paragraph中有一個minIntrinsicWidth,這個值就是我們需要的文字寬度。Paragraph可以通過ParagraphBuilder來建立,ParagraphBuilder可以接收一個ParagraphStyle,其中包含了字型樣式、字型型別、字型方向等等各種資訊。至於minIntrinsicWidth何時生效,原始碼文件中寫得很清楚,Valid only after [layout] has been called.,所以我們layout之後就可以拿到minIntrinsicWidth啦:

double calcTrueTextSize(double textSize) {
    // 測量單個數字實際長度
    var paragraph = ui.ParagraphBuilder(ui.ParagraphStyle(fontSize: textSize))
      ..addText("0");
    var p = paragraph.build()
      ..layout(ui.ParagraphConstraints(width: double.infinity));
    return p.minIntrinsicWidth;
  }
複製程式碼

  上面的程式碼就是測量數字“0”的方法,在Flutter預設數字字型中,0~9這十個數字所佔的實際繪製寬度都是一樣的,因此我們測量數字“0”就是測量了所有數字。但是,如果換成英文,那就不一樣了,英文的a~z這26個字母,即使都是小寫,測量出來的寬度也是每個字母都不一樣的,所以是沒法用在TextField上面的,因為我們沒法事先知曉使用者會輸入哪個字母。而至於中文,emmm,就比較坑了,測量值跟實際繪製的寬度完全不一樣,會小一點。

繪製UnderlineInputBorder

  自定義一個UnderlineInputBorder十分簡單,繼承一下然後重寫paint()方法即可:

@override
  void paint(
    Canvas canvas,
    Rect rect, {
    double gapStart,
    double gapExtent = 0.0,
    double gapPercentage = 0.0,
    TextDirection textDirection,
  }) {
    Path path = Path();
    path.moveTo(rect.bottomLeft.dx , rect.bottomLeft.dy);
    path.lineTo(rect.bottomLeft.dx + (textWidth + spaceWidth) * textLength,
        rect.bottomRight.dy);
    path = dashPath.dashPath(path,
        dashArray: dashPath.CircularIntervalList<double>([
          textWidth,
          spaceWidth,
        ]));
    canvas.drawPath(path, borderSide.toPaint());
  }
複製程式碼

  父的paint()方法會給我們一個rect,這個值就是我們border的可繪製區域。Flutter預設不支援虛線,我們可以藉助一下別人寫好的工具 dash_path.dartdashPath()會返回給我們一個虛線Path,這個工具類跟方便,走過路過不要錯過,建議收藏。
  TextField部分程式碼如下:

 var underLineBorder = CustomUnderlineInputBorder(
        spaceWidth: 30.0,
        textWidth: calcTrueTextSize(50.0),
        textLength: 4,
        borderSide: BorderSide(color: Colors.black26, width: 2.0));
        
TextField(
      maxLength: 4,
      keyboardType: TextInputType.number,
      style: TextStyle(
          fontSize: 50.0,
          color: Colors.black87,
          letterSpacing: 30.0),
      decoration: InputDecoration(
          hintText: '    Please input verification code',
          hintStyle: TextStyle(fontSize: 14.0, letterSpacing: 0.0),
          enabledBorder: underLineBorder,
          focusedBorder: underLineBorder),
    );
複製程式碼

  執行一下程式碼,你會發現,樣式還是有點差異,border整體向左偏移了:

Flutter花式玩轉TextField,寫一個驗證碼輸入框超簡單!
  這是因為新增了letterSpacing屬性後,TextField的第一個字元左邊會空出一半的letterSpacing的距離,所以我們在繪製border的時候將左起點往右偏移一段距離即可:

// startOffset = letterSpacing*0.5
 path.moveTo(rect.bottomLeft.dx + startOffset, rect.bottomLeft.dy);
複製程式碼

  到此為止,我們就,畫好啦~~~哈哈哈是不是真的超級簡單呀~~~

Flutter花式玩轉TextField,寫一個驗證碼輸入框超簡單!

自定義任意Border

  既然Border可以在paint()中隨心所欲地想怎麼畫就怎麼畫,那麼,理論上我們可以繪製任意樣式的Border。
  比如畫個方框:

Flutter花式玩轉TextField,寫一個驗證碼輸入框超簡單!

@override
  void paint(
    Canvas canvas,
    Rect rect, {
    double gapStart,
    double gapExtent = 0.0,
    double gapPercentage = 0.0,
    TextDirection textDirection,
  }) {
    double curStartX = rect.left + startOffset - offsetX;
    for (int i = 0; i < textLength; i++) {
      Rect r = Rect.fromLTWH(curStartX, rect.top + offsetY,
          textWidth + offsetX * 2, rect.height - offsetY * 2);
      canvas.drawRect(r, borderSide.toPaint());
      curStartX += (textWidth + spaceWidth);
    }
  }

複製程式碼

  比如畫個愛心:

Flutter花式玩轉TextField,寫一個驗證碼輸入框超簡單!

@override
  void paint(
    Canvas canvas,
    Rect rect, {
    double gapStart,
    double gapExtent = 0.0,
    double gapPercentage = 0.0,
    TextDirection textDirection,
  }) {
    double width = rect.height - offsetX;
    double radius = width * 0.25;
    // 1:editable.dart _kCaretGap
    double curStartX = startOffset - radius - offsetX - 1;
    print(
        'rect.height:${rect.height},curStartX:$curStartX,offsetX:$offsetX,startOffset:$startOffset');
    if (curStartX < 0) {
      throw ArgumentError(
          'No enough space to paint border! LetterSpace is too small.');
    }
    double top = rect.center.dy - radius * 2;
    double bottom = rect.center.dy + radius * 2;
    Path path = Path();
    for (int i = 0; i < textLength; i++) {
      path.moveTo(curStartX + radius * 2, top + radius);
      path.arcTo(
          Rect.fromCircle(
              center: Offset(curStartX + radius, top + radius), radius: radius),
          degToRad(180.0 - angleOffset),
          degToRad(180.0 + angleOffset),
          true);
      double sinLength = radius * sin(degToRad(angleOffset));
      double cosLength = radius * cos(degToRad(angleOffset));
      path.moveTo(curStartX + radius - cosLength, top + radius + sinLength);
      path.lineTo(curStartX + radius * 2, bottom);
      path.lineTo(curStartX + radius * 3 + cosLength, top + radius + sinLength);
      path.arcTo(
          Rect.fromCircle(
              center: Offset(curStartX + radius * 3, top + radius),
              radius: radius),
          degToRad(angleOffset),
          degToRad(-180.0 - angleOffset),
          true);
      curStartX += (textWidth + spaceWidth);
    }
    canvas.drawPath(path, borderSide.toPaint());
  }
複製程式碼

  甚至畫個背景圖:

Flutter花式玩轉TextField,寫一個驗證碼輸入框超簡單!

@override
  void paint(
    Canvas canvas,
    Rect rect, {
    double gapStart,
    double gapExtent = 0.0,
    double gapPercentage = 0.0,
    TextDirection textDirection,
  }) {
    double curStartX = rect.left;
    for (int i = 0; i < textLength; i++) {
      canvas.drawImage(image, Offset(curStartX, 0.0), Paint());
      curStartX += (textWidth + spaceWidth);
    }
  }
複製程式碼

碎碎念

  • 為什麼是繼承自UnderlineInputBorder,而不是InputBorder
      直接繼承自InputBorder需要重寫一大推方法,getInnerPath()getOuterPath()等等,沒必要重新計算,直接拿UnderlineInputBorder中算好的值就可以了。

  • 為什麼明明是方框的border,卻不是繼承自OutlineInputBorder呢?
      其實只是為了計算統一,因為UnderlineInputBorderOutlineInputBorder傳遞給子的rect引數會有所不同,所以如果你繼承自OutlineInputBorder也是很OK的。

相關文章