How to Overlay a Scrollable Sliver on a SliverAppBar in Flutter?

978 views Asked by At

I have a CustomScrollView with a SliverAppBar and some slivers. I want to overlay some graphics on the SliverAppBar and have them scroll with the rest of the list.

Here's the code I've been working with:

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: SliverTest(),
    );
  }
}

class SliverTest extends StatefulWidget {
  const SliverTest({super.key});

  @override
  State<SliverTest> createState() => _SliverTestState();
}

class _SliverTestState extends State<SliverTest> {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: CustomScrollView(
          slivers: [
            SliverAppBar(
              expandedHeight: 350.0,
              floating: false,
              pinned: true,
              flexibleSpace: FlexibleSpaceBar(
                background: Container(
                  color: Colors.yellow,
                  child: const Center(child: Text('My Parallax Image!')),
                ),
              ),
            ),
            SliverToBoxAdapter(
              child: Container(
                height: 100,
                color: Colors.blueAccent,
                child: Stack(
                  children: [
                    Align(
                      alignment: const Alignment(0.0, -2.0),
                      child: Container(
                        width: 50,
                        height: 50,
                        decoration: const BoxDecoration(
                          shape: BoxShape.circle,
                          color: Colors.red,
                        ),
                        child: const Center(child: Text('Red')),
                      ),
                    ),
                  ],
                ),
              ),
            ),
            SliverList(
              delegate: SliverChildBuilderDelegate(
                (BuildContext context, int index) {
                  return Container(
                    height: 50,
                    color: Colors.teal[100 * ((index % 8) + 1)],
                    child: Center(child: Text('Item #$index')),
                  );
                },
                childCount: 20,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

In this setup, I want the red circle (which is currently clipped) to be drawn on top of the yellow section of the SliverAppBar (Please note that the yellow section is just a simple placeholder for some actual images with parallax effect, and the use of a solid yellow color is merely for simplicity's sake). I've placed the red circle inside a sliver because I want it to scroll along with the rest of the list when the user interacts with the scrollable area.

Could anyone provide a simple example of how to achieve this in Flutter? I'm open to any other solutions that utilize slivers, as they provide huge convenience to the other parts of my real app. Otherwise, I'm aware that I may recreate it without utilizing the slivers. Any help would be greatly appreciated. Thanks in advance!

4

There are 4 answers

4
VonC On

You might consider using a Stack widget to overlay the graphic on top of the SliverAppBar. The challenge is, however, to make the graphic scroll along with the CustomScrollView. You can try and adjust the position of the graphic in response to the scroll offset of the CustomScrollView.

Use a NotificationListener to listen to scroll events. Calculate the position of the graphic based on the scroll offset. Use a Stackand adjust the position of the graphic dynamically as the user scrolls.

class _SliverTestState extends State<SliverTest> {
  double _offset = 0.0;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: NotificationListener<ScrollNotification>(
          onNotification: (ScrollNotification scrollInfo) {
            setState(() {
              _offset = scrollInfo.metrics.pixels;
            });
            return true;
          },
          child: Stack(
            children: [
              CustomScrollView(
                slivers: [
                  SliverAppBar(
                    expandedHeight: 350.0,
                    floating: false,
                    pinned: true,
                    flexibleSpace: FlexibleSpaceBar(
                      background: Container(
                        color: Colors.yellow,
                        child: const Center(child: Text('Yellow')),
                      ),
                    ),
                  ),
                  SliverToBoxAdapter(
                    child: Container(
                      height: 100,
                      color: Colors.blueAccent,
                    ),
                  ),
                  // other slivers
                ],
              ),
              Positioned(
                top: 350 - _offset, // Adjust position based on scroll offset
                left: MediaQuery.of(context).size.width / 2 - 25,
                child: Container(
                  width: 50,
                  height: 50,
                  decoration: const BoxDecoration(
                    shape: BoxShape.circle,
                    color: Colors.red,
                  ),
                  child: const Center(child: Text('Red')),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

You can see the NotificationListener listening to scroll events and updating the _offset variable. The Stack widget overlays the red circle over the CustomScrollView. And the Positioned widget adjusts the position of the red circle based on the scroll offset.

See if that would place the red circle on top of the SliverAppBar and make it scroll with the rest of the list.


There are some caveats.

For example, since the graphics (the Red circle) is not actually part of any sliver, it obviously does not take part in the over-scroll effect, keeping its fixed position, thus exhibiting some design shortcomings, hence my emphasis in the OP.

Also, after examining it, I realized that the Red circle needs to get behind the very SliverAppBar, that I insisted on being overlaid upon. Is there an easy fix for that?

To address the requirements of having the red circle scroll along with the slivers and also appear behind the SliverAppBar, you can try and use a CustomScrollView with a SliverPersistentHeader. The SliverPersistentHeader will allow you to create a custom header that behaves like a SliverAppBar but with more control over its contents and layout.

So, create a SliverPersistentHeader that includes the yellow background and the red circle. Adjust the position of the red circle within the custom header so that it initially appears in the desired location but still scrolls with the rest of the content. By using a SliverPersistentHeader, the red circle should naturally be placed behind the SliverAppBar as it scrolls.

class _SliverTestState extends State<SliverTest> {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: CustomScrollView(
          slivers: [
            SliverAppBar(
              expandedHeight: 350.0,
              floating: false,
              pinned: true,
              flexibleSpace: FlexibleSpaceBar(
                title: Text('App Bar'),
              ),
            ),
            SliverPersistentHeader(
              pinned: true,
              delegate: _CustomHeaderDelegate(
                minHeight: 100.0,
                maxHeight: 350.0,
                child: Container(
                  color: Colors.yellow,
                  alignment: Alignment.topCenter,
                  child: Container(
                    width: 50,
                    height: 50,
                    decoration: BoxDecoration(
                      shape: BoxShape.circle,
                      color: Colors.red,
                    ),
                    child: Center(child: Text('Red')),
                  ),
                ),
              ),
            ),
            // other slivers
          ],
        ),
      ),
    );
  }
}

class _CustomHeaderDelegate extends SliverPersistentHeaderDelegate {
  final double minHeight;
  final double maxHeight;
  final Widget child;

  _CustomHeaderDelegate({
    required this.minHeight,
    required this.maxHeight,
    required this.child,
  });

  @override
  double get minExtent => minHeight;

  @override
  double get maxExtent => maxHeight;

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    return SizedBox.expand(child: child);
  }

  @override
  bool shouldRebuild(_CustomHeaderDelegate oldDelegate) {
    return maxHeight != oldDelegate.maxHeight ||
        minHeight != oldDelegate.minHeight ||
        child != oldDelegate.child;
  }
}

The SliverAppBar should remain pinned at the top, and the custom header scrolls up underneath it.
Check if you see the red circle scroll with the rest of the list and stay behind the SliverAppBar.

4
diegoveloper On

Here you have another sample without using Listener/ScrollNotifications, just playing with the sizes :).

Result:

enter image description here

Code:

class SliverTest extends StatefulWidget {
  const SliverTest({super.key});

  @override
  State<SliverTest> createState() => _SliverTestState();
}

class _SliverTestState extends State<SliverTest> {
  @override
  Widget build(BuildContext context) {
    const circleSize = 50.0;
    const expandedHeight = 350.0;
    const headerColor = Colors.yellow;
    return MaterialApp(
      home: Scaffold(
        backgroundColor: headerColor,
        body: CustomScrollView(
          slivers: [
            SliverAppBar(
              expandedHeight: expandedHeight - circleSize / 2,
              floating: false,
              pinned: true,
              flexibleSpace: FlexibleSpaceBar(
                background: Container(
                  color: headerColor,
                  child: const Center(child: Text('Yellow')),
                ),
              ),
            ),
            SliverToBoxAdapter(
              child: Container(
                color: Colors.blueAccent,
                height: 200, //any size
                child: Stack(
                  children: [
                    Positioned(
                      top: 0,
                      left: 0,
                      right: 0,
                      height: circleSize / 2,
                      child: Container(
                        color: headerColor,
                      ),
                    ),
                    Align(
                      alignment: Alignment.topCenter,
                      child: Container(
                        width: circleSize,
                        height: circleSize,
                        decoration: const BoxDecoration(
                          shape: BoxShape.circle,
                          color: Colors.red,
                        ),
                        child: const Center(child: Text('Red')),
                      ),
                    ),
                  ],
                ),
              ),
            ),
            SliverList(
              delegate: SliverChildBuilderDelegate(
                (BuildContext context, int index) {
                  return Container(
                    height: 50,
                    color: Colors.teal[100 * ((index % 8) + 1)],
                    child: Center(child: Text('Item #$index')),
                  );
                },
                childCount: 20,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Update

Ok, based on your latest comment, I had to go deeper :), now it's working as expected from your last requirements (but more complex too).

Results:

enter image description here

Code:

class SliverTest extends StatefulWidget {
  const SliverTest({super.key});

  @override
  State<SliverTest> createState() => _SliverTestState();
}

class _SliverTestState extends State<SliverTest> {
  final _scrollController = ScrollController();
  final _layerLink = LayerLink();
  final _expandedHeight = 350.0;

  final _circleSize = 50.0;
  double _visiblePercent = 1.0;
  OverlayEntry? _overlayEntry;

  void _onListen() {
    final total = _scrollController.offset + kToolbarHeight + _circleSize / 2;
    final difference = total - _expandedHeight;
    _visiblePercent =
        ((_circleSize - difference).clamp(0.0, _circleSize).toDouble() /
            _circleSize);
    _overlayEntry?.markNeedsBuild();
  }

  @override
  void initState() {
    _scrollController.addListener(_onListen);
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _showCircularItem();
    });
    super.initState();
  }

  @override
  void dispose() {
    _scrollController.removeListener(_onListen);
    _scrollController.dispose();
    _overlayEntry?.remove();
    super.dispose();
  }

  void _showCircularItem() {
    _overlayEntry = OverlayEntry(
      builder: (BuildContext context) {
        return Positioned(
          top: 0,
          left: 0,
          right: 0,
          child: CompositedTransformFollower(
            link: _layerLink,
            offset: Offset(0.0, -_circleSize / 2),
            child: Material(
              color: Colors.transparent,
              child: ClipRect(
                clipper: _MyClipper(
                  visiblePercent: _visiblePercent,
                ),
                child: Container(
                  width: _circleSize,
                  height: _circleSize,
                  decoration: const BoxDecoration(
                    shape: BoxShape.circle,
                    color: Colors.red,
                  ),
                  child: const Center(child: Text('Red')),
                ),
              ),
            ),
          ),
        );
      },
    );
    Overlay.of(context).insert(_overlayEntry!);
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: CustomScrollView(
          controller: _scrollController,
          slivers: [
            SliverAppBar(
              expandedHeight: _expandedHeight,
              floating: false,
              pinned: true,
              flexibleSpace: FlexibleSpaceBar(
                background: Image.network(
                  'https://t4.ftcdn.net/jpg/05/49/86/39/360_F_549863991_6yPKI08MG7JiZX83tMHlhDtd6XLFAMce.jpg',
                  fit: BoxFit.cover,
                ),
              ),
            ),
            SliverToBoxAdapter(
              child: CompositedTransformTarget(
                link: _layerLink,
                child: Container(
                  color: Colors.greenAccent,
                  height: 200, //any size
                ),
              ),
            ),
            SliverList(
              delegate: SliverChildBuilderDelegate(
                (BuildContext context, int index) {
                  return Container(
                    height: 50,
                    color: Colors.teal[100 * ((index % 8) + 1)],
                    child: Center(child: Text('Item #$index')),
                  );
                },
                childCount: 20,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _MyClipper extends CustomClipper<Rect> {
  _MyClipper({
    required this.visiblePercent,
  });

  final double visiblePercent;

  @override
  Rect getClip(Size size) {
    return Rect.fromLTRB(
      0.0,
      size.height * (1.0 - visiblePercent),
      size.width,
      size.height,
    );
  }

  @override
  bool shouldReclip(_MyClipper oldClipper) {
    return visiblePercent != oldClipper.visiblePercent;
  }
}
1
Naser Ebedo On

This is the solution for your problem based on what I understood.

class SliverTest extends StatefulWidget {
  const SliverTest({super.key});

  @override
  State<SliverTest> createState() => _SliverTestState();
}

class _SliverTestState extends State<SliverTest> {
  late ScrollController _scrollController;

  @override
  void initState() {
    _scrollController = ScrollController();
    super.initState();
  }

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: CustomScrollView(
          controller: _scrollController,
          shrinkWrap: true,
          slivers: [
            SliverAppBar.large(
              pinned: true,
              floating: false,
              stretch: true,
              automaticallyImplyLeading: false,
              expandedHeight: 350.0,
              backgroundColor: Colors.purple,
              title: Center(
                child: Container(
                  width: 50,
                  height: 50,
                  decoration: const BoxDecoration(
                    shape: BoxShape.circle,
                    color: Colors.red,
                  ),
                  child: const Center(child: Text('Red')),
                ),
              ),
              flexibleSpace: FlexibleSpaceBar(
                background: Container(
                  color: Colors.yellow,
                  child: const Center(child: Text('Yellow')),
                ),
              ),
            ),
            SliverToBoxAdapter(
              child: Container(
                // height: 100,
                color: Colors.blueAccent,
                child: Stack(
                  children: [
                    SingleChildScrollView(
                      child: Column(
                        children: [
                          for (int i = 0; i < 20; i++) ...[
                            Container(
                              height: 50,
                              color: Colors.teal[100 * ((i % 8) + 1)],
                              child: Center(child: Text('Item #$i')),
                            )
                          ],
                        ],
                      ),
                    ),
                  ],
                ),
              ),
            ),
            // SliverList(
            //   delegate: SliverChildBuilderDelegate(
            //     (BuildContext context, int index) {
            //       return Container(
            //         height: 50,
            //         color: Colors.teal[100 * ((index % 8) + 1)],
            //         child: Center(child: Text('Item #$index')),
            //       );
            //     },
            //     childCount: 20,
            //   ),
            // ),
          ],
        ),
      ),
    );
  }
}
5
Trần Văn Hiếu On

To make the circle unobstructed by the appBar: You can change SliverAppBar to SliverPersistentHeader. And customize your title and red circle in the delegate section, now the red circle will always be on top and not covered by the appBar anymore.

How to customize the red circle according to the scroll of the list or the scroll of the appBar: If you want to scroll by list you just need to add ScrollController to the list. and listen for scrollController. If you want to change the scroll follow the appBar, then in the delegate section of the build function, there is an option shrinkOffset available so you can calculate this.

[Update 2023/12/08]

source

Illustrations

Illustrations