【Flutter】テキストをアニメーションさせて情報を増やす

対象者

  • FlutterにおけるUI開発に興味があり、具体的なアニメーション技術を学びたい開発者
  • ユーザーエンゲージメントを向上させるためにアプリの視覚的な要素を改善したいプロジェクトリーダーやデザイナー
  • 効果的なテキスト表示方法を探求しており、新しいUIパターンの実装に挑戦したい中級から上級のプログラマー

はじめに

AppBarのtitleが非常に長く、通常の表示では一行に収まらない場合、どのように対応すればよいでしょうか?通常、文字列の縮小や末尾の省略を考えるかもしれませんが、今回は異なるアプローチを試みました。
上司から「タイトルをアニメーションで表示できないか」という提案があり、最初は技術的に難しいのではないかと考えました。しかし、実際にいくつかの方法を試した結果、意外と簡単に実現可能であることがわかりました。

この記事では、その具体的な方法と実装過程を詳細に報告します。長いテキストをアニメーションでスマートに表示するこのテクニックは、見た目のインパクトだけでなく、ユーザー体験の向上にも寄与すると思います。

実装の検討

最初アニメーションするTextをどのように実装するか、というのが思い浮かばなかったです。
AnimatedAlignで配置を変更するのか、 AnimatedControllerで座標を変更すればよいのか。
最初は「だれかパッケージ作ってないかな」と探索する中で、ScrollControllerを使用してTextウィジェットを横スクロールさせているソースがありました。スクロールするWidgetといえば、一般的には画像やリストのような視覚的に長い要素をスクロールさせるものと思い込んでました。

以前に横長の画像をアニメーションでスクロールさせた経験があることから、同じ原理がテキストにも適用できるのではないかと考えました。実験の結果、特に問題なく動作し、この方法が実用的であることが確認できました。

実装手順

アニメーション時間の設定

アニメーションの挙動をカスタマイズするためには、開始、実行、終了の各タイミングを設定する必要があります。以下のコードでは、これらの時間をミリ秒単位で指定しています。これにより、アニメーションの全体的な流れを細かく制御できます。

const ScrollAnimation({
  super.key,
  required this.child,
  this.startTime = 2000,   // アニメーション開始までの遅延時間
  this.animateTime = 3000, // アニメーションの実行時間
  this.endTime = 2000,     // アニメーション終了後の静止時間
});

この設定により、アニメーションは総合的に7秒間持続します。ユーザーの注意を引きつけるために、これらの時間を調整することが重要です。

ScrollController によるアニメーションの処理

ScrollControllerを使用してテキストや要素をアニメーションさせる方法は、非常に直感的です。以下の関数は、指定された遅延時間後にスクロールアニメーションを開始します。このメソッドは非同期であり、アニメーションが完了するまでの流れを管理します。

Future<void> _startAnimation() async {
  await Future.delayed(Duration(milliseconds: widget.startTime));
  return _controller.animateTo(
    _controller.position.maxScrollExtent,
    duration: Duration(milliseconds: widget.animateTime),
    curve: Curves.linear,
  );
}

このコードはスクロールの最大範囲まで線形にアニメーションを行い、設定された時間内に完了させます。Curves.linearはアニメーションの速度が一定であることを保証します。

一定時間でアニメーションをする設定

アニメーションを周期的に実行するには、Timerオブジェクトを使用して定期的にアニメーション関数をトリガーします。以下のコードスニペットは、アニメーションが一定の周期で自動的に再開するように設定します。

_startAnimation().then((_) {
  _timer;
});

この設定は、_startAnimationが完了するたびにタイマーをリセットまたは再設定する必要があります。これにより、アニメーションが連続して実行されるようになります。

アニメーション完了後、一定時間後最初に戻る

アニメーションが一度完了した後、ユーザーが常に同じ開始点からアニメーションを見られるようにするために、以下のリスナーをScrollControllerに追加します。

_controller.addListener(() {
  final position = _controller.position;
  if (position.maxScrollExtent == position.pixels) {
    Future.delayed(Duration(milliseconds: widget.endTime)).then((_) {
      _controller.jumpTo(0);
    });
  }
});

このコードはスクロールが最も遠い点に到達した後、設定された終了時間が経過すると自動的にスクロールを初期位置に戻します。これにより、アニメーションがリセットされ、継続して使用できるようになります。

これらのステップを通じて、フルコントロールされたスクロールアニメーションを実装することができ、ユーザーに対して洗練された視覚体験を提供することが可能です。

スクロールする箇所の設定

Flutterでのスクロール機能は、主にSingleChildScrollViewを用いて実装されます。今回もそのWidgetを使います。
ScrollControllerを定義しており、そのコントローラにアニメーションするように設定してます。横長のWidgetにスクロールバーを付けて、自動でスクロールさせてるイメージです。
scrollDirection: Axis.horizontalの設定により、子ウィジェットは水平方向にスクロールすることができます。
このシンプルなセットアップにより、任意の長さのコンテンツをユーザーが横にスクロールして閲覧できるようになります。

この構成は他の横スクロールが必要な場面にも応用可能であり、様々なUIコンポーネントに簡単に適用できるため、非常に便利です。また、ステートフルウィジェットとして設計することで、スクロール位置やユーザーのインタラクションに基づいてUIの更新が可能になります。

class ScrollAnimation extends StatefulWidget {
 (略)
  @override
  State<ScrollAnimation> createState() => _ScrollAnimationState();
}
class _ScrollAnimationState extends State<ScrollAnimation> {
  final _controller = ScrollController();
  (略)
  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      controller: _controller,
      scrollDirection: Axis.horizontal,
      child: widget.child,
    );
  }
}

参考

まとめ

この記事で探求したテキストのアニメーション化の手法は、FlutterにおけるScrollControllerの柔軟性を示す良い例です。通常、画像やリストが横に長い要素として扱われがちですが、Textウィジェットという通常スクロールさせないWidgetにも同様に扱うことができるという発見は、ユーザーインターフェースの設計において新たな可能性を開きます。
「アニメーションするTextの実装」と聞いて、TextやAnimatedXxxxでどうにかするのではなく、通常スクロールバーの作成に使用するSingleChildScrollViewと組み合わせることで簡単に解決できました。

今回の実験を通じて、フレームワークの提供するツールを標準的な用途に留まらず、創造的に応用することの重要性を改めて認識しました。開発者はこの手法を活用して、アプリの利便性と視覚的魅力を同時に向上させることが可能です。

ソース(main.dartにコピペして動作確認用)

import 'dart:async';

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const ScrollAnimation(
          child: Text('1234567890123456789012345678901234567890'),
        ),
      ),
      body: const Center(
        child: ScrollAnimation(
          child: Text(
              '12345678901234567890123456789012345678901234567890123456789012345678901234567890'),
        ),
      ),
    );
  }
}

class ScrollAnimation extends StatefulWidget {
  const ScrollAnimation({
    super.key,
    required this.child,
    this.startTime = 2000,
    this.animateTime = 3000,
    this.endTime = 2000,
  });

  final Widget child;
  final int startTime;
  final int animateTime;
  final int endTime;

  @override
  State<ScrollAnimation> createState() => _ScrollAnimationState();
}

class _ScrollAnimationState extends State<ScrollAnimation> {
  final _controller = ScrollController();

  late final totalTime = widget.startTime + widget.animateTime + widget.endTime;

  late final _timer = Timer.periodic(
    Duration(milliseconds: totalTime),
    (_) => _startAnimation(),
  );

  Future<void> _startAnimation() async {
    await Future.delayed(Duration(milliseconds: widget.startTime));
    return _controller.animateTo(
      _controller.position.maxScrollExtent,
      duration: Duration(milliseconds: widget.animateTime),
      curve: Curves.linear,
    );
  }

  @override
  void initState() {
    super.initState();
    _controller.addListener(() {
      final position = _controller.position;
      if (position.maxScrollExtent == position.pixels) {
        Future.delayed(Duration(milliseconds: widget.endTime)).then((_) {
          _controller.jumpTo(0);
        });
      }
    });

    _startAnimation().then((_) {
      _timer;
    });
  }

  @override
  void dispose() {
    _timer.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      controller: _controller,
      scrollDirection: Axis.horizontal,
      child: widget.child,
    );
  }
}