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

相关推荐
Asort3 分钟前
JavaScript 从零开始(六):控制流语句详解——让代码拥有决策与重复能力
前端·javascript
无双_Joney22 分钟前
[更新迭代 - 1] Nestjs 在24年底更新了啥?(功能篇)
前端·后端·nestjs
在云端易逍遥24 分钟前
前端必学的 CSS Grid 布局体系
前端·css
ccnocare25 分钟前
选择文件夹路径
前端
艾小码25 分钟前
还在被超长列表卡到崩溃?3招搞定虚拟滚动,性能直接起飞!
前端·javascript·react.js
闰五月26 分钟前
JavaScript作用域与作用域链详解
前端·面试
泉城老铁30 分钟前
idea 优化卡顿
前端·后端·敏捷开发
前端康师傅30 分钟前
JavaScript 作用域常见问题及解决方案
前端·javascript
司宸31 分钟前
Prompt结构化输出:从入门到精通的系统指南
前端