Best way to create a scrollable list that dynamically grows

1k views Asked by At

I have a flutter web app (which may become a desktop app in the future) that contains a dialog box. The dialog allows the user to edit the properties of a model object that contains a list of elements. The user should be able to edit the list by adding or deleting elements to the list. So the dialog contents are of variable height -- it depends on the number of elements in the list.

I am having trouble creating a layout that dynamically resizes appropriately. What I want is for the dialog to grow as items are added to the list, up to the maximum size that would fit on the device's screen. If the contents grow larger than this, the elements in the list should be scrollable.

I've attached two screenshots of what I have working at the moment; the first has a list with only two items, which is easily displayed. The second is the same dialog with many items showing the overflow.

Dialog with Two Items

Dialog with Many Items

Here is the code for the Dialog:

  Widget build(BuildContext context) {
    return AlertDialog(
      actions: [
        FlatButton(
          child: const Text('CANCEL'),
          onPressed: () => Navigator.of(context).pop(),
        ),
        FlatButton(
          child: const Text('OK'),
          onPressed: () =>
              Navigator.of(context).pop<Modifier>(_createModifier()),
        ),
      ],
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text('Damage Editor'),
          Divider(),
          columnSpacer,
          DiceSpinner(
            onChanged: (value) => setState(() => _dice = value),
            initialValue: _dice,
            textFieldWidth: 90.0,
          ),
          columnSpacer,
          Container(
            padding: const EdgeInsets.only(left: 12.0, right: 12.0),
            decoration: BoxDecoration(
              borderRadius: BorderRadius.circular(4.0),
              border: Border.all(color: Colors.grey),
            ),
            child: DropdownButton<DamageType>(
              underline: Container(),
              value: _type,
              items: _damageTypeItems(),
              onChanged: (value) => setState(() => _type = value),
            ),
          ),
          columnSpacer,
          SwitchListTile(
            value: _direct,
            onChanged: (state) => setState(() => _direct = state),
            title: Text(_direct ? 'Internal (Direct)' : 'External (Indirect)'),
          ),
          if (!_direct) ...<Widget>[
            columnSpacer,
            CheckboxListTile(
              value: _explosive,
              onChanged: (state) => setState(() => _explosive = state),
              title: Text('Explosive'),
            ),
          ],
          columnSpacer,
          DynamicListHeader(
            title: 'Enhancements/Limitations',
            onPressed: () => setState(() =>
                _modifiers.add(TraitModifier(name: 'Undefined', percent: 0))),
          ),
          SingleChildScrollView(
            child: ListBody(
              children: _enhancementList(),
            ),
          ),
        ],
      ),
    );
  }

  List<Widget> _enhancementList() {
    var list = <Widget>[];
    _modifiers.forEach(
      (element) {
        if (_modifiers.length > 0) list.add(columnSpacer);
        list.add(_EnhancerEditor(element, index: _modifiers.indexOf(element),
            onChanged: (index, enhancer) {
          _modifiers[index] = enhancer;
        }));
      },
    );
    return list;
  }


typedef TraitModifierCallback = void Function(int, TraitModifier);

class _EnhancerEditor extends StatefulWidget {
  _EnhancerEditor(this.enhancer, {this.onChanged, this.index});

  final TraitModifier enhancer;
  final TraitModifierCallback onChanged;
  final int index;

  @override
  __EnhancerEditorState createState() => __EnhancerEditorState();
}

class __EnhancerEditorState extends State<_EnhancerEditor> {
  TextEditingController _nameController;
  TextEditingController _percentController;
  bool _validInput = true;

  @override
  void initState() {
    super.initState();

    _nameController = TextEditingController(text: widget.enhancer.name);
    _nameController.addListener(_onChanged);

    _percentController =
        TextEditingController(text: widget.enhancer.percent.toString());
    _percentController.addListener(_onChanged);
  }

  @override
  void dispose() {
    _nameController.removeListener(_onChanged);
    _nameController.dispose();

    _percentController.removeListener(_onChanged);
    _percentController.dispose();

    super.dispose();
  }

  void _onChanged() {
    String text = _percentController.text.trim();
    setState(() {
      int value = int.tryParse(text);
      _validInput = (value != null);

      if (_validInput) {
        widget.onChanged(widget.index,
            TraitModifier(name: _nameController.text, percent: value));
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return IntrinsicHeight(
      child: Row(
        children: [
          Expanded(
            child: TextField(
              controller: _nameController,
              decoration: const InputDecoration(
                labelText: 'Enhancer/Limitation',
                border: const OutlineInputBorder(),
              ),
            ),
          ),
          rowSmallSpacer,
          SizedBox(
            width: 80.0,
            child: TextField(
              controller: _percentController,
              textAlign: TextAlign.end,
              keyboardType: TextInputType.numberWithOptions(signed: true),
              inputFormatters: [
                FilteringTextInputFormatter.allow(RegExp(r'[0-9\-]'))
              ],
              decoration: const InputDecoration(
                suffixText: '%',
                labelText: 'Pct',
                border: const OutlineInputBorder(),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

1

There are 1 answers

7
dGoran On

It may sound counterintuitive but you should have ListView within Column. You need both.

In dialog Column will deal with dialog size, and ListView will deal with scrolling within.

Column(
mainAxisSize: MainAxisSize.min,
children:[
ListView(padding: EdgeInsets.all(0), 
            shrinkWrap: true, 
            physics: ClampingScrollPhysics(),

Ok, here you go ... Simple call of dialog ...

            FlatButton(
              onPressed: () {
                showDialog(
                  useRootNavigator: false,
                  context: context,
                  builder: (BuildContext context) => Test(),
                );
              },
              child: Text('Suppper'),
            )

then code of dialog ... it is pretty straight forward ... all controllers you can place in a map that you will deal with when you add new entries... both adding and removing is super easy ... let me know if further assitance is needed.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class Test extends StatefulWidget {
  @override
  _TestState createState() => _TestState();
}

class _TestState extends State<Test> {
  @override
  void initState() {
    super.initState();
  }

  Widget rowEntry(_nameController, _percentController) {
    return IntrinsicHeight(
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Expanded(
            child: TextField(
              controller: _nameController,
              decoration: const InputDecoration(
                labelText: 'Enhancer/Limitation',
                border: const OutlineInputBorder(),
              ),
            ),
          ),
          SizedBox(
            width: 80.0,
            child: TextField(
              controller: _percentController,
              textAlign: TextAlign.end,
              keyboardType: TextInputType.numberWithOptions(signed: true),
              inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9\-]'))],
              decoration: const InputDecoration(
                suffixText: '%',
                labelText: 'Pct',
                border: const OutlineInputBorder(),
              ),
            ),
          ),
        ],
      ),
    );
  }

  addEntry() async {
    entries.add('new value');
    setState(() {});
  }

  List entries = [];

  Widget build(BuildContext context) {
    return Dialog(
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
      child: Container(
        padding: EdgeInsets.all(16),
        width: 300,
        child: ListView(shrinkWrap: true, children: [
          Text('here you will put all that is above (+)'),
          Container(
            margin: EdgeInsets.only(top: 16, bottom: 16),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text('Exhancements/Limitations'),
                InkWell(
                  onTap: () {
                    addEntry();
                  },
                  child: Icon(Icons.add),
                ),
              ],
            ),
          ),
          ListView.builder(
              physics: ClampingScrollPhysics(),
              shrinkWrap: true,
              padding: EdgeInsets.all(0),
              scrollDirection: Axis.vertical,
              itemCount: entries == null ? 0 : (entries.length),
              itemBuilder: (BuildContext context, int index) {
                return rowEntry(null, null);
              }),
        ]),
      ),
    );
  }
}

result of the above code