I’ve encountered a weird issue in my Flutter code that I will try to describe – maybe somebody will have an answer for that.
I have a ListView (called _MessageList
) with chat bubbles that I want to scroll to the bottom of. This ListView is nested inside my custom Scaffold with AppBar widget called ScreenWithAppBar
. I have added a ScrollController to the ScreenWithAppBar
and added the method below to the initState method of the chat screen.
SchedulerBinding.instance.addPostFrameCallback(
(_) {
scrollController.animateTo(
scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 2000),
curve: Curves.easeOut,
);
},
);
After entering the screen however, it only scrolls a little tiny bit until the app bar disappears and then stops. I have tried adding the scroll controller to the ListView alone itself, but it does NOT work.
What can I do to scroll to the bottom of the screen?
I am attaching the whole code for the chat widgets and also for my ScreenWithAppBar
widget.
ChatScreen
widget – this is a screen that contains the list with chat bubbles:
class ChatScreen extends StatefulWidget {
const ChatScreen({
super.key,
required this.room,
});
final RoomModel room;
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
late TextEditingController messageController;
late ScrollController scrollController;
late String userId;
@override
void initState() {
super.initState();
messageController = TextEditingController();
scrollController = ScrollController();
userId = context.read<UserCubit>().getCurrentUserId();
SchedulerBinding.instance.addPostFrameCallback(
(_) {
scrollController.animateTo(
scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
},
);
}
@override
Widget build(BuildContext context) {
return ScreenWithAppBar(
scrollController: scrollController,
title: widget.room.users.firstWhere(
(String element) => element != userId,
),
bottomNavigationBar: _MessageInputBar(
messageController: messageController,
scrollController: scrollController,
room: widget.room,
userId: userId,
),
body: StreamBuilder(
initialData: widget.room,
stream: FirebaseUtils.firestore.getSnapshotStream(
path: '/chatRooms',
uid: widget.room.uid,
),
builder: (BuildContext context, AsyncSnapshot<Object> snapshot) {
return StreamBuilder<List<MessageModel>>(
stream: context.read<MessageCubit>().getMessageStream(
room: widget.room,
),
builder: (
BuildContext context,
AsyncSnapshot<List<MessageModel>> snapshot,
) {
if (snapshot.hasData) {
return _MessageList(
messageList: snapshot.data!,
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
);
},
),
);
}
@override
void dispose() {
messageController.dispose();
scrollController.dispose();
super.dispose();
}
}
_MessageList
– widget that contains a ListView that generates the chat bubbles:
class _MessageList extends StatelessWidget {
const _MessageList({
required this.messageList,
});
final List<MessageModel> messageList;
@override
Widget build(BuildContext context) {
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: messageList.length,
itemBuilder: (BuildContext context, int index) {
final MessageModel message = messageList[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: _MessageBubble(
message: message,
),
);
},
);
}
}
ScreenWithAppBar
– a custom built generic entry screen that I use everywhere:
class ScreenWithAppBar extends StatelessWidget {
const ScreenWithAppBar({
this.showAppBar = true,
this.title,
this.centerTitle = false,
this.appBarTransparent = true,
this.appBarShowBackButton = true,
this.actions,
this.bottom,
this.backButtonAlwaysEnabled = false,
this.body,
this.customPadding = 0.0,
this.physics = const BouncingScrollPhysics(),
this.isBodyScrollable = true,
this.extendBody = false,
this.extendBodyBehindAppBar = false,
this.scrollController,
this.fab,
this.fabLocation = FloatingActionButtonLocation.endFloat,
this.bottomNavigationBar,
this.onWillPop,
this.onRefresh,
this.notificationPredicate,
this.isHome = false,
this.isPrimaryColor = false,
this.isNestedScrollEnabled = true,
this.resizeToAvoidBottomInset = true,
this.usePageView = false,
this.pageViewController,
this.pageViewChildren,
super.key,
}) : assert(!backButtonAlwaysEnabled || onWillPop != null);
/// App Bar
final bool showAppBar;
final String? title;
final bool centerTitle;
final bool appBarTransparent;
final bool appBarShowBackButton;
final List<Widget>? actions;
final PreferredSizeWidget? bottom;
final bool backButtonAlwaysEnabled;
/// Body
final Widget? body;
final double customPadding;
final ScrollPhysics physics;
final bool isBodyScrollable;
final bool extendBody;
final bool extendBodyBehindAppBar;
final ScrollController? scrollController;
/// Fab
final Widget? fab;
final FloatingActionButtonLocation fabLocation;
/// BottomNavBar
final Widget? bottomNavigationBar;
/// Callbacks
final Future<bool> Function()? onWillPop;
final Future<void> Function()? onRefresh;
final bool Function(ScrollNotification)? notificationPredicate;
/// Flags
final bool isHome;
final bool isPrimaryColor;
final bool isNestedScrollEnabled;
final bool resizeToAvoidBottomInset;
/// PageView
final bool usePageView;
final PageController? pageViewController;
final List<Widget>? pageViewChildren;
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: onWillPop,
child: Scaffold(
extendBody: extendBody,
extendBodyBehindAppBar: extendBodyBehindAppBar,
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
appBar: isNestedScrollEnabled
? null
: showAppBar
? PreferredSize(
preferredSize: Size.fromHeight(
AppThemes.dimens.appBarHeight,
),
child: ChatifyAppBar(
isSliver: false,
title: title,
centerTitle: centerTitle,
transparent: appBarTransparent,
showBackButton: appBarShowBackButton,
actions: actions,
isPrimaryColor: isPrimaryColor,
bottom: bottom,
onPop: onWillPop,
backButtonAlwaysEnabled: backButtonAlwaysEnabled,
),
)
: null,
bottomNavigationBar: bottomNavigationBar,
body: SafeArea(
child: _ConditionalRefreshIndicator(
notificationPredicate: notificationPredicate,
onRefresh: onRefresh,
isNestedScrollEnabled: isNestedScrollEnabled,
usePageView: usePageView,
child: _ScreenWithAppBarBody(
title: title,
centerTitle: centerTitle,
body: body,
customPadding: customPadding,
appBarShowBackButton: appBarShowBackButton,
isHome: isHome,
showAppBar: showAppBar,
appBarTransparent: appBarTransparent,
actions: actions,
isPrimaryColor: isPrimaryColor,
isNestedScrollEnabled: isNestedScrollEnabled,
isBodyScrollable: isBodyScrollable,
physics: physics,
usePageView: usePageView,
pageViewController: pageViewController,
pageViewChildren: pageViewChildren,
scrollController: scrollController,
bottom: bottom,
backButtonAlwaysEnabled: backButtonAlwaysEnabled,
onPop: onWillPop,
),
),
),
),
);
}
}
The body of the widget above:
class _ScreenWithAppBarBody extends StatelessWidget {
const _ScreenWithAppBarBody({
required this.body,
required this.appBarTransparent,
required this.appBarShowBackButton,
required this.isHome,
required this.showAppBar,
required this.physics,
required this.customPadding,
required this.centerTitle,
required this.isPrimaryColor,
required this.isNestedScrollEnabled,
required this.isBodyScrollable,
required this.usePageView,
required this.title,
required this.actions,
required this.pageViewController,
required this.pageViewChildren,
required this.scrollController,
required this.bottom,
required this.backButtonAlwaysEnabled,
required this.onPop,
});
final Widget? body;
final bool appBarTransparent;
final bool appBarShowBackButton;
final bool isHome;
final bool showAppBar;
final ScrollPhysics physics;
final double customPadding;
final bool centerTitle;
final bool isPrimaryColor;
final bool isNestedScrollEnabled;
final bool isBodyScrollable;
final bool usePageView;
final bool backButtonAlwaysEnabled;
final String? title;
final List<Widget>? actions;
final PageController? pageViewController;
final List<Widget>? pageViewChildren;
final ScrollController? scrollController;
final PreferredSizeWidget? bottom;
final Future<bool> Function()? onPop;
@override
Widget build(BuildContext context) {
return _ConditionalNestedScrollView(
isNestedScrollEnabled: isNestedScrollEnabled && isBodyScrollable,
isHome: isHome,
showAppBar: showAppBar,
title: title,
centerTitle: centerTitle,
appBarTransparent: appBarTransparent,
appBarShowBackButton: appBarShowBackButton,
actions: actions,
isPrimaryColor: isPrimaryColor,
scrollController: scrollController,
bottom: bottom,
backButtonAlwaysEnabled: backButtonAlwaysEnabled,
onPop: onPop,
body: _ConditionalPageView(
isNestedScrollEnabled: isNestedScrollEnabled,
isBodyScrollable: isBodyScrollable,
physics: physics,
customPadding: customPadding,
body: body,
usePageView: usePageView,
pageViewController: pageViewController,
pageViewChildren: pageViewChildren,
scrollController: scrollController,
),
);
}
}
NestedScrollView inside of the body above:
class _ConditionalNestedScrollView extends StatelessWidget {
const _ConditionalNestedScrollView({
required this.body,
required this.appBarTransparent,
required this.appBarShowBackButton,
required this.isHome,
required this.showAppBar,
required this.centerTitle,
required this.isPrimaryColor,
required this.isNestedScrollEnabled,
required this.title,
required this.actions,
required this.scrollController,
required this.bottom,
required this.backButtonAlwaysEnabled,
required this.onPop,
});
final Widget body;
final bool appBarTransparent;
final bool appBarShowBackButton;
final bool isHome;
final bool showAppBar;
final bool centerTitle;
final bool isPrimaryColor;
final bool isNestedScrollEnabled;
final bool backButtonAlwaysEnabled;
final String? title;
final List<Widget>? actions;
final ScrollController? scrollController;
final PreferredSizeWidget? bottom;
final Future<bool> Function()? onPop;
@override
Widget build(BuildContext context) {
if (isNestedScrollEnabled) {
return NestedScrollView(
floatHeaderSlivers: true,
controller: scrollController,
headerSliverBuilder: (_, __) => <Widget>[
if (isHome && showAppBar)
ChatifyAppBar(
title: title ?? context.tr.app_name,
centerTitle: centerTitle,
transparent: appBarTransparent,
showBackButton: false,
actions: actions,
isPrimaryColor: isPrimaryColor,
bottom: bottom,
)
else if (showAppBar)
ChatifyAppBar(
title: title,
centerTitle: centerTitle,
transparent: appBarTransparent,
showBackButton: appBarShowBackButton,
actions: actions,
isPrimaryColor: isPrimaryColor,
bottom: bottom,
backButtonAlwaysEnabled: backButtonAlwaysEnabled,
onPop: onPop,
),
],
body: body,
);
} else {
return body;
}
}
}
PageView inside of the body above:
class _ConditionalPageView extends StatelessWidget {
const _ConditionalPageView({
required this.body,
required this.customPadding,
required this.physics,
required this.isNestedScrollEnabled,
required this.isBodyScrollable,
required this.usePageView,
required this.pageViewController,
required this.pageViewChildren,
required this.scrollController,
});
final Widget? body;
final double customPadding;
final ScrollPhysics physics;
final bool isNestedScrollEnabled;
final bool isBodyScrollable;
final bool usePageView;
final PageController? pageViewController;
final List<Widget>? pageViewChildren;
final ScrollController? scrollController;
@override
Widget build(BuildContext context) {
if (usePageView) {
return PageView.builder(
controller: pageViewController,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (BuildContext context, int index) {
return _SingleChildScrollView(
scrollController: scrollController,
physics: physics,
isNestedScrollEnabled: isNestedScrollEnabled,
isBodyScrollable: isBodyScrollable,
customPadding: customPadding,
body: pageViewChildren![index],
);
},
);
} else {
return _SingleChildScrollView(
scrollController: scrollController,
physics: physics,
isNestedScrollEnabled: isNestedScrollEnabled,
isBodyScrollable: isBodyScrollable,
customPadding: customPadding,
body: body!,
);
}
}
}
Inside of the widget above – used to display child of the ScreenWithAppBar
:
class _SingleChildScrollView extends StatelessWidget {
const _SingleChildScrollView({
required this.body,
required this.customPadding,
required this.physics,
required this.isNestedScrollEnabled,
required this.isBodyScrollable,
required this.scrollController,
});
final Widget body;
final double customPadding;
final ScrollPhysics physics;
final bool isNestedScrollEnabled;
final bool isBodyScrollable;
final ScrollController? scrollController;
@override
Widget build(BuildContext context) {
if (isBodyScrollable) {
return SingleChildScrollView(
controller: isNestedScrollEnabled ? null : scrollController,
physics: physics,
child: _Body(
customPadding: customPadding,
body: body,
),
);
} else {
return _Body(
customPadding: customPadding,
body: body,
);
}
}
}
Custom body with shimmer of the widget above:
class _Body extends StatelessWidget {
const _Body({
required this.body,
required this.customPadding,
});
final Widget body;
final double customPadding;
@override
Widget build(BuildContext context) {
return ScreenPadding(
customPadding: customPadding,
child: Shimmer(
child: body,
),
);
}
}
Sorry for such a lengthy message and code, I am in a pickle and don’t really know how to go from here. Any help would be much appreciated!
2
Answers
try this
There are a couple things I would try to get the scroll animation working properly in your nested ScrollView setup:
ChatScreen
widget itself, rather than the parentScreenWithAppBar
. This will allow you to directly control the scrolling of the inner_MessageList
ListView.Simplify the scroll view nesting – the multiple layers of
SingleChildScrollView
,PageView
, etc may be interfering with the scroll animation. Try removing unused scroll widgets.Wrap the
_MessageList
in aNotificationListener
to listen for scroll notifications and programmatically scroll when needed.Use a
ScrollController
specifically on the_MessageList
rather than the parent widgets.Call
jumpTo()
oranimateTo()
on the_MessageList
scroll controller once layout is complete.Essentially moving the scroll logic closer to the ListView itself rather than ancestor widgets should help resolve the issue.