flutterlistview

While creating dynamic list containing TextField's, the user inputs & index are lost on adding or dismissing a list item (Flutter)


Below is my minimal reproduction of my working code, where a dynamic list is created. The list has been initialised with a single element at the start. User can add more items on pressing a button or user can dismiss the item by swiping from end to start.

import 'package:flutter/material.dart';

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

class MyApp extends StatefulWidget {
  const MyApp({super.key});
  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  late List<InvoiceItemInput> itemsList;

  @override
  void initState() {
    itemsList = List<InvoiceItemInput>.from([
      InvoiceItemInput(
        parentWidth: 400.0,
        index: 1,
        key: ValueKey('1' + DateTime.now().toString()),
      )
    ]);
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: SingleChildScrollView(
          child: Column(
            children: [
              ListView.builder(
                shrinkWrap: true,
                itemBuilder: (BuildContext context, int index) => Dismissible(
                    key: ValueKey(index.toString() + DateTime.now().toString()),
                    child: itemsList[index],
                    background: Container(color: Colors.red),
                    direction: DismissDirection.endToStart,
                    onDismissed: (direction) {
                      if (direction == DismissDirection.endToStart) {
                        setState(() {
                          itemsList.removeAt(index);
                        });
                      }
                    }),
                itemCount: itemsList.length,
              ),
              Padding(
                padding: EdgeInsets.symmetric(vertical: 20.0),
                child: Align(
                  alignment: Alignment.centerRight,
                  child: OutlinedButton(
                      child: Text('ADD'),
                      onPressed: () {
                        setState(() {
                          final int index = itemsList.length;
                          itemsList.add(InvoiceItemInput(
                            parentWidth: 400.0,
                            index: index + 1,
                            key: ValueKey(
                                index.toString() + DateTime.now().toString()),
                          ));
                        });
                      }),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class InvoiceItemInput extends StatefulWidget {
  const InvoiceItemInput(
      {super.key, required this.parentWidth, required this.index});
  final double parentWidth;
  final int index;

  @override
  State<InvoiceItemInput> createState() => _InvoiceItemInputState();
}

class _InvoiceItemInputState extends State<InvoiceItemInput> {
  late TextEditingController? itemController;
  final double horizontalSpacing = 15.0;
  bool showDeleteButton = false;

  @override
  void initState() {
    itemController = TextEditingController();
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        SizedBox(height: 12.0),
        Container(
          padding: EdgeInsets.symmetric(vertical: 4.0, horizontal: 7.0),
          child: Text('Item ${widget.index}',
              style: Theme.of(context)
                  .textTheme
                  .labelLarge
                  ?.copyWith(color: Colors.white)),
          decoration: BoxDecoration(
              color: Colors.lightBlue,
              borderRadius: BorderRadius.circular(7.0)),
        ),
        SizedBox(height: 7.0),
        Wrap(
          spacing: this.horizontalSpacing,
          runSpacing: 25.0,
          children: [
            SizedBox(
              width: (widget.parentWidth - horizontalSpacing) / 2 < 200.0
                  ? (widget.parentWidth - horizontalSpacing) / 2
                  : 200.0,
              child: DropdownMenu(
                controller: itemController,
                label: Text(
                  'Item Name *',
                  style: const TextStyle(
                      fontFamily: 'Raleway',
                      fontSize: 14.0,
                      color: Colors.black87,
                      fontWeight: FontWeight.w500),
                ),
                hintText: 'Enter Item Name',
                requestFocusOnTap: true,
                enableFilter: true,
                expandedInsets: EdgeInsets.zero,
                textStyle: Theme.of(context).textTheme.bodySmall,
                menuStyle: MenuStyle(
                  backgroundColor: WidgetStateProperty.all(Colors.lightBlue),
                ),
                dropdownMenuEntries: [
                  DropdownMenuEntry(
                    value: 'Pen',
                    label: 'Pen',
                    style: MenuItemButton.styleFrom(
                      foregroundColor: Colors.white,
                      textStyle: Theme.of(context).textTheme.bodySmall,
                    ),
                  ),
                  DropdownMenuEntry(
                    value: 'Pencil',
                    label: 'Pencil',
                    style: MenuItemButton.styleFrom(
                      foregroundColor: Colors.white,
                      textStyle: Theme.of(context).textTheme.bodySmall,
                    ),
                  )
                ],
              ),
            ),
          ],
        ),
        SizedBox(height: 15.0),
      ],
    );
  }
}

Problem: When an item is added or dismissed, the changes are lost in the TextField / DropdownMenu.

Looking for: Could you please suggest a way out so that the Item index shown in a Container at top of list item also updates to new value but keeping the user input intact.


Solution

  • Building upon @Altay 's response, my answer solves updating index of an item problem too. I still have a question mentioned as a comment in the code below. Do we need to dispose removed TextControllers() from the list? how?

    import 'package:flutter/material.dart';
    
    void main() {
      runApp(const MyApp());
    }
    
    class MyApp extends StatefulWidget {
      const MyApp({super.key});
      @override
      State<MyApp> createState() => _MyAppState();
    }
    
    class _MyAppState extends State<MyApp> {
      List<ValueKey> itemKeys = [];
      List<Map<String, dynamic>> itemsControllers = [];
      late List<InvoiceItemInput> itemsList;
    
      @override
      void initState() {
        super.initState();
        itemKeys.add(ValueKey('1' + DateTime.now().toString()));
        itemsControllers.add({
          'key': itemKeys[0],
          'itemControllers':
              Set<TextEditingController>.unmodifiable([TextEditingController()]) // Using Set here in case we have multiple text controllers here
        });
        itemsList = List<InvoiceItemInput>.from([
          InvoiceItemInput(
            parentWidth: 400.0,
            index: 1,
            key: itemKeys[0],
            controllers: itemsControllers[0]['itemControllers']!,
          )
        ]);
      }
    
      @override
      void dispose() {
        for (int k = 0; k < itemsControllers.length; k++) {
          itemsControllers[k]['itemControllers']
              .forEach((controller) => controller.dispose());
        }
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          debugShowCheckedModeBanner: false,
          home: Scaffold(
            body: SingleChildScrollView(
              child: Column(
                children: [
                  ListView.builder(
                    shrinkWrap: true,
                    itemBuilder: (BuildContext context, int index) => Dismissible(
                        key: ValueKey(index.toString() + DateTime.now().toString()),
                        child: itemsList[index],
                        background: Container(color: Colors.red),
                        direction: DismissDirection.endToStart,
                        onDismissed: (direction) {
                          if (direction == DismissDirection.endToStart) {
                            final removeKey = itemKeys.elementAt(index);
                            itemKeys.removeAt(index);
                            itemsList.removeWhere(
                                (invoiceItem) => invoiceItem.key == removeKey);
                            itemsControllers.removeWhere(
                                (itemControl) => itemControl['key'] == removeKey);  // Question: Do we need to dispose these controllers removed, if so how can we do ?
                            itemsList.indexed
                                .forEach(((int, InvoiceItemInput) item) {
                              if (item.$2.index > item.$1 + 1) {
                                final replacementItem = InvoiceItemInput(
                                  parentWidth: 400.0,
                                  index: item.$1 + 1,
                                  key: item.$2.key,
                                  controllers: item.$2.controllers,
                                );
                                itemsList.removeAt(item.$1);
                                itemsList.insert(item.$1, replacementItem);
                              }
                            });
                            setState(() {});
                          }
                        }),
                    itemCount: itemsList.length,
                  ),
                  Padding(
                    padding: EdgeInsets.symmetric(vertical: 20.0),
                    child: Align(
                      alignment: Alignment.centerRight,
                      child: OutlinedButton(
                          child: Text('ADD'),
                          onPressed: () {
                            final int index = itemsList.length;
                            itemKeys.add(ValueKey(
                                index.toString() + DateTime.now().toString()));
                            itemsControllers.add({
                              'key': itemKeys[index],
                              'itemControllers':
                                  Set<TextEditingController>.unmodifiable(
                                      [TextEditingController()])
                            });
                            setState(() {
                              itemsList.add(InvoiceItemInput(
                                parentWidth: 400.0,
                                index: index + 1,
                                key: itemKeys[index],
                                controllers: itemsControllers[index]
                                    ['itemControllers'],
                              ));
                            });
                          }),
                    ),
                  ),
                ],
              ),
            ),
          ),
        );
      }
    }
    
    class InvoiceItemInput extends StatefulWidget {
      const InvoiceItemInput(
          {Key? key,
          required this.parentWidth,
          required this.index,
          required this.controllers})
          : super(key: key);
      final double parentWidth;
      final int index;
      final Set<TextEditingController> controllers;
    
      @override
      State<InvoiceItemInput> createState() => _InvoiceItemInputState();
    }
    
    class _InvoiceItemInputState extends State<InvoiceItemInput> {
      final double horizontalSpacing = 15.0;
    
      @override
      Widget build(BuildContext context) {
        return Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            SizedBox(height: 12.0),
            Container(
              padding: EdgeInsets.symmetric(vertical: 4.0, horizontal: 7.0),
              child: Text('Item ${widget.index}',
                  style: Theme.of(context)
                      .textTheme
                      .labelLarge
                      ?.copyWith(color: Colors.white)),
              decoration: BoxDecoration(
                  color: Colors.lightBlue,
                  borderRadius: BorderRadius.circular(7.0)),
            ),
            SizedBox(height: 7.0),
            Wrap(
              spacing: this.horizontalSpacing,
              runSpacing: 25.0,
              children: [
                SizedBox(
                  width: (widget.parentWidth - horizontalSpacing) / 2 < 200.0
                      ? (widget.parentWidth - horizontalSpacing) / 2
                      : 200.0,
                  child: TextField(
                    controller: widget.controllers.elementAt(0),
                    decoration: InputDecoration(
                      labelText: 'Item Name *',
                      hintText: 'Enter Item Name',
                    ),
                  ),
                ),
              ],
            ),
            SizedBox(height: 15.0),
          ],
        );
      }
    }