Search code examples
flutterwidgetscrollviewstickysinglechildscrollview

How to fix the position of a widget while scrolling on SingleChildView in flutter


I have a stateful widget class where I have a SingleChildScrollView which takes Column and a few widgets let's say w1, w2, w3, w4, and w5 all are scrollable what I want to achieve is when the user scrolls up the screen w1, w2, w4, w5 should behave as expected but w3 should stick when it reached to a fix position let say (screen height - 50).

Here is my code I am able to get the position and added a flag too "_isStuck", now I need to stick w3 widget when the flag turns true else it should scroll with the flow when the flag is false.


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

class MyWidget extends StatefulWidget {
  @override
  _MyWidgetState createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  final GlobalKey _key = GlobalKey();
  ScrollController _controller = ScrollController();
  bool _isStuck = false;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback(_afterLayout);
  }

  void _afterLayout(_) {
    _controller.addListener(
      () {
        final RenderBox renderBox =
            _key.currentContext!.findRenderObject() as RenderBox;
        final Offset offset = renderBox.localToGlobal(Offset.zero);
        final double startY = offset.dy;

        if (startY <= 120) {
          setState(() {
            _isStuck = true;
          });
        } else {
          setState(() {
            _isStuck = false;
          });
        }
        print("Check position:  - $startY - $_isStuck");
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      controller: _controller,
      child: Column(
        children: [
          Container(
            height: 400,
            color: Colors.red,
            child: const Text('w1'),
          ),
          Container(
            height: 400,
            color: Colors.green,
            child: const Text('w2'),
          ),
          RepaintBoundary(
            child: Container(
              height: 100,
              color: Colors.blue.shade400,
              key: _key,
              child: const Text('w3'),
            ),
          ),
          Container(
            height: 500,
            color: Colors.yellow,
            child: const Text('w4'),
          ),
          Container(
            height: 500,
            color: Colors.orange,
            child: const Text('w5'),
          ),
        ],
      ),
    );
  }
}

enter image description here enter image description here


Solution

  • First, create a Stack. Add a SingleChildScrollView as the first item in the Stack. Next, add a Positioned widget with w3 as its child as the second item in the Stack. This Positioned widget will only be rendered if _isStuck is true.

    Inside the SingleChildScrollView widget, you will have the w3 widget as well but it will only be visible if _isStuck is false.

    Here is the code.

    import 'package:flutter/material.dart';
    
    class MyWidget extends StatefulWidget {
      @override
      _MyWidgetState createState() => _MyWidgetState();
    }
    
    class _MyWidgetState extends State<MyWidget> {
      final GlobalKey _key = GlobalKey();
      final ScrollController _controller = ScrollController();
      bool _isStuck = false;
    
      @override
      void initState() {
        super.initState();
        WidgetsBinding.instance.addPostFrameCallback(_afterLayout);
      }
    
      void _afterLayout(_) {
        _controller.addListener(
          () {
            final RenderBox renderBox =
                _key.currentContext?.findRenderObject() as RenderBox;
    
            final Offset offset = renderBox.localToGlobal(Offset.zero);
            final double startY = offset.dy;
    
            setState(() {
              _isStuck = startY <= 120;
            });
            print("Check position:  - $startY - $_isStuck");
          },
        );
      }
    
      @override
      Widget build(BuildContext context) {
        return Stack(
          children: [
            SingleChildScrollView(
              controller: _controller,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  Container(
                    height: 400,
                    color: Colors.red,
                    child: const Text('w1'),
                  ),
                  Container(
                    height: 400,
                    color: Colors.green,
                    child: const Text('w2'),
                  ),
                  Visibility(
                    visible: !_isStuck,
                    maintainAnimation: true,
                    maintainState: true,
                    maintainSize: true,
                    child: _w3(key: _key),
                  ),
                  Container(
                    height: 500,
                    color: Colors.yellow,
                    child: const Text('w4'),
                  ),
                  Container(
                    height: 500,
                    color: Colors.orange,
                    child: const Text('w5'),
                  ),
                ],
              ),
            ),
            if (_isStuck)
              Positioned(
                top: 120,
                left: 0,
                right: 0,
                child: _w3(),
              ),
            const Padding(
              padding: EdgeInsets.fromLTRB(0, 120, 0, 0),
              child: Divider(
                color: Colors.purple,
              ),
            ),
          ],
        );
      }
    
      Widget _w3({GlobalKey<State<StatefulWidget>>? key}) {
        return RepaintBoundary(
          child: Container(
            height: 100,
            color: Colors.blue.shade400,
            child: const Text('w3'),
            key: key,
          ),
        );
      }
    }
    

    EDIT: I added the key only to the first w3 because the logic is based on the position of that widget. Also instead of not rendering the w3 at all, inside the SingleChildScrollView we are using Visibility widget to avoid the removal of the widget from the tree which causes the _key.currentContext to be null.

    Finally I changed from

    _isStuck = startY <= 120;
    

    to

    _isStuck = startY <= -120;
    

    to make sure it shows the sticky positioned w3 when the other one offscreen.

    EDIT 2: Based on the new information