Search code examples
flutterflutter-sliver

Flutter Exception: ScrollController attached to multiple scroll views


My Flutter application is throwing an exception (ScrollController attached to multiple scroll views) when navigating away from a page that has a ScrollController to control a NestedScrollView and I am not sure what I am doing wrong.

I have recreated the exception with a simple example below. I can navigate from FirstPage to SecondPage (and optionally back) just fine, but when I navigate from SecondPage to ThirdPage the exception is thrown. The SecondPage is the page that contains my custom CollapsingAppBarPage widget which I assume has the problem. CollapsingAppBarPage is simplified in this example, but in my real app it changes colors/sizes of components based on the scroll position. In this example, the exception happens when _scrollController.offset is called as the navigation begins to the ThirdPage. Also, I know FirstPage and ThirdPage could be Stateless instead of StatefulWidgets but I wanted to make it as much like my app as possible. Here is my fully working example.

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: FirstPage(),
    );
  }
}

class FirstPage extends StatefulWidget {
  @override
  _FirstPageState createState() => _FirstPageState();
}

class _FirstPageState extends State<FirstPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("First Page"),
      ),
      body: Center(
        child: RaisedButton(
          child: Text("Navigate Next"),
          onPressed: () async {
            await Navigator.push(context,
                MaterialPageRoute(builder: (context) => SecondPage())
            );
          },
        ),
      ),
    );
  }
}

class SecondPage extends StatefulWidget {
  @override
  _SecondPageState createState() => _SecondPageState();
}

class _SecondPageState extends State<SecondPage> {
  @override
  Widget build(BuildContext context) {
    return Material(
      child: CollapsingAppBarPage(
        titleText: "Second Page",
        bodyCreator: (context) {
          return ListView(
            children: <Widget>[
              Center(
                child: RaisedButton(
                  child: Text("Navigate Next"),
                  onPressed: () async {
                    await Navigator.push(context,
                        MaterialPageRoute(builder: (context) => ThirdPage())
                    );
                  },
                ),
              )
            ],
          );
        },
      ),
    );
  }
}

class ThirdPage extends StatefulWidget {
  @override
  _ThirdPageState createState() => _ThirdPageState();
}

class _ThirdPageState extends State<ThirdPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
            title: Text("Third Page")
        ),
        body: Container()
    );
  }
}

typedef CollapsingAppBarBodyCreator = Widget Function(BuildContext context);

class CollapsingAppBarPage extends StatefulWidget {

  final String titleText;
  final CollapsingAppBarBodyCreator bodyCreator;

  CollapsingAppBarPage({
    Key key,
    this.titleText,
    @required this.bodyCreator,
  }) : super(key: key);

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

class _CollapsingAppBarPageState extends State<CollapsingAppBarPage> {

  static const _kExpandedHeight = 200.0;

  ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController()
      ..addListener(() {
        setState(() {
          // force a refresh so the app bar can be updated
        });
      });
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return NestedScrollView(
      controller: _scrollController,
      headerSliverBuilder: _createSliverAppBar,
      body: widget.bodyCreator(context),
    );
  }

  List<Widget> _createSliverAppBar(BuildContext context, bool innerBoxIsScrolled) {
    // change the icon color as the page scrolls
    var collapsePercent = _getAppBarCollapsePercent();
    int rgb = ((1.0 - collapsePercent) * 255).round();
    var color = Color.fromARGB(255, rgb, rgb, rgb);

    return <Widget>[
      SliverAppBar(
        expandedHeight: _kExpandedHeight,
        pinned: true,
        iconTheme: IconThemeData(color: color),
        title: Text(widget.titleText),
      )
    ];
  }

  double _getAppBarCollapsePercent() {
    if (!_scrollController.hasClients)
      return 0.0;

    return (_scrollController.offset / (_kExpandedHeight - kToolbarHeight)).clamp(0.0, 1.0);
  }
}

Here is a truncated trace of the exception:

flutter: ══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
flutter: The following assertion was thrown building Builder(dirty):
flutter: ScrollController attached to multiple scroll views.
flutter: 'package:flutter/src/widgets/scroll_controller.dart': Failed assertion: line 111 pos 12:
flutter: '_positions.length == 1'
flutter:
flutter: Either the assertion indicates an error in the framework itself, or we should provide substantially
flutter: more information in this error message to help you determine and fix the underlying cause.
flutter: In either case, please report this assertion by filing a bug on GitHub:
flutter:   https://github.com/flutter/flutter/issues/new?template=BUG.md
flutter:
flutter: When the exception was thrown, this was the stack:
flutter: #2      ScrollController.position (package:flutter/src/widgets/scroll_controller.dart:111:12)
flutter: #3      ScrollController.offset (package:flutter/src/widgets/scroll_controller.dart:118:24)
flutter: #4      _CollapsingAppBarPageState._getAppBarCollapsePercent (package:flutter_scroll_test/main.dart:160:31)
flutter: #5      _CollapsingAppBarPageState._createSliverAppBar (package:flutter_scroll_test/main.dart:142:27)
flutter: #6      NestedScrollView._buildSlivers (package:flutter/src/widgets/nested_scroll_view.dart:271:20)
flutter: #7      _NestedScrollViewState.build.<anonymous closure> (package:flutter/src/widgets/nested_scroll_view.dart:347:29)
flutter: #8      Builder.build (package:flutter/src/widgets/basic.dart:5736:41)
flutter: #9      StatelessElement.build (package:flutter/src/widgets/framework.dart:3774:28)
flutter: #10     ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:3721:15)

Solution

  • Don't use _scrollController.offset directly as pushing routes apparently attach a scroll position to be retrieved when you approach scrolling when you pop again

    so we need to transfer it into a piece of state

    this is the complete revised code.

    import 'package:flutter/material.dart';
    
    void main() => runApp(MyApp());
    
    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Flutter Demo',
          home: FirstPage(),
        );
      }
    }
    
    class FirstPage extends StatefulWidget {
      @override
      _FirstPageState createState() => _FirstPageState();
    }
    
    class _FirstPageState extends State<FirstPage> {
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text("First Page"),
          ),
          body: Center(
            child: RaisedButton(
              child: Text("Navigate Next"),
              onPressed: () async {
                await Navigator.push(context,
                    MaterialPageRoute(builder: (context) => SecondPage())
                );
              },
            ),
          ),
        );
      }
    }
    
    class SecondPage extends StatefulWidget {
      @override
      _SecondPageState createState() => _SecondPageState();
    }
    
    class _SecondPageState extends State<SecondPage> {
      @override
      Widget build(BuildContext context) {
        return Material(
          child: CollapsingAppBarPage(
            titleText: "Second Page",
            bodyCreator: (context) {
              return ListView(
    
                children: <Widget>[
                  Center(
                    child: RaisedButton(
                      child: Text("Navigate Next"),
                      onPressed: () async {
                        await Navigator.push(context,
                            MaterialPageRoute(builder: (context) => ThirdPage())
                        );
                      },
                    ),
                  )
                ],
              );
            },
          ),
        );
      }
    }
    
    class ThirdPage extends StatefulWidget {
      @override
      _ThirdPageState createState() => _ThirdPageState();
    }
    
    class _ThirdPageState extends State<ThirdPage> {
      @override
      Widget build(BuildContext context) {
        return Scaffold(
            appBar: AppBar(
                title: Text("Third Page")
            ),
            body: Container()
        );
      }
    }
    
    typedef CollapsingAppBarBodyCreator = Widget Function(BuildContext context);
    
    class CollapsingAppBarPage extends StatefulWidget {
    
      final String titleText;
      final CollapsingAppBarBodyCreator bodyCreator;
    
      CollapsingAppBarPage({
        Key key,
        this.titleText,
        @required this.bodyCreator,
      }) : super(key: key);
    
      @override
      _CollapsingAppBarPageState createState() => _CollapsingAppBarPageState();
    }
    
    class _CollapsingAppBarPageState extends State<CollapsingAppBarPage> {
    
      static const _kExpandedHeight = 200.0;
    
      ScrollController _scrollController;
      //Offset state <-------------------------------------
      double offset = 0.0 ;
    
    
    
      @override
      void initState() {
        super.initState();
        //print("init state is called");
    
        _scrollController = ScrollController() //keepScrollOffset: false removed
          ..addListener(() {
            setState(() {
              //<-----------------------------
              offset = _scrollController.offset;
              // force a refresh so the app bar can be updated
            });
          });
      }
    
      @override
      void dispose() {
        _scrollController.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return NestedScrollView(
          controller: _scrollController,
          headerSliverBuilder: _createSliverAppBar,
          body: widget.bodyCreator(context),
        );
      }
    
      List<Widget> _createSliverAppBar(BuildContext context, bool innerBoxIsScrolled) {
        // change the icon color as the page scrolls
        print("_createSliverAppBar is called");
        var collapsePercent = _getAppBarCollapsePercent();
        int rgb = ((1.0 - collapsePercent) * 255).round();
        var color = Color.fromARGB(255, rgb, rgb, rgb);
    
        return <Widget>[
          SliverAppBar(
            expandedHeight: _kExpandedHeight,
            pinned: true,
            iconTheme: IconThemeData(color: color),
            title: Text(widget.titleText),
          )
        ];
      }
    
      double _getAppBarCollapsePercent() {
        if (!_scrollController.hasClients ){
          print("positions is ${_scrollController.positions.length}");
          return 0.0;
    
        }
    
    
        //print("offset is${_scrollController.offset} and positions is ${_scrollController.positions.length}");
        return (offset / (_kExpandedHeight - kToolbarHeight)).clamp(0.0, 1.0);
      }
    }