Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to dynamically update a ListView bottom padding to avoid having some items being hidden by another widget rendered in a Stack?

Tags:

flutter

dart

I currently have a screen with the following layout:

Stack(
  children: [
    ListView.builder(...),
    Positioned(
      bottom: 100,
      left: 0,
      right: 0,
      child: Footer(),
    ),

  ],
)

As you can see, the Footer is rendered on top of the ListView but always aligned at the bottom, leaving just 100 pixels of empty transparent space. All the ListView items should be visible when being scrolled through those 100 empty pixels.

The issue is, when the ListView is scrolled until the end, the last item remains at the bottom, visible in that empty space between the Footer and the bottom of the page, while in the ideal result that last item should scroll until is above the Footer widget.

Current vs Expected Behavior: You can see how on the left one the LAST ITEM remains at the bottom of the page while on the right one the LAST ITEM can be scrolled until it's above the FOOTER. I made the right one by setting a fixed padding on the ListView but as I explain below that method (among others regarding calculating height) is not allowed in this scenario.

One of the common solutions I was able to find is to give the ListView a bottom padding of X pixels, where X is the height of the Footer widget + 100 pixels of empty space. However, the Footer widget is dynamic and can even be animated so I can't just assign a fixed height to it. Another option was to "listen" to the Footer size changes and update the padding of the ListView given those changes (by using a GlobalKey and/or SizedChangedLayoutNotifier), but the Footer needs to be reused in several places where that method is not available. Finally, the ListView can't use shrinkWrap: true because it can have hundreds of widgets in it.

I had the idea of using Slivers, given there are persistent headers slivers which do something similar, but they don't work when used as a footer widget instead of a header.

Any suggestion is highly appreciated, thanks!

like image 592
Ademir Villena Zevallos Avatar asked Nov 22 '25 22:11

Ademir Villena Zevallos


1 Answers

I think your approach of setting the ListView.padding is very good.

I understand that the main challenge is keeping this padding in sync with the footer size, especially because the widgets are not necessarily in the same source file or close in the widget tree.

One idea to bridge this context gap is to make the footer (or a wrapping widget) report its size changes to a central place that can be used by other components that need to be aware of it. There are multiple ways to implement this, some that I can think of:

  • Service & Service Locator
  • Inherited Widget
  • and of course, the good ol' Singleton pattern, which I will use for demo for simplicity

I will also use rxdart because I prefer exposing my reactive state as ValueStreams. Feel free to replace this with a ValueNotifier or ChangeNotifier if you want.

The abstract FooterService will provide a ValueStream with footer's size. The implementation _FooterService will expose the footerSizeSubject, which will be used to emit (write) size changes.

FooterSizeListener is a widget used to wrap your actual Footer widget. This will handle listening for size changes, and emitting these changes in _FooterService. This is the only widget that needs access to the _FooterService for writing values, so they will have to be collocated.

Other classes that only need to consume (read) the footer's size can use the public abstract FooterService via FooterService.instance.

Preview:

GIF

DartPad:

https://dartpad.dev/?id=6d3e08bccd41b5f82a3533417c02d898

Code:

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

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

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

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

class TestPage extends StatelessWidget {
  static const colors = [
    Colors.red,
    Colors.blue,
    Colors.green,
    Colors.yellow,
    Colors.purple,
    Colors.orange,
    Colors.pink,
    Colors.teal,
    Colors.cyan,
    Colors.indigo,
    Colors.lime,
    Colors.amber,
    Colors.brown,
    Colors.grey,
  ];

  static const double footerBottomMargin = 100.0;

  const TestPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          StreamBuilder<Size>(
              stream: FooterService.instance.footerSizeStream,
              initialData: FooterService.instance.footerSizeStream.value,
              builder: (context, snapshot) {
                final footerHeight = snapshot.data?.height ?? 0;
                return ListView.separated(
                  padding: EdgeInsets.only(bottom: footerBottomMargin + footerHeight + 16),
                  itemCount: 20,
                  itemBuilder: (context, index) => Container(
                    color: colors[index % colors.length],
                    height: 100,
                  ),
                  separatorBuilder: (context, index) => const SizedBox(height: 8),
                );
              }),
          const Positioned(
            bottom: footerBottomMargin,
            left: 0,
            right: 0,
            child: FooterSizeListener(
              child: Footer(),
            ),
          ),
        ],
      ),
    );
  }
}

abstract class FooterService {
  ValueStream<Size> get footerSizeStream;

  static FooterService get instance => _FooterService.instance;
}

class _FooterService implements FooterService {
  _FooterService._();

  static final instance = _FooterService._();

  final footerSizeSubject = BehaviorSubject<Size>.seeded(Size.zero);

  @override
  ValueStream<Size> get footerSizeStream => footerSizeSubject.stream;
}

class FooterSizeListener extends StatefulWidget {
  final Widget child;

  const FooterSizeListener({
    super.key,
    required this.child,
  });

  @override
  State<FooterSizeListener> createState() => _FooterSizeListenerState();
}

class _FooterSizeListenerState extends State<FooterSizeListener> {
  final GlobalKey _key = GlobalKey();

  Size get size => _key.currentContext?.size ?? Size.zero;

  @override
  void initState() {
    super.initState();
    updateSize();
  }

  void updateSize() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _FooterService.instance.footerSizeSubject.add(size);
    });
  }

  @override
  Widget build(BuildContext context) {
    return NotificationListener<SizeChangedLayoutNotification>(
      onNotification: (notification) {
        updateSize();
        return true;
      },
      child: SizeChangedLayoutNotifier(
        key: _key,
        child: widget.child,
      ),
    );
  }
}

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

  @override
  State<Footer> createState() => _FooterState();
}

class _FooterState extends State<Footer> {
  static const List<double> sizes = [100, 150, 200];

  int sizeIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16),
      child: Card(
        clipBehavior: Clip.hardEdge,
        child: AnimatedContainer(
          duration: const Duration(milliseconds: 300),
          color: Colors.grey.shade200,
          height: sizes[sizeIndex],
          child: Center(
            child: Padding(
              padding: const EdgeInsets.symmetric(vertical: 8),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text('Footer'),
                  Expanded(child: SizedBox()),
                  ElevatedButton(
                    onPressed: () {
                      sizeIndex = (sizeIndex + 1) % sizes.length;
                      setState(() {});
                    },
                    child: const Text('Change size'),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}


like image 78
B0Andrew Avatar answered Nov 24 '25 22:11

B0Andrew



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!