Billy Mahmood
Billy Mahmood

Reputation: 1977

Draw a circle menu in Flutter

I am trying to draw a circle menu as seen in the attached image below, however I am new to Flutter and I have no idea where to start with something like this.

I will only have 8 peaces instead of 12 as seen in the image, and they will all be links, each link going to a different section of the app.

In the centre grey area, we will have short text

Upvotes: 3

Views: 2233

Answers (1)

Kherel
Kherel

Reputation: 16205

I hope it will give something to start.

enter image description here

Steps:

  1. Draw the svg via something like Figma
  2. Use path_parsing package to create flutter Path from svg path
  3. Create ShapeDecoration or ClipPath, I've choose ClipPath, but with ShapeDecoration you can add the shadows.
  4. Use Matrix4 for Path or Transform.rotate to rotate. I've choose `Transform.rotate twice it's was faster but using Path.transform is much cleaner approach.
  5. Calculate the place of each petal of the menu, using sine and cosine of angle.
  6. Tune everything a bit.

Happy coding! Enjoy!

p.s. It could be better if we do solution without specific number of pieces.

the sample code:

import 'dart:math' as math;

import 'package:flutter/material.dart';

const double degrees2Radians = math.pi / 180.0;

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
        backgroundColor: Colors.amber,
        body: SafeArea(
          child: MyHomePage(),
        ),
      ),
    );
  }
}

class MyHomePage extends StatelessWidget {
  final items = [
    ButtonData(title: 'one', onTap: () => print('1')),
    ButtonData(title: 'two', onTap: () => print('2')),
    ButtonData(title: 'three', onTap: () => print('3')),
    ButtonData(title: 'four', onTap: () => print('4')),
    ButtonData(title: 'five', onTap: () => print('5')),
    ButtonData(title: 'six', onTap: () => print('6')),
    ButtonData(title: 'seven', onTap: () => print('7')),
    ButtonData(title: 'eight', onTap: () => print('8')),
    ButtonData(onTap: () => print('center')),
  ];

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 300,
      width: 300,
      child: Stack(
        children: items
            .asMap()
            .map((index, buttonData) {
              if (index < 8) {
                var degree = 360 / 8 * index;
                var radian = degree * degrees2Radians;
                return MapEntry(
                  index,
                  Align(
                    alignment: Alignment(
                      math.sin(radian),
                      math.cos(radian),
                    ),
                    child: Transform.rotate(
                      angle: -radian,
                      child: MenuPetal(angle: -radian, buttonData: buttonData),
                    ),
                  ),
                );
              }
              return MapEntry(
                index,
                _centerButton(buttonData),
              );
            })
            .values
            .toList(),
      ),
    );
  }

  Widget _centerButton(ButtonData buttonData) {
    return Center(
      child: ClipRRect(
        borderRadius: BorderRadius.circular(25),
        child: GestureDetector(
          onTap: buttonData.onTap,
          child: Container(
            width: 50,
            height: 50,
            color: Colors.black38,
          ),
        ),
      ),
    );
  }
}

class ButtonData {
  final String? title;
  final void Function()? onTap;

  ButtonData({this.title, this.onTap});
}

class MenuPetal extends StatelessWidget {
  const MenuPetal({
    super.key,
    required this.angle,
    required this.buttonData,
  });

  final double angle;
  final ButtonData buttonData;
  final double factor = 0.38;

  @override
  Widget build(BuildContext context) {
    return FractionallySizedBox(
      heightFactor: factor,
      widthFactor: factor,
      child: GestureDetector(
        onTap: buttonData.onTap,
        child: ClipPath(
          clipper: MyCustomClipper(),
          child: Container(
            alignment: Alignment.center,
            decoration: BoxDecoration(
              image: DecorationImage(
                fit: BoxFit.fill,
                image:
                    NetworkImage('https://source.unsplash.com/featured/?white'),
              ),
            ),
            child: Padding(
              padding: EdgeInsets.only(top: 60),
              child: Transform.rotate(
                angle: -angle,
                child: Text(buttonData.title ?? ""),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class MyCustomClipper extends CustomClipper<Path> {
  @override
  Path getClip(Size size) {
    var x = size.width / 100 * 0.802;
    var y = size.height / 100;
    var path = Path()
      ..moveTo(39.4 * x, 6.1 * y)
      ..cubicTo(43.2 * x, -1.8 * y, 57.1 * x, -1.8 * y, 60.9 * x, 6.1 * y)
      ..lineTo(99.1 * x, 84.1 * y)
      ..cubicTo(102.1 * x, 90.2 * y, 99.1 * x, 93.9 * y, 92.0 * x, 95.6 * y)
      ..cubicTo(67.4 * x, 101.7 * y, 36.9 * x, 101.7 * y, 9.2 * x, 95.6 * y)
      ..cubicTo(1.2 * x, 93.8 * y, -1.3 * x, 88.7 * y, 1.2 * x, 84.1 * y)
      ..lineTo(39.4 * x, 6.1 * y);

    return path.shift(Offset(12, 0));
  }

  @override
  bool shouldReclip(CustomClipper<Path> oldClipper) => true;
}

Upvotes: 7

Related Questions