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

相关推荐
小行星1253 分钟前
前端把dom页面转为pdf文件下载和弹窗预览
前端·javascript·vue.js·pdf
Lysun00113 分钟前
[less] Operation on an invalid type
前端·vue·less·sass·scss
J总裁的小芒果28 分钟前
Vue3 el-table 默认选中 传入的数组
前端·javascript·elementui·typescript
Lei_zhen9631 分钟前
记录一次electron-builder报错ENOENT: no such file or directory, rename xxxx的问题
前端·javascript·electron
咖喱鱼蛋33 分钟前
Electron一些概念理解
前端·javascript·electron
yqcoder34 分钟前
Vue3 + Vite + Electron + TS 项目构建
前端·javascript·vue.js
鑫宝Code1 小时前
【React】React Router:深入理解前端路由的工作原理
前端·react.js·前端框架
Mr_Xuhhh2 小时前
重生之我在学环境变量
linux·运维·服务器·前端·chrome·算法
永乐春秋3 小时前
WEB攻防-通用漏洞&文件上传&js验证&mime&user.ini&语言特性
前端
鸽鸽程序猿3 小时前
【前端】CSS
前端·css