スワイプしたらシャーッと回るウィジェットをFlutterで作ってみた

# はじめに
今年の夏からFlutterの勉強をし始めて、そろそろインプットに飽きてきたのでアウトプットをしたいと思う今日この頃。

工具のハンドルのような(?)スワイプするとシャーッと回ってゆっくりと止まるウィジェットを作ってみた。
厳密な角度を必要としないテキトーな位置決めをしたい場合に使えるかなと思います。
![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57609/0e5d991c-d5eb-7ef7-2cf6-d6bdfbd240fc.gif)

あまりに手探りで作りすぎたのでいろいろ問題があるかと思います。

# ソースコード
| 引数名 | 説明 |
|:-:|:-:|
| child  | 回転させたいウィジェット | 
| width  | childの周囲の領域の幅 |
| height  | childの周囲の領域の高さ  |
| drag   | 回転を妨げる抵抗力(値が大きいほど抵抗が小さい) |
| gain   | 回転させる力の大きさ(値が大きいほど力が強い)  |
| onChange | 回転角が変わった際のコールバック関数 |

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

class InertialRotor extends StatefulWidget {
  final Widget child;
  final double width;
  final double height;
  final double drag;
  final double gain;
  final void Function(double value)? onChange;

  const InertialRotor({
    Key? key,
    required this.child,
    required this.width,
    required this.height,
    this.drag = 0.1,
    this.gain = 1.0,
    this.onChange,
  }) : super(key: key);

  @override
  State<InertialRotor> createState() => _InertialRotorState();
}

class _InertialRotorState extends State<InertialRotor>
    with SingleTickerProviderStateMixin {
  late final AnimationController _rotator = AnimationController.unbounded(
    vsync: this,
    value: 0.0,
  )..addListener(() {
      widget.onChange?.call(_rotator.value);
    });

  double _angleOffset = 0.0;
  Offset _lastTouchPosition = const Offset(0.0, 0.0);

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

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: widget.width,
      height: widget.height,
      child: LayoutBuilder(
        builder: (context, constraints) {
          final centerOfWidget =
              Offset(constraints.maxWidth / 2, constraints.maxHeight / 2);

          return GestureDetector(
            onPanStart: (details) {
              final touchPosition = details.localPosition - centerOfWidget;
              _angleOffset = touchPosition.direction - _rotator.value;
              _lastTouchPosition = touchPosition;
            },
            onPanUpdate: (details) {
              final touchPosition = details.localPosition - centerOfWidget;
              final currentDirection = touchPosition.direction;
              final lastDirection = _lastTouchPosition.direction;

              if (currentDirection.sign != lastDirection.sign &&
                  (currentDirection - lastDirection).abs() > math.pi) {
                if (lastDirection > 0) {
                  _angleOffset -= 2 * math.pi;
                } else {
                  _angleOffset += 2 * math.pi;
                }
              }
              _rotator.value = touchPosition.direction - _angleOffset;
              _lastTouchPosition = touchPosition;
            },
            onPanEnd: (details) {
              final releaseVelocity = details.velocity.pixelsPerSecond;
              final projectionVector = _lastTouchPosition *
                  (_lastTouchPosition.dx * releaseVelocity.dx +
                      _lastTouchPosition.dy * releaseVelocity.dy) /
                  _lastTouchPosition.distanceSquared;
              final rejectionVector = releaseVelocity - projectionVector;
              final angularVelocity =
                  rejectionVector.distance / _lastTouchPosition.distance;
              final sign = (_lastTouchPosition.dx * rejectionVector.dy -
                      _lastTouchPosition.dy * rejectionVector.dx)
                  .sign;

              _rotator.animateWith(
                FrictionSimulation(
                  widget.drag,
                  _rotator.value,
                  sign * angularVelocity * widget.gain,
                ),
              );
            },
            child: AnimatedBuilder(
              animation: _rotator,
              child: widget.child,
              builder: (context, child) {
                return Transform.rotate(
                  angle: _rotator.value,
                  child: child,
                );
              },
            ),
          );
        },
      ),
    );
  }
}
```

## ポイント
スワイプ中の更新処理`onPanUpdate()`で若干ややこしいことをやっています。
このウィジェットの回転角度には上限/下限はありません。いくらでも回せます。
ところが、位置情報を保持するクラス`Offset`における角度の定義域は$-\pi$から$+\pi$ですので、
何も対策しないと一回転したときに値がいきなり$\pi$だけ増えたり減ったりする現象が発生します。
そこで、この不連続な点を検出してオフセットを調整する処理を実装しています。

スワイプの完了処理`onPanEnd()`ではスワイプした速度のうち、回転方向の速度成分だけを抽出する処理をしています。
回転方向と垂直な成分(半径方向)の速度成分は回転に寄与しないので捨ててしまいます。
また、速度をかける点が回転中心から遠ければ遠いほど回転速度は小さくなるようにしています。
これは角速度の公式$v=r\omega$から来ています。

# こんな使い方できるかも
時刻を選ばせるときに正確な時間ではなく、大体の時間で選ばせたい場合などに使えるかもしれない(使えるか！？)

![Animation2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57609/a4c266a1-addb-dcca-d664-fb65e6b1e438.gif)


```dart:main.dart
import 'dart:math' as math;
import 'package:dial/inertial_rotor.dart';
import 'package:flutter/material.dart';

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

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

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Easy Time Selector',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const EasyTimeSelector(),
    );
  }
}

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

  @override
  State<EasyTimeSelector> createState() => _EasyTimeSelectorState();
}

class _EasyTimeSelectorState extends State<EasyTimeSelector> {
  int _hour = 0;
  int _minute = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            InertialRotor(
              width: 200,
              height: 200,
              drag: 0.3,
              gain: 3,
              onChange: (value) {
                final degreeWithin360 = (value * 180.0 / math.pi) % 360.0;
                final totalMinutes = degreeWithin360 * (24 * 60 / 360.0);
                setState(() {
                  _hour = (totalMinutes ~/ 60.0);
                  _minute = (totalMinutes - _hour * 60.0).toInt();
                });
              },
              child: const Icon(
                Icons.sunny_snowing,
                size: 200,
              ),
            ),
            Container(
              padding: const EdgeInsets.all(5),
              color: Colors.yellow,
              child: Text(
                  '${_hour.toString().padLeft(2, '0')}:${_minute.toString().padLeft(2, '0')}'),
            )
          ],
        ),
      ),
    );
  }
}
```

# 問題点
- ウィジェット(InertialRotor)の引数に`width`と`height`を渡しているが、本当は`child`の大きさに合わせて自動的に決定されるようにしたいが、やり方がわからない…