Расплывающаяся менюшка.
Tags: flutter, dart
Published: 2022-09-26 12:22:14

#Введение#
Понадобилось мне в приложении меню которое появляется по нажатию на floating button. Начал смотреть, что там такого есть в этих ваших интернетах. Мне хотелось как в самсунге меню для стилуса. Поскольку я не придумал, как это гуглить правильно, я не нашел такого меню готового. Поэтому решил сделать его сам.
![Картинка самсунга](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/aqu6kra6v46snkk5xhuu.jpeg) 

#Анимация#
Поскольку анимации во флаттере я до этого не делал, я нашел пример подобной [анимации](https://www.kindacode.com/article/flutter-floating-action-button/). По-началу я думал что надо будет просто поменять расположение всплывающих кнопок и траектории их движения, но оказалось, кнопки выплывают из-за края экрана. Мне же нужно чтобы кнопки прятались под floating action button. Посмотрел код, и такое скрытие кнопок получается из-за использования виджета Column, но ведь есть Stack.
<img width="100%" style="width:100%" src="https://media.giphy.com/media/NkjFcC36C5FS4m7N7A/giphy.gif">

Для начала располагаем всплывающие кнопки по кругу переводом из полярных координат в декартовы. Анимировать выезд кнопок будем с помощью изменения радиуса. Для этого нам потребуются объекты классов AnimationController и Tween<double>. В AnimationController укажем продолжительностьанимации, а в Tween<double> поставим изменение радиуса от 0 до некоторого максимального. Максимальный радиус и время действия анимации передадим извне. Последним элементом в Stack передадим FloatingActionButton, по нажатию на которую будет отрабатывать анимация.
```
class FloatingMenu extends StatefulWidget {
  const FloatingMenu(this.duration, this.radius, {Key? key})
      : super(key: key);

  final int duration;
  final double radius;

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

class _FloatingMenuState extends State<FloatingMenu>
    with SingleTickerProviderStateMixin {
  late AnimationController _animationController;

  late Animation<double> _buttonAnimatedIcon;

  late Animation<double> _translateButton;

  bool _isExpanded = false;

  @override
  initState() {
    _animationController = AnimationController(
        vsync: this, duration: Duration(milliseconds: widget.duration))
      ..addListener(() {
        setState(() {});
      });

    _buttonAnimatedIcon =
        Tween<double>(begin: 0.0, end: 1.0).animate(_animationController);

    _translateButton = Tween<double>(
      begin: 0,
      end: widget.radius,
    ).animate(CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeInOut,
    ));
    super.initState();
  }

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

  _toggle() {
    if (_isExpanded) {
      _animationController.reverse();
    } else {
      _animationController.forward();
    }
    _isExpanded = !_isExpanded;
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        Transform(
          transform: Matrix4.translationValues(
            cos(pi) * _translateButton.value,
            -1 * sin(pi) * _translateButton.value,
            0,
          ),
          child: FloatingActionButton(
            backgroundColor: Colors.blue,
            onPressed: () {
              /* Do something */
            },
            child: const Icon(
              Icons.photo_camera,
            ),
          ),
        ),
        Transform(
          transform: Matrix4.translationValues(
            cos(3 * pi / 4) * _translateButton.value,
            -1 * sin(3 * pi / 4) * _translateButton.value,
            0,
          ),
          child: FloatingActionButton(
            backgroundColor: Colors.red,
            onPressed: () {
              /* Do something */
            },
            child: const Icon(
              Icons.video_camera_back,
            ),
          ),
        ),
        Transform(
          transform: Matrix4.translationValues(
            cos(pi / 2) * _translateButton.value,
            -1 * sin(pi / 2) * _translateButton.value,
            0,
          ),
          child: FloatingActionButton(
            backgroundColor: Colors.amber,
            onPressed: () {
              /* Do something */
            },
            child: const Icon(Icons.photo),
          ),
        ),
        Transform(
          transform: Matrix4.translationValues(
            cos(pi / 4) * _translateButton.value,
            -1 * sin(pi / 4) * _translateButton.value,
            0,
          ),
          child: FloatingActionButton(
            backgroundColor: Colors.deepPurpleAccent,
            onPressed: () {
              /* Do something */
            },
            child: const Icon(
              Icons.people_alt_outlined,
            ),
          ),
        ),
        Transform(
          transform: Matrix4.translationValues(
            cos(0) * _translateButton.value,
            -1 * sin(0) * _translateButton.value,
            0,
          ),
          child: FloatingActionButton(
            backgroundColor: Colors.tealAccent,
            onPressed: () {
              /* Do something */
            },
            child: const Icon(
              Icons.settings,
            ),
          ),
        ),
        child: FloatingActionButton(
          onPressed: _toggle,
          child: AnimatedIcon(
            icon: AnimatedIcons.menu_close,
             progress: _buttonAnimatedIcon,
            ),
        ),
      ],
    );
  }
}
```
Ура, анимация делает именно то что мне нужно! Но появляется другая проблема, кнопки то появились, но нажать на них невозможно.
<img width="100%" style="width:100%" src="https://media.giphy.com/media/UqEX3swr1yfcixmnpV/giphy.gif">

#Следствие вели#
Итак, ищем проблему. Первое что приходит в голову, что стэк дает нажиматься только первому элементу а остальным отключает эту возможность. В самом стэке нет никаких флагов, но в флаттере такую функцию выполняет класс [IgnorePointer](https://api.flutter.dev/flutter/widgets/IgnorePointer-class.html). Пробуем обернуть кнопки и включать возможность нажатия, когда они "выплыли". Не работает.
```
...
IgnorePointer(
  ignoring: !_isExpanded,
  child: Transform(
          transform: Matrix4.translationValues(
            cos(0) * _translateButton.value,
            -1 * sin(0) * _translateButton.value,
            0,
          ),
          child: FloatingActionButton(
            backgroundColor: Colors.tealAccent,
            onPressed: () {
              /* Do something */
            },
            child: const Icon(
              Icons.settings,
            ),
          ),
        ),
  ),
...
```
Дальше я обнаружил, что возможно, класс Transform перемещает не всю кнопку, а только ее изображение и в итоге нажать ее не возможно. Пробуем заменить ее на Positioned, но нажатия все так же не проходят.
```
Positioned(
  left: cos(3 * pi / 4) * _translateButton.value,
  bottom: sin(3 * pi / 4) * _translateButton.value,
  child: FloatingActionButton(
       backgroundColor: Colors.red,
       onPressed: () {
         print("bbb");
       },
       child: const Icon(
         Icons.video_camera_back,
       ),
  ),
),
```
Продолжая искать, обнаруживаю, что у каждого контейнера есть область действия. У стека получается, что область действия размером с самый широкий элемент в нем и во время анимации эта область не изменяется. Попробуем обернуть стэк в контейнер c шириной и высотой 200. Для наглядности сделаем его зеленого цвета а не прозрачным.
![добавлен контейнер 200 на 200](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/j77jvpf6rxyqj9n9qjrj.png)
Теперь кнопки, которые находятся в зеленой зоне нажимаются! Размещаем теперь кнопку-меню по центру и подгоняем размер зеленой области по размеру. 
![добавлен контейнер под размер менюшаки](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/x47hqgthqmsarpt28rpc.png)
ВЖУХ и все работает.

<img width="100%" style="width:100%" src="https://media.giphy.com/media/CnveTlqF76lxpNfyOQ/giphy.gif">

#Полный код менюшки#
```
import 'dart:math';

import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';

class FloatingMenu extends StatefulWidget {
  const FloatingMenu(this.duration, this.radius, {Key? key})
      : super(key: key);

  final int duration;
  final double radius;

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

class _FloatingMenuState extends State<FloatingMenu>
    with SingleTickerProviderStateMixin {
  late AnimationController _animationController;

  late Animation<double> _buttonAnimatedIcon;

  late Animation<double> _translateButton;

  bool _isExpanded = false;

  @override
  initState() {
    _animationController = AnimationController(
        vsync: this, duration: Duration(milliseconds: widget.duration))
      ..addListener(() {
        setState(() {});
      });

    _buttonAnimatedIcon =
        Tween<double>(begin: 0.0, end: 1.0).animate(_animationController);

    _translateButton = Tween<double>(
      begin: 0,
      end: widget.radius,
    ).animate(CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeInOut,
    ));
    super.initState();
  }

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

  _toggle() {
    if (_isExpanded) {
      _animationController.reverse();
    } else {
      _animationController.forward();
    }
    _isExpanded = !_isExpanded;
  }

  @override
  Widget build(BuildContext context) {
    double width = widget.radius * 2 + 60;
    double height = widget.radius + 60;
    double center = width/2-30;
    return Container(
        height: height,
        width: width,
        child: Stack(
          clipBehavior: Clip.none,
          children: [
            Positioned(
              left: center + cos(pi) * _translateButton.value,
              bottom: sin(pi) * _translateButton.value,
              child: FloatingActionButton(
                backgroundColor: Colors.blue,
                onPressed: () {
                  print("aaa");
                },
                child: const Icon(
                  Icons.photo_camera,
                ),
              ),
            ),
            Positioned(
              left: center + cos(3 * pi / 4) * _translateButton.value,
              bottom: sin(3 * pi / 4) * _translateButton.value,
              child: FloatingActionButton(
                backgroundColor: Colors.red,
                onPressed: () {
                  print("bbb");
                  /* Do something */
                },
                child: const Icon(
                  Icons.video_camera_back,
                ),
              ),
            ),
            Positioned(
              left: center + cos(pi / 2) * _translateButton.value,
              bottom: sin(pi / 2) * _translateButton.value,
              child: FloatingActionButton(
                backgroundColor: Colors.amber,
                onPressed: () {
                  print("ccc");
                  /* Do something */
                },
                child: const Icon(Icons.photo),
              ),
            ),
            Positioned(
              left: center + cos(pi / 4) * _translateButton.value,
              bottom: sin(pi / 4) * _translateButton.value,
              child: FloatingActionButton(
                backgroundColor: Colors.deepPurpleAccent,
                onPressed: () {
                  print("ddd");
                  /* Do something */
                },
                child: const Icon(
                  Icons.people_alt_outlined,
                ),
              ),
            ),
            Positioned(
              left: center + cos(0) * _translateButton.value,
              bottom: sin(0) * _translateButton.value,
              child: FloatingActionButton(
                backgroundColor: Colors.tealAccent,
                onPressed: () {
                  print("eee");
                  /* Do something */
                },
                child: const Icon(
                  Icons.settings,
                ),
              ),
            ),
            Positioned(
              left: center,
              bottom: 0,
              child: FloatingActionButton(
                onPressed: _toggle,
                child: AnimatedIcon(
                  icon: AnimatedIcons.menu_close,
                  progress: _buttonAnimatedIcon,
                ),
              ),
            )
          ],
        ));
  }
}
```