我知道将 Column 放入 SingleChildScrollView 中是出了名的棘手,特别是当列中有展开的小部件时。
我正处于一个奇怪的境地,我正在尝试构建一个表单。在这种形式中,有一个筹码栏(又名标签栏)。当用户在此栏中键入文本时,该栏下方会打开一个建议列表视图。它垂直打开。但表单的布局因这个额外的高度而变得混乱。如果屏幕很短(或者表单很长),我会遇到各种各样的问题(建议的 ListView 不显示、异常等)。如下图所示,建议列表部分被屏蔽,无法使用。
@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不再被屏蔽,但表单的页面最终会变得不必要的长,并且当打开列表视图时,标签栏和下一个字段之间有很大的间隙。我尝试使用 maxHeight 的系数,但我觉得它很黑客,并且可能很容易破坏,具体取决于标签栏中建议的长度。我觉得我需要一些适合标签栏+建议列表长度的东西。您有什么想法可以改进我的布局吗?这是一个可重现的示例:
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,
);
},
),
),
尝试将
shrinkWrap : true
和 physics: NeverScrollableScrollPhysics()
设置为该列表视图。
一些增强功能:
最好检查您的小部件的行为是否有 那些
Flexible
,我注意到两个Flexible
。(如果它能工作那就没问题了)
您可以从
ConstrainedBox
中删除 SingleChildScrollView
,如果
其目的是使 SingleChldScrollView
分配整个
屏幕。
并遵循以下结构:
Column(
Expanded(
SingleChildScrollView(.....)
)
)
旧建议:在滚动视图中时要小心。
任何 ScrollView 小部件都是一种特殊类型的小部件,提供无限的空间,因此当您处理此类小部件时,不要在其中使用贪婪的小部件。
术语“贪婪小部件”指的是任何将分配所有可用空间的小部件,例如(灵活或扩展)。 如果您想想象回答这个问题:
如果空间是无限的,您如何分配所有可用空间?您不能。 但是,如果该滚动视图在其中嵌套另一个滚动视图怎么办?
再次,如果你想想象回答这个问题:
如何在另一个可滚动小部件内滚动嵌套小部件?
哪一个会被滚动?事实上,嵌套可滚动小部件在逻辑上是错误的,特别是如果它们具有相同的滚动方向。滚动控制必须授予外部可滚动小部件,并且内部小部件滚动行为必须停用。
因此,当您触摸内部可滚动小部件并向上或向下滑动它时,整个可滚动小部件(外部 + 内部)会滚动。
这是关于
NeverScrollableScrollPhyscis
的,那么
shrinkWrap : true
呢。当它设置为 true 时,意味着此滚动小部件将占用 尽可能多的内容。注意:可滚动小部件希望扩展(获取可用的内容),大多数小部件实际上都这样做。
记住:当你在滚动视图中时,你无法获取可用的内容,它是无限的。明白了吗?
我认为剩下要解释的是
ConstrainedBox
:
对小部件施加约束是一个很好的做法,但约束必须合理且符合逻辑。要达到屏幕的尺寸,通常使用
MediaQuery.of(context).size
。
因此,height代表屏幕的高度(以像素为单位),constraints中的maxHeight设置为screenHeight * 2。小部件怎么会占据屏幕高度的两倍呢,别搞混了。我们现在已经脱离了滚动视图“SingleChildScrollView”。
想象一下滚动视图是一个容器,容器的高度如何变成屏幕高度的两倍。不可以。
那么,约束的目的主要是为了让SingleChildScrollView分配整个屏幕?还记得贪婪的小部件吗?
这就是我们用 Expanded 小部件包装它的原因。
还有一个规则:那些贪婪的小部件必须有一个类型为(Row、Column或Flex)的父小部件,这是一条规则。
经过这么长的打字,我希望这足以让您走上正确的道路。
希望对您有帮助。