scrollflutterscrollerstepper

How can I make sure the stepper title does't go out of view onTap?


I am implementing a stepper in which the content displays a list of choices. Depending where the step is on the screen the scroller pushes the title out of view and only shows part of the list on tap. This doesn't feel right from a UX point of view.

I've tried altering the physics to ClampingScrollPhysics() as advised in the widget comments but this doesn't fix the issue. A cut down version of the program is below to help others recreate - just paste into the flutter demo project.

I've also updated flutter :-)

class _MyHomePageState extends State<MyHomePage> {
  static YourAge yourAge = YourAge();

  static Widget _ageSelector = CustomSelectorFromList(
      selections: yourAge.valueList,
      initialSelection: yourAge.currentSelectionIndex,
      onSelectionChanged: (int choice) {
        yourAge.value = yourAge.valueList[choice];
      });

  List<Step> _listSteps = [
    Step(
      title: Text("Step 1"),
      content: _ageSelector,
    ),
    Step(
      title: Text("Step 2"),
      content: _ageSelector,
    ),
    Step(
      title: Text("Step 3"),
      content: _ageSelector,
    ),
    Step(
      title: Text("Step 4"),
      content: _ageSelector,
    ),
    Step(
      title: Text("Step 5"),
      content: _ageSelector,
    ),
    Step(
      title: Text("Step 6"),
      content: _ageSelector,
    ),
    Step(
      title: Text("Step 7"),
      content: _ageSelector,
    ),
  ];

  static int _currentStep = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text(widget.title),
        ),
        body: Column(
          children: <Widget>[
            Expanded(
              child: Container(
                child: Stepper(
                  steps: _listSteps,
                  physics: ClampingScrollPhysics(),
                  currentStep: _currentStep,
                  onStepTapped: (step) {
                    setState(() {
                      _currentStep = step;
                    });
                  },
                ),
              ),
            ),
          ],
        ));
  }
}

abstract class DiscreteParameter {
  final String _yourValueKey;
  final List<String> _valueList;
  final String _defaultValue;

  DiscreteParameter(this._yourValueKey, this._valueList, this._defaultValue);

  String _value;

  String get value => _value;
  set value(String value) {
    _value = value;
  }

  int get currentSelectionIndex => _valueList.indexOf(_value);
  set currentSelectionIndex(int index) => () {
        _value = _valueList[index];
      };

  List<String> get valueList => _valueList;
}

class YourAge extends DiscreteParameter {
  static String _yourAgeKey = 'yourAge';
  YourAge() : super(_yourAgeKey, _ageList, aged26to35);

  //String _yourAge = aged26to35;
  static const String agedUpto15 = "<15";
  static const String aged16to25 = "16-25";
  static const String aged26to35 = "26-35";
  static const String aged36to45 = "36-45";
  static const String aged46to55 = "46-55";
  static const String aged56to65 = "56-65";
  static const String aged66plus = "66+";
  static const List<String> _ageList = [
    agedUpto15,
    aged16to25,
    aged26to35,
    aged36to45,
    aged46to55,
    aged56to65,
    aged66plus
  ];
}

class CustomSelectorFromList extends StatefulWidget {
  final Function(int) onSelectionChanged;
  final int initialSelection;
  final List<String> selections;

  @override
  _CustomSelectorFromListState createState() => _CustomSelectorFromListState(
      onSelectionChanged: onSelectionChanged,
      initialSelection: initialSelection,
      selections: selections);

  //include a callback function to parent to react to state change
  CustomSelectorFromList(
      {Key key,
      @required this.selections,
      @required this.onSelectionChanged,
      @required this.initialSelection});
}

class _CustomSelectorFromListState extends State<CustomSelectorFromList> {
  Function(int) onSelectionChanged;
  final int initialSelection;
  final List<String> selections;
  int _listLength;
  int _value = 0;

  _CustomSelectorFromListState(
      {Key key,
      @required this.selections,
      @required this.onSelectionChanged,
      @required this.initialSelection}) {
    //state is preserved so can be set from user "shared preferences"
    _value = initialSelection;
    _listLength = selections.length;
  }

  @override
  Widget build(BuildContext context) {
    //debugPrint("list length: ${selections.length.toString()}");
    return Wrap(
      direction: Axis.vertical,
      children: List<Widget>.generate(
        _listLength,
        (int index) {
          return ChoiceChip(
            label: Text(
              "${selections[index]}",
              style: TextStyle(
                  color: _value == index ? Colors.white70 : Colors.blueGrey),
            ),
            selectedColor: Colors.blueGrey,
            disabledColor: Colors.white70,
            //labelPadding: EdgeInsets.symmetric(),
            padding: const EdgeInsets.all(10),
            selected: _value == index,
            onSelected: (bool selected) {
              setState(() {
                _value = selected ? index : null;
              });
              //callback to parent widget - index of selected item
              onSelectionChanged(index);
            },
          );
        },
      ).toList(),
    );
  }
}

I expect onTap of a step to scroll to show the step title and content below. But if I reveal step 1, don't scroll and click step 2 then the title shoots upwards out of view and just the bottom few choice chips are shown, forcing the user to drag the list back down to view all options.


Solution

  • The given behaviour is a result of using the combination of: Column => Expanded => Stepper. The Column widget itself is not scrollable therefore if the children exceed the height (for example screen height) it will result in overflowing pixels. So the combination of Column => Stepper won't achieve the desired result. It is working in your case because you wrapped your Stepper with the Expanded widget which can be used for widgets which implements RenderFlex (what Stepper does) and will use all available space to render it and make it scrollable if necessary. So basically you could just use Stepper as the body of your Scaffold and you would achieve the same - just wanted to point that out as this might help you!

    Looking into the implementation of the Stepper's onStepTapped callback, it will call the Scrollable.ensureVisible(...) function to make sure the content of one Step will be visible after being tapped. So flutter tries to avoid your given behaviour at this point. It seems as the offset which will be set in this case is being miscalculated by the combination of Column => Expanded => Stepper.

    To yield a better result I tried a different approach: ListView => Stepper and set the scrollPhysics of Stepper to NeverScrollableScrollPhysics so the Stepper won't consume any scrolling input and the ListView is being used as the main scroll handler:

    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: ListView(
        children: <Widget>[
          Stepper(
            steps: _listSteps,
            physics: NeverScrollableScrollPhysics(),
            currentStep: _currentStep,
            onStepTapped: (step) {
              setState(() {
                _currentStep = step;
              });
            },
          ),
        ],
      ),
    );