Flutter 驗證碼輸入框

Cheney2006發表於2020-03-15

  在 Flutter 做的一個專案中,要用到一個驗證碼輸入框,在原生應用中很常見,但 Flutter 中資料比較少,就自己簡單寫個。
  UI 設計效果如下:

驗證碼輸入框
  分析一下,這個需要自定義一個輸入框,輸入框自動獲焦,並且輸入一位密碼的時候,輸入框就填入一位,且游標自動移到下一位框中,這就需要單獨繪製了,系統預設的輸入框沒辦法直接實現。

實現效果

Flutter 驗證碼輸入框

實現思路比較簡單,直接看程式碼就會懂了。

支援屬性

屬性名 作用
autoFocus 是否獲焦
codeLength 驗證碼長度
decoration 下劃線樣式
inputFormatter 輸入文字校驗
keyboardType 鍵盤型別
focusNode 焦點
textInputAction 用於控制鍵盤動作

主要原始碼

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_common_utils/lcfarm_size.dart';
import 'package:kappa_app/utils/lcfarm_color.dart';

/// 預設的樣式
const TextStyle defaultStyle = TextStyle(
  /// Default text color.
  color: LcfarmColor.color80000000,

  /// Default text size.
  fontSize: 24.0,
);

abstract class CodeDecoration {
  /// The style of painting text.
  final TextStyle textStyle;

  final ObscureStyle obscureStyle;

  const CodeDecoration({
    this.textStyle,
    this.obscureStyle,
  });
}

/// The object determine the obscure display
class ObscureStyle {
  /// Determine whether replace [obscureText] with number.
  final bool isTextObscure;

  /// The display text when [isTextObscure] is true
  final String obscureText;

  const ObscureStyle({
    this.isTextObscure = false,
    this.obscureText = '*',
  }) : assert(obscureText.length == 1);
}

/// The object determine the underline color etc.
class UnderlineDecoration extends CodeDecoration {
  /// The space between text and underline.
  final double gapSpace;

  /// The color of the underline.
  final Color color;

  /// The height of the underline.
  final double lineHeight;

  /// The underline changed color when user enter pin.
  final Color enteredColor;

  const UnderlineDecoration({
    TextStyle textStyle,
    ObscureStyle obscureStyle,
    this.enteredColor = LcfarmColor.color3776E9,
    this.gapSpace = 15.0,
    this.color = LcfarmColor.color24000000,
    this.lineHeight = 0.5,
  }) : super(
          textStyle: textStyle,
          obscureStyle: obscureStyle,
        );
}

class LcfarmCodeInput extends StatefulWidget {
  /// The max length of pin.
  final int codeLength;

  /// The callback will execute when user click done.
  final ValueChanged<String> onSubmit;

  /// Decorate the pin.
  final CodeDecoration decoration;

  /// Just like [TextField]'s inputFormatter.
  final List<TextInputFormatter> inputFormatters;

  /// Just like [TextField]'s keyboardType.
  final TextInputType keyboardType;

  /// Same as [TextField]'s autoFocus.
  final bool autoFocus;

  /// Same as [TextField]'s focusNode.
  final FocusNode focusNode;

  /// Same as [TextField]'s textInputAction.
  final TextInputAction textInputAction;

  LcfarmCodeInput({
    GlobalKey<LcfarmCodeInputState> key,
    this.codeLength = 6,
    this.onSubmit,
    this.decoration = const UnderlineDecoration(),
    List<TextInputFormatter> inputFormatter,
    this.keyboardType = TextInputType.number,
    this.focusNode,
    this.autoFocus = false,
    this.textInputAction = TextInputAction.done,
  })  : inputFormatters = inputFormatter ??
            <TextInputFormatter>[WhitelistingTextInputFormatter.digitsOnly],
        super(key: key);

  @override
  State createState() {
    return LcfarmCodeInputState();
  }
}

class LcfarmCodeInputState extends State<LcfarmCodeInput>
    with SingleTickerProviderStateMixin {
  ///輸入監聽器
  TextEditingController _controller = TextEditingController();

  /// The display text to the user.
  String _text;

  AnimationController _animationController;
  Animation<double> _animation;

  FocusNode _focusNode;

  @override
  void initState() {
    _focusNode = FocusNode();
    _controller.addListener(() {
      setState(() {
        _text = _controller.text;
      });
      submit(_controller.text);
    });

    _animationController =
        AnimationController(duration: Duration(milliseconds: 500), vsync: this);

    _animation = Tween(begin: 0.0, end: 255.0).animate(_animationController)
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          //動畫執行結束時反向執行動畫
          _animationController.reverse();
        } else if (status == AnimationStatus.dismissed) {
          //動畫恢復到初始狀態時執行動畫(正向)
          _animationController.forward();
        }
      })
      ..addListener(() {
        setState(() {});
      });

    ///啟動動畫
    _animationController.forward();

    super.initState();
  }

  void submit(String text) {
    if (text.length >= widget.codeLength) {
      widget.onSubmit(text.substring(0, widget.codeLength));
      _controller.text = "";
      //外部有傳focusNode就直接使用外部的,沒有則使用內部定義的
      widget.focusNode == null
          ? _focusNode.unfocus()
          : widget.focusNode.unfocus();
    }
  }

  @override
  void dispose() {
    /// Only execute when the controller is autoDispose.
    _controller.dispose();
    _animationController.dispose();
    _focusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      /// The foreground paint to display pin.
      foregroundPainter: _CodePaint(
        text: _text,
        codeLength: widget.codeLength,
        decoration: widget.decoration,
        alpha: _animation.value.toInt(),
      ),
      child: RepaintBoundary(
        child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
          TextField(
            /// Actual textEditingController.
            controller: _controller,

            /// Fake the text style.
            style: TextStyle(
              /// Hide the editing text.
              color: Colors.transparent,
            ),

            /// Hide the Cursor.
            cursorColor: Colors.transparent,

            /// Hide the cursor.
            cursorWidth: 0.0,

            /// No need to correct the user input.
            autocorrect: false,

            /// Center the input to make more natrual.
            textAlign: TextAlign.center,

            /// Disable the actual textField selection.
            enableInteractiveSelection: false,

            /// The maxLength of the pin input, the default value is 6.
            maxLength: widget.codeLength,

            /// If use system keyboard and user click done, it will execute callback
            /// Note!!! Custom keyboard in Android will not execute, see the related issue [https://github.com/flutter/flutter/issues/19027]
            onSubmitted: submit,

            /// Default text input type is number.
            keyboardType: widget.keyboardType,

            /// only accept digits.
            inputFormatters: widget.inputFormatters,

            /// Defines the keyboard focus for this widget.
            focusNode: widget.focusNode == null ? _focusNode : widget.focusNode,

            /// {@macro flutter.widgets.editableText.autofocus}
            autofocus: widget.autoFocus,

            /// The type of action button to use for the keyboard.
            ///
            /// Defaults to [TextInputAction.done]
            textInputAction: widget.textInputAction,

            /// {@macro flutter.widgets.editableText.obscureText}
            /// Default value of the obscureText is false. Make
            obscureText: true,

            /// Clear default text decoration.
            decoration: InputDecoration(
              /// Hide the counterText
              counterText: '',
              contentPadding: EdgeInsets.symmetric(vertical: LcfarmSize.dp(24)),

              /// Hide the outline border.
              border: OutlineInputBorder(
                borderSide: BorderSide.none,
              ),
            ),
          ),
        ]),
      ),
    );
  }
}

class _CodePaint extends CustomPainter {
  String text;
  final int codeLength;
  final double space;
  final CodeDecoration decoration;
  final int alpha;

  _CodePaint({
    @required String text,
    @required this.codeLength,
    this.decoration,
    this.space = 4.0,
    this.alpha,
  }) {
    text ??= "";
    this.text = text.trim();
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) =>
      !(oldDelegate is _CodePaint && oldDelegate.text == this.text);

  _drawUnderLine(Canvas canvas, Size size) {
    /// Force convert to [UnderlineDecoration].
    var dr = decoration as UnderlineDecoration;
    Paint underlinePaint = Paint()
      ..color = dr.color
      ..strokeWidth = dr.lineHeight
      ..style = PaintingStyle.stroke
      ..isAntiAlias = true;

    var startX = 0.0;
    var startY = size.height;

    /// 畫下劃線
    double singleWidth =
        (size.width - (codeLength - 1) * dr.gapSpace) / codeLength;

    for (int i = 0; i < codeLength; i++) {
      if (i == text.length && dr.enteredColor != null) {
        underlinePaint.color = dr.enteredColor;
        underlinePaint.strokeWidth = LcfarmSize.dp(1);
      } else {
        underlinePaint.color = dr.color;
        underlinePaint.strokeWidth = LcfarmSize.dp(0.5);
      }
      canvas.drawLine(Offset(startX, startY),
          Offset(startX + singleWidth, startY), underlinePaint);
      startX += singleWidth + dr.gapSpace;
    }

    /// 畫文字
    var index = 0;
    startX = 0.0;
    startY = LcfarmSize.dp(28);

    /// Determine whether display obscureText.
    bool obscureOn;
    obscureOn = decoration.obscureStyle != null &&
        decoration.obscureStyle.isTextObscure;

    /// The text style of pin.
    TextStyle textStyle;
    if (decoration.textStyle == null) {
      textStyle = defaultStyle;
    } else {
      textStyle = decoration.textStyle;
    }

    text.runes.forEach((rune) {
      String code;
      if (obscureOn) {
        code = decoration.obscureStyle.obscureText;
      } else {
        code = String.fromCharCode(rune);
      }
      TextPainter textPainter = TextPainter(
        text: TextSpan(
          style: textStyle,
          text: code,
        ),
        textAlign: TextAlign.center,
        textDirection: TextDirection.ltr,
      );

      /// Layout the text.
      textPainter.layout();

      startX = singleWidth * index +
          singleWidth / 2 -
          textPainter.width / 2 +
          dr.gapSpace * index;
      textPainter.paint(canvas, Offset(startX, startY));
      index++;
    });

    ///畫游標  如果外部有傳,則直接使用外部
    Color cursorColor =
        dr.enteredColor != null ? dr.enteredColor : LcfarmColor.color3776E9;
    cursorColor = cursorColor.withAlpha(alpha);

    double cursorWidth = LcfarmSize.dp(1);
    double cursorHeight = LcfarmSize.dp(24);

    //LogUtil.v("animation.value=$alpha");

    Paint cursorPaint = Paint()
      ..color = cursorColor
      ..strokeWidth = cursorWidth
      ..style = PaintingStyle.stroke
      ..isAntiAlias = true;

    startX = text.length * (singleWidth + dr.gapSpace) + singleWidth / 2;

    var endX = startX + cursorWidth;
    var endY = startY + cursorHeight;
//    var endY = size.height - 28.0 - 12;
//    canvas.drawLine(Offset(startX, startY), Offset(startX, endY), cursorPaint);
    //繪製圓角游標
    Rect rect = Rect.fromLTRB(startX, startY, endX, endY);
    RRect rrect = RRect.fromRectAndRadius(rect, Radius.circular(cursorWidth));
    canvas.drawRRect(rrect, cursorPaint);
  }

  @override
  void paint(Canvas canvas, Size size) {
    _drawUnderLine(canvas, size);
  }
}

複製程式碼

相關文章