Sitemap
Rahul Rathore (Flutter and Android Developer)

Rahul Rathore (Flutter and Android Developer)

Developing Mobile application since 2020.

Flutter: How I Implemented Blinkit-Like Hideable Bottom Navigation in Flutter

Press enter or click to view image in full size

Why Apps Like YouTube Hide Bottom Navigation on Scroll

If you’ve used apps like Blinkit, Instagram, or YouTube, you’ve probably noticed something subtle:

👉 The bottom navigation disappears when you scroll down
👉 It reappears instantly when you scroll up

This isn’t just eye-candy.

It solves real UX problems:

  1. Mobile screens are small
  2. Content-heavy screens need more vertical space
  3. Users focus on content while scrolling down
  4. Navigation is only needed when scrolling up or changing context

Instead of permanently occupying screen space, the bottom navigation becomes context-aware.

In this article, we’ll implement this behavior in Flutter and, more importantly, understand why each piece of code exists.

Final Result

Press enter or click to view image in full size

Core Idea

  1. Detect scroll direction
  2. Toggle bottom navigation visibility
  3. Animate bottom navigation in/out
  4. Preserve page state when switching tabs

Let’s Start Code

Main Landing Page

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

@override
State<MainLandingPage> createState() => _MainLandingPageState();
}

class _MainLandingPageState extends State<MainLandingPage> {
bool _isBottomNavVisible = true;

final _homeController = ScrollController();
final _reportsController = ScrollController();
final _bookingsController = ScrollController();
final _profileController = ScrollController();

int _index = 0;

@override
Widget build(BuildContext context) {
return Container() // todo add actual ui
}
}

Why multiple ScrollControllers?

Each tab must have its own controller

If we used one shared controller:

  • Scroll events from one tab would affect all tabs
  • Scroll position would reset or behave unpredictably
  • Bottom navigation could hide/show incorrectly

Separate controllers ensure:

  • Independent scroll tracking
  • Correct direction detection
  • Scroll position preserved per tab

Attaching Scroll Listeners

@override
void initState() {
_attachListener(_homeController);
_attachListener(_reportsController);
_attachListener(_bookingsController);
_attachListener(_profileController);
super.initState();
}

Scroll Direction Logic

void _attachListener(ScrollController controller) {
controller.addListener(() {
final direction = controller.position.userScrollDirection;

if (direction == ScrollDirection.reverse && _isBottomNavVisible) {
setState(() => _isBottomNavVisible = false);
} else if (direction == ScrollDirection.forward && !_isBottomNavVisible) {
setState(() => _isBottomNavVisible = true);
}
});
}

Why userScrollDirection?

Flutter gives us three directions:

  • forward → scrolling up
  • reverse → scrolling down
  • idle → no scroll

This makes our logic:

  • Scroll down → hide bottom nav
  • Scroll up → show bottom nav

💡 We also check _isBottomNavVisible before calling setState to avoid unnecessary rebuilds.

Define the Tab Pages list

late final _pages = [
EmptyPage(title: 'Home', scrollController: _homeController),
EmptyPage(title: 'Reports', scrollController: _reportsController),
EmptyPage(title: 'Bookings', scrollController: _bookingsController),
EmptyPage(title: 'Profile', scrollController: _profileController),
];

Each page receives its own ScrollController.

Get Rahul Rathore (Flutter and Android Developer)’s stories in your inbox

Join Medium for free to get updates from this writer.

Now, let’s create our Landing Page Scaffold step by step:

Scaffold(
extendBody: true,
body: IndexedStack(index: _index, children: _pages),
);

Why IndexedStack?

This is critical.

  • If we used body: _pages[_index]
    — Page rebuilds every time
    — Scroll position resets
    — Controllers lose their position
    — Bad UX
  • IndexedStack benefits:
    — Keeps all pages alive
    —
    Preserves scroll position
    — No unnecessary rebuilds
    — Smooth tab switching

Bottom Navigation with Animation

bottomNavigationBar: AnimatedSlide(
offset: _isBottomNavVisible ? Offset.zero : const Offset(0, 1),
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
child: NavigationBar(...),
)

Why AnimatedSlide?

  • Smooth vertical animation
  • No manual AnimationController needed
  • Offset(0, 1) moves the navigation bar downwards out of view

Navigation Bar

NavigationBar(
selectedIndex: _index,
onDestinationSelected: (index) {
setState(() => _index = index);
},
indicatorColor: Colors.transparent,
backgroundColor: Colors.white,
labelTextStyle: WidgetStateProperty.resolveWith((
Set<WidgetState> states,
) {
if (states.contains(WidgetState.selected)) {
return TextStyle(
fontSize: 12,
color: UIConstants.primary,
fontWeight: FontWeight.w600,
);
} else {
return TextStyle(fontSize: 12);
}
}
),
destinations: [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home_filled, color: UIConstants.primary),
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.info_outline),
selectedIcon: Icon(Icons.info_sharp, color: UIConstants.primary),
label: 'Reports',
),
NavigationDestination(
icon: Icon(Icons.receipt_outlined),
selectedIcon: Icon(Icons.receipt_rounded,color: UIConstants.primary),
label: 'Bookings',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person_sharp, color: UIConstants.primary),
label: 'Profile',
),
],
),

Clean Up Scroll constrollers:

@override
void dispose() {
_homeController.dispose();
_reportsController.dispose();
_labTestsController.dispose();
_bookingsController.dispose();
_profileController.dispose();
super.dispose();
}

Always dispose controllers to:

  • Prevent memory leaks
  • Avoid unexpected behavior

Page UI (Your Scrollable Content)

class EmptyPage extends StatelessWidget {
final String title;
final ScrollController scrollController;

const EmptyPage({
super.key,
required this.title,
required this.scrollController,
});

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(title)),
body: ListView(
controller: scrollController,
children: List.generate(
20,
(e) => ListTile(title: Text('$title Item $e')),
),
),
);
}
}

Each page:

  • Uses the injected ScrollController
  • Emits scroll events to the parent
  • Keeps logic clean and reusable

Final Thoughts

Patterns like scroll-aware navigation are not about adding more animations — they’re about respecting user intent. When users scroll down, they want content. When they scroll up, they want control.

By keeping the logic simple and separating responsibilities clearly, this solution stays easy to reason about while delivering a modern UX. It’s a pattern I’ve found myself reusing across projects, especially in apps with multiple content-heavy tabs.

What’s Next?

If you’d like a deeper dive into any specific Flutter or Android topic — whether it’s UI patterns, state management, performance, or architecture — let me know in the comments below.

I’ll be happy to write a follow-up post or help with a real-world problem you’re currently facing.

Thanks for reading! If you found this guide helpful, don’t forget to follow me on Medium and X(Twitter) for more coding tutorials, tips and tricks. Happy coding! 😊

--

--