flutterdartstackfloating-action-buttondartpad

Different behavior of a Flutter Stack() widget on different locations


I'm working a floating action button with some twists: when you click on the FloatingActionButton, some InkWell widgets become visible from a Stack, where you can click on multiple options. When I inserted it to my application, I experienced something weird:

If I add the unique MyFAB widget as a home: option within MaterialApp, the animation works perfectly and you can click on the small InkWell widgets without any problems:

import 'package:flutter/material.dart';
import 'dart:math';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home:  MyFAB(),
    );
  }
}

class MyFAB extends StatefulWidget {
  const MyFAB({Key? key}) : super(key: key);

  @override
  State<MyFAB> createState() => _MyFABState();
}

class _MyFABState extends State<MyFAB> with SingleTickerProviderStateMixin {
  late AnimationController _animationController;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 300),
    );

    _animation = CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeInOut,
    );
  }

  @override
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }

  void _toggle() {
    if (_animationController.isDismissed) {
      _animationController.forward();
    } else {
      _animationController.reverse();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      alignment: Alignment.center,
      children: [
        _buildOption(Icons.mood, -0.2),
        _buildOption(Icons.sentiment_satisfied, 0.27),
        _buildOption(Icons.sentiment_dissatisfied, 0.72),
        _buildOption(Icons.mood_bad, 1.2),
        FloatingActionButton(
          heroTag: "MyFAB",
          onPressed: _toggle,
          shape: const CircleBorder(),
          child: AnimatedIcon(
            icon: AnimatedIcons.menu_close,
            progress: _animation,
          ),
        ),
      ],
    );
  }

  Widget _buildOption(IconData icon, double index) {
    final double angle = (index - 1.5) * 0.5 * pi;
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        final double offsetX = _animation.value * 70 * cos(angle);
        final double offsetY = _animation.value * 70 * sin(angle);
        return Transform.translate(
          offset: Offset(offsetX, offsetY),
          child: Transform.scale(
            scale: _animation.value,
            child: Material(
              color: Colors.transparent,
              child: InkWell(
                onTap: () {
                  print('Option tapped');
                  _toggle();
                },
                borderRadius: BorderRadius.circular(20),
                splashColor: Colors.grey.withOpacity(0.5),
                child: CircleAvatar(
                  radius: 20,
                  child: Icon(icon),
                ),
              ),
            ),
          ),
        );
      },
    );
  }
}

But if I add MyFAB into a Scaffold as a floatingActionButton:, the small icons become useless, you cannot click on them anymore.

import 'package:flutter/material.dart';
import 'dart:math';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        floatingActionButton: MyFAB(),
        floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
      ),
    );
  }
}

class MyFAB extends StatefulWidget {
  const MyFAB({Key? key}) : super(key: key);

  @override
  State<MyFAB> createState() => _MyFABState();
}

class _MyFABState extends State<MyFAB> with SingleTickerProviderStateMixin {
  late AnimationController _animationController;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 300),
    );

    _animation = CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeInOut,
    );
  }

  @override
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }

  void _toggle() {
    if (_animationController.isDismissed) {
      _animationController.forward();
    } else {
      _animationController.reverse();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      alignment: Alignment.center,
      children: [
        _buildOption(Icons.mood, -0.2),
        _buildOption(Icons.sentiment_satisfied, 0.27),
        _buildOption(Icons.sentiment_dissatisfied, 0.72),
        _buildOption(Icons.mood_bad, 1.2),
        FloatingActionButton(
          heroTag: "MyFAB",
          onPressed: _toggle,
          shape: const CircleBorder(),
          child: AnimatedIcon(
            icon: AnimatedIcons.menu_close,
            progress: _animation,
          ),
        ),
      ],
    );
  }

  Widget _buildOption(IconData icon, double index) {
    final double angle = (index - 1.5) * 0.5 * pi;
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        final double offsetX = _animation.value * 70 * cos(angle);
        final double offsetY = _animation.value * 70 * sin(angle);
        return Transform.translate(
          offset: Offset(offsetX, offsetY),
          child: Transform.scale(
            scale: _animation.value,
            child: Material(
              color: Colors.transparent,
              child: InkWell(
                onTap: () {
                  print('Option tapped');
                  _toggle();
                },
                borderRadius: BorderRadius.circular(20),
                splashColor: Colors.grey.withOpacity(0.5),
                child: CircleAvatar(
                  radius: 20,
                  child: Icon(icon),
                ),
              ),
            ),
          ),
        );
      },
    );
  }
}

I got stuck at this point. What's causing this behavior? How can I use MyFAB within the Scaffold without any problems?

You can try out my code in https://dartpad.dev/

I recoded the entire MyFAB not to use Stack widget, removed the floatingActionButtonLocation option from the Scaffold, nothing helped.


Solution

  • Its because tappable area in floatingActionButton is bounded to child widget and not with the 4 extra button, this is illustration if floatingActionButton child widget is wrapped with container to enlarge the layout area, marked with yellow color:

    MaterialApp(
      home: Scaffold(
        floatingActionButton: Container(
          width: 120,
          height: 120,
          color: Colors.yellow,
          child: MyFAB(),
        ),
        floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
      ),
    );
    

    Result:

    demo area

    My suggested answer step is below:

    1. Wrap your FAB with Container or SizedBox to resize the tap area
    2. Change the Stack alignment property in your MyFAB widget to align the button to bottom inside container

    And below is the final code, changes marked with comments:

    import 'package:flutter/material.dart';
    import 'dart:math';
    
    void main() {
      runApp(MyApp());
    }
    
    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          home: Scaffold(
            floatingActionButton: Container( // <- wrap to resize area
              width: 175,
              height: 115,
              color: Colors.yellow, // <- remove the color later
              child: MyFAB(),
            ),
            floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
          ),
        );
      }
    }
    
    class MyFAB extends StatefulWidget {
      const MyFAB({Key? key}) : super(key: key);
    
      @override
      State<MyFAB> createState() => _MyFABState();
    }
    
    class _MyFABState extends State<MyFAB> with SingleTickerProviderStateMixin {
      late AnimationController _animationController;
      late Animation<double> _animation;
    
      @override
      void initState() {
        super.initState();
        _animationController = AnimationController(
          vsync: this,
          duration: Duration(milliseconds: 300),
        );
    
        _animation = CurvedAnimation(
          parent: _animationController,
          curve: Curves.easeInOut,
        );
      }
    
      @override
      void dispose() {
        _animationController.dispose();
        super.dispose();
      }
    
      void _toggle() {
        if (_animationController.isDismissed) {
          _animationController.forward();
        } else {
          _animationController.reverse();
        }
      }
    
      @override
      Widget build(BuildContext context) {
        return Stack(
          alignment: Alignment.bottomCenter, // <- align to bottom
          children: [
            _buildOption(Icons.mood, -0.2),
            _buildOption(Icons.sentiment_satisfied, 0.27),
            _buildOption(Icons.sentiment_dissatisfied, 0.72),
            _buildOption(Icons.mood_bad, 1.2),
            FloatingActionButton(
              heroTag: "MyFAB",
              onPressed: _toggle,
              shape: const CircleBorder(),
              child: AnimatedIcon(
                icon: AnimatedIcons.menu_close,
                progress: _animation,
              ),
            ),
          ],
        );
      }
    
      Widget _buildOption(IconData icon, double index) {
        final double angle = (index - 1.5) * 0.5 * pi;
        return AnimatedBuilder(
          animation: _animation,
          builder: (context, child) {
            final double offsetX = _animation.value * 70 * cos(angle);
            final double offsetY = _animation.value * 70 * sin(angle);
            return Transform.translate(
              offset: Offset(offsetX, offsetY),
              child: Transform.scale(
                scale: _animation.value,
                child: Material(
                  color: Colors.transparent,
                  child: InkWell(
                    onTap: () {
                      print('Option tapped');
                      _toggle();
                    },
                    borderRadius: BorderRadius.circular(20),
                    splashColor: Colors.grey.withOpacity(0.5),
                    child: CircleAvatar(
                      radius: 20,
                      child: Icon(icon),
                    ),
                  ),
                ),
              ),
            );
          },
        );
      }
    }
    

    And this is the result, you can remove the color:

    enter image description here

    Hopefully it can solve your problem 😉