Flutter花式玩转TextField,写一个验证码输入框超简单!

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

(写的比较急,代码还没整理好,很凌乱,emmm,果真仍是元旦以后再整理吧,→_→)github

(本方案暂时只支持数字,不支持英文字母、中文等)

canvas

国际惯例先上效果图:bash

需求分析

  这个验证码输入框的需求来源近日日群里有人提出了这么一个问题:像下面这种的控件该怎么写? ide

  乍一看这就是一个TextField,但彷佛又有那么点不太同样?我冷静思考了一下,脑子里有两套解决方案:工具

  • 一、复制一份TextField,魔改SDK。
  • 二、用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嘛,何须搞这么复杂嘛。你不信的话,我加几笔给你分析下: 字体

  text有个很重要的属性 letterSpace,用来作数字间距很方便; TextField自带直线的 UnderlineInputBorder,那咱们换成虚线的不就好了?虚线的dash值就是字体宽度和letterSpace交替。这个方案,绝对比上面两个要简单的多得多得多,嗯,很是适合我。
  好了,那么咱们先来解决第一个问题,测量字体宽度。

测量字体宽度

  字体宽度的测量一直都是一个痛点,由于,它跟textSize确定不相等,真的很差量啊······字体大小为textSize时,字体宽度并非下图中的蓝色框,而是红色框。ui

  我下面要说的测量方案也只适合数字和英文字母,若是是中文,这个测量值和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,就比较坑了,测量值跟实际绘制的宽度彻底不同,会小一点。spa

绘制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总体向左偏移了:

  这是由于添加了 letterSpacing属性后, TextField的第一个字符左边会空出一半的 letterSpacing的距离,因此咱们在绘制border的时候将左起点往右偏移一段距离便可:

// startOffset = letterSpacing*0.5
 path.moveTo(rect.bottomLeft.dx + startOffset, rect.bottomLeft.dy);
复制代码

  到此为止,咱们就,画好啦~~~哈哈哈是否是真的超级简单呀~~~

自定义任意Border

  既然Border能够在paint()中为所欲为地想怎么画就怎么画,那么,理论上咱们能够绘制任意样式的Border。
  好比画个方框:

@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);
    }
  }

复制代码

  好比画个爱心:

@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());
  }
复制代码

  甚至画个背景图:

@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的。

相关文章
相关标签/搜索