【不定期更新】Android Sunflower を Flutter で実装してみる
Android Sunflower with Compose
Jetpack Compose で Android開発する際のベストプラクティスを学べるガーデニングアプリだ:
タブ1 | タブ2 |
---|---|
Android デベロッパー | Jetpack Compose | 既存の View ベースのアプリを移行する のアプリサンプルの1つ
今回はこれと同じアプリを Flutter で構築してみる
プラットフォームを Android に限定し flutter create する:
$ flutter create --platforms=android flutter_sunflower
タブのあるホーム画面を追加しよう:
import 'package:flutter/material.dart';
import 'package:flutter_sunflower/home_screen.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Sunflower',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const HomeScreen(),
);
}
}
DefaultTabController・TabBar・TabBarView Widget でマテリアルデザインのタブコンポーネントに準拠したタブ画面を簡単に構築することができる:
import 'package:flutter/material.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
bottom: const TabBar(
tabs: [
Tab(text: 'My garden'),
Tab(text: 'Plant list'),
],
),
title: const Text('Sunflower'),
),
body: TabBarView(
children: [
Container(),
Container(),
],
),
),
);
}
}
タブのアイコン素材を準備する:
Android の Sunflower サンプルでは、アイコン素材はベクター型ドラーアブル というフォーマットで保存されている:
ic_my_garden_active.xml | ic_plant_list_active.xml |
---|---|
リソースURL | リソースURL |
VectorDrawable のままでもレンダリングする方法もあるにはある...が、面倒なのでSVGに変換しよう。
<!-- ic_my_garden_active.svg -->
<svg id="vector" xmlns="http://www.w3.org/2000/svg" width="24" height="28" viewBox="0 0 18 21"><path fill="?attr/colorOnPrimary" d="M9,21C13.97,21 18,16.97 18,12C13.03,12 9,16.03 9,21ZM2.6,9.25C2.6,10.63 3.72,11.75 5.1,11.75C5.63,11.75 6.11,11.59 6.52,11.31L6.5,11.5C6.5,12.88 7.62,14 9,14C10.38,14 11.5,12.88 11.5,11.5L11.48,11.31C11.88,11.59 12.37,11.75 12.9,11.75C14.28,11.75 15.4,10.63 15.4,9.25C15.4,8.25 14.81,7.4 13.97,7C14.81,6.6 15.4,5.75 15.4,4.75C15.4,3.37 14.28,2.25 12.9,2.25C12.37,2.25 11.89,2.41 11.48,2.69L11.5,2.5C11.5,1.12 10.38,0 9,0C7.62,0 6.5,1.12 6.5,2.5L6.52,2.69C6.12,2.41 5.63,2.25 5.1,2.25C3.72,2.25 2.6,3.37 2.6,4.75C2.6,5.75 3.19,6.6 4.03,7C3.19,7.4 2.6,8.25 2.6,9.25ZM9,4.5C10.38,4.5 11.5,5.62 11.5,7C11.5,8.38 10.38,9.5 9,9.5C7.62,9.5 6.5,8.38 6.5,7C6.5,5.62 7.62,4.5 9,4.5ZM0,12C0,16.97 4.03,21 9,21C9,16.03 4.97,12 0,12Z" fill-rule="nonzero" id="path_0"/></svg>
<!-- ic_plant_list_active.svg -->
<svg id="vector" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 20 24"><path fill="?attr/colorOnPrimary" d="M18.3246,20.6607C14.6597,24.2525 7.0157,24.716 2.7225,20.4289C3.6649,19.8496 4.6073,19.2703 6.2827,19.3861C6.911,18.6909 7.8534,18.2275 9.3194,18.4592C10.3665,11.9707 7.4346,9.1899 4.9215,8.6106C6.0733,9.3058 7.7487,10.4645 8.2723,13.3611C1.6754,13.9404 1.1518,10.001 0,6.4092C4.5026,5.3664 7.8534,6.2933 9.8429,10.001C10.3665,1.4269 15.6021,0.7317 20,0.5C19.267,4.3236 19.4764,10.4645 12.4607,11.3914C12.5654,8.7265 13.6126,6.2933 16.2304,4.0918C10.9948,5.714 10.5759,13.9404 11.5183,18.4592C12.6702,18.4592 13.4031,18.4592 14.1361,19.3861C16.1257,19.1544 17.801,20.0813 18.3246,20.6607Z" fill-rule="evenodd" id="path_0"/></svg>
VectorDrawable to SVG Converter というWebアプリが公開されているため活用させていただこう:
pubspec.yaml を編集して、リソースを参照できるようにする:
# The following section is specific to Flutter packages.
flutter:
uses-material-design: true
# これ
assets:
- assets/
...
プロジェクト直下に assets ディレクトリを作成して、アイコン素材のSVGファイルを追加しておこう
SVGのレンダリングには flutter_svg というパッケージを利用する:
では、home_screen.dart を編集して、タブにアイコンを追加してみよう:
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
bottom: TabBar(
tabs: [
Tab(
text: 'My garden',
icon: SvgPicture.asset('assets/ic_my_garden_active.svg'),
),
Tab(
text: 'Plant list',
icon: SvgPicture.asset('assets/ic_plant_list_active.svg'),
),
],
),
title: const Text('Sunflower'),
),
body: TabBarView(
children: [
Container(),
Container(),
],
),
),
);
}
}
アイコンが表示されるようになった:
SVGアイコンの色の指定について:
SvgPicture には color というパラメータがあるが、これは現在 deprecated になっている:
SvgPicture.asset(
assetName,
color: Colors.red, // deprecated
);
color の代わりに colorFilter を指定する。
色の変更だけなら BlendMode の srcIn で問題ない:
SvgPicture.asset(
assetName,
colorFilter: ColorFilter.mode(Colors.red, BlendMode.srcIn),
);
毎回生成するのは面倒なので Color に extension を追加しておくと便利:
extension ColorExt on Color {
ColorFilter get colorFilter => ColorFilter.mode(this, BlendMode.srcIn);
}
SvgPicture.asset(
assetName,
colorFilter: Colors.red.colorFilter,
);
まず、真っ先に思いつくかもしれないタブが切り替わった時に色を変えてみる安直なサンプルをこしらへてみる。先に言ってしまうと、これは不正解である:
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
var _index = 0;
void initState() {
super.initState();
_tabController = TabController(vsync: this, length: 2);
_tabController.addListener(() {
setState(() {
_index = _tabController.index;
});
});
}
void dispose() {
_tabController.dispose();
super.dispose();
}
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
bottom: TabBar(
controller: _tabController,
tabs: [
Tab(
text: 'My garden',
icon: SvgPicture.asset(
'assets/ic_my_garden_active.svg',
colorFilter: _index == 0
? colorScheme.primary.colorFilter
: colorScheme.secondary.colorFilter,
),
),
Tab(
text: 'Plant list',
icon: SvgPicture.asset(
'assets/ic_plant_list_active.svg',
colorFilter: _index == 1
? colorScheme.primary.colorFilter
: colorScheme.secondary.colorFilter,
),
),
],
),
title: const Text('Sunflower'),
),
body: TabBarView(
controller: _tabController,
children: [
Container(),
Container(),
],
),
);
}
}
(普段 Flutter Hooks を使っているため、かなり久しぶりに StatefulWidget で書いた...)
ちなみに、Flutter Hooks を使用すると、上記はもっと簡単に書くことができる:
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_svg/flutter_svg.dart';
class HomeScreen extends HookWidget {
const HomeScreen({super.key});
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final tabController = useTabController(initialLength: 2);
final index = useState(tabController.index);
useEffect(() {
tabController.addListener(() {
index.value = tabController.index;
});
return;
}, [tabController]);
return Scaffold(
appBar: AppBar(
bottom: TabBar(
controller: tabController,
tabs: [
Tab(
text: 'My garden',
icon: SvgPicture.asset(
'assets/ic_my_garden_active.svg',
colorFilter: index.value == 0
? colorScheme.primary.colorFilter
: colorScheme.secondary.colorFilter,
),
),
Tab(
text: 'Plant list',
icon: SvgPicture.asset(
'assets/ic_plant_list_active.svg',
colorFilter: index.value == 1
? colorScheme.primary.colorFilter
: colorScheme.secondary.colorFilter,
),
),
],
),
title: const Text('Sunflower'),
),
body: TabBarView(
controller: tabController,
children: [
Container(),
Container(),
],
),
);
}
}
さて、なぜ上記が不正解なのかと言うと、タブをタップした時には即座に色が変わってくれるものの、ページをスライドした時の色はラベルと同期していないからである:
tabController の index が切り替わってから色を変えているようでは遅すぎるし、そもそもスクロールのoffsetに応じて色が変化するラベルの色に追従することはできない。
簡単な解説
TabController は ChangeNotifier を継承している。
そして、addListener で登録したクロージャが呼び出されるタイミングというのは、notifyListeners が呼び出された瞬間である。
試しに tabController 内の notifyListeners にブレークポイント(BP)を設置してデバッグしてみよう。
スワイプでページを切り替えてしばらくするとBPにヒットする:
コーススタックからさらに呼び出し元に遡っていく:
そして、ここにたどり着く:
この _handleScrollNotification は TabBarView の build メソッドで ScrollNotification の Listener に登録されている:
スクロール通知が ScrollEndNotification に切り替わったタイミング、すなわちユーザが指を離してスクロールをとめたところで、tabController の index がTabBarView内のPageViewの pageController.page (ページのindex) で更新される。
その後、タブのindexの更新によって、notifyListeners が呼び出されるのである。
TabBarView の実態は、PageView のスクロール状態を監視して、tabController の index を切り替えるだけなのだと言うことも分かる。
さて、ラベルの色とアイコン画像の色を同期させるにはどうすればよいのか。
そもそも、Tab の icon プロパティには任意の Widget 渡せるのだが、Text や Icon Widget を渡した時にはアイコンの色がラベルの色に同期することがわかっている:
Tab(
text: 'My garden',
icon: Text('blaaaaaaaaa'), // 適当な Text Widget に置き換えてみる
),
Tab(
text: 'Plant list',
icon: Icon(Icons.add), // 適当な Icon Widget に置き換えてみる
),
察しが良い方は InheritedWidget で色のデータを伝搬しているのではないか、と思い至るかもしれない。
Icon Widget の中に潜っていくと答えがある:
Widget build(BuildContext context) {
...
final IconThemeData iconTheme = IconTheme.of(context); // これ
...
Color iconColor = color ?? iconTheme.color!;
...
}
color プロパティの指定のない Icon Widget は IconThemeData から、自身の色を参照している。
タブアニメーション時の色の更新については、tabs.dart の中に答えがある。
class _TabStyle extends AnimatedWidget {
...
MaterialStateColor _resolveWithLabelColor(BuildContext context) {
// タブアニメーションの進捗 (0.0 ~ 1.0)
final Animation<double> animation = listenable as Animation<double>;
// タブの選択状態に応じて lerp 関数で補間した色を返す
return MaterialStateColor.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.selected)) {
return Color.lerp(selectedColor, unselectedColor, animation.value)!;
}
return Color.lerp(unselectedColor, selectedColor, animation.value)!;
});
}
Widget build(BuildContext context) {
...
final Color color = _resolveWithLabelColor(context).resolve(states);
return DefaultTextStyle(
style: textStyle.copyWith(color: color), // Text の color を更新
child: IconTheme.merge(
data: IconThemeData(
size: 24.0,
color: color, // IconThemeData の color を更新
),
child: child,
),
);
}
}
ということで、タブに応じてSVGアイコンの色を変更するサンプルは以下の通りである:
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
bottom: const TabBar(
tabs: [
Tab(
text: 'My garden',
icon: _SvgIcon('assets/ic_my_garden_active.svg'),
),
Tab(
text: 'Plant list',
icon: _SvgIcon('assets/ic_plant_list_active.svg'),
),
],
),
title: const Text('Sunflower'),
),
body: TabBarView(
children: [
Container(),
Container(),
],
),
),
);
}
}
class _SvgIcon extends StatelessWidget {
const _SvgIcon(this.assetName);
final String assetName;
Widget build(BuildContext context) {
final iconTheme = IconTheme.of(context);
return SvgPicture.asset(
assetName,
colorFilter: ColorFilter.mode(iconTheme.color!, BlendMode.srcIn),
);
}
}
ColorSchemeの変更
MaterialApp で ThemaData を設定することができる。
flutter create 時点のテンプレートでは、ColorScheme は Colors.deepPurple
をプライマリー・カラー とて生成されている:
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Sunflower',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), // これ
useMaterial3: true,
),
home: const HomeScreen(),
);
}
}
ColorScheme.fromSeed で、プライマリー・カラーを指定するだけで、Material 3 のカラーシステムに基づいた役割ごとの色のパレットを自動生成してくれる:
Android Sunflower では Colors.kt 内にシード・カラーの情報が格納されている:
val seed = Color(0xFF256F00)
ColorScheme を変更してみよう:
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Sunflower',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF256F00)), // これ
useMaterial3: true,
),
home: const HomeScreen(),
);
}
}
Sunflower の GardenScreen.kt 内の EmptyGarden に相当するページを追加する:
GardenScreen | EmptyGarden |
---|---|
@Composable
private fun EmptyGarden(onAddPlantClick: () -> Unit, modifier: Modifier = Modifier) {
// Calls reportFullyDrawn when this composable is composed.
ReportDrawn()
Column(
modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(id = R.string.garden_empty),
style = MaterialTheme.typography.headlineSmall
)
Button(
shape = MaterialTheme.shapes.medium,
onClick = onAddPlantClick
) {
Text(
text = stringResource(id = R.string.add_plant),
style = MaterialTheme.typography.titleSmall
)
}
}
}
Column コンテナでテキストとボタンを縦に並べるのは Compose と変わらない:
import 'package:flutter/material.dart';
class GardenScreen extends StatelessWidget {
const GardenScreen({super.key});
Widget build(BuildContext context) {
return const _EmptyGarden();
}
}
class _EmptyGarden extends StatelessWidget {
const _EmptyGarden();
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Your garden is empty'),
FilledButton(
onPressed: () {},
child: Text('Add plant'),
),
],
);
}
}
Compose | Flutter |
---|---|
次に、Themeを Android Sunflower と同等のものに更新する。
Android Sunflower のテーマは、Theme.kt に定義されている:
MaterialTheme(
colorScheme = colorScheme,
shapes = Shapes,
typography = Typography,
content = content
)
そして、「Your garden is empty」のText のスタイルに相当するものは、Type.kt に Typography で定義されている:
Text(
text = stringResource(id = R.string.garden_empty),
style = MaterialTheme.typography.headlineSmall // これ
)
val Typography = Typography(
...
headlineSmall = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 30.sp
),
...
)
また、「Add plant」 ボタンの角丸については、Shapes.kt に定義されている:
Button(
shape = MaterialTheme.shapes.medium, // これ
onClick = onAddPlantClick
)
val Shapes = Shapes(
...
medium = RoundedCornerShape(
topStart = 0.dp,
topEnd = 12.dp,
bottomStart = 12.dp,
bottomEnd = 0.dp
)
)
Flutter 側では上記に相当するものを ColorTheme と同様に、 MaterialApp の ThemeData で上書きする:
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Sunflower',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF256F00)),
useMaterial3: true,
// Text の Theme を更新する
textTheme: const TextTheme(
displaySmall: TextStyle(fontSize: 36, fontWeight: FontWeight.normal),
headlineSmall: TextStyle(fontSize: 30, fontWeight: FontWeight.normal),
labelSmall: TextStyle(fontSize: 13, fontWeight: FontWeight.normal),
titleSmall: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
titleMedium: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: 0.5),
titleLarge: TextStyle(fontSize: 24, fontWeight: FontWeight.normal),
),
// FilledButton の Theme を更新する
filledButtonTheme: const FilledButtonThemeData(
style: ButtonStyle(
shape: MaterialStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.zero,
topRight: Radius.circular(12),
bottomLeft: Radius.circular(12),
bottomRight: Radius.zero,
),
),
),
),
),
),
home: const HomeScreen(),
);
}
}
更新したThemeを適用するとそれらしくなった:
Compose | Flutter |
---|---|
次は Plant list タブのスクリーンを実装していく:
PlantListScreen.kt にGridの実装がある:
@Composable
fun PlantListScreen(
plants: List<Plant>,
modifier: Modifier = Modifier,
onPlantClick: (Plant) -> Unit = {},
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = modifier.testTag("plant_list"),
contentPadding = PaddingValues(
horizontal = dimensionResource(id = R.dimen.card_side_margin),
vertical = dimensionResource(id = R.dimen.header_margin)
)
) {
items(
items = plants,
key = { it.plantId }
) { plant ->
PlantListItem(plant = plant) {
onPlantClick(plant)
}
}
}
}
また、PlantListItemView.kt にリストのセルの実装がある:
@Composable
fun ImageListItem(name: String, imageUrl: String, onClick: () -> Unit) {
Card(
onClick = onClick,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
modifier = Modifier
.padding(horizontal = dimensionResource(id = R.dimen.card_side_margin))
.padding(bottom = dimensionResource(id = R.dimen.card_bottom_margin))
) {
Column(Modifier.fillMaxWidth()) {
SunflowerImage(
model = imageUrl,
contentDescription = stringResource(R.string.a11y_plant_item_image),
Modifier
.fillMaxWidth()
.height(dimensionResource(id = R.dimen.plant_item_image_height)),
contentScale = ContentScale.Crop
)
Text(
text = name,
textAlign = TextAlign.Center,
maxLines = 1,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = dimensionResource(id = R.dimen.margin_normal))
.wrapContentWidth(Alignment.CenterHorizontally)
)
}
}
}
assets/plants.json には各植物のデータある:
[
{
"plantId": "malus-pumila",
"name": "Apple",
"description": "An apple is a sweet, edible fruit produced by an apple tree (Malus pumila). (略),
"growZoneNumber": 3,
"wateringInterval": 30,
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/5/55/Apple_orchard_in_Tasmania.jpg"
},
...
これらの材料を元にFlutter側でも実装していこう。
まずは 先の JSON を変換するためのデータクラスから実装していく:
{
"plantId": "malus-pumila",
"name": "Apple",
"description": "An apple is a sweet, edible fruit produced by an apple tree (Malus pumila).,
"growZoneNumber": 3,
"wateringInterval": 30,
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/5/55/Apple_orchard_in_Tasmania.jpg"
},
先に freezed と json_serializable を導入しておこう:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
freezed_annotation:
json_annotation:
dev_dependencies:
flutter_test:
sdk: flutter
freezed:
build_runner:
json_serializable:
Android Sunflower では Plant.kt にデータクラスが定義されている:
data class Plant(
val plantId: String,
val name: String,
val description: String,
val growZoneNumber: Int,
val wateringInterval: Int = 7,
val imageUrl: String = ""
)
freezed では下記のようになる:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'plant.freezed.dart';
part 'plant.g.dart';
sealed class Plant with _$Plant {
const factory Plant({
required String plantId,
required String name,
required String description,
required int growZoneNumber,
(7) int wateringInterval,
('') String imageUrl,
}) = _Plant;
factory Plant.fromJson(Map<String, Object?> json) => _$PlantFromJson(json);
}
dart run build_runner build
を実行して、コード生成したら準備完了!
試しに、りんごのJSONパースのテストが動作するか確認するテストを書いてみよう:
import 'dart:convert';
import 'package:flutter_sunflower/data/plant.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('plant ...', () {
const json = """
{
"plantId": "malus-pumila",
"name": "Apple",
"description": "An apple is a sweet",
"growZoneNumber": 3,
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/5/55/Apple_orchard_in_Tasmania.jpg"
}
""";
final plant = Plant.fromJson(jsonDecode(json));
expect(plant.plantId, 'malus-pumila');
expect(plant.name, 'Apple');
expect(plant.description, 'An apple is a sweet');
expect(plant.growZoneNumber, 3);
expect(plant.wateringInterval, 7); // default value
expect(plant.imageUrl,
'https://upload.wikimedia.org/wikipedia/commons/5/55/Apple_orchard_in_Tasmania.jpg');
});
}
さて、assets/plants.json を assets に追加し、これをパースしてみよう。
unit_test で assets からデータを参照する場合は、先に TestWidgetsFlutterBinding.ensureInitialized();
を実行するのを忘れずに。
リストの場合は、 jsonDecode を List<dynamic>
で受けてから map すると良い:
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter_sunflower/data/plant.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
setUp(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
test('plant list...', () async {
final json = await rootBundle.loadString('assets/plant.json');
final List<dynamic> list = jsonDecode(json);
final plantList = list.map((e) => Plant.fromJson(e)).toList();
expect(plantList.first.plantId, 'malus-pumila');
});
}
Riverpod で Plant List の Provider を作成しよう:
どうせ後でHooks を使うことになるから hooks_riverpod にした。riverpod_generator も使ってみることにする:
# pubspec.yaml
dependencies:
...
flutter_hooks:
hooks_riverpod:
riverpod_annotation:
dev_dependencies:
...
build_runner:
riverpod_generator:
provider はこんな感じにしてみた。
unit_test で モックするために json を拾ってくる Provider を分離しておく:
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter_sunflower/data/plant.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'plant_list_provider.g.dart';
Future<String> plantJson(PlantJsonRef ref) async {
return rootBundle.loadString('assets/plant.json');
}
Future<List<Plant>> plantList(PlantListRef ref) async {
final json = await ref.watch(plantJsonProvider.future);
final List<dynamic> list = jsonDecode(json);
return list.map((e) => Plant.fromJson(e)).toList();
}
build_runner を build コマンドで実行してコードを自動生成する:
$ dart run build_runner build
また、watch コマンドの場合は アノテーションのあるコードを保存した時に自動的にコード生成してくれる、お好みでお好きな方を使おう (watch 推奨):
$ dart run build_runner build
作成した provider の unit_test を書いてみる。
まず、testのディレクトリに createContainer の util を追加しておく:
/// test/utils.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
ProviderContainer createContainer({
List<Override> overrides = const [],
}) {
final container = ProviderContainer(
overrides: overrides,
);
addTearDown(container.dispose);
return container;
}
import 'package:flutter_sunflower/data/plant.dart';
import 'package:flutter_sunflower/provider/plant_list_provider.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../utils.dart';
void main() {
test('plant list provider ...', () async {
const mockJson = """
[
{
"plantId": "malus-pumila",
"name": "Apple",
"description": "An apple is a sweet, edible fruit produced by an apple tree (Malus pumila).",
"growZoneNumber": 3,
"wateringInterval": 30,
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/5/55/Apple_orchard_in_Tasmania.jpg"
},
{
"plantId": "beta-vulgaris",
"name": "Beet",
"description": "The beetroot is the taproot portion of the beet plant",
"growZoneNumber": 2,
"wateringInterval": 7,
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/2/29/Beetroot_jm26647.jpg"
}
]
""";
final container = createContainer(
overrides: [
/// テスト用の json で override しておく
plantJsonProvider.overrideWith((ref) async => mockJson),
],
);
// expect(container.read(plantListProvider), const AsyncLoading<List<Plant>>());
final result = await container.read(plantListProvider.future);
expect(result.map((e) => e.name), ['Apple', 'Beet']);
});
}
こちらもどうぞ:
Plant list のセルを作っていく。
Android Sunflower では下記のような実装になっている:
@Composable
fun ImageListItem(name: String, imageUrl: String, onClick: () -> Unit) {
Card(
onClick = onClick,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
modifier = Modifier
.padding(horizontal = dimensionResource(id = R.dimen.card_side_margin))
.padding(bottom = dimensionResource(id = R.dimen.card_bottom_margin))
) {
Column(Modifier.fillMaxWidth()) {
SunflowerImage(
model = imageUrl,
contentDescription = stringResource(R.string.a11y_plant_item_image),
Modifier
.fillMaxWidth()
.height(dimensionResource(id = R.dimen.plant_item_image_height)),
contentScale = ContentScale.Crop
)
Text(
text = name,
textAlign = TextAlign.Center,
maxLines = 1,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = dimensionResource(id = R.dimen.margin_normal))
.wrapContentWidth(Alignment.CenterHorizontally)
)
}
}
}
画像はglide:composeで表示している:
@Composable
fun SunflowerImage(
model: Any?,
contentDescription: String?,
modifier: Modifier = Modifier,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
requestBuilderTransform: RequestBuilderTransform<Drawable> = { it },
) {
if (LocalInspectionMode.current) {
Box(modifier = modifier.background(Color.Magenta))
return
}
GlideImage(
model = model,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
requestBuilderTransform = requestBuilderTransform,
loading = placeholder {
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(Modifier.size(40.dp))
}
}
)
}
Flutter 側では cached_network_image をGlideの代替として使用した:
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_sunflower/data/plant.dart';
class PlantListItemView extends StatelessWidget {
const PlantListItemView(this.plant, {super.key});
final Plant plant;
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Card(
margin: const EdgeInsets.only(left: 12, right: 12, bottom: 26),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CachedNetworkImage(
imageUrl: plant.imageUrl,
width: double.infinity,
height: 95,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
progressIndicatorBuilder: (_, __, ___) => const Center(
child: SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
plant.name,
textAlign: TextAlign.center,
maxLines: 1,
style: textTheme.titleMedium,
),
),
],
),
);
}
}
CardのThemeもAndroid Sunflower と同等のものにしておく:
final colorScheme = ColorScheme.fromSeed(seedColor: const Color(0xFF256F00));
return MaterialApp(
title: 'Flutter Sunflower',
theme: ThemeData(
colorScheme: colorScheme,
...
cardTheme: CardTheme(
color: colorScheme.secondaryContainer,
clipBehavior: Clip.antiAlias,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.zero,
topRight: Radius.circular(12),
bottomLeft: Radius.circular(12),
bottomRight: Radius.zero,
),
),
),
...
後は GridView でセルを表示するだけ:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_sunflower/plant_list_item_view.dart';
import 'package:flutter_sunflower/provider/plant_list_provider.dart';
class PlantListScreen extends ConsumerWidget {
const PlantListScreen({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final plantList = ref.watch(plantListProvider);
return switch (plantList) {
AsyncData(:final value) => GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 24),
itemCount: value.length,
itemBuilder: (context, index) {
value.sort((a, b) => a.name.compareTo(b.name));
final plant = value[index];
return PlantListItemView(plant);
},
),
_ => const SizedBox.shrink(),
};
}
}
compose | flutter |
---|---|
TODO: Plant list のセルをタップした時の詳細ページの実装
ThemeData で floatingActionButton のテーマも更新しておこう:
return MaterialApp(
title: 'Flutter Sunflower',
theme: ThemeData(
...
floatingActionButtonTheme: const FloatingActionButtonThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.zero,
topRight: Radius.circular(12),
bottomLeft: Radius.circular(12),
bottomRight: Radius.zero,
),
),
),
ERROR:D8: Cannot fit requested classes in a single dex file (# methods: 71169 > 65536)
com.android.builder.dexing.DexArchiveMergerException: Error while merging dex archives:
The number of method references in a .dex file cannot exceed 64K.
そろそろライブラリの追加によって Android 側で multidexのエラーが出る頃かもしれない
multidex がデフォルトで有効になる、minSdkVersion を 21以上に更新しておこう
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "com.example.flutter_sunflower"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
minSdkVersion 21 // これ
推奨は23である:
植物の説明文の表示に flutter_widget_from_html
を追加する:
また、HTML内のリンクをタップした時に外部ブラウザを開けるようにするために、AndroidManifest.xml に下記を追加しておく:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
...
詳細: