Flutter reader animation

# 概要

Flutterでレーダーアニメーションを実装した際の備忘録。

# プロジェクト作成

FVMについては[こちら](https://fvm.app/)

```
fvm flutter create radar_animation
```

# グリッド作成

```lib/radar_grid_painter.dart
import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'dart:math';

class RadarGridPainter extends CustomPainter {
  final int divisions;
  final Color gridColor;

  RadarGridPainter({
    this.divisions = 4,
    this.gridColor = Colors.green,
  });

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()
      ..color = gridColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = 1;

    // Draw the circles
    double radiusStep = size.width / (2 * divisions);
    for (int i = 1; i <= divisions; i++) {
      canvas.drawCircle(size.center(Offset.zero), i * radiusStep, paint);
    }

    // Draw the spokes
    double angleStep = 2 * math.pi / divisions;
    for (int i = 0; i < divisions; i++) {
      double angle = i * angleStep;
      Offset start = size.center(Offset.zero);
      Offset end = size.center(Offset(math.cos(angle) * size.width / 2, math.sin(angle) * size.width / 2));
      canvas.drawLine(start, end, paint);
    }

    final pointPaint = Paint()
    ..color = Colors.red
    ..style = PaintingStyle.fill;

    final random = Random();
    for (int i = 0; i < 5; i++) {
      final angle = random.nextDouble() * 2 * pi;
      final distance = random.nextDouble() * size.width / 2;
      final x = cos(angle) * distance;
      final y = sin(angle) * distance;
      final offset = Offset(x, y);
      canvas.drawCircle(size.center(offset), 5, pointPaint);
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return false;
  }
}
```

```main.dart
import 'package:flutter/material.dart';
import 'package:radar_animation/radar_grid_painter.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        // レダーグリッドを描画する
        child: CustomPaint(
          size: const Size(300, 300),
          painter: RadarGridPainter(),
        ),
      ),
    );
  }
}

```

![Screenshot_1714396849.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/934471/33d2b609-ca1b-a8c0-72b1-35b06cb436b8.png)

# レーダっぽくする

```main.dart
import 'package:flutter/material.dart';
import 'package:radar_animation/radar_grid_painter.dart';
import 'dart:math';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage>
  with SingleTickerProviderStateMixin {

  late AnimationController _controller;
  final List<Map<String, num>> points = [
    {'fixedAngle': Random().nextDouble() * 2 * pi, 'radius': 1},
    {'fixedAngle': Random().nextDouble() * 2 * pi, 'radius': 2},
    {'fixedAngle': Random().nextDouble() * 2 * pi, 'radius': 3},
    {'fixedAngle': Random().nextDouble() * 2 * pi, 'radius': 2},
    {'fixedAngle': Random().nextDouble() * 2 * pi, 'radius': 1},
  ];

  @override
  void initState(){
    _controller = AnimationController(vsync: this, duration: const Duration(seconds: 20))
      ..repeat();
   super.initState();
  }

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


  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      darkTheme: ThemeData(
        brightness: Brightness.dark,
      ),
      home: Expanded(
            flex: 2,
            child: Center(
              child: Stack(
                alignment: Alignment.center,
                children: [
                  // The grid background
                  CustomPaint(
                    painter: RadarGridPainter(points: points.map((point) => point.map((key, value) => MapEntry(key, value.toDouble()))).toList()),
                    size: Size(MediaQuery.of(context).size.width, MediaQuery.of(context).size.width),
                  ),
                  // The rotating radar sweep
                  RotationTransition(
                    turns: Tween(begin: 0.0, end: 3.0).animate(_controller),
                    child:Container(
                    width: MediaQuery.of(context).size.width,
                    height: MediaQuery.of(context).size.width,
                    decoration: BoxDecoration(
                      shape: BoxShape.circle,
                      gradient: SweepGradient(
                        center: Alignment.center,
                        startAngle: 0.0,
                        endAngle: 2 * pi,
                        colors: [
                          Colors.transparent,
                          Colors.green.withOpacity(0.75),
                          Colors.transparent
                        ],
                        stops: const [0.0, 0.05, 0.1],
                      ),
                    ),
                  ),
                  ),
                ],
              ),
            ),
          ),
    );
  }
}

```

```radar_grid_painter.dart
import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'dart:math';

class RadarGridPainter extends CustomPainter {
  final int divisions;
  final Color gridColor;
  final List<Map<String, double>> points;

  RadarGridPainter({
    required this.points,
    this.divisions = 4,
    this.gridColor = Colors.green,
  });

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()
      ..color = gridColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = 1;

    // Draw the circles
    double radiusStep = size.width / (2 * divisions);
    for (int i = 1; i <= divisions; i++) {
      canvas.drawCircle(size.center(Offset.zero), i * radiusStep, paint);
    }

    // Draw the spokes
    double angleStep = 2 * math.pi / divisions;
    for (int i = 0; i < divisions; i++) {
      double angle = i * angleStep;
      Offset start = size.center(Offset.zero);
      Offset end = size.center(Offset(math.cos(angle) * size.width / 2, math.sin(angle) * size.width / 2));
      canvas.drawLine(start, end, paint);
    }

    final pointPaint = Paint()
    ..color = Colors.red
    ..style = PaintingStyle.fill;

    for (int i = 0; i < points.length; i++) {
      final fixedAngle = points[i]['fixedAngle']!;
      final radius = points[i]['radius']! * min(size.width, size.height) / (2 * divisions);
      final point = Offset(size.width / 2 + radius * cos(fixedAngle - pi / 2), size.height / 2 + radius * sin(fixedAngle - pi / 2));
      canvas.drawCircle(point, 6.0, pointPaint);
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return false;
  }
}
```

![Screenshot_1714398849.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/934471/827ff8a8-ec71-b198-8eb9-70bb8d4deb06.png)



:::note warn
この記事は個人の見解であり所属する組織を代表しません。
:::