6
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?

Flutterでスクロール停止後に少し待ってからボタンを表示する

Last updated at Posted at 2025-12-18

Flutterでリストビューのスクロールが停まってから一拍置いて何かUIを表示したいときの小ネタを紹介します。

やりたいこと

このような感じで、時間差でUIを表示したいと思います。
screen-20251215-162034 (1).gif

どうやるか

Timerのコールバックで表示を制御するのも良いですが、今回は別の方法にします。

アニメーションをイージングさせた場合にCurves.easeInOutなどを使いますが、その曲線をグラフのような一定期間出力が0のままになるCurveクラスを実装したいと思います。
実際はスクロールが停止してすぐUI表示アニメーションが始まっているが、しばらくの間0が出力されるために遅延しているように見えるという訳です。

8ad0f715-8228-4462-899b-abea32d81e85.png

コードは下記のような感じになります。


/// アニメーション開始まで遅延を入れるカスタムカーブ。
/// このカーブは指定したしきい値まで値を0のまま維持し、その後アニメーションを開始します。
class DelayedCurve extends Curve {
  const DelayedCurve();

  @override
  double transform(double t) {
    const animatedDelayThreshold =
        (kDelayMilliseconds / (kDelayMilliseconds + kMovementDuration));
    final adjustedProgress = t - animatedDelayThreshold;
    if (adjustedProgress < 0) {
      return 0;
    }

    return Curves.easeInOut.transform(
      adjustedProgress / (1 - animatedDelayThreshold),
    );
  }
}
  • kDelayMillisecondsはUIの表示が始まるまでの待機時間
  • kMovementDurationはUIのアニメーションにかかる時間

ソースコード

実際に実行できる体裁まで実装したソースコードです。

import 'package:flutter/material.dart';

/// 表示アニメーションが始まるまでの時間(ミリ秒)
const int kDelayMilliseconds = 800;

/// 表示アニメーションの移動にかかる時間(ミリ秒)
const int kMovementDuration = 300;

/// UI 関連の定数
const double kButtonHorizontalMargin = 60.0;
const double kButtonBottomMargin = 20.0;
const double kButtonHiddenPosition = -56.0;
const double kButtonBorderRadius = 8.0;

/// アプリケーションのエントリーポイント。
void main() {
  runApp(const MyApp());
}

/// ルートWidget
class MyApp extends StatelessWidget {
  /// ルートアプリケーションウィジェットを作成します。
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MediaQuery(
      data: MediaQuery.of(context).copyWith(textScaler: TextScaler.noScaling),
      child: MaterialApp(
        title: "Flutter Demo",
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        ),
        home: const HomePage(),
      ),
    );
  }
}

/// アプリケーションのメインページ。
///
/// このページはスクロール可能なリストと、スクロールが停止したときに表示され、スクロールが始まると非表示になるアクションボタンを表示します。
class HomePage extends StatefulWidget {
  /// ホームページを作成します。
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  /// アクションボタンを表示するかどうか。
  bool _showButton = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Test App')),
      body: SafeArea(
        child: Stack(
          children: [
            ItemListView(onScrollNotification: _handleScrollNotification),
            AnimatedActionButton(
              isVisible: _showButton,
              onPressed: _handleButtonPress,
            ),
          ],
        ),
      ),
    );
  }

  /// リストビューからのスクロール通知を処理します。
  /// スクロール状態に応じてボタンの表示フラグを更新します。
  bool _handleScrollNotification(ScrollNotification notification) {
    final metrics = notification.metrics;

    if (notification is ScrollUpdateNotification && _showButton) {
      if (metrics.pixels < metrics.minScrollExtent ||
          metrics.pixels > metrics.maxScrollExtent) {
        /// バウンスしているときはフラグを操作しない
        return true;
      }

      setState(() {
        _showButton = false;
      });
      return true;
    }

    if (notification is ScrollEndNotification) {
      setState(() {
        _showButton = true;
      });
    }

    return true;
  }

  /// アクションボタンが押されたときの処理。
  /// ボタンが押されたときにスナックバーを表示します。
  void _handleButtonPress() {
    ScaffoldMessenger.of(
      context,
    ).showSnackBar(const SnackBar(content: Text('OkOk')));
  }
}

/// スクロール可能なアイテムリストビュー。
class ItemListView extends StatelessWidget {
  /// [onScrollNotification]コールバックはスクロールイベント発生時に呼ばれます。
  const ItemListView({super.key, required this.onScrollNotification});

  /// スクロール通知を処理するコールバック。
  /// スクロールの開始・停止を検知するために使われます。
  final bool Function(ScrollNotification) onScrollNotification;

  @override
  Widget build(BuildContext context) {
    return NotificationListener<ScrollNotification>(
      onNotification: onScrollNotification,
      child: CustomScrollView(
        physics: BouncingScrollPhysics(),
        slivers: [
          SliverList(
            delegate: SliverChildBuilderDelegate((context, index) {
              return ListTile(title: Text('Item #$index'));
            }),
          ),
        ],
      ),
    );
  }
}

/// スクロール状態に応じてアニメーションで表示・非表示が切り替わるボタン。
/// スクロールが停止したときに表示され、スクロールが始まると非表示になります。
class AnimatedActionButton extends StatelessWidget {
  /// アニメーション付きアクションボタンを作成します。
  /// [isVisible]がtrueのときボタンが表示され、falseのとき非表示になります。
  /// [onPressed]はボタンがタップされたときに呼ばれます。
  const AnimatedActionButton({
    super.key,
    required this.isVisible,
    required this.onPressed,
  });

  /// ボタンが現在表示されているかどうか。
  final bool isVisible;

  /// ボタンが押されたときのコールバック。
  final VoidCallback onPressed;

  @override
  Widget build(BuildContext context) {
    return AnimatedPositioned(
      left: kButtonHorizontalMargin,
      right: kButtonHorizontalMargin,
      bottom: isVisible ? kButtonBottomMargin : kButtonHiddenPosition,
      duration:
          isVisible
              ? const Duration(
                milliseconds: kDelayMilliseconds + kMovementDuration,
              )
              : const Duration(milliseconds: kMovementDuration),
      curve: isVisible ? const DelayedCurve() : Curves.easeInOut,
      child: OutlinedButton(
        onPressed: onPressed,
        style: OutlinedButton.styleFrom(
          backgroundColor: Colors.blue,
          foregroundColor: Colors.white,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(kButtonBorderRadius),
          ),
        ),
        child: const Text('Ok'),
      ),
    );
  }
}

/// アニメーション開始まで遅延を入れるカスタムカーブ。
/// このカーブは指定したしきい値まで値を0のまま維持し、その後アニメーションを開始します。
class DelayedCurve extends Curve {
  const DelayedCurve();

  @override
  double transform(double t) {
    const animatedDelayThreshold =
        (kDelayMilliseconds / (kDelayMilliseconds + kMovementDuration));
    final adjustedProgress = t - animatedDelayThreshold;
    if (adjustedProgress < 0) {
      return 0;
    }

    return Curves.easeInOut.transform(
      adjustedProgress / (1 - animatedDelayThreshold),
    );
  }
}

最後に

株式会社ボトルキューブではFlutterを使ったお仕事を募集中です。
お問い合わせは下記リンク先のフォームからご連絡ください。

6
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
6
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?