【Dart/Flutter】Flutter Apprenticeのテクニック抜粋 - 備考1
無料で素晴らしい教育資料Flutter Apprenticeの中で、
個人的に覚えておきたいテクニックをソースコード部分と共に抜粋したものとなります。
順を追っての説明はありませんので、詳細はFlutter Apprentice にてご確認ください。
何か思い出す際等に、お役立ちできれば幸いです。
関連記事
【Dart/Flutter】Flutter Apprenticeのテクニック抜粋
【Dart/Flutter】Flutter Apprenticeのテクニック抜粋 - 備考1
【Dart/Flutter】Flutter Apprenticeのテクニック抜粋 - 備考2
【Dart/Flutter】Flutter Apprenticeのテクニック集
はじめに
-
【参考】Flutter Apprentice:公式の学習サイト(無料)
-
Flutter実行環境
sdk: ">=2.12.0 <3.0.0"
以下、範囲大きめなテクニック
Theme設定情報のまとめ
- ThemeDataやTextThemeをまとめたクラスを別途作成
-
https://github.com/raywenderlich/flta-materials/blob/editions/2.0/03-basic-widgets/projects/final/lib/fooderlich_theme.dart
- MaterialAppのthemeに
FooderlichTheme.dark()
orFooderlichTheme.light()
と指定分け可能 - TextTheme:フォントの色、サイズと太さ
- ThemeData:上で作成したTextThemeを利用した、ThemeData
- MaterialAppのthemeに
-
https://github.com/raywenderlich/flta-materials/blob/editions/2.0/03-basic-widgets/projects/final/lib/fooderlich_theme.dart
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class FooderlichTheme {
static TextTheme lightTextTheme = TextTheme(
bodyText1: GoogleFonts.openSans(
fontSize: 14.0,
fontWeight: FontWeight.w700,
color: Colors.black,
),
headline1: GoogleFonts.openSans(
fontSize: 32.0,
fontWeight: FontWeight.bold,
color: Colors.black,
),
headline2: GoogleFonts.openSans(
fontSize: 21.0,
fontWeight: FontWeight.w700,
color: Colors.black,
),
headline3: GoogleFonts.openSans(
fontSize: 16.0,
fontWeight: FontWeight.w600,
color: Colors.black,
),
headline6: GoogleFonts.openSans(
fontSize: 20.0,
fontWeight: FontWeight.w600,
color: Colors.black,
),
);
static TextTheme darkTextTheme = TextTheme(
bodyText1: GoogleFonts.openSans(
fontSize: 14.0,
fontWeight: FontWeight.w700,
color: Colors.white,
),
headline1: GoogleFonts.openSans(
fontSize: 32.0,
fontWeight: FontWeight.bold,
color: Colors.white,
),
headline2: GoogleFonts.openSans(
fontSize: 21.0,
fontWeight: FontWeight.w700,
color: Colors.white,
),
headline3: GoogleFonts.openSans(
fontSize: 16.0,
fontWeight: FontWeight.w600,
color: Colors.white,
),
headline6: GoogleFonts.openSans(
fontSize: 20.0,
fontWeight: FontWeight.w600,
color: Colors.white,
),
);
static ThemeData light() {
return ThemeData(
brightness: Brightness.light,
checkboxTheme: CheckboxThemeData(
fillColor: MaterialStateColor.resolveWith(
(states) {
return Colors.black;
},
),
),
appBarTheme: const AppBarTheme(
foregroundColor: Colors.black,
backgroundColor: Colors.white,
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(
foregroundColor: Colors.white,
backgroundColor: Colors.black,
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
selectedItemColor: Colors.green,
),
textTheme: lightTextTheme,
);
}
static ThemeData dark() {
return ThemeData(
brightness: Brightness.dark,
appBarTheme: AppBarTheme(
foregroundColor: Colors.white,
backgroundColor: Colors.grey[900],
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(
foregroundColor: Colors.white,
backgroundColor: Colors.green,
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
selectedItemColor: Colors.green,
),
textTheme: darkTextTheme,
);
}
}
Mock活用:特定のjsonファイルを読み込む
- Mock利用(バックエンドサービスを利用する準備ができていない場合)
-
https://github.com/raywenderlich/flta-materials/blob/editions/2.0/05-scrollable-widgets/projects/final/lib/api/mock_fooderlich_service.dart
- 何かAPI利用にて取得予定のデータがある場合でも、jsonファイル等をAseetとして読み込みにしておく(以下、Asset読み込み)
await _loadAsset('assets/sample_data/sample_explore_recipes.json');
await _loadAsset('assets/sample_data/sample_friends_feed.json');
await _loadAsset('assets/sample_data/sample_recipes.json');
- 利用例
- FutureBuilderウィジェットにて、
future: mockService.getExploreData(),
- FutureBuilderウィジェットにて、
- 何かAPI利用にて取得予定のデータがある場合でも、jsonファイル等をAseetとして読み込みにしておく(以下、Asset読み込み)
-
https://github.com/raywenderlich/flta-materials/blob/editions/2.0/05-scrollable-widgets/projects/final/lib/api/mock_fooderlich_service.dart
import 'dart:convert';
import 'package:flutter/services.dart';
import '../models/models.dart';
// Mock recipe service that grabs sample json data to mock recipe request/response
class MockFooderlichService {
// Batch request that gets both today recipes and friend's feed
Future<ExploreData> getExploreData() async {
final todayRecipes = await _getTodayRecipes();
final friendPosts = await _getFriendFeed();
return ExploreData(todayRecipes, friendPosts);
}
// Get sample explore recipes json to display in ui
Future<List<ExploreRecipe>> _getTodayRecipes() async {
// Simulate api request wait time
await Future.delayed(const Duration(milliseconds: 1000));
// Load json from file system
final dataString =
await _loadAsset('assets/sample_data/sample_explore_recipes.json');
// Decode to json
final Map<String, dynamic> json = jsonDecode(dataString);
// Go through each recipe and convert json to ExploreRecipe object.
if (json['recipes'] != null) {
final recipes = <ExploreRecipe>[];
json['recipes'].forEach((v) {
recipes.add(ExploreRecipe.fromJson(v));
});
return recipes;
} else {
return [];
}
}
// Get the sample friend json posts to display in ui
Future<List<Post>> _getFriendFeed() async {
// Simulate api request wait time
await Future.delayed(const Duration(milliseconds: 1000));
// Load json from file system
final dataString =
await _loadAsset('assets/sample_data/sample_friends_feed.json');
// Decode to json
final Map<String, dynamic> json = jsonDecode(dataString);
// Go through each post and convert json to Post object.
if (json['feed'] != null) {
final posts = <Post>[];
json['feed'].forEach((v) {
posts.add(Post.fromJson(v));
});
return posts;
} else {
return [];
}
}
// Get the sample recipe json to display in ui
Future<List<SimpleRecipe>> getRecipes() async {
// Simulate api request wait time
await Future.delayed(const Duration(milliseconds: 1000));
// Load json from file system
final dataString =
await _loadAsset('assets/sample_data/sample_recipes.json');
// Decode to json
final Map<String, dynamic> json = jsonDecode(dataString);
// Go through each recipe and convert json to SimpleRecipe object.
if (json['recipes'] != null) {
final recipes = <SimpleRecipe>[];
json['recipes'].forEach((v) {
recipes.add(SimpleRecipe.fromJson(v));
});
return recipes;
} else {
return [];
}
}
// Loads sample json data from file system
Future<String> _loadAsset(String path) async {
return rootBundle.loadString(path);
}
}
Navigator2.0
Deep Links & Web URLs
- Deep Links & Web URLsを活用
- URIスキーム:アプリ独自のURIスキーム
- Android
fooderlich://raywenderlich.com/<path>
- Flutter Webのローカルデバッグ
http://localhost:60738/#/<path>
- ディープリンクのテスト(例:Android Stdioのターミナル)
- コマンド実行でスマホの画面を変更可能
※adbコマンドの場所に注意(自分で置いた場所)[XXX]/Android/Sdk/platform-tools/adb shell am start -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d 'fooderlich://raywenderlich.com/home?tab=1'
- コマンド実行でスマホの画面を変更可能
- Android
- 主に関わるファイル
-
lib/navigation/app_link.dart
-
https://github.com/raywenderlich/flta-materials/blob/editions/2.0/08-deep-links-and-web-URLs/projects/final/lib/navigation/app_link.dart
- 現在地( = URI文字列を保持)と変換を担当
- URL文字列をAppLinkに変換
static AppLink fromLocation(String? location) {}
- AppLinkをURL文字列に変換
String toLocation() {}
- URL文字列をAppLinkに変換
- 現在地( = URI文字列を保持)と変換を担当
-
https://github.com/raywenderlich/flta-materials/blob/editions/2.0/08-deep-links-and-web-URLs/projects/final/lib/navigation/app_link.dart
-
lib/navigation/app_router.dart
-
https://github.com/raywenderlich/flta-materials/blob/editions/2.0/08-deep-links-and-web-URLs/projects/final/lib/navigation/app_router.dart
- アプリの状態をURLに変換
getCurrentPath()
- URLをアプリの状態に変換
Future<void> setNewRoutePath(AppLink newLink) async {}
- アプリの状態をURLに変換
-
https://github.com/raywenderlich/flta-materials/blob/editions/2.0/08-deep-links-and-web-URLs/projects/final/lib/navigation/app_router.dart
-
lib/navigation/app_route_parser.dart
-
https://github.com/raywenderlich/flta-materials/blob/editions/2.0/08-deep-links-and-web-URLs/projects/final/lib/navigation/app_route_parser.dart
- URL文字列を一般的なユーザー定義のデータ型に解析
- オーバーライド①:AppLinkを得る
Future<AppLink> parseRouteInformation(RouteInformation routeInformation) async {}
- オーバーライド②:AppLinkからRouteInformationとする
RouteInformation restoreRouteInformation(AppLink appLink) {}
-
https://github.com/raywenderlich/flta-materials/blob/editions/2.0/08-deep-links-and-web-URLs/projects/final/lib/navigation/app_route_parser.dart
-
- URIスキーム:アプリ独自のURIスキーム
lib/navigation/app_link.dart
/// URL文字列をカプセル化するデータ型
/// = URL文字列とアプリの状態の間の中間オブジェクト
/// = URL文字列との間のナビゲーション構成を解析
class AppLink {
// 1
/// /home?tab=0の/home部分
static const String kHomePath = '/home';
static const String kOnboardingPath = '/onboarding';
static const String kLoginPath = '/login';
static const String kProfilePath = '/profile';
static const String kItemPath = '/item';
// 2
/// /home?tab=0のtab部分
static const String kTabParam = 'tab';
static const String kIdParam = 'id';
// 3
/// /home?tab=0の/home部分の現在地 = URI文字列
String? location;
// 4
int? currentTab;
// 5
String? itemId;
// 6
AppLink({
this.location,
this.currentTab,
this.itemId,
});
/// URL文字列をAppLinkに変換
static AppLink fromLocation(String? location) {
// 1
/// 特殊文字も対応
/// hello!world → hello%21world
location = Uri.decodeFull(location ?? '');
// 2
print('fromLocation(String? location)');
print(location);
final uri = Uri.parse(location);
print(uri);
/// Map<String, String>型(ここに、tabやid情報もある)
/// →存在すれば抽出する
final params = uri.queryParameters;
// 3
final currentTab = int.tryParse(params[AppLink.kTabParam] ?? '');
// 4
final itemId = params[AppLink.kIdParam];
// 5
final link = AppLink(
location: uri.path,
currentTab: currentTab,
itemId: itemId,
);
// 6
return link;
}
/// AppLinkをURL文字列に変換
String toLocation() {
print('toLocation()');
print(location);
// 1
/// クエリパラメータのキーと値のペアを文字列形式にフォーマットする内部関数
/// この関数内で利用される関数
/// 「tab=0&」のようにKey Valueの文字列を作成
String addKeyValPair({
required String key,
String? value,
}) =>
value == null ? '' : '${key}=$value&';
// 2
switch (location) {
// 3
case kLoginPath:
return kLoginPath;
// 4
case kOnboardingPath:
return kOnboardingPath;
// 5
case kProfilePath:
return kProfilePath;
// 6
/// /itemには、クエリパラメータidが存在する場合がある(編集の場合)
case kItemPath:
var loc = '$kItemPath?';
loc += addKeyValPair(
key: kIdParam,
value: itemId,
);
return Uri.encodeFull(loc);
// 7
/// /homeには、クエリパラメータtabが存在する
default:
var loc = '$kHomePath?';
loc += addKeyValPair(
key: kTabParam,
value: currentTab.toString(),
);
return Uri.encodeFull(loc);
}
}
}
lib/navigation/app_router.dart
import 'package:flutter/material.dart';
import '../models/models.dart';
import '../screens/screens.dart';
import 'app_link.dart';
/// 「extends RouterDelegate」から
/// 「extends RouterDelegate<AppLink>」となっている
class AppRouter extends RouterDelegate<AppLink>
with ChangeNotifier, PopNavigatorRouterDelegateMixin {
final GlobalKey<NavigatorState> navigatorKey;
final AppStateManager appStateManager;
final GroceryManager groceryManager;
final ProfileManager profileManager;
AppRouter({
required this.appStateManager,
required this.groceryManager,
required this.profileManager,
}) : navigatorKey = GlobalKey<NavigatorState>() {
appStateManager.addListener(notifyListeners);
groceryManager.addListener(notifyListeners);
profileManager.addListener(notifyListeners);
}
void dispose() {
appStateManager.removeListener(notifyListeners);
groceryManager.removeListener(notifyListeners);
profileManager.removeListener(notifyListeners);
super.dispose();
}
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
onPopPage: _handlePopPage,
pages: [
if (!appStateManager.isInitialized) ...[
SplashScreen.page(),
] else if (!appStateManager.isLoggedIn) ...[
LoginScreen.page(),
] else if (!appStateManager.isOnboardingComplete) ...[
OnboardingScreen.page(),
] else ...[
Home.page(appStateManager.getSelectedTab),
if (groceryManager.isCreatingNewItem)
GroceryItemScreen.page(onCreate: (item) {
groceryManager.addItem(item);
}, onUpdate: (item, index) {
// No update
}),
if (groceryManager.selectedIndex != -1)
GroceryItemScreen.page(
item: groceryManager.selectedGroceryItem,
index: groceryManager.selectedIndex,
onCreate: (_) {
// No create
},
onUpdate: (item, index) {
groceryManager.updateItem(item, index);
}),
if (profileManager.didSelectUser)
ProfileScreen.page(profileManager.getUser),
if (profileManager.didTapOnRaywenderlich) WebViewScreen.page(),
]
],
);
}
bool _handlePopPage(Route<dynamic> route, result) {
if (!route.didPop(result)) {
return false;
}
if (route.settings.name == FooderlichPages.onboardingPath) {
appStateManager.logout();
}
if (route.settings.name == FooderlichPages.groceryItemDetails) {
groceryManager.groceryItemTapped(-1);
}
if (route.settings.name == FooderlichPages.profilePath) {
profileManager.tapOnProfile(false);
}
if (route.settings.name == FooderlichPages.raywenderlich) {
profileManager.tapOnRaywenderlich(false);
}
return true;
}
/// 【オーバーライドが必要】
/// currentConfiguration
/// =現在のアプリ状態からAppLink(RouterDelegate<AppLink>で指定したT)
/// =アプリの状態をAppLinkオブジェクトに変換するヘルパー関数を呼び出す
AppLink get currentConfiguration => getCurrentPath();
/// アプリの状態をAppLinkオブジェクトに変換するヘルパー関数
/// 例:ログインされていない→AppLinkのlocationはAppLink.kLoginPath
AppLink getCurrentPath() {
if (!appStateManager.isLoggedIn) {
return AppLink(location: AppLink.kLoginPath);
} else if (!appStateManager.isOnboardingComplete) {
return AppLink(location: AppLink.kOnboardingPath);
} else if (profileManager.didSelectUser) {
return AppLink(location: AppLink.kProfilePath);
} else if (groceryManager.isCreatingNewItem) {
return AppLink(location: AppLink.kItemPath);
} else if (groceryManager.selectedGroceryItem != null) {
final id = groceryManager.selectedGroceryItem?.id;
return AppLink(
location: AppLink.kItemPath,
itemId: id,
);
}
/// 特に特殊な条件を持っていなければ、ホーム画面を出す
else {
return AppLink(
location: AppLink.kHomePath,
currentTab: appStateManager.getSelectedTab);
}
}
// 1
/// 【URLをアプリの状態に変換】
/// ※アプリの状態をURLに変換は上記の「getCurrentPath()」で実施
/// 新しいルートがプッシュされたときに呼び出す
/// 新しいAppLinkとする
/// 引数もAppLink型を持つ
Future<void> setNewRoutePath(AppLink newLink) async {
// 2
switch (newLink.location) {
// 3
case AppLink.kProfilePath:
/// そのページに必要な処理もここで記載可能
profileManager.tapOnProfile(true);
break;
// 4
case AppLink.kItemPath:
final itemId = newLink.itemId;
// 5
/// 選択している場合(編集)
if (itemId != null) {
groceryManager.setSelectedGroceryItem(itemId);
}
/// 選択していない場合(新規)
else {
// 6
groceryManager.createNewItem();
}
// 7
/// URL等直接指定可能なので、ここで
/// プロファイル画面を出さない設定が必要
profileManager.tapOnProfile(false);
break;
// 8
case AppLink.kHomePath:
// 9
/// 何も指定が無い場合は、「0」の番号のページ
/// URLに直接/homeと入力した場合
appStateManager.goToTab(newLink.currentTab ?? 0);
// 10
/// URL等直接指定可能なので、ここで
/// プロファイル画面やアイテム画面を出さない設定が必要
profileManager.tapOnProfile(false);
groceryManager.groceryItemTapped(-1);
break;
// 11
/// 場所が存在しない場合は、何もしません
/// URLで直接aaa等入力しても、表示していた画面のURLに戻る
default:
break;
}
}
}
lib/navigation/app_route_parser.dart
import 'package:flutter/material.dart';
import 'app_link.dart';
// 1
/// 「extends RouteInformationParser<AppLink>」により、
/// RouteInformationProviderからルート文字列を取得
/// URL文字列を一般的なユーザー定義のデータ型に解析
class AppRouteParser extends RouteInformationParser<AppLink> {
// 2
/// オーバーライド①:AppLinkを得る
/// routeInformationは、ルート情報のURL文字列を持つlocationが含まれる
Future<AppLink> parseRouteInformation(
RouteInformation routeInformation) async {
// 3
final link = AppLink.fromLocation(routeInformation.location);
return link;
}
// 4
/// オーバーライド②:AppLinkからRouteInformationとする
/// (アプリの操作で、状態が変更されnotifyListeners()が呼ばれ変更する際利用)
RouteInformation restoreRouteInformation(AppLink appLink) {
// 5
final location = appLink.toLocation();
print("restoreRouteInformation(AppLink appLink)");
print(location);
// 6
return RouteInformation(location: location);
}
}
Androidでは、android/app/src/main/AndroidManifest.xml
に
以下設定が必要
<!-- Android用:Deep linking -->
<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="fooderlich"
android:host="raywenderlich.com" />
</intent-filter>
↓全文
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="[アプリのパッケージ名]">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="flutter_apprentice"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Android用:Deep linking -->
<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="fooderlich"
android:host="raywenderlich.com" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
shared_preferences:ログインとオンボーディング情報保存
- 「shared_preferences」利用した、ログインや、オンボーディング画面をすでに開いていたかの保存と利用
-
lib/models/app_cache.dart
-
lib/models/app_state_manager.dart
-
https://github.com/raywenderlich/flta-materials/blob/editions/2.0/08-deep-links-and-web-URLs/projects/final/lib/models/app_state_manager.dart
- アプリ起動時に、AppCacheクラスを利用し、キャッシュ情報を取得・保存
final _appCache = AppCache();
- アプリ起動時に、AppCacheクラスを利用し、キャッシュ情報を取得・保存
-
https://github.com/raywenderlich/flta-materials/blob/editions/2.0/08-deep-links-and-web-URLs/projects/final/lib/models/app_state_manager.dart
-
lib/models/app_cache.dart
import 'package:shared_preferences/shared_preferences.dart';
/// ユーザーのログインやオンボーディングステータスなどのユーザー情報をキャッシュ
/// 「shared_preferences」利用
class AppCache {
static const kUser = 'user';
static const kOnboarding = 'onboarding';
Future<void> invalidate() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(kUser, false);
await prefs.setBool(kOnboarding, false);
}
Future<void> cacheUser() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(kUser, true);
}
Future<void> completeOnboarding() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(kOnboarding, true);
}
Future<bool> isUserLoggedIn() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(kUser) ?? false;
}
Future<bool> didCompleteOnboarding() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(kOnboarding) ?? false;
}
}
lib/models/app_state_manager.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'app_cache.dart';
class FooderlichTab {
static const int explore = 0;
static const int recipes = 1;
static const int toBuy = 2;
}
class AppStateManager extends ChangeNotifier {
bool _initialized = false;
bool _loggedIn = false;
bool _onboardingComplete = false;
int _selectedTab = FooderlichTab.explore;
/// AppCacheユーザーのログインとオンボーディングのステータスを
/// 確認するために依存
final _appCache = AppCache();
bool get isInitialized => _initialized;
bool get isLoggedIn => _loggedIn;
bool get isOnboardingComplete => _onboardingComplete;
int get getSelectedTab => _selectedTab;
void initializeApp() async {
_loggedIn = await _appCache.isUserLoggedIn();
_onboardingComplete = await _appCache.didCompleteOnboarding();
Timer(const Duration(milliseconds: 2000), () {
_initialized = true;
notifyListeners();
});
}
void login(String username, String password) async {
_loggedIn = true;
await _appCache.cacheUser();
notifyListeners();
}
void onboarded() async {
_onboardingComplete = true;
await _appCache.completeOnboarding();
notifyListeners();
}
void goToTab(index) {
_selectedTab = index;
notifyListeners();
}
void goToRecipes() {
_selectedTab = FooderlichTab.recipes;
notifyListeners();
}
void logout() async {
_initialized = false;
_selectedTab = 0;
await _appCache.invalidate();
initializeApp();
notifyListeners();
}
}
shared_preferences:ボトムナビゲーションのタブ情報保存
- 「shared_preferences」利用した、ボトムナビゲーションのどこのタブを開いていたかの保存・利用
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'colors.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'myrecipes/my_recipes_list.dart';
import 'recipes/recipe_list.dart';
import 'shopping/shopping_list.dart';
import 'package:flutter/material.dart';
class MainScreen extends StatefulWidget {
const MainScreen({Key? key}) : super(key: key);
_MainScreenState createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
int _selectedIndex = 0;
List<Widget> pageList = <Widget>[];
static const String prefSelectedIndexKey = 'selectedIndex';
void initState() {
super.initState();
pageList.add(const RecipeList());
pageList.add(const MyRecipesList());
pageList.add(const ShoppingList());
getCurrentIndex();
}
void saveCurrentIndex() async {
final prefs = await SharedPreferences.getInstance();
prefs.setInt(prefSelectedIndexKey, _selectedIndex);
}
void getCurrentIndex() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.containsKey(prefSelectedIndexKey)) {
setState(() {
final index = prefs.getInt(prefSelectedIndexKey);
if (index != null) {
_selectedIndex = index;
}
});
}
}
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
saveCurrentIndex();
}
Widget build(BuildContext context) {
String title;
switch (_selectedIndex) {
case 0:
title = 'Recipes';
break;
case 1:
title = 'Bookmarks';
break;
case 2:
title = 'Groceries';
break;
default:
title = 'Recipes';
break;
}
return Scaffold(
bottomNavigationBar: BottomNavigationBar(
items: [
BottomNavigationBarItem(
icon: SvgPicture.asset('assets/images/icon_recipe.svg',
color: _selectedIndex == 0 ? green : Colors.grey,
semanticsLabel: 'Recipes'),
label: 'Recipes'),
BottomNavigationBarItem(
icon: SvgPicture.asset('assets/images/icon_bookmarks.svg',
color: _selectedIndex == 1 ? green : Colors.grey,
semanticsLabel: 'Bookmarks'),
label: 'Bookmarks'),
BottomNavigationBarItem(
icon: SvgPicture.asset('assets/images/icon_shopping_list.svg',
color: _selectedIndex == 2 ? green : Colors.grey,
semanticsLabel: 'Groceries'),
label: 'Groceries'),
],
currentIndex: _selectedIndex,
selectedItemColor: green,
onTap: _onItemTapped,
),
appBar: AppBar(
elevation: 0,
backgroundColor: Colors.white,
systemOverlayStyle: const SystemUiOverlayStyle(
systemNavigationBarColor: Colors.white,
statusBarColor: Colors.white,
statusBarBrightness: Brightness.light,
statusBarIconBrightness: Brightness.dark,
systemNavigationBarDividerColor: Colors.white,
//Navigation bar divider color
systemNavigationBarIconBrightness:
Brightness.light, //navigation bar icon
),
title: Text(
title,
style: const TextStyle(
fontSize: 20, fontWeight: FontWeight.w500, color: Colors.black),
),
),
body: IndexedStack(
index: _selectedIndex,
children: pageList,
),
);
}
}
JSON形式のデータ取り扱い
-
JSONシリアライズを利用し、「Xxx.fromJson」と「Xxx.toJson」のインターフェイスのみ作成
-
準備
-
pubspec.yaml
へ、コード作成用dev_dependencies:
の追加も必要-
dependencies:
json_annotation: ^4.1.0
-
dev_dependencies:
build_runner: ^2.1.1
json_serializable: ^4.1.4
-
-
-
便利ポイント
-
# flutter pub run build_runner build
や、# flutter pub run build_runner watch
等で、recipe_model.g.dart
にコードを生成 -
json_annotationとjson_serializableの2つのパッケージを利用し、「Xxx.fromJson」と「Xxx.toJson」の中身を自動作成
-
-
主な関連ファイル
-
lib/network/recipe_model.dart
-
lib/network/recipe_model.g.dart
-
-
lib/network/recipe_model.dart
import 'package:json_annotation/json_annotation.dart';
/// 作成したファイル
part 'recipe_model.g.dart';
/// 【想定json】シリアル化しようとしているJSON
/// この章では、レシピアイテムのフィールドlabelとimageフィールドを使用
/// ※実際のものとやや異なる
/// {
/// "q": "pasta",
/// "from": 0,
/// "to": 10,
/// "more": true,
/// "count": 33060,
/// "hits": [
/// {
/// "recipe": {
/// "uri": "http://www.edamam.com/ontologies/edamam.owl#recipe_09b4dbdf0c7244c462a4d2622d88958e",
/// "label": "Pasta Frittata Recipe",
/// "image": "https://www.edamam.com/web-img/5a5/5a5220b7a65c911a1480502ed0532b5c.jpg",
/// "source": "Food Republic",
/// "url": "http://www.foodrepublic.com/2012/01/21/pasta-frittata-recipe",
/// }
/// ]
/// }
/// 【作成するクラス】
/// Add @JsonSerializable() class APIRecipeQuery
/// Add @JsonSerializable() class APIHits
/// Add @JsonSerializable() class APIRecipe
/// Add @JsonSerializable() class APIIngredients
/// 【作成コマンド】
/// flutter pub run build_runner watch
/// アノテーション「@JsonSerializable()」
/// 'recipe_model.g.dart'にコードを生成
/// →json_annotationとjson_serializableの2つの
/// パッケージを利用し、「Xxx.fromJson」と「Xxx.toJson」の中身を自動作成
()
class APIRecipeQuery {
/// 「_$APIRecipeQueryFromJson(json)」
/// 自動で'recipe_model.g.dart'に作成されるもの
factory APIRecipeQuery.fromJson(Map<String, dynamic> json) =>
_$APIRecipeQueryFromJson(json);
/// 「_$APIRecipeQueryToJson(this)」
/// 自動で'recipe_model.g.dart'に作成されるもの
Map<String, dynamic> toJson() => _$APIRecipeQueryToJson(this);
/// 実際のjsonでのフィールド値はq
(name: 'q')
String query;
int from;
int to;
bool more;
int count;
/// 【ポイント】
/// ここで、単純なStringやint値で無い値も、
/// 自分で新しく作成したものを定義(後ろにクラス作成)可能!
/// jsonの中の配列を、自分で定義したAPIHitsの配列とする
List<APIHits> hits;
APIRecipeQuery({
required this.query,
required this.from,
required this.to,
required this.more,
required this.count,
required this.hits,
});
}
()
class APIHits {
/// jsonの中の配列のAPIHitsの中身は、
/// "recipe"のMapとして、自分で定義したAPIRecipeとする
APIRecipe recipe;
APIHits({
required this.recipe,
});
factory APIHits.fromJson(Map<String, dynamic> json) =>
_$APIHitsFromJson(json);
Map<String, dynamic> toJson() => _$APIHitsToJson(this);
}
()
class APIRecipe {
String label;
String image;
String url;
/// 各"recipe"の値であるAPIRecipeの持ち物として、
/// "ingredients"は、二つのString値を持つMapの配列
/// APIIngredientsとする
List<APIIngredients> ingredients;
double calories;
double totalWeight;
double totalTime;
APIRecipe({
required this.label,
required this.image,
required this.url,
required this.ingredients,
required this.calories,
required this.totalWeight,
required this.totalTime,
});
factory APIRecipe.fromJson(Map<String, dynamic> json) =>
_$APIRecipeFromJson(json);
Map<String, dynamic> toJson() => _$APIRecipeToJson(this);
}
/// カロリーを文字列に変換するヘルパーメソッド
String getCalories(double? calories) {
if (calories == null) {
return '0 KCAL';
}
return calories.floor().toString() + ' KCAL';
}
/// 重量を文字列に変換するヘルパーメソッド
String getWeight(double? weight) {
if (weight == null) {
return '0g';
}
return weight.floor().toString() + 'g';
}
()
class APIIngredients {
(name: 'text')
String name;
double weight;
APIIngredients({
required this.name,
required this.weight,
});
factory APIIngredients.fromJson(Map<String, dynamic> json) =>
_$APIIngredientsFromJson(json);
Map<String, dynamic> toJson() => _$APIIngredientsToJson(this);
}
lib/network/recipe_model.g.dart
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'recipe_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
APIRecipeQuery _$APIRecipeQueryFromJson(Map<String, dynamic> json) =>
APIRecipeQuery(
query: json['q'] as String,
from: json['from'] as int,
to: json['to'] as int,
more: json['more'] as bool,
count: json['count'] as int,
/// ここもうまいこと自動で繋いでくれる
hits: (json['hits'] as List<dynamic>)
.map((e) => APIHits.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$APIRecipeQueryToJson(APIRecipeQuery instance) =>
<String, dynamic>{
'q': instance.query,
'from': instance.from,
'to': instance.to,
'more': instance.more,
'count': instance.count,
'hits': instance.hits,
};
APIHits _$APIHitsFromJson(Map<String, dynamic> json) => APIHits(
recipe: APIRecipe.fromJson(json['recipe'] as Map<String, dynamic>),
);
Map<String, dynamic> _$APIHitsToJson(APIHits instance) => <String, dynamic>{
'recipe': instance.recipe,
};
APIRecipe _$APIRecipeFromJson(Map<String, dynamic> json) => APIRecipe(
label: json['label'] as String,
image: json['image'] as String,
url: json['url'] as String,
ingredients: (json['ingredients'] as List<dynamic>)
.map((e) => APIIngredients.fromJson(e as Map<String, dynamic>))
.toList(),
calories: (json['calories'] as num).toDouble(),
totalWeight: (json['totalWeight'] as num).toDouble(),
totalTime: (json['totalTime'] as num).toDouble(),
);
Map<String, dynamic> _$APIRecipeToJson(APIRecipe instance) => <String, dynamic>{
'label': instance.label,
'image': instance.image,
'url': instance.url,
'ingredients': instance.ingredients,
'calories': instance.calories,
'totalWeight': instance.totalWeight,
'totalTime': instance.totalTime,
};
APIIngredients _$APIIngredientsFromJson(Map<String, dynamic> json) =>
APIIngredients(
name: json['text'] as String,
weight: (json['weight'] as num).toDouble(),
);
Map<String, dynamic> _$APIIngredientsToJson(APIIngredients instance) =>
<String, dynamic>{
'text': instance.name,
'weight': instance.weight,
};
※手動の場合を考えると
{
"recipe": {
"uri": "http://www.edamam.com/ontologies/edamam.owl#recipe_b79327d05b8e5b838ad6cfd9576b30b6",
"label": "Chicken Vesuvio"
}
}
/// 【確認のため追加】
/// 手動でJSONをシリアル化するコード
///
/// 【想定json】
/// {
/// "recipe": {
/// "uri": "http://www.edamam.com/ontologies/edamam.owl#recipe_b79327d05b8e5b838ad6cfd9576b30b6",
/// "label": "Chicken Vesuvio"
/// }
/// }
///
/// 2よりも多くの項目を持つと、大変になってくる
/// →json_annotationとjson_serializableの2つの
/// パッケージを利用し、「Xxx.fromJson」と「Xxx.toJson」の中身を自動作成
class Recipe {
final String uri;
final String label;
Recipe({
required this.uri,
required this.label,
});
factory Recipe.fromJson(Map<String, dynamic> json) {
return Recipe(
uri: json['uri'] as String,
label: json['label'] as String,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{'uri': uri, 'label': label};
}
}
スクロールの監視:下部までスクロール後にGet API再実施
- APIを利用し、取得したページをスクロールで確認 & 下までスクロール後に再度追加取得API実施
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../network/recipe_model.dart';
import '../../network/recipe_service.dart';
import '../colors.dart';
import '../recipe_card.dart';
import '../recipes/recipe_details.dart';
import '../widgets/custom_dropdown.dart';
class RecipeList extends StatefulWidget {
const RecipeList({Key? key}) : super(key: key);
_RecipeListState createState() => _RecipeListState();
}
class _RecipeListState extends State<RecipeList> {
static const String prefSearchKey = 'previousSearches';
late TextEditingController searchTextController;
/// スクロールを検知し、下まで見たら、
/// 次の検索を実行する用(一度に全てを検索はしない)
final ScrollController _scrollController = ScrollController();
List<APIHits> currentSearchList = [];
int currentCount = 0;
int currentStartPosition = 0;
int currentEndPosition = 20;
int pageCount = 20; // 1にすると、検索結果が1つのみとなる
bool hasMore = false;
bool loading = false;
bool inErrorState = false;
List<String> previousSearches = <String>[];
void initState() {
super.initState();
getPreviousSearches();
searchTextController = TextEditingController(text: '');
_scrollController
..addListener(() {
final triggerFetchMoreSize =
0.7 * _scrollController.position.maxScrollExtent;
if (_scrollController.position.pixels > triggerFetchMoreSize) {
if (hasMore &&
currentEndPosition < currentCount &&
!loading &&
!inErrorState) {
setState(() {
/// 0→20の検索の次に、下部までスクロールすると、次を実施する
/// https://api.edamam.com/search?app_id=[app_id]&app_key=[app_key]&q=tomato&from=20&to=40
///
loading = true;
currentStartPosition = currentEndPosition;
currentEndPosition =
min(currentStartPosition + pageCount, currentCount);
});
}
}
});
}
/// APIを実行し、response.body = Jsonの文字列を処理
/// APIRecipeQueryに変換する
/// (ここは、前章で用意したjsonファイルから読み込む場合と同様)
Future<APIRecipeQuery> getRecipeData(String query, int from, int to) async {
final recipeJson = await RecipeService().getRecipes(query, from, to);
final recipeMap = json.decode(recipeJson);
return APIRecipeQuery.fromJson(recipeMap);
}
void dispose() {
searchTextController.dispose();
super.dispose();
}
void savePreviousSearches() async {
final prefs = await SharedPreferences.getInstance();
prefs.setStringList(prefSearchKey, previousSearches);
}
void getPreviousSearches() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.containsKey(prefSearchKey)) {
final searches = prefs.getStringList(prefSearchKey);
if (searches != null) {
previousSearches = searches;
} else {
previousSearches = <String>[];
}
}
}
Widget build(BuildContext context) {
return Container(
color: Colors.white,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: <Widget>[
_buildSearchCard(),
_buildRecipeLoader(context),
],
),
),
);
}
Widget _buildSearchCard() {
return Card(
elevation: 4,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8.0))),
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
startSearch(searchTextController.text);
final currentFocus = FocusScope.of(context);
if (!currentFocus.hasPrimaryFocus) {
currentFocus.unfocus();
}
},
),
const SizedBox(
width: 6.0,
),
Expanded(
child: Row(
children: <Widget>[
Expanded(
child: TextField(
decoration: const InputDecoration(
border: InputBorder.none, hintText: 'Search'),
autofocus: false,
textInputAction: TextInputAction.done,
onSubmitted: (value) {
if (!previousSearches.contains(value)) {
previousSearches.add(value);
savePreviousSearches();
}
},
controller: searchTextController,
)),
PopupMenuButton<String>(
icon: const Icon(
Icons.arrow_drop_down,
color: lightGrey,
),
onSelected: (String value) {
searchTextController.text = value;
startSearch(searchTextController.text);
},
itemBuilder: (BuildContext context) {
return previousSearches
.map<CustomDropdownMenuItem<String>>((String value) {
return CustomDropdownMenuItem<String>(
text: value,
value: value,
callback: () {
setState(() {
previousSearches.remove(value);
Navigator.pop(context);
});
},
);
}).toList();
},
),
],
),
),
],
),
),
);
}
void startSearch(String value) {
setState(() {
currentSearchList.clear();
currentCount = 0;
currentEndPosition = pageCount;
currentStartPosition = 0;
hasMore = true;
value = value.trim();
if (!previousSearches.contains(value)) {
previousSearches.add(value);
savePreviousSearches();
}
});
}
Widget _buildRecipeLoader(BuildContext context) {
if (searchTextController.text.length < 3) {
return Container();
}
// TODO: change with new response
return FutureBuilder<APIRecipeQuery>(
// TODO: change with new RecipeService
/// 検索APIを実行→このファイル内で作成した「Future<APIRecipeQuery>」返す関数
/// trim()で不要な空白削除
future: getRecipeData(searchTextController.text.trim(),
currentStartPosition, currentEndPosition),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasError) {
return Center(
child: Text(
snapshot.error.toString(),
textAlign: TextAlign.center,
textScaleFactor: 1.3,
),
);
}
loading = false;
// TODO: change with new snapshot
final query = snapshot.data;
inErrorState = false;
if (query != null) {
print('query');
print(query.count);
print(query.more);
print(query.hits);
print(query.to);
/// queryの中から必要な情報を抜き出す
currentCount = query.count;
hasMore = query.more;
currentSearchList.addAll(query.hits);
/// データの最後にいない場合はcurrentEndPositionを現在の場所に設定
/// →検索結果の数がすくない場合
if (query.to < currentEndPosition) {
currentEndPosition = query.to;
print('currentEndPosition:1');
print(currentEndPosition);
}
print('currentEndPosition:2');
print(currentEndPosition);
}
return _buildRecipeList(context, currentSearchList);
}
/// コネクションが正常終了しない場合:検索結果がある場合は、現状維持
else {
if (currentCount == 0) {
// Show a loading indicator while waiting for the movies
return const Center(
child: CircularProgressIndicator(),
);
} else {
return _buildRecipeList(context, currentSearchList);
}
}
},
);
}
/// 現在の検索結果「currentSearchList」の表示
Widget _buildRecipeList(BuildContext recipeListContext, List<APIHits> hits) {
/// デバイスの画面サイズを取得
final size = MediaQuery.of(context).size;
const itemHeight = 310;
final itemWidth = size.width / 2;
return Flexible(
child: GridView.builder(
controller: _scrollController,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: (itemWidth / itemHeight),
),
itemCount: hits.length,
itemBuilder: (BuildContext context, int index) {
return _buildRecipeCard(recipeListContext, hits, index);
},
),
);
}
/// 検索結果のそれぞれのレシピ表示
Widget _buildRecipeCard(
BuildContext topLevelContext, List<APIHits> hits, int index) {
final recipe = hits[index].recipe;
return GestureDetector(
onTap: () {
Navigator.push(topLevelContext, MaterialPageRoute(
builder: (context) {
return const RecipeDetails();
},
));
},
child: recipeCard(recipe),
);
}
}
Discussion