Flutter 封装:输入框组件 NExpandTextfield

一、需求来源

开发时遇到一个需求:

  • 从接口拉取到数据展示,根据情况(目前超过三行)显示展开折叠菜单;
  • 展示之后可以编辑,编辑状态需要显示当前字符数;
  • 编辑结束保存提交接口后再二次展示;

效果如下:

二、使用示例

核心代码 复制代码
NSectionHeader(
  title: "填空组件封装",
  child: Container(
    // color: Colors.yellowAccent,
    padding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
    child: ValueListenableBuilder(
       valueListenable: readOnly,
       builder: (context, value, child){

        return NExpandTextfield(
          text: textEditingController.text,
          initiallyExpanded: true,
          expandMaxLine: null,
          readOnly: value,
          textStyle: TextStyle(
            color: Colors.black87,
            fontSize: 14,
          ),
        );
      }
    ),
  ),
),

三、组件源码

php 复制代码
//
//  NExpandTextfield.dart
//  flutter_templet_project
//
//  Created by shang on 2024/3/16 14:50.
//  Copyright © 2024/3/16 shang. All rights reserved.
//

import 'package:flutter/material.dart';
import 'package:flutter_templet_project/extension/build_context_ext.dart';
import 'package:flutter_templet_project/extension/ddlog.dart';
import 'package:flutter_templet_project/extension/string_ext.dart';
import 'package:flutter_templet_project/extension/text_painter_ext.dart';


/// 用 Textfield实现的文字折叠组件
class NExpandTextfield extends StatefulWidget {
   const NExpandTextfield({
    super.key,
    required this.text,
    required this.textStyle,
    this.expandMaxLine,
    this.expandMinLine = 3,
    // this.expandTitleStyle,
    this.readOnly = true,
    this.maxLength = 300,
    this.initiallyExpanded = false,
  });

  /// 字符串
  final String text;

  /// 字符串样式
   final TextStyle? textStyle;

  /// 超过一行初始展开状态
   final bool initiallyExpanded;

  /// 展开状态最大行
   final int? expandMaxLine;

  /// 展开状态最小行
   final int expandMinLine;

  /// 最大字符数
   final int maxLength;

   final bool readOnly;

   /// 展开按钮文字样式
   // final TextStyle? expandTitleStyle;

  @override
  _NExpandTextfieldState createState() => _NExpandTextfieldState();
}

class _NExpandTextfieldState extends State<NExpandTextfield> {
  late bool isExpand = widget.initiallyExpanded;

  final textEditingController = TextEditingController();

  late final wordCount =
      ValueNotifier(textEditingController.text.characters.length);

  @override
  void initState() {
    super.initState();
    textEditingController.addListener(onListener);
  }

  void onListener() {
    wordCount.value = textEditingController.text.characters.length;
  }

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
        builder: (BuildContext context, BoxConstraints constraints) {
      final textPainter = TextPainterExt.getTextPainter(
        text: widget.text,
        textStyle: widget.textStyle,
        maxLine: widget.expandMinLine,
        maxWidth: constraints.maxWidth,
      );
      // final numberOfLines = textPainter.computeLineMetrics().length;
      // debugPrint("numberOfLines:${numberOfLines}");

      var isBeyond = textPainter.didExceedMaxLines;

      return StatefulBuilder(
          builder: (BuildContext context, StateSetter setState) {
        // final btnTitle = isExpand ? "收起" : "展开";

        final toggleImage = (isExpand
                ? "icon_expand_arrow_up.png"
                : "icon_expand_arrow_down.png")
            .toAssetImage();

        onToggle() {
          isExpand = !isExpand;
          setState(() {});
        }

        final child = Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.end,
          children: [
            buildTextField(
              text: widget.text,
              style: widget.textStyle,
              maxLines: isExpand ? widget.expandMaxLine : widget.expandMinLine,
              readOnly: widget.readOnly,
              maxLength: widget.maxLength,
            ),
            Offstage(
              offstage: !isBeyond || !widget.readOnly,
              child: InkWell(
                onTap: onToggle,
                child: Container(
                  padding: const EdgeInsets.only(top: 8, bottom: 8),
                  alignment: Alignment.center,
                  child: Image(
                    image: toggleImage,
                    width: 21,
                    height: 8,
                    color: context.primaryColor,
                  ),
                ),
              ),
            ),
          ],
        );

        if (!widget.readOnly) {
          return child;
        }

        return Stack(
          children: [
            child,
            Positioned(
              bottom: 25,
              left: 0,
              right: 0,
              child: Visibility(
                visible: isBeyond && !isExpand,
                child: InkWell(
                  onTap: onToggle,
                  child: Container(
                    width: double.maxFinite,
                    height: 25,
                    decoration: const BoxDecoration(
                      gradient: LinearGradient(
                        colors: [Color(0x99FFFFFF), Colors.white],
                        begin: Alignment.topCenter,
                        end: Alignment.bottomCenter,
                      ),
                    ),
                  ),
                ),
              ),
            )
          ],
        );
      });
    });
  }

  Widget buildTextField({
    required String text,
    required TextStyle? style,
    required int? maxLines,
    bool readOnly = true,
    int maxLength = 150,
  }) {
    final border = OutlineInputBorder(
      borderSide: BorderSide(color: Colors.transparent),
      borderRadius: BorderRadius.circular(8),
    );

    textEditingController.text = text;

    return TextField(
      controller: textEditingController,
      style: style,
      textAlignVertical: TextAlignVertical.center,
      maxLines: maxLines,
      scrollPhysics: readOnly ? NeverScrollableScrollPhysics() : null,
      readOnly: readOnly,
      maxLength: readOnly? null : maxLength,
      decoration: InputDecoration(
        // labelText: "请输入",
        hintText: "请输入",
        hintStyle: TextStyle(color: Colors.black38),
        filled: true,
        // fillColor: Colors.yellow,
        border: border,
        enabledBorder: border,
        focusedBorder: border,
        contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
        // counterText: readOnly ? null : "${textEditingController.text.length}/$maxWordCount",
        counter: readOnly ? null : ValueListenableBuilder(
          valueListenable: wordCount,
          builder: (context, value, child) {

              return Text.rich(
                TextSpan(
                  children: [
                    TextSpan(
                      text: "$value",
                      style: const TextStyle(
                        fontSize: 12,
                        fontWeight: FontWeight.w500,
                        color: Colors.black87,
                      ),
                    ),
                    TextSpan(
                      text: '/$maxLength',
                      style: const TextStyle(
                        fontSize: 12,
                        fontWeight: FontWeight.w500,
                        color: Color(0xFF737373),
                      ),
                    ),
                  ],
                ),
                maxLines: 3,
              );

            }),
        // isCollapsed: isCollapsed,
        // contentPadding: contentPadding,
        // suffixIcon: suffixIcon,
        // suffixIconConstraints: suffixIconConstraints,
      ),
    );
  }
}

总结

1、核心是通过 TextPainter 布局后:

php 复制代码
final textPainter = TextPainterExt.getTextPainter(
  text: widget.text,
  textStyle: widget.textStyle,
  maxLine: widget.expandMinLine,
  maxWidth: constraints.maxWidth,
);
/// 是否超出
var isBeyond = textPainter.didExceedMaxLines;

然后做判断进行处理,目前最优的方法。

2、兼顾性能,一个组件实现展示,编辑,减少代码维护复杂度;在 Flutter 中 输入框组件封装时尽量避免调用 setState 方法;

3. NExpandText 的进阶版本

github

相关推荐
hunter2062061 小时前
ubuntu向一个pc主机通过web发送数据,pc端通过工具直接查看收到的数据
linux·前端·ubuntu
qzhqbb1 小时前
web服务器 网站部署的架构
服务器·前端·架构
刻刻帝的海角1 小时前
CSS 颜色
前端·css
浪浪山小白兔2 小时前
HTML5 新表单属性详解
前端·html·html5
lee5763 小时前
npm run dev 时直接打开Chrome浏览器
前端·chrome·npm
2401_897579653 小时前
AI赋能Flutter开发:ScriptEcho助你高效构建跨端应用
前端·人工智能·flutter
limit for me3 小时前
react上增加错误边界 当存在错误时 不会显示白屏
前端·react.js·前端框架
浏览器爱好者3 小时前
如何构建一个简单的React应用?
前端·react.js·前端框架
qq_392794484 小时前
前端缓存策略:强缓存与协商缓存深度剖析
前端·缓存
小美的打工日记4 小时前
ES6+新特性,var、let 和 const 的区别
前端·javascript·es6