Search code examples
flutterflutter-animation

Animate an unconstrained widget in Flutter


Basically, I have a top navigation bar (menu) and want it to expand on mouse hover. When the onHover property becomes true, it should expand and animate downwards, and when the onHover property becomes false, it should collapse and animate upwards.

However, when it expands, it's height should be constrained by the size of its child + padding. I don't want to set a specific height to the container.

For reference, this is my code:

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

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  bool isTopBarHovered = false;

  void handleTopBarHover(bool isHovered) {
    setState(() {
      isTopBarHovered = isHovered;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: MediaQuery.of(context).size.width < 800
          ? AppBar()
          : PreferredSize(
              preferredSize: Size(MediaQuery.of(context).size.width, 48.0),
              child: TopNavigationBar(onHover: handleTopBarHover),
            ),
      body: Stack(
        children: [
          Container(),
          Positioned(
            top: 0,
            left: 0,
            right: 0,
            child: AnimatedSize(
              duration: const Duration(milliseconds: 250),
              curve: Curves.easeInOut,
              child: isTopBarHovered
                  ? Container(
                    color: Colors.black,
                    child: const Padding(
                      padding: EdgeInsets.symmetric(vertical: 88.0),
                      child: Center(
                        child: Text('Additional Widget'),
                      ),
                    ),
                  )
                  : const SizedBox(height: 0),
            ),
          ),
        ],
      ),
    );
  }
}
class TopNavigationBar extends StatefulWidget {
  final Function(bool) onHover;

  const TopNavigationBar({super.key, required this.onHover});

  @override
  State<TopNavigationBar> createState() => _TopNavigationBarState();
}

class _TopNavigationBarState extends State<TopNavigationBar> {
  @override
  Widget build(BuildContext context) {
    bool isHover = false;

    return Container(
      color: Colors.black,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          InkWell(
            onTap:(){},
            onHover: (val) {
              setState(() {
                isHover = val;
              });
              widget.onHover(isHover);
            },
            child: const Padding(
              padding: EdgeInsets.symmetric(vertical: 16.0, horizontal: 8.0),
              child: Text(
                "Item 01",
                style: TextStyle(color: Colors.white),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

This works partially, I can only get to make it animate when expanding, when it collapses it just disappears without animating.


Solution

  • This is the explanation and the demo for the @pskink answer

    The code before:

    Positioned(
      top: 0,
      left: 0,
      right: 0,
      child: AnimatedSize(
        duration: const Duration(milliseconds: 250),
        curve: Curves.easeInOut,
        child: isTopBarHovered
            ? Container(
              color: Colors.black,
              child: const Padding(
                padding: EdgeInsets.symmetric(vertical: 88.0),
                child: Center(
                  child: Text('Additional Widget'),
                ),
              ),
            )
            : const SizedBox(height: 0),
      ),
    ),
    

    Step:

    1. Replace AnimatedSize with AnimatedAlign and set alignment to bottom because you will animate the widget downwards so you must stick it to bottom
    2. Animate changes with heightFactor, Its like sizing the layout bounding box scale, either 1 or 0

    The code after:

    Positioned(
      top: 0,
      left: 0,
      right: 0,
      child: AnimatedAlign(
        duration: const Duration(milliseconds: 250),
        alignment: Alignment.bottomCenter,
        heightFactor: isTopBarHovered ? 1 : 0,
        curve: Curves.easeInOut,
        child: Container(
          color: Colors.black,
          child: const Padding(
            padding: EdgeInsets.symmetric(vertical: 88.0),
            child: Center(
              child: Text(
                'Additional Widget',
                style: TextStyle(color: Colors.white),
              ),
            ),
          ),
        ),
      ),
    ),
    

    This is the result:

    enter image description here

    Thanks to @pskink

    This is the final code:

    class HomePage extends StatefulWidget {
      const HomePage({super.key});
    
      @override
      State<HomePage> createState() => _HomePageState();
    }
    
    class _HomePageState extends State<HomePage> {
      bool isTopBarHovered = false;
    
      void handleTopBarHover(bool isHovered) {
        setState(() {
          isTopBarHovered = isHovered;
        });
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: MediaQuery.of(context).size.width < 800
              ? AppBar()
              : PreferredSize(
                  preferredSize: Size(MediaQuery.of(context).size.width, 48.0),
                  child: TopNavigationBar(onHover: handleTopBarHover),
                ),
          body: Stack(
            children: [
              Container(),
              Positioned(
                top: 0,
                left: 0,
                right: 0,
                child: AnimatedAlign(
                  duration: const Duration(milliseconds: 250),
                  alignment: Alignment.bottomCenter,
                  heightFactor: isTopBarHovered ? 1 : 0,
                  curve: Curves.easeInOut,
                  child: Container(
                    color: Colors.black,
                    child: const Padding(
                      padding: EdgeInsets.symmetric(vertical: 88.0),
                      child: Center(
                        child: Text(
                          'Additional Widget',
                          style: TextStyle(color: Colors.white),
                        ),
                      ),
                    ),
                  ),
                ),
              ),
            ],
          ),
        );
      }
    }
    
    class TopNavigationBar extends StatefulWidget {
      final Function(bool) onHover;
    
      const TopNavigationBar({super.key, required this.onHover});
    
      @override
      State<TopNavigationBar> createState() => _TopNavigationBarState();
    }
    
    class _TopNavigationBarState extends State<TopNavigationBar> {
      @override
      Widget build(BuildContext context) {
        bool isHover = false;
    
        return Container(
          color: Colors.black,
          child: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              InkWell(
                onTap: () {},
                onHover: (val) {
                  setState(() {
                    isHover = val;
                  });
                  widget.onHover(isHover);
                },
                child: const Padding(
                  padding: EdgeInsets.symmetric(vertical: 16.0, horizontal: 8.0),
                  child: Text(
                    "Item 01",
                    style: TextStyle(color: Colors.white),
                  ),
                ),
              ),
            ],
          ),
        );
      }
    }