我知道将列放入SingleChildScrollView中是出了名的棘手,尤其是如果列中有扩展的小部件.

我正处于一种奇怪的情况,我正在try 构建一个表单.在这种形式中,有一个筹码条(又名标签条).当用户在此栏内输入文本时,栏下方会打开建议的ListView.它垂直打开.但表格的布局被这个额外的高度搞砸了.如果屏幕很短(或者表单很长),我就会遇到各种问题(建议的ListView不显示、异常等).请参阅下图,建议列表部分被掩盖,无法使用.

enter image description here

  @override
  Widget build(BuildContext context) {
        var scaffold = Scaffold(
        appBar: AppBar(
            centerTitle: true,
            title: Text('Test layout')),
        body: SingleChildScrollView(
            child: ConstrainedBox(
                constraints: BoxConstraints(
                    maxHeight: MediaQuery.of(context).size.height,
                    minHeight: MediaQuery.of(context).size.height),
                child: buildForm(context))));

    return scaffold;
  }

我相信这是因为我使用maxHeight: MediaQuery.of(context).size.height作为框约束.如果我使用maxHeight: MediaQuery.of(context).size.height * 2,建议的ListView将不再被屏蔽,但表单的页面最终变得不必要地长,并且当打开ListView时,标签栏和下一个字段之间存在很大的间隙.我try 使用maMaxHeight的系数,但我觉得它很黑客,并且很容易根据标签栏中建议的长度而崩溃.我觉得我需要一些可以适应标签栏长度+建议列表的东西.你有什么 idea 来改进我的布局吗?这是一个可复制的例子:

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';


void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final _formKey = GlobalKey<FormBuilderState>();
  final EditableChipField _tagBar = EditableChipField();

  @override
  Widget build(BuildContext context) {
        var scaffold = Scaffold(
        appBar: AppBar(
            centerTitle: true,
            title: Text('Test layout')),
        body: SingleChildScrollView(
            child: ConstrainedBox(
                constraints: BoxConstraints(
                    maxHeight: MediaQuery.of(context).size.height * 2,
                    minHeight: MediaQuery.of(context).size.height),
                child: buildForm(context))));

    return scaffold;
  }

  Widget buildForm(BuildContext context) {
    return FormBuilder(
        key: _formKey,
        child: Padding(
            padding: EdgeInsets.all(20),
            child: Column(children: [
              SizedBox(height: 40),
              FormBuilderTextField(
                  name: 'name'),
              Flexible(fit: FlexFit.loose, child: _tagBar),
              SizedBox(height: 40),
              FormBuilderTextField(
                  name: 'other'),
              SizedBox(height: 40),
              Row(children: [
                Expanded(
                    child: ElevatedButton(
                  child: Text("Cancel"),
                  onPressed: () {},
                )),
                const SizedBox(width: 20),
                Expanded(
                    child: FilledButton(
                        child: Text("OK"), onPressed: () {})),
              ]),
            ])));
  }
}

const List<String> _pizzaToppings = <String>[
  'Olives',
  'Tomato',
  'Cheese',
  'Pepperoni',
  'Bacon',
  'Onion',
  'Jalapeno',
  'Mushrooms',
  'Pineapple',
];

class EditableChipField extends StatefulWidget {
  List<String> chips = <String>[];

  EditableChipField({super.key});

  @override
  EditableChipFieldState createState() {
    return EditableChipFieldState();
  }
}

class EditableChipFieldState extends State<EditableChipField> {
  final FocusNode _chipFocusNode = FocusNode();
  List<String> _suggestions = <String>[];

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        ChipsInput<String>(
          values: widget.chips,
          decoration: InputDecoration(
            prefixIcon: Icon(Icons.local_pizza_rounded),
            hintText: 'Search for toppings',
          ),
          strutStyle: const StrutStyle(fontSize: 15),
          onChanged: _onChanged,
          onSubmitted: _onSubmitted,
          chipBuilder: _chipBuilder,
          onTextChanged: _onSearchChanged,
        ),
        if (_suggestions.isNotEmpty)
          Flexible(
            fit: FlexFit.loose,
            child: ListView.builder(
              itemCount: _suggestions.length,
              itemBuilder: (BuildContext context, int index) {
                return ChipSuggestion(
                  _suggestions[index],
                  onTap: _selectSuggestion,
                );
              },
            ),
          ),
      ],
    );
  }

  Future<void> _onSearchChanged(String value) async {
    final List<String> results = await _suggestionCallback(value);
    setState(() {
      _suggestions = results
          .where((String chip) => !widget.chips.contains(chip))
          .toList();
    });
  }

  Widget _chipBuilder(BuildContext context, String chip) {
    return ChipInputChip(
      chip: chip,
      onDeleted: _onChipDeleted,
      onSelected: _onChipTapped,
    );
  }

  void _selectSuggestion(String chip) {
    setState(() {
      widget.chips.add(chip);
      _suggestions = <String>[];
    });
  }

  void _onChipTapped(String chip) {}

  void _onChipDeleted(String chip) {
    setState(() {
      widget.chips.remove(chip);
      _suggestions = <String>[];
    });
  }

  void _onSubmitted(String text) {
    if (text.trim().isNotEmpty) {
      setState(() {
        widget.chips = <String>[...widget.chips, text.trim()];
      });
    } else {
      _chipFocusNode.unfocus();
      setState(() {
        widget.chips = <String>[];
      });
    }
  }

  void _onChanged(List<String> data) {
    setState(() {
      widget.chips = data;
    });
  }

  FutureOr<List<String>> _suggestionCallback(String text) {
    if (text.isNotEmpty) {
      return _pizzaToppings.where((String chip) {
        return chip.toLowerCase().contains(text.toLowerCase());
      }).toList();
    }
    return const <String>[];
  }
}

class ChipsInput<T> extends StatefulWidget {
  const ChipsInput({
    super.key,
    required this.values,
    this.decoration = const InputDecoration(),
    this.style,
    this.strutStyle,
    required this.chipBuilder,
    required this.onChanged,
    this.onChipTapped,
    this.onSubmitted,
    this.onTextChanged,
  });

  final List<T> values;
  final InputDecoration decoration;
  final TextStyle? style;
  final StrutStyle? strutStyle;

  final ValueChanged<List<T>> onChanged;
  final ValueChanged<T>? onChipTapped;
  final ValueChanged<String>? onSubmitted;
  final ValueChanged<String>? onTextChanged;

  final Widget Function(BuildContext context, T data) chipBuilder;

  @override
  ChipsInputState<T> createState() => ChipsInputState<T>();
}

class ChipsInputState<T> extends State<ChipsInput<T>> {
  @visibleForTesting
  late final ChipsInputEditingController<T> controller;

  String _previousText = '';
  TextSelection? _previousSelection;

  @override
  void initState() {
    super.initState();

    controller = ChipsInputEditingController<T>(
      <T>[...widget.values],
      widget.chipBuilder,
    );
    controller.addListener(_textListener);
  }

  @override
  void dispose() {
    controller.removeListener(_textListener);
    controller.dispose();

    super.dispose();
  }

  void _textListener() {
    final String currentText = controller.text;

    if (_previousSelection != null) {
      final int currentNumber = countReplacements(currentText);
      final int previousNumber = countReplacements(_previousText);

      final int cursorEnd = _previousSelection!.extentOffset;
      final int cursorStart = _previousSelection!.baseOffset;

      final List<T> values = <T>[...widget.values];

      // If the current number and the previous number of replacements are different, then
      // the user has deleted the InputChip using the keyboard. In this case, we trigger
      // the onChanged callback. We need to be sure also that the current number of
      // replacements is different from the input chip to avoid double-deletion.
      if (currentNumber < previousNumber && currentNumber != values.length) {
        if (cursorStart == cursorEnd) {
          values.removeRange(cursorStart - 1, cursorEnd);
        } else {
          if (cursorStart > cursorEnd) {
            values.removeRange(cursorEnd, cursorStart);
          } else {
            values.removeRange(cursorStart, cursorEnd);
          }
        }
        widget.onChanged(values);
      }
    }

    _previousText = currentText;
    _previousSelection = controller.selection;
  }

  static int countReplacements(String text) {
    return text.codeUnits
        .where(
            (int u) => u == ChipsInputEditingController.kObjectReplacementChar)
        .length;
  }

  @override
  Widget build(BuildContext context) {
    controller.updateValues(<T>[...widget.values]);

    return TextField(
      minLines: 1,
      maxLines: 3,
      textInputAction: TextInputAction.done,
      style: widget.style,
      strutStyle: widget.strutStyle,
      controller: controller,
      decoration: InputDecoration(
          border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
          hintText: 'Add tags'),
      onChanged: (String value) =>
          widget.onTextChanged?.call(controller.textWithoutReplacements),
      onSubmitted: (String value) =>
          widget.onSubmitted?.call(controller.textWithoutReplacements),
    );
  }
}

class ChipsInputEditingController<T> extends TextEditingController {
  ChipsInputEditingController(this.values, this.chipBuilder)
      : super(
          text: String.fromCharCode(kObjectReplacementChar) * values.length,
        );

  // This constant character acts as a placeholder in the TextField text value.
  // There will be one character for each of the InputChip displayed.
  static const int kObjectReplacementChar = 0xFFFE;

  List<T> values;

  final Widget Function(BuildContext context, T data) chipBuilder;

  /// Called whenever chip is either added or removed
  /// from the outside the context of the text field.
  void updateValues(List<T> values) {
    if (values.length != this.values.length) {
      final String char = String.fromCharCode(kObjectReplacementChar);
      final int length = values.length;
      value = TextEditingValue(
        text: char * length,
        selection: TextSelection.collapsed(offset: length),
      );
      this.values = values;
    }
  }

  String get textWithoutReplacements {
    final String char = String.fromCharCode(kObjectReplacementChar);
    return text.replaceAll(RegExp(char), '');
  }

  String get textWithReplacements => text;

  @override
  TextSpan buildTextSpan(
      {required BuildContext context,
      TextStyle? style,
      required bool withComposing}) {
    final Iterable<WidgetSpan> chipWidgets =
        values.map((T v) => WidgetSpan(child: chipBuilder(context, v)));

    return TextSpan(
      style: style,
      children: <InlineSpan>[
        ...chipWidgets,
        if (textWithoutReplacements.isNotEmpty)
          TextSpan(text: textWithoutReplacements)
      ],
    );
  }
}

class ChipSuggestion extends StatelessWidget {
  const ChipSuggestion(this.chip, {super.key, this.onTap});

  final String chip;
  final ValueChanged<String>? onTap;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      key: ObjectKey(chip),
      leading: CircleAvatar(
        child: Text(
          chip[0].toUpperCase(),
        ),
      ),
      title: Text(chip),
      onTap: () => onTap?.call(chip),
    );
  }
}

class ChipInputChip extends StatelessWidget {
  const ChipInputChip({
    super.key,
    required this.chip,
    required this.onDeleted,
    required this.onSelected,
  });

  final String chip;
  final ValueChanged<String> onDeleted;
  final ValueChanged<String> onSelected;

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.only(right: 3),
      child: InputChip(
        key: ObjectKey(chip),
        label: Text(chip),
        avatar: CircleAvatar(
          child: Text(chip[0].toUpperCase()),
        ),
        onDeleted: () => onDeleted(chip),
        onSelected: (bool value) => onSelected(chip),
        materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
        padding: const EdgeInsets.all(2),
      ),
    );
  }
}

推荐答案

我快速看了一眼,我认为小部件的那部分引起了问题:

您正在将ListView嵌入SingleChildScrollView

 if (_suggestions.isNotEmpty)
          Flexible(
            fit: FlexFit.loose,
            child: ListView.builder(
              itemCount: _suggestions.length,
              itemBuilder: (BuildContext context, int index) {
                return ChipSuggestion(
                  _suggestions[index],
                  onTap: _selectSuggestion,
                );
              },
            ),
          ),

try 将shrinkWrap : truephysics: NeverScrollableScrollPhysics()设置到该列表视图.

一些增强功能:

  • 最好判断带有和不带有的小部件的行为 那Flexible,我注意到两个Flexible.(如果有效的话就没关系)

  • 您可以从SingleChildScrollView中删除ConstrainedBox,如果 其目的是让SingleChldScrollView人分配全部 屏幕

并遵循此 struct :

Column(
 Expanded(
  SingleChildScrollView(.....)
 )
 )

旧建议:be careful while you are inside a scroll view.


解释

任何ScrollView小部件都是一种特殊的小部件,可以提供无限的空间,因此当您处理此类小部件时,不要在其中使用贪婪的小部件.

术语greedy widgets指的是任何将分配所有可用空间的小部件,例如(灵活或扩展).

如果你想想象,请回答这个问题:How can you allocate all of the available space, if that space is infinite?你不能.

但是,如果该滚动视图中嵌入了另一个滚动视图怎么办?

再说一遍,如果你需要想象,请回答这个问题:

How can you scroll the nested widget while it's inside another scrollable widget

Which one will be scrolled?

事实上,拥有嵌套可滚动小部件在逻辑上是错误的,特别是如果它们具有相同的滚动方向.必须将滚动控制授予外部可滚动小部件,并且必须停用内部小部件滚动行为.

因此,当您touch 内部可滚动小部件并上下滑动时,整个可滚动小部件(外部+内部)会滚动.

那大约是NeverScrollableScrollPhyscis,大约是shrinkWrap : true.

当它设置为true时,意味着此滚动小部件将需要as much as it fits.

注意:可滚动小部件希望扩展(获取可用的内容),大多数小部件实际上是这样做的.

请记住:当您处于滚动视图中时,您无法获取可用内容,它是无限的.明白了吗?

我认为剩下要解释的是ConstrainedBox:

对小部件施加约束是一个很好的做法,但约束必须合理且合乎逻辑.

要达到屏幕的尺寸,通常使用MediaQuery.of(context).size.

因此,height代表屏幕的高度(以像素为单位),约束中的max Height设置为screenHeight * 2.

小部件怎么会占据屏幕高度的两倍,不要困惑.我们现在退出了滚动视图"SingleChildScrollView".

想象滚动视图是一个容器,容器高度如何变成屏幕高度的两倍.它不能.

那么,约束的目的主要是为了让SingleChildScrollView分配整个屏幕吗?remember the greedy widgets?

这就是我们用扩展小部件包装它的原因.

还有一条规则:那些贪婪的小部件必须有一个类型(行、列或Flex)的父小部件,这是一条规则.

经过长时间的打字,我希望这足以让您走上正确的道路.

希望对你有帮助.

Flutter相关问答推荐

如何防止alert 对话框在收到通知时出现在某个flutter页面中

手势捕获的异常:类型';Double';不是类型转换中类型';Double';的子类型

文本未设置在集装箱摆动的中心

来自FutureProvider的数据只打印两个不同的变量,它们对一个实例只有一次相同的值,为什么?

我有一个问题:类型';()=>;Null';不是类型转换中类型';(Int)=>;void';的子类型

Flutter 翼doctor 显示的错误,我似乎不能修复.问题:系统32、Android工具链、Windows 10 SDK

有没有一种正确的方法可以通过上下滑动在两个小部件(在我的情况下是应用程序栏)之间切换?

如何从DART中的事件列表中获取即将到来的日期

Android工作室Flutter 热重新加载更新后不工作

如何在flutter中使用youtube_explod_start加载下一页

2023 年如何使用 youtube_explode_dart 包下载 URL?

Flutter 应用程序在启动画面冻结(java.lang.RuntimeException:无法启动活动 ComponentInfo)

如何对齐卡片小部件中的图标按钮?

flutter 创建一个带有 2 个图标的按钮

Flutter 如何使用 ClipPath 编辑容器的顶部?

无法将参数类型Future Function(String?)分配给参数类型void Function(NotificationResponse)?

Flutter:如何判断嵌套列表是否包含值

我已经删除了我的 .android 密钥库,但我没有使用它,如何生成一个新的?

在 Flutter 应用程序中全局使用 assets 文件夹

Flutter - 如何调用此函数?