1. はじめに
Flutter は、1 つのコードベースからネイティブにコンパイルして、モバイル、ウェブ、デスクトップの美しいアプリケーションを作成できる Google の UI ツールキットです。この Codelab では、Android、iOS、ウェブ、Windows、macOS、Linux のいずれであっても、それが実行されているプラットフォームに適応する Flutter アプリの作成方法を学びます。
学習内容
- モバイル用に設計された Flutter アプリを、Flutter でサポートされている 6 つのプラットフォームすべてで動作するように改良する方法
- プラットフォームを検出するための各種の Flutter API と、それぞれの API を使用すべき状況
- ウェブでアプリを実行する際の制約事項と前提事項に対する適応
- 複数のパッケージを共存させながら使用して、Flutter の全プラットフォームをサポートする方法
作成するアプリの概要
この Codelab では、Android と iOS に対応した、Flutter の YouTube 再生リストを調べるアプリを最初に作成します。次に、このアプリケーションを、与えられたサイズのアプリケーション ウィンドウに情報を表示する方法を変更して、3 つのデスクトップ プラットフォーム(Windows、macOS、Linux)で機能するように適応させます。さらに、ウェブユーザーが想定するように、アプリに表示されるテキストを選択可能にして、アプリケーションをウェブに適応させます。最後に、アプリに認証を追加して、Flutter チームが作成した再生リストではなく、自分の再生リストを調べることができるようにします。このために、Android、iOS、ウェブでは、Windows、macOS、Linux の 3 つのデスクトップ プラットフォームとは異なる認証方法が必要となります。
以下は、この Flutter アプリの Android と iOS でのスクリーンショットです。
以下は、このアプリが macOS のワイド スクリーン レイアウトで実行されているときのスクリーンショットです。
この Codelab では、モバイル向けの Flutter アプリを、6 つあるすべての Flutter プラットフォームで機能するアダプティブ アプリに変えることに集中します。関連のない概念とコードブロックについては軽く触れるにとどめ、そのままコピーして貼り付けられるようにしています。
この Codelab で学びたいことは次のどれですか?
2. Flutter の開発環境をセットアップする
このラボを完了するには、Flutter SDK とエディタの 2 つのソフトウェアが必要です。
この Codelab は、次のいずれかのデバイスを使って実行できます。
- パソコンに接続され、デベロッパー モードに設定された物理デバイス(Android または iOS)
- iOS シミュレータ(Xcode ツールのインストールが必要)
- Android Emulator(Android Studio でセットアップが必要)
- ブラウザ(デバッグには Chrome が必要)
- Windows、Linux、macOS のデスクトップ アプリケーション。開発はデプロイする予定のプラットフォームで行う必要があります。たとえば、Windows のデスクトップ アプリを開発する場合は、適切なビルドチェーンにアクセスできるように Windows で開発する必要があります。オペレーティング システム固有の要件については、docs.flutter.dev/desktop に詳しい説明があります。
3. 始める
開発環境を確認する
開発準備がすべて終わっていることを確認するための最も簡単な方法は、次のコマンドを実行することです。
$ flutter doctor
チェックマークのないものが表示された場合は、次を実行して、何が間違っているかを詳しく調べます。
$ flutter doctor -v
モバイル開発用またはデスクトップ開発用のデベロッパー ツールをインストールする必要があるかもしれません。ツールの設定方法に関する詳細は、ホスト オペレーティング システムによって異なります。Flutter のインストールに関するドキュメントを確認してください。
Flutter プロジェクトを作成する
Flutter コマンドライン ツールを使って Flutter プロジェクトを作成することで、デスクトップ アプリ向けに Flutter のコーディングを簡単に開始できます。また、IDE で UI から Flutter プロジェクトを作成するワークフローが提供されている場合もあります。
$ flutter create adaptive_app Creating project adaptive_app [Eliding listing of created files] Running "flutter pub get" in adaptive_app... 2,445ms Wrote 128 files. All done! In order to run your application, type: $ cd adaptive_app $ flutter run Your application code is in adaptive_app/lib/main.dart.
問題なく動作していることを確認するために、以下のように、ボイラープレート Flutter アプリケーションをモバイルアプリとして実行します。または、IDE でこのプロジェクトを開き、その機能を使用してアプリケーションを実行します。さきほどの作業のおかげで、デスクトップ アプリケーションとして実行する以外の選択肢はなくなっているはずです。
$ flutter run Launching lib/main.dart on iPhone 12 in debug mode... Running Xcode build... └─Compiling, linking and signing... 15.9s Xcode build done. 41.2s Syncing files to device iPhone 12... 213ms Flutter run key commands. r Hot reload. 🔥🔥🔥 R Hot restart. h List all available interactive commands. d Detach (terminate "flutter run" but leave application running). c Clear the screen q Quit (terminate the application on the device). 💪 Running with sound null safety 💪 An Observatory debugger and profiler on iPhone 12 is available at: http://127.0.0.1:60071/t9hy0pnIWgE=/ Activating Dart DevTools... 3.3s The Flutter DevTools debugger and profiler on iPhone 12 is available at: http://127.0.0.1:9101?uri=http://127.0.0.1:60071/t9hy0pnIWgE=/
これでアプリが実行されるようになりました。以下のように lib/main.dart の内容を変更し、ホットリロードを行ってコンテンツを更新します。ホットリロードを行う方法は、アプリの実行方法によって異なります。コマンドラインからアプリを実行している場合は、コンソール ウィンドウに「r」を入力します。エディタから実行している場合は、ファイルを保存するだけでホットリロードがトリガーされます。
lib/main.dart
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const ResizeablePage(),
);
}
}
class ResizeablePage extends StatelessWidget {
const ResizeablePage({super.key});
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final themePlatform = Theme.of(context).platform;
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'Window properties',
style: Theme.of(context).textTheme.headline5,
),
const SizedBox(height: 8),
SizedBox(
width: 350,
child: Table(
textBaseline: TextBaseline.alphabetic,
children: <TableRow>[
_fillTableRow(
context: context,
property: 'Window Size',
value: '${mediaQuery.size.width.toStringAsFixed(1)} x '
'${mediaQuery.size.height.toStringAsFixed(1)}',
),
_fillTableRow(
context: context,
property: 'Device Pixel Ratio',
value: mediaQuery.devicePixelRatio.toStringAsFixed(2),
),
_fillTableRow(
context: context,
property: 'Platform.isXXX',
value: platformDescription(),
),
_fillTableRow(
context: context,
property: 'Theme.of(ctx).platform',
value: themePlatform.toString(),
),
],
),
),
],
),
),
);
}
TableRow _fillTableRow(
{required BuildContext context,
required String property,
required String value}) {
return TableRow(
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.baseline,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(property),
),
),
TableCell(
verticalAlignment: TableCellVerticalAlignment.baseline,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(value),
),
),
],
);
}
String platformDescription() {
if (kIsWeb) {
return 'Web';
} else if (Platform.isAndroid) {
return 'Android';
} else if (Platform.isIOS) {
return 'iOS';
} else if (Platform.isWindows) {
return 'Windows';
} else if (Platform.isMacOS) {
return 'macOS';
} else if (Platform.isLinux) {
return 'Linux';
} else if (Platform.isFuchsia) {
return 'Fuchsia';
} else {
return 'Unknown';
}
}
}
上記のアプリは、プラットフォームを検知して、それに適応する方法を把握してもらえるように設計したものです。以下は、このアプリが Android と iOS でネイティブに動作しているところです。
以下は、同じコードが macOS と、macOS 内の Chrome でネイティブに動作しているところです。
ここで重要なのは、一見したところ、Flutter は、コンテンツをそれが動作しているディスプレイに適応させるために、できることを行っているということです。これらのスクリーンショットを撮影したノートパソコンが高解像度の Mac ディスプレイを備えているため、このアプリの macOS 版とウェブ版の両方がデバイス ピクセル比 2 でレンダリングされています。それに対して、iPhone 12 では 3、Pixel 2 では 2.63 になっています。すべての場合で表示されているテキストは概ね同じなので、デベロッパーの仕事はかなり簡単になっています。
もう一点は、コードが実行されているプラットフォームを確認する 2 つの方法によって、結果の値が異なることです。1 つ目の方法では dart:io
からインポートした Platform
オブジェクトを調べ、2 つ目の方法(ウィジェットの build
メソッドの中でのみ利用可能)では BuildContext
引数から Theme
オブジェクトを取得します。
2 つの方法で得られる結果が異なるのは、目的が異なるためです。dart:io
からインポートした Platform
オブジェクトは、レンダリング方法から独立した判断をするために使用するものです。典型的な例は、どのプラグインを使用するかの判断です。物理プラットフォームに対応しているネイティブ実装が存在する場合と存在しない場合があります。
BuildContext
から Theme
を抽出するのは、テーマ中心の実装判断のためです。典型的な例は、Slider.adaptive
で説明しているような、Material スライダーと Cupertino スライダーのどちらを使うかの判断です。
次のセクションでは、Android と iOS だけに最適化された、基本的な YouTube 再生リスト エクスプローラ アプリを作成します。それ以降のセクションでは、パソコンとウェブでの動作を改善する、さまざまな適応を追加します。
4. モバイルアプリを作成する
パッケージを追加する
このアプリでは、YouTube Data API、状態管理、テーマ設定を使用するために、さまざまな Flutter パッケージを使用します。
$ flutter pub add googleapis Resolving dependencies... + _discoveryapis_commons 1.0.3 async 2.8.2 (2.9.0 available) characters 1.2.0 (1.2.1 available) clock 1.1.0 (1.1.1 available) fake_async 1.3.0 (1.3.1 available) + googleapis 9.1.0 + http 0.13.4 + http_parser 4.0.1 matcher 0.12.11 (0.12.12 available) material_color_utilities 0.1.4 (0.1.5 available) meta 1.7.0 (1.8.0 available) path 1.8.1 (1.8.2 available) source_span 1.8.2 (1.9.0 available) string_scanner 1.1.0 (1.1.1 available) term_glyph 1.2.0 (1.2.1 available) test_api 0.4.9 (0.4.12 available) + typed_data 1.3.1 Changed 5 dependencies!
1 つ目のパッケージ googleapis
は、Google API へのアクセス用に生成された Dart ライブラリです。
$ flutter pub add http Resolving dependencies... async 2.8.2 (2.9.0 available) characters 1.2.0 (1.2.1 available) clock 1.1.0 (1.1.1 available) fake_async 1.3.0 (1.3.1 available) matcher 0.12.11 (0.12.12 available) material_color_utilities 0.1.4 (0.1.5 available) meta 1.7.0 (1.8.0 available) path 1.8.1 (1.8.2 available) source_span 1.8.2 (1.9.0 available) string_scanner 1.1.0 (1.1.1 available) term_glyph 1.2.0 (1.2.1 available) test_api 0.4.9 (0.4.12 available) Got dependencies!
http
パッケージは、API キーを使用して YouTube Data API にアクセスする機能を構築するために利用します。
$ flutter pub add provider Resolving dependencies... async 2.8.2 (2.9.0 available) characters 1.2.0 (1.2.1 available) clock 1.1.0 (1.1.1 available) fake_async 1.3.0 (1.3.1 available) matcher 0.12.11 (0.12.12 available) material_color_utilities 0.1.4 (0.1.5 available) meta 1.7.0 (1.8.0 available) + nested 1.0.0 path 1.8.1 (1.8.2 available) + provider 6.0.3 source_span 1.8.2 (1.9.0 available) string_scanner 1.1.0 (1.1.1 available) term_glyph 1.2.0 (1.2.1 available) test_api 0.4.9 (0.4.12 available) Changed 2 dependencies!
状態管理には、provider
を使用します。
$ flutter pub add url_launcher Resolving dependencies... async 2.8.2 (2.9.0 available) characters 1.2.0 (1.2.1 available) clock 1.1.0 (1.1.1 available) fake_async 1.3.0 (1.3.1 available) + flutter_web_plugins 0.0.0 from sdk flutter + js 0.6.4 matcher 0.12.11 (0.12.12 available) material_color_utilities 0.1.4 (0.1.5 available) meta 1.7.0 (1.8.0 available) path 1.8.1 (1.8.2 available) + plugin_platform_interface 2.1.2 source_span 1.8.2 (1.9.0 available) string_scanner 1.1.0 (1.1.1 available) term_glyph 1.2.0 (1.2.1 available) test_api 0.4.9 (0.4.12 available) + url_launcher 6.1.4 + url_launcher_android 6.0.17 + url_launcher_ios 6.0.17 + url_launcher_linux 3.0.1 + url_launcher_macos 3.0.1 + url_launcher_platform_interface 2.1.0 + url_launcher_web 2.0.12 + url_launcher_windows 3.0.1 Changed 11 dependencies!
url_launcher
は、再生リストの動画を開始する手段として使用します。解決される依存関係からわかるように、url_launcher
には、デフォルトの Android と iOS 用以外にも、Windows、macOS、Linux、ウェブ用の実装が用意されています。これは、プラットフォーム固有のコードを作成する必要のない機能の 1 つです。
$ flutter pub add flex_color_scheme Resolving dependencies... async 2.8.2 (2.9.0 available) characters 1.2.0 (1.2.1 available) clock 1.1.0 (1.1.1 available) fake_async 1.3.0 (1.3.1 available) + flex_color_scheme 5.1.0 matcher 0.12.11 (0.12.12 available) material_color_utilities 0.1.4 (0.1.5 available) meta 1.7.0 (1.8.0 available) path 1.8.1 (1.8.2 available) source_span 1.8.2 (1.9.0 available) string_scanner 1.1.0 (1.1.1 available) term_glyph 1.2.0 (1.2.1 available) test_api 0.4.9 (0.4.12 available) Changed 1 dependency!m
このパッケージは、適切なデフォルト カラーパターンをアプリに設定するためだけのものです。機能の全貌については、flex_color_scheme
のドキュメントをご覧ください。
$ flutter pub add go_router Resolving dependencies... async 2.9.0 (2.10.0 available) boolean_selector 2.1.0 (2.1.1 available) collection 1.16.0 (1.17.0 available) + flutter_web_plugins 0.0.0 from sdk flutter + go_router 5.2.0 + js 0.6.4 (0.6.5 available) + logging 1.1.0 matcher 0.12.12 (0.12.13 available) material_color_utilities 0.1.5 (0.2.0 available) source_span 1.9.0 (1.9.1 available) stack_trace 1.10.0 (1.11.0 available) stream_channel 2.1.0 (2.1.1 available) string_scanner 1.1.1 (1.2.0 available) test_api 0.4.12 (0.4.16 available) vector_math 2.1.2 (2.1.4 available) Changed 4 dependencies!
画面間のナビゲーションを実現するには、go_router をプロジェクトに追加します。
このパッケージには、Flutter の Router を使用してナビゲーションするための便利な URL ベースの API が用意されています。
モバイルアプリ向けに url_launcher
を設定する
url_launcher
プラグインには、Android と iOS のランナーアプリの設定が必要です。iOS の Flutter ランナーでは、以下の行を辞書 plist
に追加します。
ios/Runner/Info.plist
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
<string>tel</string>
<string>mailto</string>
</array>
Android の Flutter ランナーでは、以下の行を Manifest.xml
に追加します。この queries
ノードを、manifest
ノードの直接の子で、かつ application
ノードと同レベルになるように追加します。
android/app/src/main/AndroidManifest.xml
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.DIAL" />
<data android:scheme="tel" />
</intent>
<intent>
<action android:name="android.intent.action.SEND" />
<data android:mimeType="*/*" />
</intent>
</queries>
上記の必要な設定変更の詳細については、url_launcher
のドキュメントをご覧ください。
YouTube Data API にアクセスする
YouTube Data API にアクセスして再生リストを一覧するには、API プロジェクトを作成して必要な API キーを生成する必要があります。以下の手順では、Google アカウントをすでに持っていることを前提としていますので、まだの場合は作成してください。
以下のように、Play Console にアクセスして、API プロジェクトを作成します。
プロジェクトができたら、API ライブラリのページに移動します。検索ボックスに「youtube」と入力し、「youtube data api v3」を選択します。
YouTube Data API v3 の詳細ページで、この API を有効にします。
API が有効になったら、認証情報のページに移動して API キーを作成します。
数秒後に、ダイアログが開き、新規の API キーが表示されます。このキーは、すぐ後で使用します。
コードを追加する
この手順の残りの部分では、コメントのない、モバイルアプリを作成するための大量のコードをカット アンド ペーストします。この Codelab の目的は、モバイルアプリを作成し、それをデスクトップとウェブの両方に適応させることです。モバイル向け Flutter アプリの作成の初歩については、初めての Flutter アプリの作成、パート 1、パート 2、Flutter を使用した美しい UI の作成をご覧ください。
以下のファイルを追加します。最初は、アプリの状態オブジェクトです。
lib/src/app_state.dart
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:http/http.dart' as http;
class FlutterDevPlaylists extends ChangeNotifier {
FlutterDevPlaylists({
required String flutterDevAccountId,
required String youTubeApiKey,
}) : _flutterDevAccountId = flutterDevAccountId {
_api = YouTubeApi(
_ApiKeyClient(
client: http.Client(),
key: youTubeApiKey,
),
);
_loadPlaylists();
}
Future<void> _loadPlaylists() async {
String? nextPageToken;
_playlists.clear();
do {
final response = await _api.playlists.list(
['snippet', 'contentDetails', 'id'],
channelId: _flutterDevAccountId,
maxResults: 50,
pageToken: nextPageToken,
);
_playlists.addAll(response.items!);
_playlists.sort((a, b) => a.snippet!.title!
.toLowerCase()
.compareTo(b.snippet!.title!.toLowerCase()));
notifyListeners();
} while (nextPageToken != null);
}
final String _flutterDevAccountId;
late final YouTubeApi _api;
final List<Playlist> _playlists = [];
List<Playlist> get playlists => UnmodifiableListView(_playlists);
final Map<String, List<PlaylistItem>> _playlistItems = {};
List<PlaylistItem> playlistItems({required String playlistId}) {
if (!_playlistItems.containsKey(playlistId)) {
_playlistItems[playlistId] = [];
_retrievePlaylist(playlistId);
}
return UnmodifiableListView(_playlistItems[playlistId]!);
}
Future<void> _retrievePlaylist(String playlistId) async {
String? nextPageToken;
do {
var response = await _api.playlistItems.list(
['snippet', 'contentDetails'],
playlistId: playlistId,
maxResults: 25,
pageToken: nextPageToken,
);
var items = response.items;
if (items != null) {
_playlistItems[playlistId]!.addAll(items);
}
notifyListeners();
nextPageToken = response.nextPageToken;
} while (nextPageToken != null);
}
}
class _ApiKeyClient extends http.BaseClient {
_ApiKeyClient({required this.key, required this.client});
final String key;
final http.Client client;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
final url = request.url.replace(queryParameters: <String, List<String>>{
...request.url.queryParametersAll,
'key': [key]
});
return client.send(http.Request(request.method, url));
}
}
次は、個別の再生リストの詳細ページを追加します。
lib/src/playlist_details.dart
import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';
import 'app_state.dart';
class PlaylistDetails extends StatelessWidget {
const PlaylistDetails(
{required this.playlistId, required this.playlistName, super.key});
final String playlistId;
final String playlistName;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(playlistName),
),
body: Consumer<FlutterDevPlaylists>(
builder: (context, playlists, _) {
final playlistItems = playlists.playlistItems(playlistId: playlistId);
if (playlistItems.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return _PlaylistDetailsListView(playlistItems: playlistItems);
},
),
);
}
}
class _PlaylistDetailsListView extends StatelessWidget {
const _PlaylistDetailsListView({required this.playlistItems});
final List<PlaylistItem> playlistItems;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: playlistItems.length,
itemBuilder: (context, index) {
final playlistItem = playlistItems[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
alignment: Alignment.center,
children: [
if (playlistItem.snippet!.thumbnails!.high != null)
Image.network(playlistItem.snippet!.thumbnails!.high!.url!),
_buildGradient(context),
_buildTitleAndSubtitle(context, playlistItem),
_buildPlayButton(context, playlistItem),
],
),
),
);
},
);
}
Widget _buildGradient(BuildContext context) {
return Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.transparent, Theme.of(context).backgroundColor],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.5, 0.95],
),
),
),
);
}
Widget _buildTitleAndSubtitle(
BuildContext context, PlaylistItem playlistItem) {
return Positioned(
left: 20,
right: 0,
bottom: 20,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
playlistItem.snippet!.title!,
style: Theme.of(context).textTheme.bodyText1!.copyWith(
fontSize: 18,
// fontWeight: FontWeight.bold,
),
),
if (playlistItem.snippet!.videoOwnerChannelTitle != null)
Text(
playlistItem.snippet!.videoOwnerChannelTitle!,
style: Theme.of(context).textTheme.bodyText2!.copyWith(
fontSize: 12,
),
),
],
),
);
}
Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
return Stack(
alignment: AlignmentDirectional.center,
children: [
Container(
width: 42,
height: 42,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(
Radius.circular(21),
),
),
),
Link(
uri: Uri.parse(
'https://www.youtube.com/watch?v=${playlistItem.snippet!.resourceId!.videoId}'),
builder: (context, followLink) => IconButton(
onPressed: followLink,
color: Colors.red,
icon: const Icon(Icons.play_circle_fill),
iconSize: 45,
),
),
],
);
}
}
次は、再生リストのリストを追加します。
lib/src/playlists.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'app_state.dart';
class Playlists extends StatelessWidget {
const Playlists({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('FlutterDev Playlists'),
),
body: Consumer<FlutterDevPlaylists>(
builder: (context, flutterDev, child) {
final playlists = flutterDev.playlists;
if (playlists.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
return _PlaylistsListView(items: playlists);
},
),
);
}
}
class _PlaylistsListView extends StatelessWidget {
const _PlaylistsListView({required this.items});
final List<Playlist> items;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
var playlist = items[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
leading: Image.network(
playlist.snippet!.thumbnails!.default_!.url!,
),
title: Text(playlist.snippet!.title!),
subtitle: Text(
playlist.snippet!.description!,
),
onTap: () {
context.go(
Uri(
path: '/playlist/${playlist.id}',
queryParameters: <String, String>{
'title': playlist.snippet!.title!
},
).toString(),
);
},
),
);
},
);
}
}
さらに、main.dart
ファイルの内容を次のように置き換えます。
lib/main.dart
import 'dart:io';
import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'src/app_state.dart';
import 'src/playlist_details.dart';
import 'src/playlists.dart';
// From https://www.youtube.com/channel/UCwXdFgeE9KYzlDdR7TG9cMw
const flutterDevAccountId = 'UCwXdFgeE9KYzlDdR7TG9cMw';
// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';
final _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (context, state) {
return const Playlists();
},
routes: <RouteBase>[
GoRoute(
path: 'playlist/:id',
builder: (context, state) {
final title = state.queryParams['title']!;
final id = state.params['id']!;
return PlaylistDetails(
playlistId: id,
playlistName: title,
);
},
),
],
),
],
);
void main() {
if (youTubeApiKey == 'AIzaNotAnApiKey') {
print('youTubeApiKey has not been configured.');
exit(1);
}
runApp(ChangeNotifierProvider<FlutterDevPlaylists>(
create: (context) => FlutterDevPlaylists(
flutterDevAccountId: flutterDevAccountId,
youTubeApiKey: youTubeApiKey,
),
child: const PlaylistsApp(),
));
}
class PlaylistsApp extends StatelessWidget {
const PlaylistsApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'FlutterDev Playlists',
theme: FlexColorScheme.light(
scheme: FlexScheme.red,
useMaterial3: true,
).toTheme,
darkTheme: FlexColorScheme.dark(
scheme: FlexScheme.red,
useMaterial3: true,
).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
このコードを Android と iOS で実行するまで、あと少しです。もう少し変更します。14 行目の youTubeApiKey
定数を、前のステップで生成した YouTube API のキーに変更します。
lib/main.dart
// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';
このアプリを macOS で実行するには、次のように、HTTP リクエストを発行できるようにする必要があります。次のように DebugProfile.entitlements
と Release.entitilements
の両方のファイルを編集します。
macos/Runner/DebugProfile.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<!-- add the following two lines -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
macos/Runner/Release.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- add the following two lines -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
アプリを実行する
これでアプリが完成しました。Android Emulator や iPhone シミュレータで正常に実行できるはずです。Flutter の再生リストが表示され、再生リストを選択すると再生リストに動画が表示されます。そして、再生ボタンをクリックすると、その動画を視聴するための YouTube のインターフェースが起動されます。
しかし、デスクトップで実行すると、通常のデスクトップ サイズのウィンドウに拡張されたときに、不自然なレイアウトで表示されます。次のステップでは、これを適応させる方法について見ていきます。
5. デスクトップに適応する
デスクトップでの問題
Windows、macOS、Linux のいずれかのネイティブなデスクトップ プラットフォームでアプリを実行すると、興味深い問題が発生します。機能は問題ありませんが、おかしな表示がされます。
これを修正するには、スプリット ビューを追加して、左側に再生リスト、右側に動画を表示します。ただし、このレイアウトを作動させるのは、Android と iOS 以外で実行されていて、ウィンドウに十分な幅がある場合だけです。以下の手順で、この機能の実装方法を示します。
まず、このレイアウトの構築に利用する split_view
パッケージを追加します。
$ flutter pub add split_view Resolving dependencies... + split_view 3.1.0 test_api 0.4.3 (0.4.8 available) Changed 1 dependency!
アダプティブ ウィジェットを導入する
この Codelab で使用するパターンは、画面幅、プラットフォーム テーマなどの属性に応じて実装を選択する Adaptive ウィジェットの導入です。ここでは、Playlists
と PlaylistDetails
との間のやり取りを再構築する AdaptivePlaylists
ウィジェットを導入します。lib/main.dart
ファイルを次のように編集します。
lib/main.dart
import 'dart:io';
import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'src/adaptive_playlists.dart';
import 'src/app_state.dart';
import 'src/playlist_details.dart';
// From https://www.youtube.com/channel/UCwXdFgeE9KYzlDdR7TG9cMw
const flutterDevAccountId = 'UCwXdFgeE9KYzlDdR7TG9cMw';
// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';
final _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (context, state) {
return const AdaptivePlaylists();
},
routes: <RouteBase>[
GoRoute(
path: 'playlist/:id',
builder: (context, state) {
final title = state.queryParams['title']!;
final id = state.params['id']!;
return Scaffold(
appBar: AppBar(title: Text(title)),
body: PlaylistDetails(
playlistId: id,
playlistName: title,
),
);
},
),
],
),
],
);
void main() {
if (youTubeApiKey == 'AIzaNotAnApiKey') {
print('youTubeApiKey has not been configured.');
exit(1);
}
runApp(ChangeNotifierProvider<FlutterDevPlaylists>(
create: (context) => FlutterDevPlaylists(
flutterDevAccountId: flutterDevAccountId,
youTubeApiKey: youTubeApiKey,
),
child: const PlaylistsApp(),
));
}
class PlaylistsApp extends StatelessWidget {
const PlaylistsApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'FlutterDev Playlists',
theme: FlexColorScheme.light(
scheme: FlexScheme.red,
useMaterial3: true,
).toTheme,
darkTheme: FlexColorScheme.dark(
scheme: FlexScheme.red,
useMaterial3: true,
).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
次に、AdaptivePlaylist ウィジェットのファイルを作成します。
lib/src/adaptive_playlists.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:split_view/split_view.dart';
import 'playlist_details.dart';
import 'playlists.dart';
class AdaptivePlaylists extends StatelessWidget {
const AdaptivePlaylists({super.key});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final targetPlatform = Theme.of(context).platform;
if (targetPlatform == TargetPlatform.android ||
targetPlatform == TargetPlatform.iOS ||
screenWidth <= 600) {
return const NarrowDisplayPlaylists();
} else {
return const WideDisplayPlaylists();
}
}
}
class NarrowDisplayPlaylists extends StatelessWidget {
const NarrowDisplayPlaylists({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('FlutterDev Playlists')),
body: Playlists(
playlistSelected: (playlist) {
context.go(
Uri(
path: '/playlist/${playlist.id}',
queryParameters: <String, String>{
'title': playlist.snippet!.title!
},
).toString(),
);
},
),
);
}
}
class WideDisplayPlaylists extends StatefulWidget {
const WideDisplayPlaylists({super.key});
@override
State<WideDisplayPlaylists> createState() => _WideDisplayPlaylistsState();
}
class _WideDisplayPlaylistsState extends State<WideDisplayPlaylists> {
Playlist? selectedPlaylist;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: selectedPlaylist == null
? const Text('FlutterDev Playlists')
: Text('FlutterDev Playlist: ${selectedPlaylist!.snippet!.title!}'),
),
body: SplitView(
viewMode: SplitViewMode.Horizontal,
children: [
Playlists(playlistSelected: (playlist) {
setState(() {
selectedPlaylist = playlist;
});
}),
if (selectedPlaylist != null)
PlaylistDetails(
playlistId: selectedPlaylist!.id!,
playlistName: selectedPlaylist!.snippet!.title!)
else
const Center(
child: Text('Select a playlist'),
),
],
),
);
}
}
このファイルは、いくつかの理由から興味深いものとなっています。まず、ウィンドウの幅を両方とも使用し(MediaQuery.of(context).size.width
を使用)、テーマを調査して(Theme.of(context).platform
を使用)、SplitView
ウィジェットで幅の広いレイアウトを表示するか、それを使用せずに狭いレイアウトを表示するかを決めています。
2 つ目に気付くのは、前にハードコードしたナビゲーション処理で対応していることです。そのために、Playlists
ウィジェットでコールバックの引数を外に出しています。このコールバックは、ユーザーが再生リストを選択したので、その再生リストの表示に必要な処理を行う必要があることを、自分を取り囲んでいるコードに通知するコールバックです。さらに気付くのは、Scaffold
が Playlists
ウィジェットと PlaylistDetails
とウィジェットからくくり出されたため、これらのウィジェットがトップレベルではなくなったことです。
次に、src/lib/playlists.dart
ファイルを以下のように編集します。
lib/src/playlists.dart
import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'app_state.dart';
class Playlists extends StatelessWidget {
const Playlists({super.key, required this.playlistSelected});
final PlaylistsListSelected playlistSelected;
@override
Widget build(BuildContext context) {
return Consumer<FlutterDevPlaylists>(
builder: (context, flutterDev, child) {
final playlists = flutterDev.playlists;
if (playlists.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
return _PlaylistsListView(
items: playlists,
playlistSelected: playlistSelected,
);
},
);
}
}
typedef PlaylistsListSelected = void Function(Playlist playlist);
class _PlaylistsListView extends StatefulWidget {
const _PlaylistsListView({
required this.items,
required this.playlistSelected,
});
final List<Playlist> items;
final PlaylistsListSelected playlistSelected;
@override
State<_PlaylistsListView> createState() => _PlaylistsListViewState();
}
class _PlaylistsListViewState extends State<_PlaylistsListView> {
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _scrollController,
itemCount: widget.items.length,
itemBuilder: (context, index) {
var playlist = widget.items[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
leading: Image.network(
playlist.snippet!.thumbnails!.default_!.url!,
),
title: Text(playlist.snippet!.title!),
subtitle: Text(
playlist.snippet!.description!,
),
onTap: () {
widget.playlistSelected(playlist);
},
),
);
},
);
}
}
このファイルには多数の変更があります。前述の playlistSelected コールバックの導入以外にも、Scaffold
ウィジェットが排除され、_PlaylistsListView
ウィジェットがステートレスからステートフルに変えられています。この変更が必要となる理由は、構築と破棄が必要な被所有 ScrollController
を導入したためです。
ScrollController
の導入は、幅広のレイアウトで 2 つの ListView
ウィジェットを並べているために必要となることから、興味深いものとなっています。スマートフォンでは 1 つの ListView
を使うものとされていたので、すべての ListView
が、それぞれのライフサイクルの間、寿命の長い 1 つの ScrollController との間で接続と切り離しを行うこともあり得ます。それとは異なり、デスクトップの世界では、複数の ListView
を並べることは理にかなっています。
最後に、lib/src/playlist_details.dart ファイルを次のように編集します。
lib/src/playlist_details.dart
import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';
import 'app_state.dart';
class PlaylistDetails extends StatelessWidget {
const PlaylistDetails(
{required this.playlistId, required this.playlistName, super.key});
final String playlistId;
final String playlistName;
@override
Widget build(BuildContext context) {
return Consumer<FlutterDevPlaylists>(
builder: (context, playlists, _) {
final playlistItems = playlists.playlistItems(playlistId: playlistId);
if (playlistItems.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return _PlaylistDetailsListView(playlistItems: playlistItems);
},
);
}
}
class _PlaylistDetailsListView extends StatefulWidget {
const _PlaylistDetailsListView({required this.playlistItems});
final List<PlaylistItem> playlistItems;
@override
State<_PlaylistDetailsListView> createState() =>
_PlaylistDetailsListViewState();
}
class _PlaylistDetailsListViewState extends State<_PlaylistDetailsListView> {
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _scrollController,
itemCount: widget.playlistItems.length,
itemBuilder: (context, index) {
final playlistItem = widget.playlistItems[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
alignment: Alignment.center,
children: [
if (playlistItem.snippet!.thumbnails!.high != null)
Image.network(playlistItem.snippet!.thumbnails!.high!.url!),
_buildGradient(context),
_buildTitleAndSubtitle(context, playlistItem),
_buildPlayButton(context, playlistItem),
],
),
),
);
},
);
}
Widget _buildGradient(BuildContext context) {
return Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.transparent, Theme.of(context).backgroundColor],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.5, 0.95],
),
),
),
);
}
Widget _buildTitleAndSubtitle(
BuildContext context, PlaylistItem playlistItem) {
return Positioned(
left: 20,
right: 0,
bottom: 20,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
playlistItem.snippet!.title!,
style: Theme.of(context).textTheme.bodyText1!.copyWith(
fontSize: 18,
// fontWeight: FontWeight.bold,
),
),
if (playlistItem.snippet!.videoOwnerChannelTitle != null)
Text(
playlistItem.snippet!.videoOwnerChannelTitle!,
style: Theme.of(context).textTheme.bodyText2!.copyWith(
fontSize: 12,
),
),
],
),
);
}
Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
return Stack(
alignment: AlignmentDirectional.center,
children: [
Container(
width: 42,
height: 42,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(
Radius.circular(21),
),
),
),
Link(
uri: Uri.parse(
'https://www.youtube.com/watch?v=${playlistItem.snippet!.resourceId!.videoId}'),
builder: (context, followLink) => IconButton(
onPressed: followLink,
color: Colors.red,
icon: const Icon(Icons.play_circle_fill),
iconSize: 45,
),
),
],
);
}
}
上の Playlists
ウィジェットと同様に、このファイルでも、Scaffold
ウィジェットを排除し、被所有の ScrollController
を導入する変更を行っています。
アプリを再度実行する
アプリを Windows、macOS、Linux のうちの好きなパソコンで実行します。これで期待どおりに動作するはずです。
6. ウェブに適応する
この画像はどういうことでしょうか?
このアプリをそのままウェブで実行すると、前のステップからレイアウトが変更されているとはいえ、ウェブブラウザ環境に適応させるには、まだ作業が必要だとわかります。
デバッグ コンソールに目をやれば、次にやるべきことを示す親切なヒントが表示されています。
════════ Exception caught by image resource service ════════════════════════════ Failed to load network image. Image URL: https://i.ytimg.com/vi/QIW35-vcA2o/default.jpg Trying to load an image from another domain? Find answers at: https://flutter.dev/docs/development/platform-integration/web-images ═════════════════════════════════════════════════════════════════════════
CORS プロキシを作成する
この画像レンダリングの問題に対処するには、プロキシ ウェブサービスを導入して、必要とされているクロスオリジン リソース シェアリング ヘッダーを加えるという方法があります。ターミナルを立ち上げて、次のように Dart ウェブサーバーを作成します。
$ dart create --template server-shelf yt_cors_proxy Creating yt_cors_proxy using template server-shelf... .gitignore analysis_options.yaml CHANGELOG.md pubspec.yaml README.md Dockerfile .dockerignore test/server_test.dart bin/server.dart Running pub get... 3.9s Resolving dependencies... Changed 53 dependencies! Created project yt_cors_proxy in yt_cors_proxy! In order to get started, run the following commands: cd yt_cors_proxy dart run bin/server.dart
次のように、yt_cors_proxy
サーバーのディレクトリに移動し、必要な依存関係をいくつか追加します。
$ cd yt_cors_proxy $ dart pub add shelf_cors_headers Resolving dependencies... + shelf_cors_headers 0.1.2 Changed 1 dependency! $ dart pub add http "http" was found in dev_dependencies. Removing "http" and adding it to dependencies instead. Resolving dependencies... Got dependencies!
現在の依存関係の中に不要になったものがあります。それを次のようにして削除します。
$ dart pub remove args Resolving dependencies... These packages are no longer being depended on: - args 2.2.0 Changed 1 dependency! $ dart pub remove shelf_router Resolving dependencies... These packages are no longer being depended on: - http_methods 1.1.0 - shelf_router 1.1.1 Changed 2 dependencies!
次に、server.dart ファイルの内容を、以下と一致するように変更します。
yt_cors_proxy/bin/server.dart
import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart';
import 'package:shelf_cors_headers/shelf_cors_headers.dart';
Future<Response> _requestHandler(Request req) async {
final target = req.url.replace(scheme: 'https', host: 'i.ytimg.com');
final response = await http.get(target);
return Response.ok(response.bodyBytes, headers: response.headers);
}
void main(List<String> args) async {
// Use any available host or container IP (usually `0.0.0.0`).
final ip = InternetAddress.anyIPv4;
// Configure a pipeline that adds CORS headers and proxies requests.
final handler = Pipeline()
.addMiddleware(logRequests())
.addMiddleware(corsHeaders(headers: {ACCESS_CONTROL_ALLOW_ORIGIN: '*'}))
.addHandler(_requestHandler);
// For running in containers, we respect the PORT environment variable.
final port = int.parse(Platform.environment['PORT'] ?? '8080');
final server = await serve(handler, ip, port);
print('Server listening on port ${server.port}');
}
次のようにして、このサーバーを実行できます。
$ dart run bin/server.dart Server listening on port 8080
別の方法として、次のように、Docker イメージとして作成し、できた Docker イメージを実行することもできます。
$ docker build . -t yt-cors-proxy [+] Building 2.7s (14/14) FINISHED $ docker run -p 8080:8080 yt-cors-proxy Server listening on port 8080
次に、ウェブブラウザで実行されているときのみ、この CORS プロキシを利用するように、Flutter コードを変更します。
2 つのアダプタブル ウィジェット
1 つ目のウィジェットは、CORS プロキシの使い方に関するものです。
lib/src/adaptive_image.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class AdaptiveImage extends StatelessWidget {
AdaptiveImage.network(String url, {super.key}) {
if (kIsWeb) {
_url = Uri.parse(url)
.replace(host: 'localhost', port: 8080, scheme: 'http')
.toString();
} else {
_url = url;
}
}
late final String _url;
@override
Widget build(BuildContext context) {
return Image.network(_url);
}
}
注目したいのは、テーマ設定による違いではなく、実行時プラットフォームによる違いだということから、kIsWeb
を使用していることです。2 つ目のアダプタブル ウィジェットは、次のように、テキストは選択可能だというウェブブラウザ ユーザーの期待に対応しています。
lib/src/adaptive_text.dart
import 'package:flutter/material.dart';
class AdaptiveText extends StatelessWidget {
const AdaptiveText(this.data, {super.key, this.style});
final String data;
final TextStyle? style;
@override
Widget build(BuildContext context) {
switch (Theme.of(context).platform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
return Text(data, style: style);
default:
return SelectableText(data, style: style);
}
}
}
次に、これらの適応をコードベース全体に広げます。
lib/src/playlist_details.dart
import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';
import 'adaptive_image.dart'; // Add this line,
import 'adaptive_text.dart'; // and this line
import 'app_state.dart';
class PlaylistDetails extends StatelessWidget {
const PlaylistDetails(
{required this.playlistId, required this.playlistName, super.key});
final String playlistId;
final String playlistName;
@override
Widget build(BuildContext context) {
return Consumer<FlutterDevPlaylists>(
builder: (context, playlists, _) {
final playlistItems = playlists.playlistItems(playlistId: playlistId);
if (playlistItems.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return _PlaylistDetailsListView(playlistItems: playlistItems);
},
);
}
}
class _PlaylistDetailsListView extends StatefulWidget {
const _PlaylistDetailsListView({required this.playlistItems});
final List<PlaylistItem> playlistItems;
@override
State<_PlaylistDetailsListView> createState() =>
_PlaylistDetailsListViewState();
}
class _PlaylistDetailsListViewState extends State<_PlaylistDetailsListView> {
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _scrollController,
itemCount: widget.playlistItems.length,
itemBuilder: (context, index) {
final playlistItem = widget.playlistItems[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
alignment: Alignment.center,
children: [
if (playlistItem.snippet!.thumbnails!.high != null)
AdaptiveImage.network( // Modify this line
playlistItem.snippet!.thumbnails!.high!.url!),
_buildGradient(context),
_buildTitleAndSubtitle(context, playlistItem),
_buildPlayButton(context, playlistItem),
],
),
),
);
},
);
}
Widget _buildGradient(BuildContext context) {
return Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.transparent, Theme.of(context).backgroundColor],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.5, 0.95],
),
),
),
);
}
Widget _buildTitleAndSubtitle(
BuildContext context, PlaylistItem playlistItem) {
return Positioned(
left: 20,
right: 0,
bottom: 20,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AdaptiveText( // This line
playlistItem.snippet!.title!,
style: Theme.of(context).textTheme.bodyText1!.copyWith(
fontSize: 18,
// fontWeight: FontWeight.bold,
),
),
if (playlistItem.snippet!.videoOwnerChannelTitle != null)
AdaptiveText( // And, this line
playlistItem.snippet!.videoOwnerChannelTitle!,
style: Theme.of(context).textTheme.bodyText2!.copyWith(
fontSize: 12,
),
),
],
),
);
}
Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
return Stack(
alignment: AlignmentDirectional.center,
children: [
Container(
width: 42,
height: 42,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(
Radius.circular(21),
),
),
),
Link(
uri: Uri.parse(
'https://www.youtube.com/watch?v=${playlistItem.snippet!.resourceId!.videoId}'),
builder: (context, followLink) => IconButton(
onPressed: followLink,
color: Colors.red,
icon: const Icon(Icons.play_circle_fill),
iconSize: 45,
),
),
],
);
}
}
上記のコードでは、Image.network
ウィジェットと Text
ウィジェットの両方を適応させました。次は、Playlists
ウィジェットを適応させます。
lib/src/playlists.dart
import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'adaptive_image.dart'; // Add this line
import 'app_state.dart';
class Playlists extends StatelessWidget {
const Playlists({super.key, required this.playlistSelected});
final PlaylistsListSelected playlistSelected;
@override
Widget build(BuildContext context) {
return Consumer<FlutterDevPlaylists>(
builder: (context, flutterDev, child) {
final playlists = flutterDev.playlists;
if (playlists.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
return _PlaylistsListView(
items: playlists,
playlistSelected: playlistSelected,
);
},
);
}
}
typedef PlaylistsListSelected = void Function(Playlist playlist);
class _PlaylistsListView extends StatefulWidget {
const _PlaylistsListView({
required this.items,
required this.playlistSelected,
});
final List<Playlist> items;
final PlaylistsListSelected playlistSelected;
@override
State<_PlaylistsListView> createState() => _PlaylistsListViewState();
}
class _PlaylistsListViewState extends State<_PlaylistsListView> {
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _scrollController,
itemCount: widget.items.length,
itemBuilder: (context, index) {
var playlist = widget.items[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
leading: AdaptiveImage.network( // Change this one.
playlist.snippet!.thumbnails!.default_!.url!,
),
title: Text(playlist.snippet!.title!),
subtitle: Text(
playlist.snippet!.description!,
),
onTap: () {
widget.playlistSelected(playlist);
},
),
);
},
);
}
}
今回は Image.network
ウィジェットのみを適応させ、Text
ウィジェットはそのままにしました。これは、Text ウィジェットを適応させると、テキストがタップされたときに ListTile
の onTap
機能がブロックされてしまうため、意図的に行ったものです。
アプリをウェブ上で正しく実行する
CORS プロキシが実行されているので、アプリのウェブ版を実行することができ、次のように表示されます。
7. アダプティブな認証
このステップでは、アプリを拡張し、ユーザーを認証してから、そのユーザーの再生リストを表示する機能を追加します。OAuth の処理は、Android、iOS、ウェブ、Windows、macOS、Linux の間で大きく異なるため、アプリが実行される各種プラットフォームに対応するために、複数のプラグインを使用する必要があります。
Google 認証を実現するプラグインを追加する
Google 認証に対応するために、3 つのパッケージをインストールします。
$ flutter pub add googleapis_auth Resolving dependencies... + crypto 3.0.1 + googleapis_auth 1.3.0 test_api 0.4.3 (0.4.8 available) Changed 2 dependencies! $ flutter pub add google_sign_in Resolving dependencies... + google_sign_in 5.2.1 + google_sign_in_platform_interface 2.1.0 + google_sign_in_web 0.10.0+3 + quiver 3.0.1+1 test_api 0.4.3 (0.4.8 available) Changed 4 dependencies! $ flutter pub add extension_google_sign_in_as_googleapis_auth Resolving dependencies... + extension_google_sign_in_as_googleapis_auth 2.0.4 test_api 0.4.3 (0.4.8 available) Changed 1 dependency!
ユーザーのウェブブラウザを使用して Windows、macOS、Linux 上で認証するために、googleapis_auth
プラグインを使用します。Android、iOS、ウェブ上では、google_sign_in
と extension_google_sign_in_as_googleapis_auth
を使用します。後者は、この 2 つのパッケージの間で相互運用性を確保するためのつなぎの役目を果たします。
コードを更新する
まずは、再利用可能な抽象化として AdaptiveLogin ウィジェットを作成します。このウィジェットは再利用することを前提に設計されているため、設定がいくつか必要です。
lib/src/adaptive_login.dart
import 'dart:io' show Platform;
import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:googleapis_auth/auth_io.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';
import 'app_state.dart';
typedef _AdaptiveLoginButtonWidget = Widget Function({
required VoidCallback? onPressed,
});
class AdaptiveLogin extends StatelessWidget {
const AdaptiveLogin({
super.key,
required this.clientId,
required this.scopes,
required this.loginButtonChild,
});
final ClientId clientId;
final List<String> scopes;
final Widget loginButtonChild;
@override
Widget build(BuildContext context) {
if (kIsWeb || Platform.isAndroid || Platform.isIOS) {
return _GoogleSignInLogin(
button: _loginButton,
scopes: scopes,
);
} else {
return _GoogleApisAuthLogin(
button: _loginButton,
scopes: scopes,
clientId: clientId,
);
}
}
Widget _loginButton({required VoidCallback? onPressed}) => ElevatedButton(
onPressed: onPressed,
child: loginButtonChild,
);
}
class _GoogleSignInLogin extends StatefulWidget {
const _GoogleSignInLogin({
required this.button,
required this.scopes,
});
final _AdaptiveLoginButtonWidget button;
final List<String> scopes;
@override
State<_GoogleSignInLogin> createState() => _GoogleSignInLoginState();
}
class _GoogleSignInLoginState extends State<_GoogleSignInLogin> {
@override
initState() {
super.initState();
_googleSignIn = GoogleSignIn(
scopes: widget.scopes,
);
_googleSignIn.onCurrentUserChanged.listen((account) {
if (account != null) {
_googleSignIn.authenticatedClient().then((authClient) {
if (authClient != null) {
context.read<AuthedUserPlaylists>().authClient = authClient;
context.go('/');
}
});
}
});
}
late final GoogleSignIn _googleSignIn;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: widget.button(onPressed: () {
_googleSignIn.signIn();
}),
),
);
}
}
class _GoogleApisAuthLogin extends StatefulWidget {
const _GoogleApisAuthLogin({
required this.button,
required this.scopes,
required this.clientId,
});
final _AdaptiveLoginButtonWidget button;
final List<String> scopes;
final ClientId clientId;
@override
State<_GoogleApisAuthLogin> createState() => _GoogleApisAuthLoginState();
}
class _GoogleApisAuthLoginState extends State<_GoogleApisAuthLogin> {
@override
initState() {
super.initState();
clientViaUserConsent(widget.clientId, widget.scopes, (url) {
setState(() {
_authUrl = Uri.parse(url);
});
}).then((authClient) {
context.read<AuthedUserPlaylists>().authClient = authClient;
context.go('/');
});
}
Uri? _authUrl;
@override
Widget build(BuildContext context) {
final authUrl = _authUrl;
if (authUrl != null) {
return Scaffold(
body: Center(
child: Link(
uri: authUrl,
builder: (context, followLink) =>
widget.button(onPressed: followLink),
),
),
);
}
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
}
このファイルでは多くの作業を行います。重要なのは AdaptiveLogin
の build
メソッドです。ここでは、kIsWeb
と、dart:io
の Platform.isXXX
呼び出しを組み合わせて実行時のプラットフォームをチェックし、Android、iOS、ウェブの場合にはステートフル ウィジェット _GoogleSignInLogin
をインスタンス化し、Windows、macOS、Linux の場合はステートフル ウィジェット _GoogleApisAuthLogin
を構築します。
これらのクラスを使用するには、もう 1 つの設定が必要です。この設定は、この新しいウィジェットを使用するために残りのコードベースを更新してから行います。まず、FlutterDevPlaylists
という名前を AuthedUserPlaylists
に変えて、新たな目的が表現されるようにするとともに、そのコードを更新して、構築後に http.Client
が渡されるようにします。そして、_ApiKeyClient
クラスは不要になります。
lib/src/app_state.dart
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:http/http.dart' as http;
class AuthedUserPlaylists extends ChangeNotifier { // Rename class
set authClient(http.Client client) { // Drop constructor, add setter
_api = YouTubeApi(client);
_loadPlaylists();
}
bool get isLoggedIn => _api != null; // Add property
Future<void> _loadPlaylists() async {
String? nextPageToken;
_playlists.clear();
do {
final response = await _api!.playlists.list( // Add ! to _api
['snippet', 'contentDetails', 'id'],
mine: true, // convert from channelId: to mine:
maxResults: 50,
pageToken: nextPageToken,
);
_playlists.addAll(response.items!);
_playlists.sort((a, b) => a.snippet!.title!
.toLowerCase()
.compareTo(b.snippet!.title!.toLowerCase()));
notifyListeners();
} while (nextPageToken != null);
}
YouTubeApi? _api; // Convert from late final to optional
final List<Playlist> _playlists = [];
List<Playlist> get playlists => UnmodifiableListView(_playlists);
final Map<String, List<PlaylistItem>> _playlistItems = {};
List<PlaylistItem> playlistItems({required String playlistId}) {
if (!_playlistItems.containsKey(playlistId)) {
_playlistItems[playlistId] = [];
_retrievePlaylist(playlistId);
}
return UnmodifiableListView(_playlistItems[playlistId]!);
}
Future<void> _retrievePlaylist(String playlistId) async {
String? nextPageToken;
do {
var response = await _api!.playlistItems.list( // Add ! to _api
['snippet', 'contentDetails'],
playlistId: playlistId,
maxResults: 25,
pageToken: nextPageToken,
);
var items = response.items;
if (items != null) {
_playlistItems[playlistId]!.addAll(items);
}
notifyListeners();
nextPageToken = response.nextPageToken;
} while (nextPageToken != null);
}
}
// Delete the now unused _ApiKeyClient class
次に、PlaylistDetails
ウィジェットを更新して、指定のアプリケーション状態オブジェクトに新しい名前を使用します。
lib/src/playlist_details.dart
class PlaylistDetails extends StatelessWidget {
const PlaylistDetails(
{required this.playlistId, required this.playlistName, super.key});
final String playlistId;
final String playlistName;
@override
Widget build(BuildContext context) {
return Consumer<AuthedUserPlaylists>( // Update this line
builder: (context, flutterDev, _) {
final playlistItems = flutterDev.playlistItems(playlistId: playlistId);
if (playlistItems.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return _PlaylistDetailsListView(playlistItems: playlistItems);
},
);
}
}
同じように Playlists
ウィジェットを更新します。
lib/src/playlists.dart
class Playlists extends StatelessWidget {
const Playlists({required this.playlistSelected, super.key});
final PlaylistsListSelected playlistSelected;
@override
Widget build(BuildContext context) {
return Consumer<AuthedUserPlaylists>( // Update this line
builder: (context, flutterDev, child) {
final playlists = flutterDev.playlists;
if (playlists.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
return _PlaylistsListView(
items: playlists,
playlistSelected: playlistSelected,
);
},
);
}
}
最後に、main.dart
ファイルを更新して、新しい AdaptiveLogin
ウィジェットを正しく使用するようにします。
lib/main.dart
// Drop dart:io import
import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis_auth/googleapis_auth.dart'; // Add this line
import 'package:provider/provider.dart';
import 'src/adaptive_login.dart'; // Add this line
import 'src/adaptive_playlists.dart';
import 'src/app_state.dart';
import 'src/playlist_details.dart';
// Drop flutterDevAccountId and youTubeApiKey
// Add from this line
// From https://developers.google.com/youtube/v3/guides/auth/installed-apps#identify-access-scopes
final scopes = [
'https://www.googleapis.com/auth/youtube.readonly',
];
// TODO: Replace with your Client ID and Client Secret for Desktop configuration
final clientId = ClientId(
'TODO-Client-ID.apps.googleusercontent.com',
'TODO-Client-secret',
);
// To this line
final _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (context, state) {
return const AdaptivePlaylists();
},
// Add redirect configuration
redirect: (context, state) {
if (!context.read<AuthedUserPlaylists>().isLoggedIn) {
return '/login';
} else {
return null;
}
},
// To this line
routes: <RouteBase>[
// Add new login Route
GoRoute(
path: 'login',
builder: (context, state) {
return AdaptiveLogin(
clientId: clientId,
scopes: scopes,
loginButtonChild: const Text('Login to YouTube'),
);
},
),
// To this line
GoRoute(
path: 'playlist/:id',
builder: (context, state) {
final title = state.queryParams['title']!;
final id = state.params['id']!;
return Scaffold(
appBar: AppBar(title: Text(title)),
body: PlaylistDetails(
playlistId: id,
playlistName: title,
),
);
},
),
],
),
],
);
void main() {
runApp(ChangeNotifierProvider<AuthedUserPlaylists>( // Modify this line
create: (context) => AuthedUserPlaylists(), // Modify this line
child: const PlaylistsApp(),
));
}
class PlaylistsApp extends StatelessWidget {
const PlaylistsApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Your Playlists', // Change FlutterDev to Your
theme: FlexColorScheme.light(
scheme: FlexScheme.red,
useMaterial3: true,
).toTheme,
darkTheme: FlexColorScheme.dark(
scheme: FlexScheme.red,
useMaterial3: true,
).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
このファイルへの変更には、Flutter の YouTube 再生リストを表示していたのを、認証済みユーザーの再生リストを表示するようにする変更が反映されています。コードはこれで完成ですが、google_sign_in
パッケージと googleapis_auth
パッケージの認証の設定を正しく行うためには、このファイルと個々の Runner アプリの配下にあるファイルに、いくつかの変更を行う必要があります。
googleapis_auth
を設定する
認証を設定するための最初のステップとして、前に設定して使用していた API キーを削除します。API プロジェクトの認証情報のページに移動して、API キーを削除します。
削除ボタンを押すと、確認を求めるポップアップが表示されます。
次に、OAuth クライアント ID を作成します。
[アプリケーションの種類] で [デスクトップ アプリ] を選択します。
名前はそのままにして、[作成] をクリックします。
これにより、クライアント ID とクライアント シークレットが作成されます。googleapis_auth
フローを設定するために、これらを lib/main.dart
に追加する必要があります。実装上重要な点として、googleapis_auth フローが、生成された OAuth トークンを取得するために localhost で実行されている一時的なウェブサーバーを使用することがあります。macOS では、このトークンのために macos/Runner/Release.entitlements
ファイルに対する変更が必要となります。
macos/Runner/Release.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- add the following two lines -->
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
macos/Runner/DebugProfile.entitlements
ファイルには、ホットリロードと Dart VM デバッグツールを有効にするために、すでに com.apple.security.network.server
のエンタイトルメントが設定されているため、同様の変更を行う必要はありません。
これで、アプリを Windows、macOS、Linux で実行できるようになりました(これらのターゲットでコンパイルしている場合)。
google_sign_in
を Android 向けに設定する
API プロジェクトの認証情報のページに戻り、別の OAuth クライアント ID を作成しますが、今回は [Android] を選択します。
フォームの残りでは、パッケージ名に android/app/src/main/AndroidManifest.xml
で宣言したパッケージを入力します。手順どおりの名前にしていれば、com.example.adaptive_app
のはずです。Google Cloud Platform Console ヘルプの手順に沿って、SHA-1 証明書のフィンガープリントを抽出します。
Android でアプリを正しく動作させるには、これで十分です。使用する Google API によっては、生成された JSON ファイルをアプリケーション バンドルに追加する必要があります。
google_sign_in
を iOS 向けに設定する
API プロジェクトの認証情報のページに戻り、別の OAuth クライアント ID を作成しますが、今回は [iOS] を選択します。
フォームの残りでは、Xcode で ios/Runner.xcworkspace
を開いて、バンドル ID を入力します。プロジェクト ナビゲータに移動し、ナビゲータで [Runner] を選択してから、[General] タブを選択し、[Bundle Identifier] をコピーします。この Codelab の手順を忠実に実行していれば、com.example.adaptiveApp
のはずです。
ローカルの開発には不要なので、今回は「App Store ID」と「Team ID」を無視してください。
生成された .plist
ファイルをダウンロードします。名前は生成されたクライアント ID がベースになっています。ダウンロードしたファイルの名前を GoogleService-Info.plist
に変更し、それを実行中の Xcode エディタにドラッグして、左側のナビゲータの Runner/Runner
の配下の Info.plist
ファイルの隣に並べます。Xcode のオプション ダイアログで、[Copy items if needed]、[Create folder references]、[Add to targets] の [Runner] を選択します。
Xcode を終了し、任意の IDE で以下を Info.plist
に追加します。
ios/Runner/Info.plist
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<!-- TODO Replace this value: -->
<!-- Copied from GoogleService-Info.plist key REVERSED_CLIENT_ID -->
<string>com.googleusercontent.apps.TODO-REPLACE-ME</string>
</array>
</dict>
</array>
値は、生成された GoogleService-Info.plist
のエントリに合わせる必要があります。最小 iOS バージョンは 9 に設定してください。ios/Podfile
を次のように編集します。
ios/Podfile
# iOS 9 for google_sign_in
platform :ios, '9.0' # Uncomment this line
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
use_modular_headers!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
アプリを実行して、ログインすると、自分の再生リストが表示されます。
google_sign_in
をウェブ向けに設定する
API プロジェクトの認証情報のページに戻り、別の OAuth クライアント ID を作成しますが、今回は [ウェブ アプリケーション] を選択します。
残りのフォームでは、[承認済みの JavaScript 生成元] に次のように入力します。
これによってクライアント ID が生成されます。以下の meta
タグを web/index.html
に追加し、生成されたクライアント ID が含まれるように更新します。
web/index.html
<meta name="google-signin-client_id" content="YOUR_GOOGLE_SIGN_IN_OAUTH_CLIENT_ID.apps.googleusercontent.com">
このサンプルを実行するには、もう少し作業が必要です。前のステップで作成した CORS プロキシを実行する必要があります。また、次の手順に沿って、ウェブ アプリケーションの OAuth クライアント ID のフォームで指定したポートで Flutter ウェブアプリを実行する必要もあります。
ターミナルで、次のようにして CORS プロキシ サーバーを実行します。
$ dart run bin/server.dart Server listening on port 8080
別のターミナルで、次のようにして Flutter アプリを実行します。
$ flutter run -d chrome --web-hostname localhost --web-port 8090 Launching lib/main.dart on Chrome in debug mode... Waiting for connection from debug service on Chrome... 20.4s This app is linked to the debug service: ws://127.0.0.1:52430/Nb3Q7puZqvI=/ws Debug service listening on ws://127.0.0.1:52430/Nb3Q7puZqvI=/ws 💪 Running with sound null safety 💪 🔥 To hot restart changes while running, press "r" or "R". For a more detailed help message, press "h". To quit, press "q".
再度ログインすると、自分の再生リストが表示されます。
8. 次のステップ
おめでとうございます!
この Codelab を修了し、Flutter でサポートされている 6 つのプラットフォームすべてで動作するアダプティブな Flutter アプリを作成しました。画面のレイアウト方法、テキストの操作方法、画像の読み込み方法、認証の動作における違いに対応するようコードを適用させました。
アプリケーションで適応できることは、ほかにも多くあります。実行される環境にコードを適応させるためのその他の方法については、アダプティブなアプリの作成をご覧ください。