Flutterアプリの初回起動時に、チュートリアル画面を実装したのでメモ。
環境など
ツールなど | バージョンなど |
---|---|
MacBook Air Early2015 | macOS Mojave 10.14.5 |
Android Studio | 3.6.1 |
Java | 1.8.0_131 |
Flutter | 1.12.13+hotfix.5 |
Dart | 2.7.0 |
Xcode | 10.2 |
依存パッケージ
-
flutter_sliding_tutorial
- スライディングチュートリアル向けのパッケージです。スライディングページ自体はPageViewで作る必要がありますが、インジケーターとの同期を楽にしてくれます
-
shared_preferences
- 設定ファイルの読み書きパッケージ。各ネイティブの実装に一番忠実っぽいです。
- チュートリアルを表示したかどうかのフラグを保存するのに使います。
dependencies:
...
flutter_sliding_tutorial: ^0.1.0
shared_preferences: ^0.5.7+1
flutter pub get
を忘れずに。
チュートリアル画像の用意
頑張って画像ファイルを作るしかありません。
私は4画面分作るのにGWの後半を費やしました(笑)
それと、インジケーター用の画像も用意した方が良いですね。36pxくらいであれば良いんじゃ無いでしょうか?
実装
1.チュートリアルページウィジェット
ウィジェットクラスTutorialPage
を作ります。
インジケーターはStack
で載せるという手もあるかも知れませんが、画像に被ってしまうと困るのでColumn
で下から並べ、余った領域を画像が全部使う、という設定にしました。
/// TutorialPage
class TutorialPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final ValueNotifier<double> notifier = ValueNotifier(0);
int pageCount = 4;
return Scaffold(
backgroundColor: Color.fromARGB(255, 151, 212, 217),
body: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Expanded(
child: SlidingTutorial(
pageCount: pageCount,
notifier: notifier,
),
),
Container(
width: double.infinity,
height: 40,
color: Colors.white,
child: SlidingIndicator(
indicatorCount: pageCount,
notifier: notifier,
activeIndicator: Image.asset(
'assets/tutorial/dot1.png',
),
inActiveIndicator: Image.asset(
'assets/tutorial/dot2.png',
),
margin: 8,
sizeIndicator: 20,
),
),
],
),
);
}
}
ValueNotifier
というのは、ChangeNotifier
の一種で、インジケーターとPageView
の連携に使うものです。
SlidingIndicator
はflutter_sliding_tutorialパッケージのクラスです。
SlidingTutorial
は自作のクラスで、PageView
を持っています。
flutter_sliding_tutorialパッケージはPageView
は作ってはくれないんで、そこは自前で作る必要があります。
crossAxisAlignment: CrossAxisAlignment.end
が、下からサイズ固定のものを並べる指定です。
で、残った領域を、Expanded
が使い切ります。
2.PageView
PageView
を持つSlidingTutorial
ウィジェットクラスを作ります。PageView
はStatefulWidget
じゃないと動かないです。
/// Pager(メインのView Pager部分)
class SlidingTutorial extends StatefulWidget {
final ValueNotifier<double> notifier;
final int pageCount;
SlidingTutorial({Key key, this.notifier, this.pageCount}) : super(key: key);
@override
State<SlidingTutorial> createState() => _TutorialState();
}
class _TutorialState extends State<SlidingTutorial> {
final _pageController = PageController(initialPage: 0);
@override
void initState() {
_pageController.addListener(_onScroll);
super.initState();
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return PageView(
controller: _pageController,
children: List<Widget>.generate(
widget.pageCount,
(index) => _getPageByIndex(index),
),
);
}
Widget _getPageByIndex(int index) {
final imageName = "assets/tutorial/tutorial${index + 1}.png";
if (index < 3) {
return Image.asset(
imageName,
alignment: Alignment.center,
fit: BoxFit.contain,
);
} else {
return GestureDetector(
onTap: () {
_onFinishTutorial();
}, // handle your image tap here
child: Image.asset(
imageName,
key: Key("tutorialLastPage"),
alignment: Alignment.center,
fit: BoxFit.contain,
),
);
}
}
void _onScroll() {
widget.notifier?.value = _pageController.page;
}
普通のPageView
と作り方は変わらないですが、PageController#addListener
していて、スクロールしたときValueNotifier
にも通知をしているところがインジケーターとの同期のための実装ですね。
最後のページだけ、タップを受け取りたいのでGestureDetector
で括っています。こうすると、わざわざボタンなどを配置したりしなくても、ページ全体のタップを拾うことが出来ます。
key: Key("tutorialLastPage")
の指定で、テストの時にウィジェットを取りやすくしています。
3.前の画面に戻る
操作してみると分かりますが、Androidの端末の戻るボタンをタップすると、チュートリアル画面が終了してしまいます。
なので、まずWillPopScope
でScaffold
全体を括ってしまいます。
body: WillPopScope(
onWillPop: () async => false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
...
],
),
),
);
onWillPop: () async => false,
で、戻る処理を無効にしています。これはActionBarに戻るナビゲーターアイコンが出ている場合も無効にしてくれますが、この画面はActionBar自体表示してないのであまり関係ないですね。
で、前の画面に戻る処理は、_onFinishTutorial
でやります。
void _onFinishTutorial() async {
// 設定を変更する
final preference = await SharedPreferences.getInstance();
preference.setBool(PreferenceKey.TUTORIAL_DONE, true);
// Topまで戻る
Navigator.pop(context);
}
4.チュートリアル画面への遷移キックの実装
初回起動だったらチュートリアル画面へ遷移する、というのを入れれば完成です。
トップページのルートウィジェットがStatefulWidget
なら、initState
でチェックしてやればいいでしょうか。
では、StatelessWidget
にしている場合は?
WidgetsBinding.instance.addPostFrameCallback
というのが使えます。
/// TopPage
class TopPage extends StatelessWidget {
static const String TITLE_TOP = 'TOPタイトル';
final String date;
TopPage({Key key, this.date}) : super(key: key);
@override
Widget build(BuildContext context) {
// 画面の描画が終わったタイミングで表示させる
WidgetsBinding.instance.addPostFrameCallback((_) => _showTutorial());
return Scaffold(
...
);
}
}
こんな便利な方法があったとは!私も初めて知りました^^;
_showTutorial
内では、Preferenceの値をチェックして、起動するかどうか決めています。
void _showTutorial() async {
final preference = await SharedPreferences.getInstance();
// 最初の起動ならチュートリアル表示
if (preference.getBool(PreferenceKey.TUTORIAL_DONE) != true) {
// ルート遷移
// ※まだProviderは使えないのでここでやる
final NavigationService _navigationService = locator<NavigationService>();
_navigationService.navigateTo('/tutorial');
}
}
Providerパターンを使ったMVVMのViewModelで画面遷移コードを書きたかったので、ナビゲーター呼び出しでContext
を不要にするNavigationService
というのを作って使っていましたが(詳細はこちら参照)、このタイミングではまだProviderにアクセスできません。なのでやむを得ず、ここだけ直接ウィジェットクラス内で呼んでしまっています。無念・・・
そういう面倒くさいことをしていない人は、ここで普通に
Navigator.of(context).pushNamed('/tutorial');
とすれば良いでしょう。
完成イメージ
画像は勿論ダミーです。
テスト
今回は、shared_preferencesパッケージがモック値設定の関数を作ってくれているので、それを利用します。
https://pub.dev/packages/shared_preferences#testing
1.既存テストへの影響
既存のテストでは、チュートリアルを表示しようとしないようにするため、先ほどのshared_preferencesのモック値設定を利用して、既に表示済みにしておきます。
setUpAll(() async {
// チュートリアル表示済みにして、表示されないようにする
SharedPreferences.setMockInitialValues(
{PreferenceKey.TUTORIAL_DONE: true});
});
2.チュートリアル画面のテスト
(1)チュートリアルウィジェット単体のテスト
あまり細かいことをやると、PageView
やflutter_sliding_tutorialパッケージのテストになってしまうので、表示されている画像が正しいこと、スワイプできること、ページ数が正しいこと、くらいのテストにします。
/// TutorialPageのテスト
void main() {
final testViewPortSize = Size(1080, 1776);
final TestWidgetsFlutterBinding binding =
TestWidgetsFlutterBinding.ensureInitialized();
/// テスト用の起動(ウィジェット単体テスト用)
Widget testTopPage() {
return TestApp(TutorialPage());
}
group('TutorialPageのテスト', () {
setUpAll(() async {
testSetupLocator(
analyticsService: MockAnalyticsService(),
versionCheckService: FalseMockVersionCheckService());
});
testWidgets('SlidingTutorialのチェック', (WidgetTester tester) async {
await binding.setSurfaceSize(testViewPortSize);
await tester.pumpWidget(testTopPage());
await tester.pumpAndSettle();
WidgetPredicate predicate =
(Widget widget) => widget is SlidingTutorial && widget.pageCount == 4;
expect(find.byWidgetPredicate(predicate), findsOneWidget);
});
testWidgets('SlidingIndicatorのチェック', (WidgetTester tester) async {
await binding.setSurfaceSize(testViewPortSize);
await tester.pumpWidget(testTopPage());
await tester.pumpAndSettle(); // ローディング終了を待つ
expect(find.byType(SlidingIndicator), findsOneWidget);
// アクティブなインジケーター数1
expect(findByAssetImage('assets/tutorial/dot1.png'), findsOneWidget);
// 非アクティブなインジケーター数(アクティブなのも裏にいるらしいので4)
expect(findByAssetImage('assets/tutorial/dot2.png'), findsNWidgets(4));
});
testWidgets('PageViewのチェック', (WidgetTester tester) async {
await binding.setSurfaceSize(testViewPortSize);
await tester.pumpWidget(testTopPage());
await tester.pumpAndSettle(); // ローディング終了を待つ
expect(find.byType(PageView), findsOneWidget);
});
testWidgets('右にスワイプできる', (WidgetTester tester) async {
await binding.setSurfaceSize(testViewPortSize);
await tester.pumpWidget(testTopPage());
await tester.pumpAndSettle(); // ローディング終了を待つ
// 最初のページの画像
expect(findByAssetImage('assets/tutorial/tutorial1.png'), findsOneWidget);
await tester.fling(find.byType(PageView), Offset(-500.0, 0.0), 300);
// 次のページの画像
expect(findByAssetImage('assets/tutorial/tutorial2.png'), findsOneWidget);
});
testViewPortSize
とTestWidgetsFlutterBinding
は、Flutterのウィジェットテストではデフォルトで横長の画面になるため(恐らくWebブラウザ用の設定)、それを縦長に指定するために使います。
TestApp
は、指定のウィジェットをMaterialApp
ウィジェット配下で直接起動する為に作ったもので、以下のようなコードです。この中でリポジトリクラスのモック化などもやってしまっています。
class TestApp extends StatelessWidget {
TestApp(this.widget);
final Widget widget;
@override
Widget build(BuildContext context) {
return Provider<RecordRepository>(
create: (context) => MockRecordRepository(),
dispose: (context, bloc) => bloc.dispose(),
child: MaterialApp(
// 日本語フォント設定
localizationsDelegates: [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: [
Locale('ja', ''), // Japanese
],
home: widget,
),
);
}
}
findByAssetImage
は以前こちらの記事で紹介した関数です。
/// 指定のpathを表示しているAssetImageを見つける
Finder findByAssetImage(String path) {
final finder = find.byWidgetPredicate((Widget widget) {
if (widget is Image && widget.image is AssetImage) {
final assetImage = widget.image as AssetImage;
return assetImage.keyName == path;
}
return false;
});
return finder;
}
後は、似たようなテストを追加して、最後のページまで表示されている画像が正しいか確認すれば良いでしょう。
(2)最終ページの遷移のテスト
最終ページではタップしてTopページに戻れること、最終ページ以外では逆にタップしても何も起きないこと、を確認します。
なお、TOPページに戻れないと行けないので、このテストは、TOPページ画面から起動するようにします。
group('画面遷移のテスト', () {
setUp(() {
// チュートリアルが表示されるようにする
SharedPreferences.setMockInitialValues(
{PreferenceKey.TUTORIAL_DONE: false});
});
testWidgets('途中ページではタップしても何も起きない', (WidgetTester tester) async {
await binding.setSurfaceSize(testViewPortSize);
await tester.pumpWidget(testMyApp('2020-02-02'));
await tester.pumpAndSettle(); // ローディング終了を待つ
expect(find.text(TopPage.TITLE_TOP), findsNothing);
// タップ
var finder = findByAssetImage('assets/tutorial/tutorial1.png');
await tester.tap(finder);
expect(find.text(TopPage.TITLE_TOP), findsNothing);
// 次ページまでスワイプ
await tester.fling(find.byType(PageView), Offset(-500.0, 0.0), 300);
await tester.pumpAndSettle();
// タップ
finder = findByAssetImage('assets/tutorial/tutorial2.png');
await tester.tap(finder);
await tester.pumpAndSettle();
expect(find.text(TopPage.TITLE_TOP), findsNothing);
// 次ページまでスワイプ
await tester.fling(find.byType(PageView), Offset(-500.0, 0.0), 300);
await tester.pumpAndSettle();
// タップ
finder = findByAssetImage('assets/tutorial/tutorial3.png');
await tester.tap(finder);
await tester.pumpAndSettle();
expect(find.text(TopPage.TITLE_TOP), findsNothing);
});
testWidgets('最終ページまで遷移してタップ', (WidgetTester tester) async {
await binding.setSurfaceSize(testViewPortSize);
await tester.pumpWidget(testMyApp('2020-02-02'));
await tester.pumpAndSettle(); // ローディング終了を待つ
expect(find.text(TopPage.TITLE_TOP), findsNothing);
// 最後のページまでスワイプ
await tester.fling(find.byType(PageView), Offset(-500.0, 0.0), 300);
await tester.pumpAndSettle();
await tester.fling(find.byType(PageView), Offset(-500.0, 0.0), 300);
await tester.pumpAndSettle();
await tester.fling(find.byType(PageView), Offset(-500.0, 0.0), 300);
await tester.pumpAndSettle();
// タップ
await tester.tap(find.byKey(Key('tutorialLastPage')));
await tester.pumpAndSettle();
expect(find.text(TopPage.TITLE_TOP), findsOneWidget);
});
});
setUp
関数で、1テスト毎に毎回Preference値のモック値を設定しています。
find.byKey(Key('tutorialLastPage'))
で指定しているKeyは、チュートリアル画面の最終ページのImage
にだけ設定しておいた、Keyです。
testMyApp
が、Topページから起動するための関数で、次のようになっています。
/// テスト用のアプリトップウィジェットから起動(画面遷移のテスト用)
/// Topページに戻るテストがあるので、アプリのルートから起動が必要
Widget testMyApp(String date) {
return Provider<RecordRepository>(
create: (context) => MockRecordRepository(),
dispose: (context, bloc) => bloc.dispose(),
child: MyApp(date: date));
}
3.driveテスト
CIで回しながらスクショを撮るため、driveテストにも追加しました。
チュートリアル画面表示→通常画面表示という流れで構わないので、Prefrernceの値はfalse
にします。
void main() {
// チュートリアル表示からやる
// ignore: invalid_use_of_visible_for_testing_member
SharedPreferences.setMockInitialValues({PreferenceKey.TUTORIAL_DONE: false});
enableFlutterDriverExtension();
testMain(MockVersionCheckService());
}
void main() {
group('App Driver Test', () {
FlutterDriver driver;
setUpAll(() async {
driver = await FlutterDriver.connect();
});
tearDownAll(() async {
if (driver != null) {
driver.close();
}
});
Future<void> _takeScreenShot(String filename) async {
await driver.waitUntilNoTransientCallbacks();
final pixels = await driver.screenshot();
final file = File('./test_driver/screenshots/$filename.png');
await file.writeAsBytes(pixels);
print('wrote $file');
}
test('チュートリアル1', () async {
final health = await driver.checkHealth();
print(health.status);
await _takeScreenShot('tutorial1');
});
test('チュートリアル2', () async {
final health = await driver.checkHealth();
print(health.status);
final finder = find.byType('PageView');
await driver.scroll(finder, -500, 0, Duration(milliseconds: 200));
await driver.waitFor(finder);
await _takeScreenShot('tutorial2');
});
test('チュートリアル3', () async {
final health = await driver.checkHealth();
print(health.status);
final finder = find.byType('PageView');
await driver.scroll(finder, -500, 0, Duration(milliseconds: 200));
await driver.waitFor(finder);
await _takeScreenShot('tutorial3');
});
test('チュートリアル4', () async {
final health = await driver.checkHealth();
print(health.status);
final finder = find.byType('PageView');
await driver.scroll(finder, -500, 0, Duration(milliseconds: 200));
await driver.waitFor(finder);
await _takeScreenShot('tutorial4');
});
test('起動TOP画面', () async {
final health = await driver.checkHealth();
print(health.status);
// チュートリアル終了画面でタップ
await driver.tap(find.byValueKey('tutorialLastPage'));
await _takeScreenShot('top');
});
...
});
driver.tap(find.byValueKey('tutorialLastPage'))
で、最終ページのImage
にセットしたKey名をここでも使っています。
各テスト関数が、前のテストの状況を引き継いでいるのがお分かりでしょうか?
このように、Flutterのdriveテストでは、いわゆる「シナリオ」をちゃんと考えたテストが必要になります。
参考サイト
【Flutter】アプリ開発_初心者のアプリをプロっぽくする最強のpackageを紹介
https://qiita.com/kazumaz/items/876e162cf429014661d8
FlutterのNavigatorで画面遷移
https://qiita.com/granoeste/items/19c119ffc36a016e6223
Flutterでチュートリアル画面を作ろう
https://speakerdeck.com/karmactonics/flutterdetiyutoriaruhua-mian-wozuo-rou