🐧

【Flutter】カーソルの動きに反応するインタラクティブなボタンを実装する

2023/12/20に公開

実装例

実行例
今回はこのようなボタンを Flutter で実装します。
特定の範囲内にマウスカーソルが入るとカーソルの位置によってハイライトの位置が動きます。

※マウスカーソルを使用しているので、Web のみ対応になりますが、カーソルをアニメーションや GestureDetector で代用するとモバイルでも動きのあるボタンを実装できると思います。

Button を作る

実行例

適当にボタンを用意します。今回は GestureDetector と Container でボタンを実装します。
ポイントとなるのがハイライトの部分ですが、RadialGradient でハイライトを表現します。
ハイライトの中心位置をカーソルに合わせれば追従して動きます。

※ハイライトがボタン外へ出てしまうのを防ぐために、ClipRRect などを使ってマスクすると良いでしょう。

ボタン
Container(
    decoration: BoxDecoration(
        gradient: RadialGradient(
            center: Alignment(x, y),// ここを変化させる
            radius: 1.5,
            colors: const [
                Colors.white,
                Colors.white12
            ],
        ),
    ),
    ...

座標を取得する

必要となるのが、マウスカーソルの絶対座標とボタンの絶対座標です。
カーソル位置は MouseRegion で、ボタンの位置はレンダリング後でないと位置は取得できないので、Global Key を使って RenderBox から取得します。

カーソル座標
MouseRegion(
    onHover: (PointerHoverEvent event) {
        print(event.position); // -> Offset(dx, dy)
    },
    child: Container(...
ボタン座標
final GlobalKey containerKey = GlobalKey();

WidgetsBinding.instance.addPostFrameCallback((_) {
    RenderBox renderBox = containerKey.currentContext!.findRenderObject() as RenderBox;
    Offset buttonPosition = renderBox.localToGlobal(Offset.zero);
});

https://api.flutter.dev/flutter/widgets/MouseRegion-class.html
https://zenn.dev/s134/articles/20231119rendering_size

ボタンに対するカーソルの相対座標を計算する

上記の絶対座標を使ってボタン中心からカーソルまでの位置を計算します。
これを Alignment に変換することでグラデーションの中心位置を動的にできます。

相対座標
/// ボタンのセンターを計算
///
/// buttonPosition ボタンの絶対位置(ウィジェット左上)
/// renderBox.size レンダリングされたボタンのサイズ
///
Offset buttonCenter =
    Offset(buttonPosition.dx + renderBox.size.width / 2, buttonPosition.dy + renderBox.size.height / 2);

/// カーソルを中心としたAlignmentを計算
///
/// mousePosition カーソルの絶対位置
/// renderBoxSize レンダリングされたウィジェットのサイズを格納した変数
///
Alignment mouseHoverAlignment = Alignment((mousePosition.dx - buttonCenter.dx) / renderBoxSize.width * 2,
        (mousePosition.dy - buttonCenter.dy) / renderBoxSize.height * 2);

あとはこの Alignment をグラデーションの中心位置に指定するだけです。

グラデーション
RadialGradient(
    center: mouseHoverAlignment,// 上記のAlignment
    radius: 1.5,
    colors: const [
        Colors.white,
        Colors.white12
    ],
),

コード全文

全文
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

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

  
  _InteractiveButtonState createState() => _InteractiveButtonState();
}

class _InteractiveButtonState extends State<InteractiveButton> {
  final GlobalKey containerKey = GlobalKey();
  Offset mousePosition = const Offset(0, 0);
  Offset buttonCenter = const Offset(0, 0);
  Alignment mouseHoverAlignment = Alignment.topLeft;
  Size renderBoxSize = const Size(0, 0);

  
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      RenderBox renderBox = containerKey.currentContext!.findRenderObject() as RenderBox;
      Offset buttonPosition = renderBox.localToGlobal(Offset.zero);
      setState(() {
        // レンダリングされたボタンの中心座標を計算
        buttonCenter =
            Offset(buttonPosition.dx + renderBox.size.width / 2, buttonPosition.dy + renderBox.size.height / 2);
        renderBoxSize = renderBox.size;
      });
    });
  }

  
  Widget build(BuildContext context) {
    const double buttonWidth = 300.0;
    const double buttonHeight = buttonWidth / 2.5;
    const double borderWidth = 3;

    return Scaffold(
      backgroundColor: Colors.blue[200],
      body: Center(
        child: MouseRegion(
          onHover: (PointerHoverEvent event) {
            setState(() {
              mousePosition = event.position;
              // カーソルからボタン中心までの距離をAlignmentで表現
              mouseHoverAlignment = Alignment((mousePosition.dx - buttonCenter.dx) / renderBoxSize.width * 2,
                  (mousePosition.dy - buttonCenter.dy) / renderBoxSize.height * 2);
            });
          },
          child: Container(
            padding: const EdgeInsets.all(100), // カーソル反応開始範囲
            decoration: const BoxDecoration(
              border: Border.fromBorderSide(BorderSide(color: Colors.grey, width: 1)),
            ),
            child: GestureDetector(
              onTap: () => print('Tapped'),
              // outer Button
              child: Container(
                key: containerKey, // ボタン座標を取得するためのKey
                width: buttonWidth,
                height: buttonHeight,
                padding: const EdgeInsets.all(borderWidth),
                decoration: BoxDecoration(
                  // borderのグラデーション
                  gradient: const LinearGradient(
                    begin: Alignment(0.9, -1.5),
                    end: Alignment.bottomRight,
                    colors: [Color.fromARGB(255, 255, 234, 234), Color.fromARGB(255, 120, 22, 22)],
                  ),
                  borderRadius: const BorderRadius.all(Radius.circular(buttonHeight / 3)),
                  boxShadow: [
                    BoxShadow(
                      color: Colors.black.withOpacity(0.5),
                      blurRadius: 10,
                      offset: const Offset(5, 5),
                    ),
                  ],
                ),
                // inner Button
                child: Container(
                    decoration: const BoxDecoration(
                      borderRadius: BorderRadius.all(Radius.circular(buttonHeight / 3)),
                      boxShadow: [
                        BoxShadow(
                          color: Colors.red,
                          spreadRadius: -5,
                          blurRadius: 10,
                          offset: Offset(0, 0),
                        ),
                      ],
                    ),
                    child: ClipRRect(
                      borderRadius: const BorderRadius.all(Radius.circular(buttonHeight / 3)),
                      child: Container(
                        decoration: BoxDecoration(
                          gradient: RadialGradient(
                            center: mouseHoverAlignment, // グラデーションの中心をカーソルの位置にする
                            radius: 1.5,
                            colors: const [
                              Colors.white70,
                              Colors.white70,
                              Colors.white60,
                              Colors.white38,
                              Colors.white12
                            ],
                          ),
                        ),
                        child: const Center(
                          child: Text(
                            'Button',
                            style: TextStyle(
                              fontSize: 50,
                              fontWeight: FontWeight.bold,
                              color: Colors.black54,
                            ),
                          ),
                        ),
                      ),
                    )
                  ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Discussion