Next, create the file for the AdaptivePlaylist widget:
Contents
5. Adapting to the desktop
The desktop problem
If you run the app on one of the native desktop platforms, Windows, macOS, or Linux, you will notice an interesting problem. It works, but it looks … odd.
作ったアプリをWindows,macOS,Linuxのいずれかのネイティブデスクトッププラットフォームで実行すると興味深い問題点に気づくでしょう。
このアプリは動くのですが、見た目が、、、見にくいです。
A fix for this is to add a split view, listing the playlists on the left, and the videos on the right.
この問題を解決するために、プレイリストを画面の左側に、ビデオを画面右側に配置するスプリットビューのレイアウトを新しく作りましょう。
However, you only want this layout to kick in when the code isn’t running on Android or iOS, and the window is wide enough.
しかし、そのレイアウト(スピリットビュー)は、コードがAndroidやiOSで実行されておらずウィンドウが十分に広いときだけ有効にしたいです。
The following instructions show how to implement this capability.
下記の解説で実装方法を示します。
First, add in the split_view
package to aid in constructing the layout.
このレイアウトの実装のためにsplit_viewというパッケージを追加します。
$ flutter pub add split_view
Introducing adaptive widgets
The pattern you are going to use in this codelab is to introduce Adaptive widgets that make implementation choices based on attributes like screen width, platform theme, and the like.
このコードラボで使用するパターンは、画面幅やプラットフォームのテーマなどの属性に基づいて実装を選択するAdaptiveウィジェットを導入することです。
In this case, you are going to introduce an AdaptivePlaylists
widget that re-works how Playlists
and PlaylistDetails
interact. Edit the lib/main.dart
file as follows:
今回は、AdaptivePlaylists ウィジェットを導入し、Playlist と PlaylistDetails がどのように相互作用するかを再作成します。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:provider/provider.dart'; import 'src/adaptive_playlists.dart'; // Add this import import 'src/app_state.dart'; // Delete the src/playlists.dart import // From https://www.youtube.com/channel/UCwXdFgeE9KYzlDdR7TG9cMw const flutterDevAccountId = 'UCwXdFgeE9KYzlDdR7TG9cMw'; // TODO: Replace with your YouTube API Key const youTubeApiKey = 'AIzaNotAnApiKey'; 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({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp( title: 'FlutterDev Playlists', theme: FlexColorScheme.light(scheme: FlexScheme.red).toTheme, darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme, themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer debugShowCheckedModeBanner: false, home: const AdaptivePlaylists(), // Edit this line ); } }
Next, create the file for the AdaptivePlaylist widget:
次にAdaptivePlaylist widget用のファイルを新規作成します。
lib/src/adaptive_playlists.dart
import 'package:flutter/material.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({Key? key}) : super(key: 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({ Key? key, }) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('FlutterDev Playlists')), body: Playlists( playlistSelected: (playlist) { Navigator.push( context, MaterialPageRoute<void>( builder: (context) { return Scaffold( appBar: AppBar(title: Text(playlist.snippet!.title!)), body: PlaylistDetails( playlistId: playlist.id!, playlistName: playlist.snippet!.title!, ), ); }, ), ); }, ), ); } } class WideDisplayPlaylists extends StatefulWidget { const WideDisplayPlaylists({ Key? key, }) : super(key: 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'), ), ], ), ); } }
This file is interesting for a couple of different reasons. First, it’s using both the width of the window (using MediaQuery.of(context).size.width
), and you are inspecting the theme (using Theme.of(context).platform
) to decide whether to display a wide layout with the SplitView
widget, or a narrow display without it.
このファイルは、いくつかの異なる理由で興味深いものです。まず、ウィンドウの幅 (MediaQuery.of(context).size.width を使用) と、テーマ (Theme.of(context).platform を使用) の両方を使用して、SplitView ウィジェットでワイドなレイアウトを表示するか、そうせずに狭いディスプレイをするかを決定している点です。
The second point of note is that it’s dealing with the previously hard coded handling of pushing a route. This is done by surfacing a callback argument in the Playlists
widget that notifies the surrounding code that the user has selected a playlist, and needs to do whatever is required to display that playlist. Also note that the Scaffold
has been factored out of the Playlists
and PlaylistDetails
widgets now that these widgets aren’t top level.
2 つ目のポイントは、これまでハードコードされていたルートをプッシュする処理を行うことです。これは、Playlists ウィジェットのコールバック引数を表面化して、ユーザーがプレイリストを選択したことを周囲のコードに通知し、そのプレイリストを表示するために必要な処理を行うことで実現されています。また、Scaffold は Playlists および PlaylistDetails ウィジェットから除外され、これらのウィジェットはトップレベルではなくなりました。
Next, edit the src/lib/playlists.dart
file as follows:
次に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({required this.playlistSelected, Key? key}) : super(key: key); 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({ Key? key, required this.items, required this.playlistSelected, }) : super(key: key); 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); }, ), ); }, ); } }
There is a lot of change in this file. Apart from the aforementioned introduction of a playlistSelected callback, and the elimination of the Scaffold
widget, the _PlaylistsListView
widget is converted from stateless to stateful.
このファイルでは、多くの変更があります。前述の playlistSelected コールバックの導入と Scaffold ウィジェットの廃止のほか、_PlaylistsListView ウィジェットがステートレスからステートフルに変換されました。
This change is required due to the introduction of an owned ScrollController
that has to be constructed and destroyed.
この変更は、構築と破棄が必要なScrollController が導入されたために必要になったものです。
The introduction of a ScrollController
is interesting because it is required due to the fact that on a wide layout you have two ListView
widgets side by side.
ScrollController の導入は、ワイドレイアウトで 2 つの ListView ウィジェットが並んでいるために必要であり、興味深いものです。
On a mobile phone it is traditional to have a single ListView
, and thus there can be a single long-lived ScrollController that all ListView
s attach to, and detach from, during their individual life cycles. Desktop is different, in a world where multiple ListView
s side by side make sense.
携帯電話では、伝統的にListViewは1つで、そのため寿命の長いScrollControllerが1つあり、すべてのListViewがそれぞれのライフサイクルでアタッチしたり、デタッチしたりすることができます。デスクトップでは、複数のListViewを並べて使うのが普通です。
And finally, edit the lib/src/playlist_details.dart file as follows:
最後に、 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, Key? key}) : super(key: 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({Key? key, required this.playlistItems}) : super(key: key); 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, ), ), ], ); } }
Akin to the Playlists
widget above, this file also has changes for the elimination of the Scaffold
widget, and the introduction of an owned ScrollController
.
上記のプレイリストウィジェットと同様に、このファイルにも、Scaffoldウィジェットを削除し、所有するScrollControllerを導入するための変更が加えられています。
Run the app again!
Running the app on your choice of desktop, be it Windows, macOS, or Linux. It should now work as you’d expect.
選択したデスクトップでアプリを実行してみましょう。期待通りの表示になりました。
パート3へ >>
参考
https://codelabs.developers.google.com/codelabs/flutter-adaptive-app?hl=en#4