Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions example/lib/example_scroll_form.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import 'package:chips_input_autocomplete/chips_input_autocomplete.dart';
import 'package:flutter/material.dart';

/// Example demonstrating the fix for the scroll error when using an external controller.
///
/// This example creates a scrollable form with ChipsInputAutocomplete widgets.
/// When the widget scrolls out of view and back, it should continue to work
/// without errors because the external controller is not disposed.
class ChipsInputExampleScrollForm extends StatefulWidget {
const ChipsInputExampleScrollForm({super.key});

@override
State<ChipsInputExampleScrollForm> createState() =>
_ChipsInputExampleScrollFormState();
}

class _ChipsInputExampleScrollFormState
extends State<ChipsInputExampleScrollForm> {
static const double _scrollSpacing = 300.0;

final _formKey = GlobalKey<FormState>();
final _companyNameController = TextEditingController();
final _industryChipController = ChipsAutocompleteController();
final _companyNameController2 = TextEditingController();

final List<String> industryValues = [
'Technology',
'Healthcare',
'Finance',
'Education',
'Manufacturing',
'Retail',
'Real Estate',
'Entertainment',
'Transportation',
'Agriculture'
];

@override
void dispose() {
_companyNameController.dispose();
_industryChipController.dispose();
_companyNameController2.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Text(
'Scrollable Form Example',
style: Theme.of(context).textTheme.titleLarge,
),
),
),
Text(
'This demonstrates the fix for the scroll error. '
'The ChipsInputAutocomplete widget uses an external controller '
'and can be scrolled without errors.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
Expanded(
child: Form(
key: _formKey,
child: ListView(
shrinkWrap: true,
children: [
// First TextField
TextField(
controller: _companyNameController,
decoration: const InputDecoration(
labelText: 'Company Name',
hintText: 'Enter company name',
border: OutlineInputBorder(),
),
),
const SizedBox(height: _scrollSpacing), // Add spacing to force scrolling

// ChipsInputAutocomplete with external controller
const Text(
'Select Industries:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
ChipsInputAutocomplete(
options: industryValues,
createCharacter: ',',
controller: _industryChipController,
widgetContainerDecoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(10),
),
decorationTextField: const InputDecoration(
hintText: 'Type to search industries...',
),
),
const SizedBox(height: _scrollSpacing), // Add spacing to force scrolling

// Second TextField
TextField(
controller: _companyNameController2,
decoration: const InputDecoration(
labelText: 'Additional Info',
hintText: 'Enter additional information',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 20),

// Submit Button
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Company: ${_companyNameController.text}, '
'Industries: ${_industryChipController.chips.join(', ')}',
),
),
);
}
},
child: const Text('Submit'),
),
],
),
),
),
],
),
),
),
);
}
}
8 changes: 8 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:chips_input_autocomplete_example/example_insert_on_select.dart';
import 'package:chips_input_autocomplete_example/example_only_options.dart';
import 'package:chips_input_autocomplete_example/constants.dart';
import 'package:chips_input_autocomplete_example/example_options_async_basic.dart';
import 'package:chips_input_autocomplete_example/example_scroll_form.dart';
import 'package:chips_input_autocomplete_example/example_secondary_theme.dart';
import 'package:flutter/material.dart';
import 'package:chips_input_autocomplete/chips_input_autocomplete.dart';
Expand Down Expand Up @@ -241,6 +242,13 @@ class _HomePageState extends State<HomePage> {
height: 16,
),
const ChipsInputExampleForm(),
const SizedBox(
height: 16,
),
const SizedBox(
height: 400,
child: ChipsInputExampleScrollForm(),
),
],
),
),
Expand Down
14 changes: 12 additions & 2 deletions lib/src/chips_input_autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -243,11 +243,14 @@ class ChipsInputAutocompleteState extends State<ChipsInputAutocomplete> {
late final ChipsAutocompleteController _chipsAutocompleteController;
late final GlobalKey<FormFieldState<List<String>>> _formFieldKey;
late final FocusNode _focusNode;
late final bool _controllerCreatedInternally;
late final bool _focusNodeCreatedInternally;
String? _errorText;

@override
void initState() {
super.initState();
_controllerCreatedInternally = widget.controller == null;
_chipsAutocompleteController =
widget.controller ?? ChipsAutocompleteController();
if (widget.options != null) {
Expand All @@ -262,13 +265,20 @@ class ChipsInputAutocompleteState extends State<ChipsInputAutocomplete> {
_formFieldKey =
widget.formFieldKey ?? GlobalKey<FormFieldState<List<String>>>();
_chipsAutocompleteController.formFieldKey = _formFieldKey;
_focusNodeCreatedInternally = widget.focusNode == null;
_focusNode = widget.focusNode ?? FocusNode();
}

@override
void dispose() {
_chipsAutocompleteController.dispose();
_focusNode.dispose();
// Only dispose the controller if we created it internally
if (_controllerCreatedInternally) {
_chipsAutocompleteController.dispose();
}
// Only dispose the focus node if we created it internally
if (_focusNodeCreatedInternally) {
_focusNode.dispose();
}
super.dispose();
}

Expand Down
133 changes: 133 additions & 0 deletions test/chips_input_autocomplete_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,137 @@ void main() {
reason:
'Chip not added \nchipsAutocompleteController.chips: ${chipsAutocompleteController.chips} \nchipsAutocompleteController.text: ${chipsAutocompleteController.text}');
});

// Test 3: Controller disposal - external controller should not be disposed
testWidgets(
'External controller should not be disposed when widget is disposed',
(WidgetTester tester) async {
final externalController = ChipsAutocompleteController();

// Verify controller is not disposed initially
expect(() => externalController.chips, returnsNormally);

await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: ListView(
children: [
SizedBox(height: 1000), // Force scrolling
ChipsInputAutocomplete(
controller: externalController,
options: const ['option1', 'option2'],
),
SizedBox(height: 1000),
],
),
),
));

// Verify controller works
expect(() => externalController.chips, returnsNormally);

// Remove the widget by replacing with a different widget
await tester.pumpWidget(const MaterialApp(
home: Scaffold(
body: Text('Replaced'),
),
));

// Verify the external controller is still usable after widget disposal
expect(() => externalController.chips, returnsNormally);
expect(() => externalController.addChip('test'), returnsNormally);
expect(externalController.chips.contains('test'), true);

// Clean up
externalController.dispose();
});

// Test 4: FocusNode disposal - external FocusNode should not be disposed
testWidgets(
'External FocusNode should not be disposed when widget is disposed',
(WidgetTester tester) async {
final externalFocusNode = FocusNode();

// Verify FocusNode is not disposed initially
expect(externalFocusNode.hasFocus, isFalse);

await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: ChipsInputAutocomplete(
focusNode: externalFocusNode,
options: const ['option1', 'option2'],
),
),
));

// Remove the widget
await tester.pumpWidget(const MaterialApp(
home: Scaffold(
body: Text('Replaced'),
),
));

// Verify the external FocusNode is still usable after widget disposal
expect(() => externalFocusNode.hasFocus, returnsNormally);

// Clean up
externalFocusNode.dispose();
});

// Test 5: Scroll test - widget with external controller in scrollable list
testWidgets(
'Widget with external controller should work correctly in scrollable list',
(WidgetTester tester) async {
final controller = ChipsAutocompleteController();

await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: ListView(
children: [
const TextField(
decoration: InputDecoration(hintText: 'Field 1'),
),
const SizedBox(height: 500),
ChipsInputAutocomplete(
controller: controller,
options: const ['Apple', 'Banana', 'Cherry'],
widgetContainerDecoration: BoxDecoration(
border: Border.all(color: Colors.grey),
),
),
const SizedBox(height: 500),
const TextField(
decoration: InputDecoration(hintText: 'Field 2'),
),
],
),
),
));

// Add a chip
controller.addChip('Apple');
await tester.pump();

expect(controller.chips.contains('Apple'), true);

// Scroll down so the widget goes out of view
await tester.drag(find.byType(ListView), const Offset(0, -600));
await tester.pumpAndSettle();

// Verify controller is still working
expect(() => controller.chips, returnsNormally);
expect(controller.chips.contains('Apple'), true);

// Scroll back up
await tester.drag(find.byType(ListView), const Offset(0, 600));
await tester.pumpAndSettle();

// Verify widget is still functional
expect(controller.chips.contains('Apple'), true);
controller.addChip('Banana');
await tester.pump();
expect(controller.chips.length, 2);

// Clean up
controller.dispose();
});
}