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!
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:
Singleton pattern, which I will use for demo for simplicityI 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.

https://dartpad.dev/?id=6d3e08bccd41b5f82a3533417c02d898
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'),
),
],
),
),
),
),
),
);
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With