0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-08-28

はじめに

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

工具のハンドルのような(?)スワイプするとシャーッと回ってゆっくりと止まるウィジェットを作ってみた。
厳密な角度を必要としないテキトーな位置決めをしたい場合に使えるかなと思います。
Animation.gif

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

ソースコード

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

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)の引数にwidthheightを渡しているが、本当はchildの大きさに合わせて自動的に決定されるようにしたいが、やり方がわからない…
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?