mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00

<!-- Thanks for filing a pull request! Reviewers are typically assigned within a week of filing a request. To learn more about code review, see our documentation on Tree Hygiene: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md --> This PR removes redundant useMaterial3: true as described in https://github.com/flutter/flutter/issues/162818 *List which issues are fixed by this PR. You must list at least one issue. An issue is not required if the PR fixes something trivial like a typo.* - https://github.com/flutter/flutter/issues/162818 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --------- Co-authored-by: Qun Cheng <36861262+QuncCccccc@users.noreply.github.com>
345 lines
9.5 KiB
Dart
345 lines
9.5 KiB
Dart
// Copyright 2014 The Flutter Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style license that can be
|
|
// found in the LICENSE file.
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
const List<String> _pizzaToppings = <String>[
|
|
'Olives',
|
|
'Tomato',
|
|
'Cheese',
|
|
'Pepperoni',
|
|
'Bacon',
|
|
'Onion',
|
|
'Jalapeno',
|
|
'Mushrooms',
|
|
'Pineapple',
|
|
];
|
|
|
|
void main() => runApp(const EditableChipFieldApp());
|
|
|
|
class EditableChipFieldApp extends StatelessWidget {
|
|
const EditableChipFieldApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return const MaterialApp(home: EditableChipFieldExample());
|
|
}
|
|
}
|
|
|
|
class EditableChipFieldExample extends StatefulWidget {
|
|
const EditableChipFieldExample({super.key});
|
|
|
|
@override
|
|
EditableChipFieldExampleState createState() {
|
|
return EditableChipFieldExampleState();
|
|
}
|
|
}
|
|
|
|
class EditableChipFieldExampleState extends State<EditableChipFieldExample> {
|
|
final FocusNode _chipFocusNode = FocusNode();
|
|
List<String> _toppings = <String>[_pizzaToppings.first];
|
|
List<String> _suggestions = <String>[];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Editable Chip Field Sample')),
|
|
body: Column(
|
|
children: <Widget>[
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: ChipsInput<String>(
|
|
values: _toppings,
|
|
decoration: const 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)
|
|
Expanded(
|
|
child: ListView.builder(
|
|
itemCount: _suggestions.length,
|
|
itemBuilder: (BuildContext context, int index) {
|
|
return ToppingSuggestion(_suggestions[index], onTap: _selectSuggestion);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _onSearchChanged(String value) async {
|
|
final List<String> results = await _suggestionCallback(value);
|
|
setState(() {
|
|
_suggestions = results.where((String topping) => !_toppings.contains(topping)).toList();
|
|
});
|
|
}
|
|
|
|
Widget _chipBuilder(BuildContext context, String topping) {
|
|
return ToppingInputChip(topping: topping, onDeleted: _onChipDeleted, onSelected: _onChipTapped);
|
|
}
|
|
|
|
void _selectSuggestion(String topping) {
|
|
setState(() {
|
|
_toppings.add(topping);
|
|
_suggestions = <String>[];
|
|
});
|
|
}
|
|
|
|
void _onChipTapped(String topping) {}
|
|
|
|
void _onChipDeleted(String topping) {
|
|
setState(() {
|
|
_toppings.remove(topping);
|
|
_suggestions = <String>[];
|
|
});
|
|
}
|
|
|
|
void _onSubmitted(String text) {
|
|
if (text.trim().isNotEmpty) {
|
|
setState(() {
|
|
_toppings = <String>[..._toppings, text.trim()];
|
|
});
|
|
} else {
|
|
_chipFocusNode.unfocus();
|
|
setState(() {
|
|
_toppings = <String>[];
|
|
});
|
|
}
|
|
}
|
|
|
|
void _onChanged(List<String> data) {
|
|
setState(() {
|
|
_toppings = data;
|
|
});
|
|
}
|
|
|
|
FutureOr<List<String>> _suggestionCallback(String text) {
|
|
if (text.isNotEmpty) {
|
|
return _pizzaToppings.where((String topping) {
|
|
return topping.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,
|
|
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 ToppingSuggestion extends StatelessWidget {
|
|
const ToppingSuggestion(this.topping, {super.key, this.onTap});
|
|
|
|
final String topping;
|
|
final ValueChanged<String>? onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListTile(
|
|
key: ObjectKey(topping),
|
|
leading: CircleAvatar(child: Text(topping[0].toUpperCase())),
|
|
title: Text(topping),
|
|
onTap: () => onTap?.call(topping),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ToppingInputChip extends StatelessWidget {
|
|
const ToppingInputChip({
|
|
super.key,
|
|
required this.topping,
|
|
required this.onDeleted,
|
|
required this.onSelected,
|
|
});
|
|
|
|
final String topping;
|
|
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(topping),
|
|
label: Text(topping),
|
|
avatar: CircleAvatar(child: Text(topping[0].toUpperCase())),
|
|
onDeleted: () => onDeleted(topping),
|
|
onSelected: (bool value) => onSelected(topping),
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
padding: const EdgeInsets.all(2),
|
|
),
|
|
);
|
|
}
|
|
}
|