ゲームに学ぶロード画面の設計(ユーザーを退屈させない仕組み)
現在、方言を話すおしゃべり猫型ロボット「ミーア」を開発中。
現在、アプリで話す性格タイプや方言を切り替えたときに、話す音声ファイルをESP32にダウロードしている。
音声ファイルが100以上あるので、ダウンロード完了までに1分くらいかかり、ダウンロード進捗のインジケーターは1%単位で動的に表示しているものの、メッセージは「完了まで一分くらい待っててね。」で固定表示にしている。
この、ユーザーへの待ち時間を対策したいと思い、そういえば、Nintendo Switchでゼルダの伝説をプレイしていた時に、ローディング中に、技や小ネタ集をランダムテキスト表示していて飽きさせない工夫をしていたなと思い、それを踏襲することにした。
詳細は、ゲームに学ぶロード画面の設計(ユーザーを退屈させない仕組み)というタイトルで、下記記事に記載されている。
https://marron-web.site/blog/4ndst8/
現状のコード解説
現状のアプリ側のflutterのコードは下記。
import 'package:clocky_app/widgets/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:clocky_app/grpc/notification.pbenum.dart';
import 'package:clocky_app/services/grpc_service.dart';
OverlayEntry? currentOverlayEntry;
void showOverlay(BuildContext context, double progress, String message) {
// 既存のオーバーレイがあれば削除
currentOverlayEntry?.remove();
currentOverlayEntry = null;
currentOverlayEntry = OverlayEntry(
builder: (context) => Stack(children: <Widget>[
ModalBarrier(
color: Colors.grey.withOpacity(0.5),
dismissible: false,
),
Center(
child: Material(
color: Colors.transparent, // 透明な背景色
child: Container(
padding: const EdgeInsets.all(32),
color: Colors.white,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Text("完了まで一分くらい待っててね。"),
Spacing.h16(),
LinearProgressIndicator(
value: progress / 100,
backgroundColor: Colors.grey[200],
valueColor: const AlwaysStoppedAnimation<Color>(Colors.blue),
),
Spacing.h16(),
Text("${message}: ${(progress).toStringAsFixed(0)}%")
],
),
),
),
),
]),
);
// オーバーレイを表示
Overlay.of(context).insert(currentOverlayEntry!);
}
void removeOverlay() {
currentOverlayEntry?.remove();
currentOverlayEntry = null;
}
class NotificationWidget extends ConsumerWidget {
final Widget child;
const NotificationWidget({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
final notification = ref.watch(notificationStreamProvider);
notification.whenData((response) {
Future.delayed(Duration.zero, () {
double progress = response.deviceStatus.downloadProgress.progress;
DownloadStatus status = response.deviceStatus.downloadProgress.status;
switch (status) {
case DownloadStatus.DOWNLOAD_STATUS_DOWNLOADING:
// オーバーレイを表示
showOverlay(context, progress, "ダウンロード中");
break;
case DownloadStatus.DOWNLOAD_STATUS_COMPLETE:
removeOverlay();
break;
case DownloadStatus.DOWNLOAD_STATUS_FAILED:
removeOverlay();
break;
case DownloadStatus.DOWNLOAD_STATUS_INIT:
// 初期状態の処理
break;
}
});
});
return child;
}
}
final notificationStreamProvider = StreamProvider<StreamResponse>((ref) {
final grpcService = ref.watch(grpcServiceProvider);
final user = ref.read(userProvider);
if (user?.uid == null) {
return const Stream.empty();
}
return grpcService.listenNotifications(user!.id!);
});
フロー
- gRPCサービスからの、音声ファイルダウンロードの進捗状況の通知が
notificationStreamProvider
を通して流れる。 NotificationWidget
がこのプロバイダーを購読し、新しいデータがあると反応する。- ダウンロードの進捗に応じて、
showOverlay
とremoveOverlay
関数を用いて、オーバーレイを表示したり削除したりする。
NotificationWidget
内のnotification.whenData
は、notificationStreamProvider
から新しいデータが来たときに呼び出される。このコードでは、ダウンロードの進捗状況が変わるたびに、showOverlay
関数を呼び出している。つまり、ダウンロードの進捗が1%変化するたびに、オーバーレイが更新されることになる。
showOverlay
関数は、既存のオーバーレイがあればそれを削除し、新しい進捗情報で新しいオーバーレイを作成する。このため、オーバーレイ上に表示される文言(メッセージと進捗率)も、新しい情報に基づいて更新される。
gRPC(gRPC Remote Procedure Call)
Googleによって開発されたオープンソースのリモートプロシージャコール(RPC)システム。
gRPCを使うと、異なるマシン上で実行されているアプリケーション間で効率的にメッセージを交換できる。
gRPCはデフォルトでプロトコルバッファ(ProtoBuf)を使用する。ProtoBufは、言語・プラットフォームに依存しないシリアライズ機能を提供するデータ形式。JSONやXMLなどの伝統的なフォーマットに比べて、より小さいサイズで、より速い速度でデータを伝送できる。
gRPCの、.protoファイルからコンパイルしてGo言語で使用するまでの記事はこちら。
StreamProvider
状態管理のためのパッケージ「Riverpod」のProviderの中でも、リアルタイムなデータを提供するために使用される。
今回のダウンロード状況のように進捗が連続して更新される場合、Providerの中でもStreamProvider
が適している。これは、連続したデータの流れをリアルタイムで監視し、データの変更があるたびにUIを更新するために役立つ。
https://riverpod.dev/docs/providers/stream_provider
表示する文言の準備
性格や方言別に話すフレーズを、表示することにした。
今回、合計で22個のフレーズをピックアップ。ここから、5秒間隔でダウンロード中に文言をランダムに表示したいと思う。
(標準)冷やしたトマトで入眠モードにしよう!
(標準)食べ過ぎ?まあ、美味しいものには罪なし!
(皮肉)遅刻?時間は相対的なものだから!
(皮肉)見て分かるでしょ。落ち込んでるの
(おせっかい)その足元、大丈夫?足元から冷えるんだから
(おせっかい)ここしっかり覚えてね。テスト出るから
(天然)うわっ、鍋焦げちゃった!油断してた~
(天然)げっ、お風呂の栓抜けてた。ショックー
(ロマンチスト)深夜ドライブしたい気分だな
(ロマンチスト)熱中症。ばたんきゅ。みんなの愛が熱い
(大阪弁)邪魔すんねんやったら、帰ってー
(大阪弁)そんなけったいな服に1万も使ったん!
(博多弁)ばり暑いっちゃけど。溶けそう
(博多弁)たまには落ち込むこともあるたいね
(鹿児島弁)こんなところで、ないしちょっとけー?
(鹿児島弁)だいやめは しょつが いっばん
(広島弁)あがーなこと、いよーる
(広島弁)今日もカープは勝~ち勝~ち勝っち勝ち~!!
(津軽弁)お話したいはんでDM送ってける?
(津軽弁)こごの水、しゃっこくて、めなー
(京都弁)よーうつってはりますなぁ
(京都弁)その服はもっさいですわ
コード修正
修正したコードがこちら
ランダムメッセージを引数として追加
showOverlay
関数は固定のメッセージ("完了まで一分くらい待っててね。"
)だったが、メッセージを関数の引数として外部化。ランダムに選ばれるメッセージのリストと、それらをランダムに選択するgetRandomMessage
関数を追加して、引数に与えた。
メッセージの更新頻度を5秒間隔に制御
ダウンロードの進捗が1%単位で進むたびに、showOverlay
関数が呼ばれる。ダウンロード進捗状況が変化するたびにUIが更新される。つまり、進捗バー(LinearProgressIndicator
)の表示がリアルタイムで変わる。
進捗バーは1%単位で表示したいので、この状態は維持しつつ、メッセージのみ5秒ごとの更新に変更したい。
lastMessageUpdateTime
変数を使用して、新しいメッセージが生成されるのは、前回の更新から5秒以上経過した場合のみに変更
メッセージの表示
showOverlay
関数は、新しいオーバーレイを表示するたびにcurrentMessage
(現在のメッセージ)を引数として受け取る。ただし、このcurrentMessage
は5秒ごとにしか更新されないため、UIが再描画されても、メッセージは5秒おきにしか変わらない。
結果として、ダウンロード進捗状況はリアルタイムで更新されるのに対し、表示されるメッセージは5秒おきにしか変わらないため、ユーザーにはメッセージが5秒ごとに更新されているように見える。
import 'dart:math';
import 'package:clocky_app/widgets/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:clocky_app/grpc/notification.pbenum.dart';
import 'package:clocky_app/services/grpc_service.dart';
const List<String> messages = [
"(標準)冷やしたトマトで入眠モードにしよう!",
"(標準)食べ過ぎ?まあ、美味しいものには罪なし!",
"(皮肉)遅刻?時間は相対的なものだから!",
"(皮肉)見て分かるでしょ。落ち込んでるの",
"(おせっかい)その足元、大丈夫?足元から冷えるんだから",
"(おせっかい)ここしっかり覚えてね。テスト出るから",
"(天然)うわっ、鍋焦げちゃった!油断してた~",
"(天然)げっ、お風呂の栓抜けてた。ショックー",
"(ロマンチスト)深夜ドライブしたい気分だな",
"(ロマンチスト)熱中症。ばたんきゅ。みんなの愛が熱い",
"(大阪弁)邪魔すんねんやったら、帰ってー",
"(大阪弁)そんなけったいな服に1万も使ったん!",
"(博多弁)ばり暑いっちゃけど。溶けそう",
"(博多弁)たまには落ち込むこともあるたいね",
"(鹿児島弁)こんなところで、ないしちょっとけー?",
"(鹿児島弁)だいやめは しょつが いっばん",
"(広島弁)あがーなこと、いよーる",
"(広島弁)今日もカープは勝~ち勝~ち勝っち勝ち~!!",
"(津軽弁)お話したいはんでDM送ってける?",
"(津軽弁)こごの水、しゃっこくて、めなー",
"(京都弁)よーうつってはりますなぁ",
"(京都弁)その服はもっさいですわ"
];
String getRandomMessage() {
final random = Random();
int index = random.nextInt(messages.length);
return messages[index];
}
OverlayEntry? currentOverlayEntry;
void showOverlay(
BuildContext context, String overlayText, double progress, String message) {
// 既存のオーバーレイがあれば削除
currentOverlayEntry?.remove();
currentOverlayEntry = null;
currentOverlayEntry = OverlayEntry(
builder: (context) => Stack(children: <Widget>[
ModalBarrier(
color: Colors.grey.withOpacity(0.5),
dismissible: false,
),
Center(
child: Material(
color: Colors.transparent, // 透明な背景色
child: Container(
padding: const EdgeInsets.all(32),
color: Colors.white,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(overlayText),
Spacing.h16(),
LinearProgressIndicator(
value: progress / 100,
backgroundColor: Colors.grey[200],
valueColor: const AlwaysStoppedAnimation<Color>(Colors.blue),
),
Spacing.h16(),
Text("${message}: ${(progress).toStringAsFixed(0)}%")
],
),
),
),
),
]),
);
// オーバーレイを表示
Overlay.of(context).insert(currentOverlayEntry!);
}
void removeOverlay() {
currentOverlayEntry?.remove();
currentOverlayEntry = null;
}
class NotificationWidget extends ConsumerWidget {
final Widget child;
DateTime? lastMessageUpdateTime; // 最後にメッセージを更新した時刻を追跡する変数
String currentMessage = getRandomMessage(); // 現在のメッセージを保持する変数
NotificationWidget({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
final notification = ref.watch(notificationStreamProvider);
notification.whenData((response) {
Future.delayed(Duration.zero, () {
double progress = response.deviceStatus.downloadProgress.progress;
DownloadStatus status = response.deviceStatus.downloadProgress.status;
print("Download Status: $status");
switch (status) {
case DownloadStatus.DOWNLOAD_STATUS_DOWNLOADING:
// 進捗が1%未満の場合、初期メッセージを表示
if (progress < 1) {
currentMessage = "ダウンロード完了まで1分くらいかかります。少し待っててね。";
} else if (lastMessageUpdateTime == null ||
DateTime.now().difference(lastMessageUpdateTime!).inSeconds >=
5) {
currentMessage = getRandomMessage();
lastMessageUpdateTime = DateTime.now();
}
showOverlay(context, currentMessage, progress, "ダウンロード中");
break;
case DownloadStatus.DOWNLOAD_STATUS_COMPLETE:
showOverlay(context, "ダウンロード完了しました!。", 100, "ダウンロード完了");
Future.delayed(Duration(seconds: 2), () {
removeOverlay();
});
break;
case DownloadStatus.DOWNLOAD_STATUS_FAILED:
showOverlay(context, "ダウンロードに失敗しました。", progress, "");
removeOverlay();
break;
case DownloadStatus.DOWNLOAD_STATUS_INIT:
// 初期状態の処理
break;
}
});
});
return child;
}
}
完成!
無事、ダウンロード中に、「完了まで一分くらい待っててね。」の固定メッセージではなく、ミーアちゃんが話す、さまざまな性格パターンや方言でのメッセージをランダムに表示できるようになった。
これで、少しは、ダウンロード中にユーザーを退屈させないように改善できた。
コメント