
Flutterにおいてスライドビューのインジケーターをプログレス表示にする方法
自動的にスライドするカルーセル画像の切り替わり時間をわかりやすくする方法

はじめに
iOS / Android においてスターバックスアプリが公開されていますが、そのiOSアプリにおいて、リッチなUIパーツがあります。
ギフトのタブを開くと、画面上にカルーセル表示されたバナー画像があり、一定時間(約4秒ほど)で自動的にスライドする仕様になっています。
このバナーがあとどのくらいで自動スライドするのか分かりやすいように、カルーセルの下にある定番のインジケーターの該当の位置のドットにプログレスバー表示を兼ねたようなUIにしているのが特徴的です。

たかだか4秒くらいの待ち時間なのですが、プログレスバーの表示があることで、スライドする時間が分かる安心感があります。
このような細かなUIの配慮が満足度を上げ、アプリとしての質を1ランク向上させてくれます。
スターバックスアプリは、iOSとAndroidそれぞれネイティブで作られていると思われるため、Flutterによるクロスプラットフォーム開発ではないのですが、今回はこのようなUIをFlutterで再現してみたいと思います。
(ちなみにスターバックスアプリのAndroid版にはこのリッチなUIはなく、カルーセルも自動スライドにはなっていませんでした)
対処方法
インジケーターの該当位置の表示を少し横長の円表示にすることは、Flutterのパッケージの「SmoothPageIndicator」を使って対応することができます。
smooth_page_indicator

「SmoothPageIndicator」にはインジケータードット(アクティブドット)をアニメーション化するためのエフェクトが多数あり、そのうちの「Expanding Dots」というのが近いイメージです。

ハリボテのスターバックスの「Gift」タブ同様のページを作ったので、実際にこの「SmoothPageIndicator」を適用してみます。

ソースコードは以下のとおりです。(なお、Riverpod+Hooksで開発しています)
class StarbucksPage extends HookConsumerWidget {
const StarbucksPage({super.key});
static const sbThemeColor = Color(0xFF00744C);
static const sbThemeSubColor = Color(0xFFD2D2D2);
@override
Widget build(BuildContext context, WidgetRef ref) {
final index = ref.watch(sbCarouselIndexProvider).index;
return Scaffold(
appBar: AppBar(...),
body: Column(
children: [
//カルーセル
SizedBox(
width: double.infinity,
height: 90,
child: CarouselSlider(
items: [
Image.asset('images/sb_ad01.png', fit: BoxFit.cover),
Image.asset('images/sb_ad02.png', fit: BoxFit.cover),
Image.asset('images/sb_ad03.png', fit: BoxFit.cover),
Image.asset('images/sb_ad04.png', fit: BoxFit.cover),
],
options: CarouselOptions(
viewportFraction: 1.0,
autoPlay: true,
autoPlayInterval: Duration(milliseconds: 4000),
onPageChanged: (index, reason) {
//ChangeNotifierにページ切り替わりを通知
ref.read(sbCarouselIndexProvider).moveToPage(index);
},
),
),
),
//インジケータ(indexが更新されアクティブなドット位置が変わる)
const SizedBox(height: 8),
AnimatedSmoothIndicator(
activeIndex: index,
count: 4,
effect: const ExpandingDotsEffect(
expansionFactor: 3.0,
dotWidth: 8,
dotHeight: 8,
spacing: 8.0,
activeDotColor: sbThemeColor,
dotColor: sbThemeSubColor,
),
),
...
],
),
);
}
}
AnimatedSmoothIndicatorでドットの総数と、アクティブなインデックスを設定します。
エフェクトには、ExpandingDotsEffectを使用し、細かい表示を設定します。
なお、カルーセル表示には、「CarouselSlider」パッケージを使用しています。
carousel_slider
しかし、この時点で現在位置のドットの見た目やアニメーションはだいぶイメージに近づきましたが、どのくらいで自動スクロール開始するかを分かりやすくする時間の進捗要素がありません。
更にプログレスバーの要素を付け加える必要がありそうです。
ちなみに、このUIは「Amazon Music」アプリをはじめとしたいくつかのアプリでも見られ、割と広まってきたUIなのかもしれません。

「SmoothPageIndicator」パッケージのカスタマイズには限界があり、そのままプログレスバーの要素を付け加えることはできないため、自前で独自の実装をすることにしました。
「LinearProgressIndicator」でプログレスバーを、「AnimatedBuilder」でインジケーターのアクティブなドットを横長にするアニメーションを表現するようにしています。
ソースコードは以下のとおりです。
キモとなるUI部分は「buildCarouselIndicator」という関数にしたので、先ほどの「AnimatedSmoothIndicator」と置き換えます。
//AnimatedSmoothIndicator(...),
buildCarouselIndicator(ref: ref, index: index, count: 4),
Widget buildCarouselIndicator({required WidgetRef ref, required int index, required int count}) {
final progressController = useAnimationController(duration: Duration(milliseconds: 4000));
bool isStarted = false;
useEffect(() {
progressController.reset();
progressController.forward();
Future.delayed(Duration.zero, () {
isStarted = true;
});
return null;
});
// indicatorを手動で横並び表示する
// indexの位置に当たるdotは、4秒経過を視覚的にわかりやすくするためプログレスバーとする
return AnimatedBuilder(
animation: progressController,
builder: (context, child) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(count, (i) {
if (i == index) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
margin: const EdgeInsets.symmetric(horizontal: 4),
width: isStarted ? 24 : 8,
height: 8,
child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: LinearProgressIndicator(
value: progressController.value,
borderRadius: BorderRadius.all(Radius.circular(8)),
valueColor: AlwaysStoppedAnimation<Color>(sbThemeColor),
backgroundColor: sbThemeSubColor,
),
),
);
} else {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
width: 8,
height: 8,
decoration: BoxDecoration(
color: sbThemeSubColor,
shape: BoxShape.circle,
),
);
}
}),
);
},
);
}
プログレスバー部分は、「LinearProgressIndicator」を使用します。
valueは、4秒かけて進捗度が進めばよいため、AnimationControllerを使用して、4秒間が設定されるように設定します。(Riverpod+Hooksを採用しているため、useAnimationControllerを使用)
インジケータのアクティブ位置(index)に応じて、ドットの形状を変えればよいのですが、それだけでは、横長に広がるアニメーションがされないため、「AnimatedBuilder」でアニメーションを追加して、スターバックスアプリのイメージに近づけます。
このインジケータ(要するにこの関数)が再構築される際に、横幅を最小の8ptに設定しておき、呼ばれたらアクティブなドットの横幅を24ptに引き伸ばす部分をアニメーション化します。
構築の初回は、isStartedを初期値falseにしておき、useEffectを使って構築時に少し遅れてisStartedをtrueにするようにします。
同じくプログレスバーの値も構築初期は0にしたいので、ここでresetしてstartするようにしています。
そしてアニメーションさせる該当の部分をAnimatedContainerで括り、横幅をisStartedで判定させて変えるようにすれば引き伸ばされるようなアニメーションが追加できます。

もちろんカルーセル部分を手動でスライドした場合にも、きちんとドット位置もプログレスバーも機能します。
まとめ
先程の「SmoothPageIndicator」を使用したものと、独自のUIの差が分かりやすいように、並べて表示してみました。

やはりプログレスバーがあることで、バナーが切り替わるタイミングがわかりやすくなり安心感がありますよね。
