7

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!

3 Answers 3

12
+75

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;
  }
}
Sign up to request clarification or add additional context in comments.

4 Comments

Thank you for your creative solution. It’s a clever visual trick using the matching yellow colors to create the illusion of layering, it's fine for solid colors. However, in my case, the yellow placeholder will be replaced with actual images. The use of a solid yellow color was merely for simplicity’s sake. Therefore, it does not work in my case. Could you please suggest a solution that would work with images instead of solid colors?
sure, I didn't know that , now I updated my answer :). Enjoy!
It seems quite promising. I'm looking forward to trying it out. Your help is greatly appreciated! Thank you for all your efforts.
@goodUser was this answer helpful? If so you can mark this as accepted :)
-1

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,
            //   ),
            // ),
          ],
        ),
      ),
    );
  }
}

1 Comment

Thanks for your response, but no, that's not what I'm looking for. You can examine the code in my original post, or check the other answers to get the whole picture that I'm after.
-1

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

5 Comments

Thanks for your response. It would be really helpful if you could provide a code snippet demonstrating your suggestions.
@goodUser I went through a few other developers' answers and your responses to those answers. I feel like I still don't really understand your wishes. However, I will also write a small example, to talk about how I understand your wishes.
In my example there will be 4 components. 1 sliver app Bar (customize with SliverPersistentHeader,), 1 any widget (here is a blue circle), 1 list, 1 background image for sliver app bar. The above 4 ingredients are completely independent and can be replaced with whatever you desire. The Backgroup has a transparency that changes when scrolled. The blue circle also has content that changes when scrolled.
Although I don't really understand you, every effect and layer in my example is customizable, you can customize it according to your wishes. I will edit the illustration on the answer and the entire source code on github. you can see at link github
Don't hesitate to share more, if my examples don't really meet your expectations

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.