Flutterで画面をドラッグ・スワイプで開く

Flutterで画面をドラッグ・スワイプで開く

interactive_transition

はじめに

開発しているFlutterアプリで、左から右にドラッグ・スワイプしたら別の画面を開くという要件がありました。(上のGIFのような感じ)
今回はこれの実装方法について説明します。ドロワーメニューなどを実装する際に役に立つと思います。

前提

ポイント

  • (1) 画面を左から右にスライドして表示する動きを実現するにはSlideTransitionを使います。
  • (2) ドラッグ・スワイプ操作を検知するのに、GestureDetectorを使います。
  • (3) ドラッグ・スワイプした量に応じて、AnimationControllerのvalueを更新します。これにより、ドラッグしたらそれに合わせてインタラクティブに画面を表示する動きを実現します。

コード全体

大まかな構成は以下の通りです。

  • Stack
    • CupertinoPageScaffold(表示元の画面)
    • SlideTransition
      • _DrawerPage(表示したい画面)
    • GestureDetector

import 'package:flutter/cupertino.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return const CupertinoApp(
      title: 'Flutter Demo',
      home: 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 final AnimationController _animationController;

  double? _dragStartX;
  double? _lastDragUpdatedX;
  bool? _isOpening;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 250));
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        CupertinoPageScaffold(
          navigationBar: CupertinoNavigationBar(middle: Text(widget.title)),
          child: Center(
            child: CupertinoButton(
              child: const Text('Open Drawer'),
              onPressed: () {
                _animationController.forward();
              },
            ),
          ),
        ),

        // (1) SlideTransitionを使って、左から右にスライドするような方法で画面を表示します。
        SlideTransition(
          position: Tween<Offset>(
            begin: const Offset(-1, 0),
            end: const Offset(0, 0),
          ).animate(_animationController),
          child: _DrawerPage(
            onCloseTap: () => _animationController.reverse(),
          ),
        ),

        LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
          final viewWidth = constraints.maxWidth;
          // (2) GestureDetectorでスワイプ・ドラッグ操作を検知します。
          return GestureDetector(
            onHorizontalDragDown: (DragDownDetails details) {
              _clear();
            },
            onHorizontalDragStart: (DragStartDetails details) {
              _dragStartX = details.localPosition.dx;
              _isOpening = _animationController.value != 1.0;
            },
            onHorizontalDragUpdate: (DragUpdateDetails details) {
              final dragStartX = _dragStartX;
              final isOpening = _isOpening;
              if (dragStartX == null || isOpening == null) return;

              final newX = details.localPosition.dx;
              if (isOpening) {
                if (_lastDragUpdatedX == null && newX <= dragStartX) {
                  return;
                }
              } else {
                if (_lastDragUpdatedX == null && newX >= dragStartX) {
                  return;
                }
              }

              _lastDragUpdatedX = newX;
              // (3) ドラッグ・スワイプしている指の位置に応じて画面をスライドするため、AnimationControllerのvalueを更新します。
              _animationController.value = newX / viewWidth;
            },
            onHorizontalDragEnd: (DragEndDetails details) {
              final dragStartX = _dragStartX;
              final lastDragUpdateX = _lastDragUpdatedX;
              if (dragStartX == null || lastDragUpdateX == null) return;
              _animationController.animateTo((lastDragUpdateX / viewWidth).round().toDouble());

              _clear();
            },
            onHorizontalDragCancel: () {
              _clear();
            },
          );
        }),
      ],
    );
  }

  void _clear() {
    _dragStartX = null;
    _lastDragUpdatedX = null;
    _isOpening = null;
  }
}

class _DrawerPage extends StatelessWidget {
  final void Function() onCloseTap;

  const _DrawerPage({
    super.key,
    required this.onCloseTap,
  });

  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      backgroundColor: const Color(0x00000000),
      child: Stack(
        children: [
          Container(color: const Color(0x880000FF)),
          Positioned(
            top: 16,
            right: 16,
            child: SafeArea(
              child: CupertinoButton(
                onPressed: onCloseTap,
                child: const Text(
                  'Close',
                  style: TextStyle(color: Color(0xFFFFFFFF)),
                ),
              ),
            ),
          )
        ],
      ),
    );
  }
}

解説

(1) 画面を左から右にスライドして表示する動きを実現するにはSlideTransitionを使います。

  • 下記の通り、スライドして表示するにはSlideTransitionを使います。
    • SlideTransitionはAnimatedWidgetのサブクラスです。AnimatedWidgetはImplicitlyAnimatedWidgetと異なり、開発者が明示的にAnimationを渡してあげる必要があります。
      • 話がそれますが、ImplicitlyAnimatedWidgetは、たとえばAnimatedSlideのようにOffset(位置を表すxとyの組)を渡すと、その位置に自動でアニメーション付きで移動します。開発者が明示的にAnimationを渡す必要はありません。
      • 今回、ドラッグ・スワイプした量に応じて開発者がAnimationの進み具合を制御したいので、外からAnimationを渡すことのできるSlideTransitionを使っています。
      • このAnimationの進み具合の制御には、AnimationControllerを使います。
    • Tweenを使って、AnimationControllerの0.0〜1.0のdouble型の値を、Offset型(Offset(-1, 0): 隠れた状態〜Offset(0, 0): 全体が現れた状態)の値に変換しています。
    • SlideTransitionのchildに表示したい画面(今回、_DrawerPage)を指定しています。
class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
  // ...
  late final AnimationController _animationController;
  // ...
  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 250));
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        CupertinoPageScaffold(/*...*/), // 表示元の画面
        SlideTransition(
          position: Tween<Offset>(
            begin: const Offset(-1, 0),
            end: const Offset(0, 0),
          ).animate(_animationController),
          // ↓表示したい画面
          child: _DrawerPage(
            onCloseTap: () => _animationController.reverse(),
          ),
        ),
        // ...
      ],
    );
  }
}

(2) ドラッグ・スワイプ操作を検知するのに、GestureDetectorを使います。(3) ドラッグ・スワイプした量に応じて、AnimationControllerのvalueを更新します。

  • GestureDetectorのonHorizontalDragXXX()(XXXには、Down, Start,...などが入ります)を使って、ドラッグ・スワイプを検知します。
    • Down→Start→Update→EndまたはCancelという流れになります。
    • Down, End, Cancelで、ドラッグ状態をクリアしています。_clear()メソッド。
    • Startでは、今回開きたい_DrawerPageを開くところなのか閉じるところなのか判定します。_animationController.valueが1.0だったら、一度animationが完了しているということなので、開いている状態ということになります。そのため、この状態からドラッグした場合閉じたいため、_isOpeningはfalseになります。
    • Updateで、_animationController.value = newX / viewWidthで、今のドラッグ位置(newX)に応じて、Animationの進み具合(0.0〜1.0)を設定しています。
      • ↑これにより、ドラッグした量に応じてインタラクティブに_DrawerPageが表示されます。
    • Endでは、開こうとしている場合、画面の左半分で指を離したら開かずに閉じる、画面の右半分で指を離したら開く、という動きを実現しています。
class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
  // ...
  double? _dragStartX;
  double? _lastDragUpdatedX;
  bool? _isOpening;
  // ...
  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        CupertinoPageScaffold(/*...*/), // 表示元の画面
    SlideTransition(/*...*/),
        LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
          final viewWidth = constraints.maxWidth;
          return GestureDetector(
            onHorizontalDragDown: (DragDownDetails details) {
              _clear();
            },
            onHorizontalDragStart: (DragStartDetails details) {
              _dragStartX = details.localPosition.dx;
              _isOpening = _animationController.value != 1.0;
            },
            onHorizontalDragUpdate: (DragUpdateDetails details) {
              final dragStartX = _dragStartX;
              final isOpening = _isOpening;
              if (dragStartX == null || isOpening == null) return;

              final newX = details.localPosition.dx;
              if (isOpening) {
                if (_lastDragUpdatedX == null && newX <= dragStartX) {
                  return; // 開こうとしているのに、最初のドラッグ時の指の動きが右から左だったら、何もしないで抜ける。
                }
              } else {
                if (_lastDragUpdatedX == null && newX >= dragStartX) {
                  return; // 閉じようとしているのに、最初のドラッグ時の指の動きが左から右だったら、何もしないで抜ける。
                }
              }

              _lastDragUpdatedX = newX;
              _animationController.value = newX / viewWidth;
            },
            onHorizontalDragEnd: (DragEndDetails details) {
              final dragStartX = _dragStartX;
              final lastDragUpdateX = _lastDragUpdatedX;
              if (dragStartX == null || lastDragUpdateX == null) return;
              // lastDragUpdateX / viewWidthの値が、0.0以上0.5未満なら閉じます(animateTo(0.0)します)。0.5以上なら開きます(animateTo(1.0)します)。round()は四捨五入する関数です。
              _animationController.animateTo((lastDragUpdateX / viewWidth).round().toDouble());

              _clear();
            },
            onHorizontalDragCancel: () {
              _clear();
            },
          );
        }),
      ],
    );
  }
  void _clear() {
    _dragStartX = null;
    _lastDragUpdatedX = null;
    _isOpening = null;
  }
}

補足

  • (関係ないですが)GestureDetectorのonPanXXX系のメソッドとonHorizontalDragXXX系のメソッドの違いはなんなのか?
    • ドキュメント上は以下のように異なっていました。ドキュメントからは違いはよくわからないです。
      • onPanXXX系

        A pointer has contacted the screen with a primary button and has begun to move.
        onPanStart property

      • onHorizontalDragXXX系

        A pointer has contacted the screen with a primary button and has begun to move horizontally.
        onHorizontalDragStart property

    • onPanXXX系とonHorizontalDragXXX系の両方を実装すると、後者だけ呼び出される。onPanXXX系だけ実装した時はonPanXXX系が呼び出される。
      • このことから、前者は後者を含んでいると言えそうです。Panは方向を問わず指を画面から離さないまま動かす動き。HorizontalDragはPanの動きのうち水平方向に動かしたもののみを指す。

最後に

最後までお読みくださりありがとうございます。
最近、Flutterアプリの開発で、このようなAnimationを使った実装をする機会が多いので、今後もこのような情報を共有しようと思います。
読者の方のFlutterのAnimation理解の一助となっていると嬉しいです。

コメント

タイトルとURLをコピーしました