美文网首页Flutter开发圈Flutter
Flutter花式玩转TextField,写一个验证码输入框超简

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

作者: 吉原拉面 | 来源:发表于2018-12-29 17:38 被阅读4001次

    GitHub地址:https://github.com/yumi0629/FlutterUI/tree/master/lib/verificationcode

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

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

    国际惯例先上效果图:

    image

    需求分析

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


    image

      乍一看这就是一个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嘛,何必搞这么复杂嘛。你不信的话,我加几笔给你分析下:

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

    测量字体宽度

      字体宽度的测量一直都是一个痛点,因为,它跟textSize肯定不相等,真的不好量啊······字体大小为textSize时,字体宽度并不是下图中的蓝色框,而是红色框。

    image
      我下面要说的测量方案也只适合数字和英文字母,如果是中文,这个测量值和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整体向左偏移了:

    image
      这是因为添加了letterSpacing属性后,TextField的第一个字符左边会空出一半的letterSpacing的距离,所以我们在绘制border的时候将左起点往右偏移一段距离即可:
    // startOffset = letterSpacing*0.5
     path.moveTo(rect.bottomLeft.dx + startOffset, rect.bottomLeft.dy);
    

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


    image

    自定义任意Border

      既然Border可以在paint()中随心所欲地想怎么画就怎么画,那么,理论上我们可以绘制任意样式的Border。
      比如画个方框:

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

      比如画个爱心:

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

      甚至画个背景图:

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

    相关文章

      网友评论

        本文标题:Flutter花式玩转TextField,写一个验证码输入框超简

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