Flutter Go_router Scroll To Top On NavigationDestination Click
Hey guys! Let's dive into a common scenario in Flutter app development: implementing a scroll-to-top feature when a user clicks on a NavigationDestination
that's already active. This is super useful for improving user experience, especially in apps with long lists or feeds. We'll be focusing on using go_router
for navigation and how to integrate the scroll-to-top functionality seamlessly. If the list is already at the top, we want to trigger a refresh. Sounds cool, right? Let's get started!
Understanding the Problem
So, imagine you have a bottom navigation bar with several destinations, like "Home," "Search," and "Profile." When a user taps on a destination, the app navigates to the corresponding screen. But what if the user is already on the "Home" screen and taps the "Home" icon again? Ideally, we'd want the app to either scroll the list back to the top or refresh the content if it's already at the top. This is where things get a little tricky, but don't worry, we'll break it down.
The main challenge lies in detecting when a NavigationDestination
is tapped while it's already the active route. go_router
handles navigation beautifully, but we need to add some extra logic to handle this specific scenario. We'll explore how to listen for these taps and then trigger the scroll-to-top or refresh actions accordingly. We will be using list view in this explanation but it can be implemented on any Widget.
Why is this important?
Think about your users. They might be scrolling through a long list of items, and tapping the active navigation icon is a natural way to quickly jump back to the beginning. Without this functionality, they'd have to manually scroll all the way up, which can be a frustrating experience. By implementing this scroll-to-top behavior, you're making your app more user-friendly and intuitive. This adds to a much smoother user experience.
Setting Up go_router
First things first, let's make sure we have go_router
set up correctly. If you're not familiar with go_router
, it's a fantastic package for declarative routing in Flutter. It simplifies navigation and makes managing routes a breeze. If you are familiar with the package you know how declarative and easy navigation is to implement.
Adding the Dependency
Add go_router
to your pubspec.yaml
file:
dependencies:
flutter:
sdk: flutter
go_router: ^7.0.0 #(check the latest version)
Run flutter pub get
to fetch the dependency.
Configuring Routes
Now, let's define our routes using go_router
. We'll create a simple example with three destinations: "Home," "Search," and "Profile."
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
final _rootNavigatorKey = GlobalKey<NavigatorState>();
final _shellNavigatorKey = GlobalKey<NavigatorState>();
final goRouter = GoRouter(
navigatorKey: _rootNavigatorKey,
initialLocation: '/home',
routes: [
ShellRoute(
navigatorKey: _shellNavigatorKey,
builder: (context, state, child) {
return ScaffoldWithNavBar(child: child);
},
routes: [
GoRoute(
path: '/home',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/search',
builder: (context, state) => const SearchScreen(),
),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfileScreen(),
),
],
),
],
);
class ScaffoldWithNavBar extends StatelessWidget {
const ScaffoldWithNavBar({required this.child, Key? key}) : super(key: key);
final Widget child;
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: BottomNavigationBar(
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
],
currentIndex: _calculateSelectedIndex(context),
onTap: (int index) => _onItemTapped(index, context),
),
);
}
static int _calculateSelectedIndex(BuildContext context) {
final String location = GoRouterState.of(context).uri.toString();
if (location.startsWith('/home')) {
return 0;
}
if (location.startsWith('/search')) {
return 1;
}
if (location.startsWith('/profile')) {
return 2;
}
return 0;
}
void _onItemTapped(int index, BuildContext context) {
switch (index) {
case 0:
GoRouter.of(context).go('/home');
break;
case 1:
GoRouter.of(context).go('/search');
break;
case 2:
GoRouter.of(context).go('/profile');
break;
}
}
}
class HomeScreen extends StatelessWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return const Scaffold(body: Center(child: Text('Home Screen')));
}
}
class SearchScreen extends StatelessWidget {
const SearchScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return const Scaffold(body: Center(child: Text('Search Screen')));
}
}
class ProfileScreen extends StatelessWidget {
const ProfileScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return const Scaffold(body: Center(child: Text('Profile Screen')));
}
}
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: goRouter,
);
}
}
This code sets up a basic go_router
configuration with three routes and a BottomNavigationBar
. Now, let's focus on the scroll-to-top functionality.
Implementing Scroll-to-Top
The core of our solution involves detecting when the user taps the currently active NavigationDestination
and then triggering the scroll-to-top action. We'll need to:
- Get a reference to the
ScrollController
of the list view. - Check if the tapped destination is the currently active route.
- If it is, use the
ScrollController
to scroll to the top.
Getting the ScrollController
First, let's assume we have a ListView
in our HomeScreen
. We'll need to create a ScrollController
and attach it to the ListView
.
class HomeScreen extends StatefulWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
controller: _scrollController,
itemCount: 100,
itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
),
);
}
}
We've created a ScrollController
named _scrollController
and attached it to our ListView.builder
. Remember to dispose of the controller in the dispose
method to prevent memory leaks.
Detecting Active Route Taps
Now, we need to modify the _onItemTapped
function in our ScaffoldWithNavBar
to detect taps on the active route. We'll check if the tapped route is the same as the current route. If it is, we'll trigger the scroll-to-top action.
void _onItemTapped(int index, BuildContext context) {
final String currentRoute = GoRouterState.of(context).uri.toString();
String tappedRoute;
switch (index) {
case 0:
tappedRoute = '/home';
break;
case 1:
tappedRoute = '/search';
break;
case 2:
tappedRoute = '/profile';
break;
default:
return;
}
if (currentRoute == tappedRoute) {
// Trigger scroll-to-top or refresh
final homeScreenState = context.findAncestorStateOfType<_HomeScreenState>();
if (homeScreenState != null) {
_scrollToTopOrRefresh(homeScreenState);
}
} else {
GoRouter.of(context).go(tappedRoute);
}
}
void _scrollToTopOrRefresh(_HomeScreenState homeScreenState) {
if (homeScreenState._scrollController.offset > 0) {
homeScreenState._scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
} else {
// Trigger refresh logic here
// For example, you can call a method to reload data
print('List is at the top, refreshing...');
// homeScreenState.refreshData(); // Example refresh method
}
}
In this code:
- We get the current route using
GoRouterState.of(context).uri.toString()
. - We determine the tapped route based on the index.
- We compare the current route and the tapped route. If they're the same, we trigger the scroll-to-top or refresh action.
- We use
context.findAncestorStateOfType<_HomeScreenState>()
to get a reference to the_HomeScreenState
and then call a new method_scrollToTopOrRefresh
. - Inside
_scrollToTopOrRefresh
we check if the list offset is greater than 0, if it is, we scroll to the top. If it is at the top we can implement a refresh functionality
Scrolling to Top
Inside the _scrollToTopOrRefresh
function, we use the ScrollController
to animate the list to the top:
homeScreenState._scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
This code animates the scroll position to 0 (the top of the list) with a smooth animation.
Triggering a Refresh
If the list is already at the top, we want to trigger a refresh. This could involve reloading data from a server or refreshing the list items. For simplicity, we'll just print a message to the console in this example.
// Trigger refresh logic here
print('List is at the top, refreshing...');
// homeScreenState.refreshData(); // Example refresh method
Note: You'll need to implement your own refresh logic based on your app's requirements. For example, you might have a method in your HomeScreen
to fetch and update the list data.
Putting It All Together
Here's the complete code for our ScaffoldWithNavBar
and HomeScreen
:
class ScaffoldWithNavBar extends StatelessWidget {
const ScaffoldWithNavBar({required this.child, Key? key}) : super(key: key);
final Widget child;
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: BottomNavigationBar(
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
],
currentIndex: _calculateSelectedIndex(context),
onTap: (int index) => _onItemTapped(index, context),
),
);
}
static int _calculateSelectedIndex(BuildContext context) {
final String location = GoRouterState.of(context).uri.toString();
if (location.startsWith('/home')) {
return 0;
}
if (location.startsWith('/search')) {
return 1;
}
if (location.startsWith('/profile')) {
return 2;
}
return 0;
}
void _onItemTapped(int index, BuildContext context) {
final String currentRoute = GoRouterState.of(context).uri.toString();
String tappedRoute;
switch (index) {
case 0:
tappedRoute = '/home';
break;
case 1:
tappedRoute = '/search';
break;
case 2:
tappedRoute = '/profile';
break;
default:
return;
}
if (currentRoute == tappedRoute) {
// Trigger scroll-to-top or refresh
final homeScreenState = context.findAncestorStateOfType<_HomeScreenState>();
if (homeScreenState != null) {
_scrollToTopOrRefresh(homeScreenState);
}
} else {
GoRouter.of(context).go(tappedRoute);
}
}
void _scrollToTopOrRefresh(_HomeScreenState homeScreenState) {
if (homeScreenState._scrollController.offset > 0) {
homeScreenState._scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
} else {
// Trigger refresh logic here
// For example, you can call a method to reload data
print('List is at the top, refreshing...');
// homeScreenState.refreshData(); // Example refresh method
}
}
}
class HomeScreen extends StatefulWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
controller: _scrollController,
itemCount: 100,
itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
),
);
}
}
Conclusion
And there you have it! We've successfully implemented a scroll-to-top feature in our Flutter app using go_router
. This approach enhances user experience by providing a quick way to return to the top of a list or trigger a refresh.
Remember, this is just one way to implement this functionality. You can customize it further based on your app's specific needs. For instance, you might want to use a different animation curve or implement a more sophisticated refresh mechanism. But the core idea remains the same: listen for taps on the active NavigationDestination
and trigger the appropriate action.
Keep experimenting, keep building, and most importantly, keep making your apps awesome!