Flutter Go_router Scroll To Top On NavigationDestination Click

by StackCamp Team 63 views

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:

  1. Get a reference to the ScrollController of the list view.
  2. Check if the tapped destination is the currently active route.
  3. 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!