Flutter Hooks Tutorial – Hide FAB Animation – 100% Widget Code Reuse

Code located inside widgets is hard to reuse and furthermore, you often end up cluttering up the lifecycle methods, such as initState(), with a lot of code. You'd love to keep true to the single responsibility principle... BUT HOW when Flutter doesn't let you?

Enter Flutter Hooks - a way to separate your UI logic into independent and composable "hooks" that will instantly improve code sharing, cleanliness and thus also maintainability!

What we are going to build

The perfect candidate for learning how hooks work is hiding a FloatingActionButton when scrolling a ListView. The starter project already contains code implementing this functionality in the standard "no hooks" way.

Life without flutter_hooks

That standard way is rather messy, I'd say. Our widget needs to be stateful and it needs to be mixed in with a SingleTickerProviderStateMixin (learn why in this animation tutorial). Oh, and don't forget to dispose!

home_page.dart

class HomePage extends StatefulWidget {
  const HomePage({
    Key key,
  }) : super(key: key);

  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage>
    with SingleTickerProviderStateMixin {
  ScrollController _scrollController;
  AnimationController _hideFabAnimController;
  	
  @override
  void dispose() { 
    _scrollController.dispose();
    _hideFabAnimController.dispose();
    super.dispose();
  }
  ...
}

All of this code is merely for set up. Granted, most of it can be generated with VS Code extensions but still, that's a lot of boilerplate centered in one place. Imagine adding a few other controllers on top of this and you have a real mess on your hands.

Now we can finally get to the actual UI logic and control the animation when the user scrolls. This isn't a tutorial on animation or scrolling but the code should be fairly simple to understand.

home_page.dart

@override
void initState() {
  super.initState();
  _scrollController = ScrollController();
  _hideFabAnimController = AnimationController(
    vsync: this,
    duration: kThemeAnimationDuration,
    value: 1, // initially visible
  );

  _scrollController.addListener(() {
    switch (_scrollController.position.userScrollDirection) {
      // Scrolling up - forward the animation (value goes to 1)
      case ScrollDirection.forward:
        _hideFabAnimController.forward();
        break;
      // Scrolling down - reverse the animation (value goes to 0)
      case ScrollDirection.reverse:
        _hideFabAnimController.reverse();
        break;
      // Idle - keep FAB visibility unchanged
      case ScrollDirection.idle:
        break;
    }
  });
}

OK, nice... Having everything set up, we can finally connect our controllers to the widgets.

home_page.dart

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: Text("Let's Scroll"),
    ),
    floatingActionButton: FadeTransition(
      opacity: _hideFabAnimController,
      child: ScaleTransition(
        scale: _hideFabAnimController,
        child: FloatingActionButton.extended(
          label: const Text('Useless Floating Action Button'),
          onPressed: () {},
        ),
      ),
    ),
    floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
    body: ListView(
      controller: _scrollController,
      children: <Widget>[
        for (int i = 0; i < 5; i++)
          Card(child: FittedBox(child: FlutterLogo())),
      ],
    ),
  );
}

Multiple levels of BAD

Except for the last code snippet with the build method, the other ones leave much to be desired. Why is this not an optimal way to do animation or handle any kind of widget-specific state, for that matter? Well, we have just completely coupled the UI logic with a specific widget, in this case HomePage.

With StatefulWidgets, we have to keep the controllers as class fields - we need to initialize them and dispose of them, after all. And yes, we could extract at least the scrollController.addListener call into some sort of a static method to reuse at least that in a different widget but that's about it. We can't get rid of most of the boilerplate.

In addition, having so much code inside one class hinders readability, especially if you need to add more state to it down the line. While Flutter does indeed allow for all kinds of code in the "UI layer", wouldn't it be better to move it somewhere else and have all widgets practically stateless?

Hooking up the hooks ?

Although we cannot mark all of our widgets to be StatelessWidgets, we can get pretty close by making them HookWidgets! First, let's add the flutter_hooks dependency.

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  flutter_hooks: ^0.7.0

Hooks work very much like the State objects of StatefulWidgets with one important difference - while you cannot have multiple States associated with one StatefulWidget, you can have multiple HookStates associated with one HookWidget. Sounds confusing? No worries, it's easy once you see the code.

There are a bunch of predefined hooks which we can use. One of them is for obtaining an AnimationController. We also need a hook for obtaining and setting up the ScrollController and since there's no predefined hook for that, we'll create our own!

From Stateful to a HookWidget

The first change to make is to create something resembling a StatelessWidget, the difference being that it will actually be a HookWidget. It's no longer going to be mixed in with a SingleTickerProviderStateMixin and we can remove all the boilerplate code for the AnimationController from initState and dispose.

Let's leave the ScrollController intact for now so that we can transfer the code to a custom Hook. Of course, doing so will cause a lot of errors.

Ignoring the inevitable errors stemming from an unfinished refactoring, we can now obtain an AnimationController without all the ceremony straight from the build method.

home_page.dart

class HomePage extends HookWidget {

  // ERRORS HERE

  @override
  Widget build(BuildContext context) {
    final hideFabAnimController = useAnimationController(
        duration: kThemeAnimationDuration, initialValue: 1);

    return Scaffold(
      appBar: AppBar(
        title: Text("Let's Scroll"),
      ),
      floatingActionButton: FadeTransition(
        opacity: hideFabAnimController,
        child: ScaleTransition(
          scale: hideFabAnimController,
          child: FloatingActionButton.extended(
            label: const Text('Useless Floating Action Button'),
            onPressed: () {},
          ),
        ),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
      body: ListView(
        controller: _scrollController,
        children: <Widget>[
          for (int i = 0; i < 5; i++)
            Card(child: FittedBox(child: FlutterLogo())),
        ],
      ),
    );
  }
}

Creating a custom hook

The custom ScrollController hook will need to instantiate a ScrollControlleradd a listener to it which will update an AnimationController passed in as a parameter, return the ScrollController so that we can use it from the UI. It will practically hold all the code which was previously in the State object of a StatefulWidget.

The simplest form of a hook can be just a function. Let's create one under hooks/scroll_controller_for_animation.dart.

hooks/scroll_controller_for_animation.dart

ScrollController useScrollControllerForAnimation(
  AnimationController animationController,
) {
  final ScrollController scrollController = ScrollController();
  scrollController.addListener(() {
    switch (scrollController.position.userScrollDirection) {
      // Scrolling up - forward the animation (value goes to 1)
      case ScrollDirection.forward:
        animationController.forward();
        break;
      // Scrolling down - reverse the animation (value goes to 0)
      case ScrollDirection.reverse:
        animationController.reverse();
        break;
      case ScrollDirection.idle:
        break;
    }
  });
  return scrollController;
}

Simple, all we did was to move the logic to this new function. Now we can obtain this hook from the build method.

home_page.dart

class HomePage extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final hideFabAnimController = useAnimationController(
        duration: kThemeAnimationDuration, initialValue: 1);
    final scrollController =
        useScrollControllerForAnimation(hideFabAnimController);

    return Scaffold(
      appBar: AppBar(
        title: Text("Let's Scroll"),
      ),
      floatingActionButton: FadeTransition(
        opacity: hideFabAnimController,
        child: ScaleTransition(
          scale: hideFabAnimController,
          child: FloatingActionButton.extended(
            label: const Text('Useless Floating Action Button'),
            onPressed: () {},
          ),
        ),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
      body: ListView(
        controller: scrollController,
        children: <Widget>[
          for (int i = 0; i < 5; i++)
            Card(child: FittedBox(child: FlutterLogo())),
        ],
      ),
    );
  }
}

Running the app will result in the expected FAB animation! Yay ?. But wait! If you look closely at the hook we've created, there's one thing missing - we don't call scrollController.dispose() ?

The Hook class

Functions are elegant, functions are cool but hooks created as simple functions cannot possibly know about the underlying widget's lifecycle. This means that whenever you instantiate something which needs to know about the lifecycle, you need to create a fully-blown Hook class. 

Functional hooks are still great for occasions where you don't need the widget lifecycle. Read more about it in the official documentation.

Let's create a package-private (you'll see why in a bit) _ScrollControllerForAnimationHook together with its state _ScrollControllerForAnimationHookState. Hooks really resemble StatefulWidgets so if you know the basics of Flutter, the following should come to you naturally.

hooks/scroll_controller_for_animation.dart

class _ScrollControllerForAnimationHook extends Hook<ScrollController> {
  final AnimationController animationController;

  const _ScrollControllerForAnimationHook({
    @required this.animationController,
  });

  @override
  _ScrollControllerForAnimationHookState createState() =>
      _ScrollControllerForAnimationHookState();
}

class _ScrollControllerForAnimationHookState
    extends HookState<ScrollController, _ScrollControllerForAnimationHook> {
  ScrollController _scrollController;

  @override
  void initHook() {
    _scrollController = ScrollController();
    _scrollController.addListener(() {
      switch (_scrollController.position.userScrollDirection) {
        case ScrollDirection.forward:
          // State has the "widget" property
          // HookState has the "hook" property
          hook.animationController.forward();
          break;
        case ScrollDirection.reverse:
          hook.animationController.reverse();
          break;
        case ScrollDirection.idle:
          break;
      }
    });
  }

  // Build doesn't return a Widget but rather the ScrollController
  @override
  ScrollController build(BuildContext context) => _scrollController;

  // This is what we came here for
  @override
  void dispose() => _scrollController.dispose();
}

The Hook code is structured literally just like a StatefulWidget and most importantly, we have access to the dispose method!

You may argue we just created a whole lot of boilerplate and you're correct! Hooks are not a silver bullet and you have to constantly weigh other factors. For example, using a hook only in one widget isn't a good time investment. If you have a hook which you intend to use 10 times though, then go ahead and create it!

So, why is the class package-private? Registering a Hook with a HookWidget happens with a call to Hook.use. We'll hide this fact behind the function which previously held all the hook code.

hooks/scroll_controller_for_animation.dart

ScrollController useScrollControllerForAnimation(
  AnimationController animationController,
) {
  return Hook.use(_ScrollControllerForAnimationHook(
    animationController: animationController,
  ));
}

Because the UI is already calling this function to obtain the hook, we don't need to make any further code modifications. Running the app will result in the expected behavior AND the ScrollController will now be properly disposed.

Hooks are a great way to manage the complexity of your UI code. You can choose from a variety of predefined hooks or create your own as we did in this tutorial. As with any tool, even hooks can be used both for good and evil, so choose when to use them wisely! Using the predefined ones is always fine but think twice before investing time into creating your own.

About the author 

Matt Rešetár

Matt is an app developer with a knack for teaching others. Working as a freelancer and most importantly developer educator, he is set on helping other people succeed in their Flutter app development career.

You may also like

  • Nice tuto, you got me hooked ? on hooks ?
    I know but this is just a simple example, but I think it required too much boilerplate to implement something simple that requires instantiantion and disposal. To avoid having to create custom hooks for these simple use cases, I created the LifecycleHook idea, inspired by StatefulBuilder. It’s just a draft, but kinda works:
    https://github.com/netd777/flutter-hooks-fab-animation-tutorial

  • {"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
    >