What Storyboarding In Flutter Will Google Introduce At IO 2024
I found this package while searching what new stuff Google's cloud and VSCode powered IDE IDX would bring to the table.
Imagine this storyboarding so easy that you just create scenes of pages and then the storyboard app is auto generated. But, what if there was more? Such as the benefit of re-using those same storyboarding scenes to then become the BDD on the testing side of the app.
Bryan Oltman earlier this year started a project named stager as a side project as he works for Google. That same project is in the process of being maintained and improved by Google, and it is renamed to Flutter Stager. But, we do not have to wait until Google IO 2024 to learn how to use it as we can use the stager package example to see how it works.
Stager Key Concepts
So let's start with the scene concept. A scene can be a widget or page but works as a separate unit of UI being mocked. Each scene has a build function with an EnvironmentAwareApp wrapper and a function setUp to set up UI dependencies.
Let's see an example:
@GenerateMocks(<Type>[Api]) | |
import 'posts_list_page_scenes.mocks.dart'; | |
/// Defines a shared build method used by subclasses and a [MockApi] subclasses | |
/// can use to control the behavior of the [PostsListPage]. | |
abstract class BasePostsListScene extends Scene { | |
/// A mock dependency of [PostsListPage]. Mock the value of [Api.fetchPosts] | |
/// to put the staged [PostsListPage] into different states. | |
late MockApi mockApi; | |
@override | |
Widget build() { | |
return EnvironmentAwareApp( | |
home: Provider<Api>.value( | |
value: mockApi, | |
child: const PostsListPage(), | |
), | |
); | |
} | |
@override | |
Future<void> setUp() async { | |
mockApi = MockApi(); | |
} | |
} |
One can also go beyond the default set of environmental controls of setting theme-mode, text scale, etc. like this:
class CounterScene extends Scene { | |
// A [StepperControl] allows the user to increment and decrement a value using "-" and | |
// "+" buttons. [EnvironmentControl]s will trigger a Scene rebuild when they update | |
// their values. | |
final StepperControl<int> stepperControl = StepperControl<int>( | |
title: 'My Control', | |
stateKey: 'MyControl.Key', | |
defaultValue: 0, | |
onDecrementPressed: (int currentValue) => currentValue + 1, | |
onIncrementPressed: (int currentValue) => currentValue - 1, | |
); | |
@override | |
String get title => 'Counter'; | |
@override | |
final List<EnvironmentControl<Object?>> environmentControls = | |
<EnvironmentControl<Object?>>[ | |
stepperControl, | |
]; | |
@override | |
Widget build() { | |
return EnvironmentAwareApp( | |
home: Scaffold( | |
body: Center( | |
child: Text(stepperControl.currentValue.toString()), | |
), | |
), | |
); | |
} | |
} |
But, since we are mocking to storyboard the UI widget or screen, what about re-using that for BDD testing? Here is a snippet demoing test BDD re-use:
testWidgets('shows an empty state', (WidgetTester tester) async { | |
final Scene scene = EmptyListScene(); | |
await scene.setUp(); | |
await tester.pumpWidget(scene.build()); | |
await tester.pump(); | |
expect(find.text('No posts'), findsOneWidget); | |
}); |
You need to set a final variable to the scene under test. Then call the setUp method in that scene. Then a tester pumpWidget call suppling the scene build method as a parameter. Then call one more tester pump method. Last in that sequence is the expect method calls and matching.
The temp stager package is here on pub-dot-dev:
Keep in mind that the closer we get to Google IO 2024 that will change to the package name of Flutter Stager:
Now let's see a full example.
A Stager Full Example
Keep in mind while this demo uses Mockito, Equatable, and Provider you can use any mocking, data-class value object, and data binding libraries you want to use.
The main function is still the same just like a normal app:
/* | |
Copyright 2023 Google LLC | |
Licensed under the Apache License, Version 2.0 (the "License"); | |
you may not use this file except in compliance with the License. | |
You may obtain a copy of the License at | |
https://www.apache.org/licenses/LICENSE-2.0 | |
Unless required by applicable law or agreed to in writing, software | |
distributed under the License is distributed on an "AS IS" BASIS, | |
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
See the License for the specific language governing permissions and | |
limitations under the License. | |
*/ | |
import 'package:flutter/material.dart'; | |
import 'package:provider/provider.dart'; | |
import 'pages/posts_list/posts_list_page.dart'; | |
import 'shared/api.dart'; | |
void main() { | |
runApp(const MyApp()); | |
} | |
/// The main app. | |
class MyApp extends StatelessWidget { | |
/// Creates a [MyApp]. | |
const MyApp({super.key}); | |
@override | |
Widget build(BuildContext context) { | |
return Provider<Api>.value( | |
value: Api(), | |
child: MaterialApp( | |
title: 'Flutter Demo', | |
theme: ThemeData( | |
primarySwatch: Colors.blue, | |
), | |
home: const PostsListPage(), | |
), | |
); | |
} | |
} |
Now, let's address something in good app design on the code side.
Put the domain exposed API in a nice accessible class! So let's see what is in this API class:
/* | |
Copyright 2023 Google LLC | |
Licensed under the Apache License, Version 2.0 (the "License"); | |
you may not use this file except in compliance with the License. | |
You may obtain a copy of the License at | |
https://www.apache.org/licenses/LICENSE-2.0 | |
Unless required by applicable law or agreed to in writing, software | |
distributed under the License is distributed on an "AS IS" BASIS, | |
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
See the License for the specific language governing permissions and | |
limitations under the License. | |
*/ | |
import 'post.dart'; | |
import 'user.dart'; | |
/// A fake class meant to represent an API client. | |
class Api { | |
/// Waits 2 seconds and returns [Post.fakePosts]. | |
Future<List<Post>> fetchPosts({User? user}) async { | |
await Future<void>.delayed(const Duration(seconds: 2)); | |
return Post.fakePosts(user: user); | |
} | |
} |
What we have is a class faking access to the model via the Post fakePosts method call:
/* | |
Copyright 2023 Google LLC | |
Licensed under the Apache License, Version 2.0 (the "License"); | |
you may not use this file except in compliance with the License. | |
You may obtain a copy of the License at | |
https://www.apache.org/licenses/LICENSE-2.0 | |
Unless required by applicable law or agreed to in writing, software | |
distributed under the License is distributed on an "AS IS" BASIS, | |
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
See the License for the specific language governing permissions and | |
limitations under the License. | |
*/ | |
import 'package:equatable/equatable.dart'; | |
import 'user.dart'; | |
/// A single tweet-like entry. | |
class Post extends Equatable { | |
/// Creates a [Post]. | |
const Post({ | |
required this.id, | |
required this.text, | |
required this.author, | |
required this.time, | |
}); | |
/// The id of this post. | |
final int id; | |
/// The content of this post. | |
final String text; | |
/// The author of this post. | |
final User author; | |
/// When this post was created. | |
final DateTime time; | |
@override | |
List<Object?> get props => <Object?>[id, text, author, time]; | |
@override | |
String toString() { | |
return '$author: $text'; | |
} | |
/// Generates a List of [Post]s. If [user] is specified, all posts will have | |
/// that user as an author. If no [user] is specified, each [Post] will have a | |
/// distinct fake [User] as its author. | |
static List<Post> fakePosts({User? user}) => List<Post>.generate( | |
20, | |
(int index) => Post( | |
id: index + 1, | |
text: 'Post ${index + 1}', | |
author: user ?? User.fakeUser(id: index + 1), | |
time: DateTime(2023, 1, 1, 1).add(Duration(minutes: index)), | |
), | |
); | |
} |
That means to fully incorporate this way of storyboarding into Flutter App development we have to supply mock methods in our model data classes.
Now, let's see a page and its scene. The post detail page:
/* | |
Copyright 2023 Google LLC | |
Licensed under the Apache License, Version 2.0 (the "License"); | |
you may not use this file except in compliance with the License. | |
You may obtain a copy of the License at | |
https://www.apache.org/licenses/LICENSE-2.0 | |
Unless required by applicable law or agreed to in writing, software | |
distributed under the License is distributed on an "AS IS" BASIS, | |
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
See the License for the specific language governing permissions and | |
limitations under the License. | |
*/ | |
import 'package:flutter/material.dart'; | |
import '../../shared/post.dart'; | |
import '../user_detail/user_detail_page.dart'; | |
/// A page for a single [Post]. | |
class PostDetailPage extends StatelessWidget { | |
/// Creates a [PostDetailPage]. | |
const PostDetailPage({super.key, required this.post}); | |
/// The [Post] being displayed. | |
final Post post; | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
appBar: AppBar( | |
title: const Text('Post'), | |
actions: <PopupMenuButton<int>>[ | |
PopupMenuButton<int>( | |
onSelected: (_) { | |
final NavigatorState navigatorstate = Navigator.of(context); | |
navigatorstate.push( | |
MaterialPageRoute<void>( | |
builder: (BuildContext context) => | |
UserDetailPage(user: post.author), | |
), | |
); | |
}, | |
itemBuilder: (BuildContext context) => <PopupMenuEntry<int>>[ | |
const PopupMenuItem<int>( | |
value: 0, | |
child: Text('View User'), | |
), | |
], | |
) | |
], | |
), | |
body: Padding( | |
padding: const EdgeInsets.all(10), | |
child: Column( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: <Widget>[ | |
Row( | |
children: <Widget>[ | |
CircleAvatar( | |
child: Text( | |
post.author.name | |
.split(' ') | |
.map((String e) => e[0].toUpperCase()) | |
.join(), | |
), | |
), | |
const SizedBox(width: 10), | |
Text(post.time.toString()), | |
], | |
), | |
const SizedBox(height: 10), | |
Text( | |
post.text, | |
style: Theme.of(context).textTheme.headlineSmall, | |
), | |
], | |
), | |
), | |
); | |
} | |
} |
And the scene would be:
/* | |
Copyright 2023 Google LLC | |
Licensed under the Apache License, Version 2.0 (the "License"); | |
you may not use this file except in compliance with the License. | |
You may obtain a copy of the License at | |
https://www.apache.org/licenses/LICENSE-2.0 | |
Unless required by applicable law or agreed to in writing, software | |
distributed under the License is distributed on an "AS IS" BASIS, | |
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
See the License for the specific language governing permissions and | |
limitations under the License. | |
*/ | |
import 'package:flutter/material.dart'; | |
import 'package:stager/stager.dart'; | |
import '../../shared/post.dart'; | |
import 'post_detail_page.dart'; | |
/// A scene demonstrating a [PostDetailPage] with content. | |
class PostDetailPageScene extends Scene { | |
/// The [EnvironmentState] key that maps to the currentPost value. | |
static const String _currentPostKey = 'PostDetailPageScene.CurrentPost'; | |
/// Creates an [EnvironmentControl] that allows the user to choose which | |
/// [Post] is displayed in this Scene. | |
final DropdownControl<Post> postSelectorControl = DropdownControl<Post>( | |
title: 'Post', | |
stateKey: _currentPostKey, | |
defaultValue: Post.fakePosts().first, | |
items: Post.fakePosts(), | |
); | |
@override | |
String get title => 'Post Detail'; | |
/// This [Scene] overrides the otional [environmentControls] getter to add a | |
/// custom control to the Stager environment control panel. | |
@override | |
late final List<EnvironmentControl<Object?>> environmentControls = | |
<EnvironmentControl<Object?>>[ | |
postSelectorControl, | |
]; | |
@override | |
Widget build(BuildContext context) { | |
return EnvironmentAwareApp( | |
home: PostDetailPage( | |
post: postSelectorControl.currentValue, | |
), | |
); | |
} | |
} |
Now once a scene is coded we generate the stager app by this build runner command:
flutter pub run build_runner build --delete-conflicting-outputs
Then to run the app it would be:
flutter run -t lib/pages/post_detail/post_detail_page_scenes.stager_app.g.dart
And last let me step through re-suing that for BDD testing. First, the test extension part:
/* | |
Copyright 2023 Google LLC | |
Licensed under the Apache License, Version 2.0 (the "License"); | |
you may not use this file except in compliance with the License. | |
You may obtain a copy of the License at | |
https://www.apache.org/licenses/LICENSE-2.0 | |
Unless required by applicable law or agreed to in writing, software | |
distributed under the License is distributed on an "AS IS" BASIS, | |
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
See the License for the specific language governing permissions and | |
limitations under the License. | |
*/ | |
import 'package:flutter/material.dart'; | |
import 'package:flutter_test/flutter_test.dart'; | |
import 'package:stager/stager.dart'; | |
/// Convenience functions for widget testing [Scene]s. | |
/// | |
/// TODO: move this to a new stager_testing package | |
extension Testing on Scene { | |
/// A convenience function that prepares a [Scene] for widget testing. | |
/// | |
/// Calls [Scene.setUp] with either [environmentState] or a default | |
/// [EnvironmentState] if none is provided and provides [Scene.build] with a | |
/// valid BuildContext. | |
Future<void> setUpAndPump(WidgetTester tester) async { | |
await this.setUp(); | |
await tester.pumpWidget( | |
Builder( | |
builder: (BuildContext context) => build(context), | |
), | |
); | |
await tester.pump(); | |
} | |
} |
And our post detail scene test would be:
/* | |
Copyright 2023 Google LLC | |
Licensed under the Apache License, Version 2.0 (the "License"); | |
you may not use this file except in compliance with the License. | |
You may obtain a copy of the License at | |
https://www.apache.org/licenses/LICENSE-2.0 | |
Unless required by applicable law or agreed to in writing, software | |
distributed under the License is distributed on an "AS IS" BASIS, | |
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
See the License for the specific language governing permissions and | |
limitations under the License. | |
*/ | |
import 'package:flutter/material.dart'; | |
import 'package:flutter_test/flutter_test.dart'; | |
import 'package:stager/stager.dart'; | |
import 'package:story_boarding_demo/pages/posts_list/posts_list_page_scenes.dart'; | |
import '../scene_test_extensions.dart'; | |
void main() { | |
testWidgets('shows a loading state', (WidgetTester tester) async { | |
final Scene scene = LoadingScene(); | |
await scene.setUpAndPump(tester); | |
expect(find.byType(CircularProgressIndicator), findsOneWidget); | |
}); | |
testWidgets('shows an error state', (WidgetTester tester) async { | |
final Scene scene = ErrorScene(); | |
await scene.setUpAndPump(tester); | |
expect(find.text('Error'), findsOneWidget); | |
}); | |
testWidgets('shows an empty state', (WidgetTester tester) async { | |
final Scene scene = EmptyListScene(); | |
await scene.setUpAndPump(tester); | |
expect(find.text('No posts'), findsOneWidget); | |
}); | |
testWidgets('shows posts', (WidgetTester tester) async { | |
final Scene scene = WithPostsScene(); | |
await scene.setUpAndPump(tester); | |
expect(find.text('Post 1'), findsOneWidget); | |
}); | |
} |
Thoughts
Storyboarding so easy and fully re-usable as BDD testing. It doesn't get simpler than that to fully work through the UX process for your flutter app UI design.
Yes, other components is device preview set up to get screen device frame shots for the app stakeholders. It is easier if you use the specific set-up I came up with which will be covered in another post.
Are we having fun designing flutter apps yet?