Flutterでリストビューのスクロールが停まってから一拍置いて何かUIを表示したいときの小ネタを紹介します。
やりたいこと
どうやるか
Timerのコールバックで表示を制御するのも良いですが、今回は別の方法にします。
アニメーションをイージングさせた場合にCurves.easeInOutなどを使いますが、その曲線をグラフのような一定期間出力が0のままになるCurveクラスを実装したいと思います。
実際はスクロールが停止してすぐUI表示アニメーションが始まっているが、しばらくの間0が出力されるために遅延しているように見えるという訳です。
コードは下記のような感じになります。
/// アニメーション開始まで遅延を入れるカスタムカーブ。
/// このカーブは指定したしきい値まで値を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を使ったお仕事を募集中です。
お問い合わせは下記リンク先のフォームからご連絡ください。

