diff --git a/example/lib/example_scroll_form.dart b/example/lib/example_scroll_form.dart new file mode 100644 index 0000000..3d94029 --- /dev/null +++ b/example/lib/example_scroll_form.dart @@ -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 createState() => + _ChipsInputExampleScrollFormState(); +} + +class _ChipsInputExampleScrollFormState + extends State { + static const double _scrollSpacing = 300.0; + + final _formKey = GlobalKey(); + final _companyNameController = TextEditingController(); + final _industryChipController = ChipsAutocompleteController(); + final _companyNameController2 = TextEditingController(); + + final List 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'), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 16e5a87..928c571 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -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'; @@ -241,6 +242,13 @@ class _HomePageState extends State { height: 16, ), const ChipsInputExampleForm(), + const SizedBox( + height: 16, + ), + const SizedBox( + height: 400, + child: ChipsInputExampleScrollForm(), + ), ], ), ), diff --git a/lib/src/chips_input_autocomplete.dart b/lib/src/chips_input_autocomplete.dart index 1d1486d..1a31bd3 100644 --- a/lib/src/chips_input_autocomplete.dart +++ b/lib/src/chips_input_autocomplete.dart @@ -243,11 +243,14 @@ class ChipsInputAutocompleteState extends State { late final ChipsAutocompleteController _chipsAutocompleteController; late final GlobalKey>> _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) { @@ -262,13 +265,20 @@ class ChipsInputAutocompleteState extends State { _formFieldKey = widget.formFieldKey ?? GlobalKey>>(); _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(); } diff --git a/test/chips_input_autocomplete_test.dart b/test/chips_input_autocomplete_test.dart index cc7508d..aca82e1 100644 --- a/test/chips_input_autocomplete_test.dart +++ b/test/chips_input_autocomplete_test.dart @@ -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(); + }); }