DEV Community

Cover image for Расплывающаяся менюшка.
AVTarasov210
AVTarasov210

Posted on

Расплывающаяся менюшка.

Введение

Понадобилось мне в приложении меню которое появляется по нажатию на floating button. Начал смотреть, что там такого есть в этих ваших интернетах. Мне хотелось как в самсунге меню для стилуса. Поскольку я не придумал, как это гуглить правильно, я не нашел такого меню готового. Поэтому решил сделать его сам.
Картинка самсунга

Анимация

Поскольку анимации во флаттере я до этого не делал, я нашел пример подобной анимации. По-началу я думал что надо будет просто поменять расположение всплывающих кнопок и траектории их движения, но оказалось, кнопки выплывают из-за края экрана. Мне же нужно чтобы кнопки прятались под floating action button. Посмотрел код, и такое скрытие кнопок получается из-за использования виджета Column, но ведь есть Stack.

Для начала располагаем всплывающие кнопки по кругу переводом из полярных координат в декартовы. Анимировать выезд кнопок будем с помощью изменения радиуса. Для этого нам потребуются объекты классов AnimationController и Tween. В AnimationController укажем продолжительностьанимации, а в Tween поставим изменение радиуса от 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,
            ),
        ),
      ],
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Ура, анимация делает именно то что мне нужно! Но появляется другая проблема, кнопки то появились, но нажать на них невозможно.

Следствие вели

Итак, ищем проблему. Первое что приходит в голову, что стэк дает нажиматься только первому элементу а остальным отключает эту возможность. В самом стэке нет никаких флагов, но в флаттере такую функцию выполняет класс IgnorePointer. Пробуем обернуть кнопки и включать возможность нажатия, когда они "выплыли". Не работает.

...
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,
            ),
          ),
        ),
  ),
...
Enter fullscreen mode Exit fullscreen mode

Дальше я обнаружил, что возможно, класс 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,
       ),
  ),
),
Enter fullscreen mode Exit fullscreen mode

Продолжая искать, обнаруживаю, что у каждого контейнера есть область действия. У стека получается, что область действия размером с самый широкий элемент в нем и во время анимации эта область не изменяется. Попробуем обернуть стэк в контейнер c шириной и высотой 200. Для наглядности сделаем его зеленого цвета а не прозрачным.
добавлен контейнер 200 на 200
Теперь кнопки, которые находятся в зеленой зоне нажимаются! Размещаем теперь кнопку-меню по центру и подгоняем размер зеленой области по размеру.
добавлен контейнер под размер менюшаки
ВЖУХ и все работает.

Полный код менюшки

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,
                ),
              ),
            )
          ],
        ));
  }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)