Flutter アプリを「退屈なアプリ」から「見栄えの良いアプリ」に変える

1. はじめに

Flutter は、1 つのコードベースからネイティブにコンパイルして、モバイル、ウェブ、デスクトップの美しいアプリケーションを作成できる Google の UI ツールキットです。Flutter は既存のコードと組み合わせることも可能です。世界中のデベロッパーの皆様にご利用いただける、無料のオープンソースです。

この Codelab では、Flutter 音楽アプリケーションを拡張して、退屈なアプリを魅力的なものにしていきます。これを実現するため、この Codelab ではマテリアル 3 で導入されたツールと API を使用します。

学習内容

  • プラットフォーム全体で使用可能で見栄えの良い Flutter アプリを作成する方法。
  • アプリのテキストをデザインしてユーザー エクスペリエンスに組み込む方法。
  • 適切な色を選択する方法、ウィジェットをカスタマイズする方法、独自のテーマを作成する方法、ダークモードを簡単に実装する方法。
  • クロス プラットフォームのアダプティブ アプリを作成する方法。
  • どの画面でも適切に表示されるアプリを作成する方法。
  • Flutter アプリに動きを追加して魅力的にする方法。

前提条件:

この Codelab は、Flutter の使用経験があることを前提としています。まだの場合は、まず基本的な使い方を学習してください。次のリンクが役に立ちます。

作成するアプリの概要

この Codelab では、MyArtist というアプリのホーム画面を作成します。このアプリは、ファンがお気に入りのアーティストの最新情報を把握できる音楽プレーヤー アプリです。プラットフォームの外観を美しくするためにアプリのデザインを変更する方法について説明します。

次のアニメーション GIF は、この Codelab の完了時にアプリがどのように動作するのかを示しています。

4a0f6509a18aaf30.gif 1557a5d9dab19d75.gif

この Codelab で学びたいことは次のどれですか?

このトピックは初めてなので、簡単に概要を知りたい。 このトピックについてある程度は知っているが、復習したい。プロジェクトで使用するサンプルコードを確認したい。特定の項目に関する説明を確認したい。

2. Flutter 環境をセットアップする

このラボを完了するには、Flutter SDKエディタの 2 つのソフトウェアが必要です。

この Codelab は、次のいずれかのデバイスを使って実行できます。

  • パソコンに接続され、デベロッパー モードに設定された物理デバイス(Android または iOS
  • iOS シミュレータ(Xcode ツールのインストールが必要)
  • Android Emulator(Android Studio でセットアップが必要)
  • ブラウザ(デバッグには Chrome が必要)
  • WindowsLinuxmacOS のデスクトップ アプリケーション。開発はデプロイする予定のプラットフォームで行う必要があります。たとえば、Windows のデスクトップ アプリを開発する場合は、適切なビルドチェーンにアクセスできるように Windows で開発する必要があります。オペレーティング システム固有の要件については、flutter.dev/desktop に詳しい説明があります。

3. Codelab スターター アプリを入手する

GitHub からクローンを作成する

GitHub からこの Codelab のクローンを作成するには、次のコマンドを実行します。

git clone https://github.com/flutter/codelabs.git
cd codelabs/boring_to_beautiful/step_01/

問題なく動作していることを確認するために、以下のように、Flutter アプリケーションをデスクトップ アプリケーションとして実行します。または、IDE でこのプロジェクトを開き、その機能を使用してアプリケーションを実行します。

a3c16fc17be25f6c.png アプリを実行します。

完了しました。MyArtist のホーム画面のスターター コードが実行されているはずです。MyArtist のホーム画面が表示されます。パソコンでは問題ないように見えますが、モバイルではどうでしょう。あまりよくありません。たとえば、ノッチが考慮されていません。でも心配はいりません。この問題は修正します。

9ebe486bc7dfa36b.png 1b30e16df3cde215.png

コードについて

では、コードの内容について見てみましょう。

lib/src/features/home/view/home_screen.dart を開きます。次の情報が表示されます。

import 'package:adaptive_components/adaptive_components.dart';
import 'package:flutter/material.dart';

import '../../../shared/classes/classes.dart';
import '../../../shared/extensions.dart';
import '../../../shared/providers/providers.dart';
import '../../../shared/views/views.dart';
import '../../playlists/view/playlist_songs.dart';
import 'view.dart';

class HomeScreen extends StatefulWidget {
  const HomeScreen({Key? key}) : super(key: key);

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  @override
  Widget build(BuildContext context) {
    final PlaylistsProvider playlistProvider = PlaylistsProvider();
    final List<Playlist> playlists = playlistProvider.playlists;
    final Playlist topSongs = playlistProvider.topSongs;
    final Playlist newReleases = playlistProvider.newReleases;
    final ArtistsProvider artistsProvider = ArtistsProvider();
    final List<Artist> artists = artistsProvider.artists;
    return LayoutBuilder(
      builder: (context, constraints) {
        // Add conditional mobile layout

        return Scaffold(
          body: SingleChildScrollView(
            child: AdaptiveColumn(
              children: [
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Padding(
                    padding: const EdgeInsets.all(2), // Modify this line
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        Expanded(
                          child: Text(
                            'Good morning',
                            style: context.displaySmall,
                          ),
                        ),
                        const SizedBox(width: 20),
                        const BrightnessToggle(),
                      ],
                    ),
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Column(
                    children: [
                      const HomeHighlight(),
                      LayoutBuilder(
                        builder: (context, constraints) => HomeArtists(
                          artists: artists,
                          constraints: constraints,
                        ),
                      ),
                    ],
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Padding(
                        padding: const EdgeInsets.all(2), // Modify this line
                        child: Text(
                          'Recently played',
                          style: context.headlineSmall,
                        ),
                      ),
                      HomeRecent(
                        playlists: playlists,
                      ),
                    ],
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Padding(
                    padding: const EdgeInsets.all(2), // Modify this line
                    child: Row(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Flexible(
                          flex: 10,
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.start,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Padding(
                                padding:
                                    const EdgeInsets.all(2), // Modify this line
                                child: Text(
                                  'Top Songs Today',
                                  style: context.titleLarge,
                                ),
                              ),
                              LayoutBuilder(
                                builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: topSongs,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                        // Add spacer between tables
                        Flexible(
                          flex: 10,
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.start,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Padding(
                                padding:
                                    const EdgeInsets.all(2), // Modify this line
                                child: Text(
                                  'New Releases',
                                  style: context.titleLarge,
                                ),
                              ),
                              LayoutBuilder(
                                builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: newReleases,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}

このファイルは、material.dart をインポートし、次の 2 つのクラスを使用してステートフル ウィジェットを実装します。

  • import ステートメントは、マテリアル コンポーネントを使用できるようにします。
  • HomeScreen クラスは、表示されるページ全体を表します。
  • _HomeScreenState クラスの build() メソッドは、ウィジェット ツリーのルートを作成します。これは、UI 内のすべてのウィジェットの作成方法に影響します。

4.タイポグラフィを活用する

テキストはどこにでも存在します。テキストはユーザーとのコミュニケーションに便利な手段です。アプリをフレンドリーで楽しいものにするのか、あるいは、信頼性が高くプロフェッショナルなものにするのか。それによって選択肢が異なります。お気に入りのバンキング アプリで Comic Sans が使用されていないのは、それなりの理由があります。テキストの表示方法は、アプリに対するユーザーの第一印象に影響します。テキストは慎重に使用する必要があります。そのための方法をいくつか紹介しましょう。

説明するのではなく、「見せる」ことを心がける

可能な限り、「説明する」のではなく「見せる」ようにしましょう。たとえば、スターター アプリの NavigationRail にはメインルートごとにタブがありますが、先頭のアイコンは同じです。

86c5f73b3aa5fd35.png

ユーザーは各タブのテキストを読む必要があるため、これはよくありません。まず、視覚的な手がかりを追加してみましょう。こうすれば、ユーザーは先頭のアイコンをすばやく確認して、目的のタブを見つけやすくなります。これは、ローカライズやアクセシビリティの向上にも役立ちます。

a3c16fc17be25f6c.png lib/src/shared/router.dart で、各移動先(home、playlist、people)の先頭にアイコンを追加します。

const List<NavigationDestination> destinations = [
  NavigationDestination(
    label: 'Home',
    icon: Icon(Icons.home), // Modify this line
    route: '/',
  ),
  NavigationDestination(
    label: 'Playlists',
    icon: Icon(Icons.playlist_add_check), // Modify this line
    route: '/playlists',
  ),
  NavigationDestination(
    label: 'Artists',
    icon: Icon(Icons.people), // Modify this line
    route: '/artists',
  ),
];

23278e4f4610fbf4.png

トラブルシューティング

アプリが正しく実行されていない場合は、入力ミスがないか探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。

フォントを慎重に選ぶ

フォントによってアプリケーションの個性が決まります。そのため、適切なフォントを選択することが重要です。フォントを選択する際は、次の点に注意してください。

  • Sans Serif か Serif: Serif フォントは文字の線の端に飾り(ひげ)の付いた、よりフォーマルなフォントです。Sans Serif には飾りがなく、よりカジュアルな用途に使用される傾向があります。34bf54e4cad90101.png: Sans Serif の大文字 T と Serif の大文字 T
  • すべてが大文字のフォント: すべて大文字を使用することは短いテキスト(広告見出しなど)に注目させる場合に適していますが、使いすぎると無視されてしまう可能性があります。
  • タイトルと本文での大文字 / 小文字: タイトルやラベルを追加する場合は、大文字の使い方に注意してください。タイトルでは「This Is a Title Case Title」のように各単語の先頭文字を大文字にします。これはどちらかというとフォーマルな印象になります。本文では、固有名詞と文の最初の単語の 1 文字目を大文字にします(「This is a sentence case title」)。これは、くだけた感じになり、カジュアルな印象を与えます。
  • カーニング(文字間隔)、行の長さ(画面に対するテキスト全体の幅)、行の高さ(各行の文字の高さ): 「多すぎず少なすぎず」がアプリの読みやすさを向上させます。たとえば、段落のない大きなテキストは読みづらくなります。

音楽アプリは楽しいものにする必要があるため、Google Fonts で Montserrat などの Sans Serif フォントを選択します。

a3c16fc17be25f6c.png コマンドラインから、google_fonts パッケージを取得します。また、pubspec ファイルを更新して、フォントをアプリの依存関係として追加します。

$ flutter pub add google_fonts
<?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/>
// Make sure these lines are present from here...
        <key>com.apple.security.network.client</key> //For macOS only
        <true/>
// .. To here
        <key>com.apple.security.network.server</key>
        <true/>
</dict>
</plist>

a3c16fc17be25f6c.png lib/src/shared/extensions.dart で、新しいパッケージをインポートします。

import 'package:google_fonts/google_fonts.dart';  // Add this line.

a3c16fc17be25f6c.png TextTheme: に Montserrat を設定します。

TextTheme get textTheme => GoogleFonts.montserratTextTheme(theme.textTheme); // Modify this line

a3c16fc17be25f6c.png 変更を有効にするため、7f9a9e103c7b5e5.png をホットリロードします(IDE のボタンを使用するか、コマンドラインから「r」と入力してホットリロードします)。

ff6f09f4cc39c21e.png

新しい NavigationRail アイコンと Montserrat フォントのテキストが表示されます。

トラブルシューティング

アプリが正しく実行されていない場合は、入力ミスがないか探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。

5. テーマを設定する

テーマを使用して色とテキスト スタイルを指定することで、アプリで構造化されたデザインと統一性を実現できます。テーマを使用すると、細かいこと(ウィジェットごとに正確な色を指定するなど)を気にすることなく、UI をすばやく実装できます。

Flutter デベロッパーは通常、次のいずれかの方法でカスタムテーマのコンポーネントを作成します。

  • 独自のテーマを設定したカスタム ウィジェットを作成する。
  • デフォルト ウィジェットにスコープテーマを作成する。

この例では、lib/src/shared/providers/theme.dart にあるテーマ プロバイダを使用して、アプリ全体で統一されたテーマのウィジェットと色を作成します。

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:material_color_utilities/material_color_utilities.dart';

class NoAnimationPageTransitionsBuilder extends PageTransitionsBuilder {
 const NoAnimationPageTransitionsBuilder();

 @override
 Widget buildTransitions<T>(
   PageRoute<T> route,
   BuildContext context,
   Animation<double> animation,
   Animation<double> secondaryAnimation,
   Widget child,
 ) {
   return child;
 }
}

class ThemeSettingChange extends Notification {
 ThemeSettingChange({required this.settings});
 final ThemeSettings settings;
}

class ThemeProvider extends InheritedWidget {
 const ThemeProvider(
     {Key? key,
     required this.settings,
     required this.lightDynamic,
     required this.darkDynamic,
     required Widget child})
     : super(key: key, child: child);

 final ValueNotifier<ThemeSettings> settings;
 final ColorScheme? lightDynamic;
 final ColorScheme? darkDynamic;

 final pageTransitionsTheme = const PageTransitionsTheme(
   builders: <TargetPlatform, PageTransitionsBuilder>{
     TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
     TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
     TargetPlatform.linux: NoAnimationPageTransitionsBuilder(),
     TargetPlatform.macOS: NoAnimationPageTransitionsBuilder(),
     TargetPlatform.windows: NoAnimationPageTransitionsBuilder(),
   },
 );

 Color custom(CustomColor custom) {
   if (custom.blend) {
     return blend(custom.color);
   } else {
     return custom.color;
   }
 }

 Color blend(Color targetColor) {
   return Color(
       Blend.harmonize(targetColor.value, settings.value.sourceColor.value));
 }

 Color source(Color? target) {
   Color source = settings.value.sourceColor;
   if (target != null) {
     source = blend(target);
   }
   return source;
 }

 ColorScheme colors(Brightness brightness, Color? targetColor) {
   final dynamicPrimary = brightness == Brightness.light
       ? lightDynamic?.primary
       : darkDynamic?.primary;
   return ColorScheme.fromSeed(
     seedColor: dynamicPrimary ?? source(targetColor),
     brightness: brightness,
   );
 }

 ShapeBorder get shapeMedium => RoundedRectangleBorder(
       borderRadius: BorderRadius.circular(8),
     );

 CardTheme cardTheme() {
   return CardTheme(
     elevation: 0,
     shape: shapeMedium,
     clipBehavior: Clip.antiAlias,
   );
 }

 ListTileThemeData listTileTheme(ColorScheme colors) {
   return ListTileThemeData(
     shape: shapeMedium,
     selectedColor: colors.secondary,
   );
 }

 AppBarTheme appBarTheme(ColorScheme colors) {
   return AppBarTheme(
     elevation: 0,
     backgroundColor: colors.surface,
     foregroundColor: colors.onSurface,
   );
 }

 TabBarTheme tabBarTheme(ColorScheme colors) {
   return TabBarTheme(
     labelColor: colors.secondary,
     unselectedLabelColor: colors.onSurfaceVariant,
     indicator: BoxDecoration(
       border: Border(
         bottom: BorderSide(
           color: colors.secondary,
           width: 2,
         ),
       ),
     ),
   );
 }

 BottomAppBarTheme bottomAppBarTheme(ColorScheme colors) {
   return BottomAppBarTheme(
     color: colors.surface,
     elevation: 0,
   );
 }

 BottomNavigationBarThemeData bottomNavigationBarTheme(ColorScheme colors) {
   return BottomNavigationBarThemeData(
     type: BottomNavigationBarType.fixed,
     backgroundColor: colors.surfaceVariant,
     selectedItemColor: colors.onSurface,
     unselectedItemColor: colors.onSurfaceVariant,
     elevation: 0,
     landscapeLayout: BottomNavigationBarLandscapeLayout.centered,
   );
 }

 NavigationRailThemeData navigationRailTheme(ColorScheme colors) {
   return const NavigationRailThemeData();
 }

 DrawerThemeData drawerTheme(ColorScheme colors) {
   return DrawerThemeData(
     backgroundColor: colors.surface,
   );
 }

 ThemeData light([Color? targetColor]) {
   final _colors = colors(Brightness.light, targetColor);
   return ThemeData.light().copyWith(
     pageTransitionsTheme: pageTransitionsTheme,
     colorScheme: _colors,
     appBarTheme: appBarTheme(_colors),
     cardTheme: cardTheme(),
     listTileTheme: listTileTheme(_colors),
     bottomAppBarTheme: bottomAppBarTheme(_colors),
     bottomNavigationBarTheme: bottomNavigationBarTheme(_colors),
     navigationRailTheme: navigationRailTheme(_colors),
     tabBarTheme: tabBarTheme(_colors),
     drawerTheme: drawerTheme(_colors),
     scaffoldBackgroundColor: _colors.background,
     useMaterial3: true,
   );
 }

 ThemeData dark([Color? targetColor]) {
   final _colors = colors(Brightness.dark, targetColor);
   return ThemeData.dark().copyWith(
     pageTransitionsTheme: pageTransitionsTheme,
     colorScheme: _colors,
     appBarTheme: appBarTheme(_colors),
     cardTheme: cardTheme(),
     listTileTheme: listTileTheme(_colors),
     bottomAppBarTheme: bottomAppBarTheme(_colors),
     bottomNavigationBarTheme: bottomNavigationBarTheme(_colors),
     navigationRailTheme: navigationRailTheme(_colors),
     tabBarTheme: tabBarTheme(_colors),
     drawerTheme: drawerTheme(_colors),
     scaffoldBackgroundColor: _colors.background,
     useMaterial3: true,
   );
 }

 ThemeMode themeMode() {
   return settings.value.themeMode;
 }

 ThemeData theme(BuildContext context, [Color? targetColor]) {
   final brightness = MediaQuery.of(context).platformBrightness;
   return brightness == Brightness.light
       ? light(targetColor)
       : dark(targetColor);
 }

 static ThemeProvider of(BuildContext context) {
   return context.dependOnInheritedWidgetOfExactType<ThemeProvider>()!;
 }

 @override
 bool updateShouldNotify(covariant ThemeProvider oldWidget) {
   return oldWidget.settings != settings;
 }
}

class ThemeSettings {
 ThemeSettings({
   required this.sourceColor,
   required this.themeMode,
 });

 final Color sourceColor;
 final ThemeMode themeMode;
}

Color randomColor() {
 return Color(Random().nextInt(0xFFFFFFFF));
}

// Custom Colors
const linkColor = CustomColor(
 name: 'Link Color',
 color: Color(0xFF00B0FF),
);

class CustomColor {
 const CustomColor({
   required this.name,
   required this.color,
   this.blend = true,
 });

 final String name;
 final Color color;
 final bool blend;

 Color value(ThemeProvider provider) {
   return provider.custom(this);
 }
}

a3c16fc17be25f6c.pngプロバイダを使用するには、インスタンスを作成し、lib/src/shared/app.dart にある MaterialApp のスコープテーマ オブジェクトに渡します。これは、ネストされた Theme オブジェクトに継承されます。

import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import 'playback/bloc/bloc.dart';
import 'providers/theme.dart';
import 'router.dart';

class MyApp extends StatefulWidget {
 const MyApp({Key? key}) : super(key: key);

 @override
 State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
 final settings = ValueNotifier(ThemeSettings(
   sourceColor:  Colors.pink,
   themeMode: ThemeMode.system,
 ));
 @override
 Widget build(BuildContext context) {
   return BlocProvider<PlaybackBloc>(
     create: (context) => PlaybackBloc(),
     child: DynamicColorBuilder(
       builder: (lightDynamic, darkDynamic) => ThemeProvider(
           lightDynamic: lightDynamic,
           darkDynamic: darkDynamic,
           settings: settings,
           child: NotificationListener<ThemeSettingChange>(
             onNotification: (notification) {
               settings.value = notification.settings;
               return true;
             },
             child: ValueListenableBuilder<ThemeSettings>(
               valueListenable: settings,
               builder: (context, value, _) {
                 final theme = ThemeProvider.of(context); // Add this line
                 return MaterialApp.router(
                   debugShowCheckedModeBanner: false,
                   title: 'Flutter Demo',
                   theme: theme.light(settings.value.sourceColor), // Add this line
                   routeInformationParser: appRouter.routeInformationParser,
                   routerDelegate: appRouter.routerDelegate,
                 );
               },
             ),
           )),
     ),
   );
 }
}

テーマを設定したら、アプリケーションの色を選択します。

適切な色のセットを選択するのは意外と難しいものです。プライマリ カラーの概念は理解していても、アプリに複数の色を設定したいこともあります。テキストはどの色にするのか。タイトルは。コンテンツやリンクはどうするか。背景色はどの色が良いのか。考えることは少なくありません。Material Theme Builder は、マテリアル 3 で導入されたウェブベースのカラーツールで、これを使用すると、アプリの補完的なカラーセットを選択できます。

a3c16fc17be25f6c.pngアプリのソースカラーを選択するには、Material Theme Builder を開いて、UI の色を確認します。ブランドの美しさや自分の好みに合った色を選ぶことが重要です。

テーマを作成した後、プライマリ カラーのふきだしを右クリックすると、プライマリ カラーの 16 進数値を含むダイアログが表示されます。この値をコピーします(このダイアログで、色を設定することもできます)。

a6201933c4be275c.gif

a3c16fc17be25f6c.pngプライマリ カラーの 16 進値をテーマ プロバイダに渡します。たとえば、16 進数の色コード #00cbe6Color(0xff00cbe6) として指定されます。ThemeProvider は、Material Theme Builder でプレビューした補完的な色のセットを含む ThemeData を生成します。

final settings = ValueNotifier(ThemeSettings(
   sourceColor:  Color(0xff00cbe6), // Replace this color
   themeMode: ThemeMode.system,
 ));

アプリをホット リスタートします。プライマリ カラーが設定され、アプリの表現力が増しています。すべての新しい色にアクセスするには、コンテキスト内でテーマを参照して ColorScheme を取得します。

final colors = Theme.of(context).colorScheme;

a3c16fc17be25f6c.png特定の色を使用するには、colorSchemeカラーロールにアクセスします。lib/src/shared/views/outlined_card.dart に移動し、OutlinedCard に枠線を付けます。

class _OutlinedCardState extends State<OutlinedCard> {
  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      cursor: widget.clickable
          ? SystemMouseCursors.click
          : SystemMouseCursors.basic,
      child: Container(
        child: widget.child,
        // Add from here...
        decoration: BoxDecoration(
          border: Border.all(
            color: Theme.of(context).colorScheme.outline,
            width: 1,
          ),
        ),
        // ... To here.
      ),
    );
  }
}

マテリアル 3 には、互いに補完するニュアンスを持つカラーロールが導入されています。これは UI 全体に使用でき、新しい表現レイヤを追加できます。次のような新しいカラーロールがあります。

  • PrimaryOnPrimaryPrimaryContainerOnPrimaryContainer
  • SecondaryOnSecondarySecondaryContainerOnSecondaryContainer
  • TertiaryOnTertiaryTertiaryContainerOnTertiaryContainer
  • ErrorOnErrorErrorContainerOnErrorContainer
  • BackgroundOnBackground
  • SurfaceOnSurfaceSurfaceVariantOnSurfaceVariant
  • ShadowOutlineInversePrimary

また、新しいデザイン トークンはライトテーマとダークテーマの両方をサポートしています。

7b51703ed96196a4.png

これらのカラーロールを使用して、UI のさまざまな部分に意味や強調を割り当てることができます。目立たないコンポーネントでもダイナミック カラーを利用できます。

a3c16fc17be25f6c.png ユーザーは、デバイスのシステム設定でアプリの明るさを調整できます。lib/src/shared/app.dart で、デバイスがダークモードに設定されている場合は、ダークモードとテーマモードを MaterialApp に返します。

return MaterialApp.router(
  debugShowCheckedModeBanner: false,
  title: 'Flutter Demo',
  theme: theme.light(settings.value.sourceColor),
  darkTheme: theme.dark(settings.value.sourceColor), // Add this line
  themeMode: theme.themeMode(), // Add this line
  routeInformationParser: appRouter.routeInformationParser,
  routerDelegate: appRouter.routerDelegate,
);

右上にある月のアイコンをクリックして、ダークモードを有効にします。

60ad6e64df0c5957.gif

トラブルシューティング

アプリが正しく実行されていない場合は、次のリンクのコードを確認してから、続きに進んでください。

6. アダプティブ デザインを追加する

Flutter を使用すると、ほとんどの場所で実行できるアプリを構築できますが、すべてのアプリが同じ場所で同じように動作するとは限りません。ユーザーは、プラットフォームごとに異なる動作や機能を求めています。

マテリアルには、アダプティブ レイアウトを簡単に使用するためのパッケージが用意されています。これらの Flutter パッケージは GitHub にあります。

クロス プラットフォームのアダプティブ アプリケーションを作成する場合は、プラットフォームについて次の点に注意する必要があります。

  • 入力方法: マウス、タップ、ゲームパッド
  • フォントサイズ、デバイスの向き、視距離
  • 画面サイズとフォーム ファクタ: スマートフォン、タブレット、折りたたみ式デバイス、デスクトップ、ウェブ

a3c16fc17be25f6c.png lib/src/shared/views/adaptive_navigation.dart ファイルには、ナビゲーション クラスが含まれています。これは、本文をレンダリングする宛先とコンテンツのリストを提供します。このレイアウトは複数の画面で使用するため、それぞれの子に渡す共有のベース レイアウトがあります。ナビゲーション レールはパソコンや大画面に適していますが、モバイルの場合は、下部にナビゲーション バーを表示して、モバイル対応のレイアウトにする必要があります。

import 'package:flutter/material.dart';

class AdaptiveNavigation extends StatelessWidget {
  const AdaptiveNavigation({
    Key? key,
    required this.destinations,
    required this.selectedIndex,
    required this.onDestinationSelected,
    required this.child,
  }) : super(key: key);

  final List<NavigationDestination> destinations;
  final int selectedIndex;
  final void Function(int index) onDestinationSelected;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, dimens) {
        // Tablet Layout
        if (dimens.maxWidth >= 600) { // Add this line
          return Scaffold(
            body: Row(
              children: [
                NavigationRail(
                  extended: dimens.maxWidth >= 800,
                  minExtendedWidth: 180,
                  destinations: destinations
                      .map((e) => NavigationRailDestination(
                            icon: e.icon,
                            label: Text(e.label),
                          ))
                      .toList(),
                  selectedIndex: selectedIndex,
                  onDestinationSelected: onDestinationSelected,
                ),
                Expanded(child: child),
              ],
            ),
          );
        } // Add this line

        // Mobile Layout
        // Add from here...
        return Scaffold(
          body: child,
          bottomNavigationBar: NavigationBar(
            destinations: destinations,
            selectedIndex: selectedIndex,
            onDestinationSelected: onDestinationSelected,
          ),
        );
        // ... To here.
      },
    );
  }
}

d1825d6f0d23314.png

すべての画面が同じサイズとは限りません。スマートフォンにデスクトップ版のアプリを表示しようとする場合、画面全体の調整が必要になります。画面の外観に基づいてアプリの外観を変更しなければならない場合もあります。レスポンシブ デザインにすると、どのサイズの画面でもアプリが適切に表示されます。

アプリをレスポンシブにするには、いくつかのアダプティブ ブレークポイントを設定します(デバッグのブレークポイントとは異なります)。これらのブレークポイントで、アプリがレイアウトを変更する際の画面サイズを指定します。

小さな画面では、コンテンツを縮小しないと、大きな画面ほど多くのコンテンツを表示できません。縮小されたデスクトップ アプリと感じさせないように、コンテンツをタブで分割するモバイル用のレイアウトを作成します。これにより、アプリのネイティブ感が高まります。

さまざまなターゲット向けに最適化されたレイアウトを設計する際は、次の拡張方法をおすすめします(これは lib/src/shared/extensions.dart の MyArtist プロジェクトで定義されています)。

extension BreakpointUtils on BoxConstraints {
  bool get isTablet => maxWidth > 730;
  bool get isDesktop => maxWidth > 1200;
  bool get isMobile => !isTablet && !isDesktop;
}

730 ピクセルより大きく 1,200 ピクセルより小さい画面はタブレットと見なします。1,200 ピクセルを超えるものはすべてパソコンと見なします。デバイスがタブレットでもパソコンでもない場合、そのデバイスはモバイルと見なします。アダプティブ ブレークポイントについて詳しくは、material.io をご覧ください。adaptive_breakpoints パッケージの使用も検討してください。

ホーム画面のレスポンシブ レイアウトでは、12 列のグリッドをベースに AdaptiveContainerAdaptiveColumn を使用し、adaptive_componentsadaptive_breakpoints パッケージを使用して、マテリアル デザインのレスポンシブ グリッド レイアウトを実装しています。

return LayoutBuilder(
      builder: (context, constraints) {
        return Scaffold(
          body: SingleChildScrollView(
            child: AdaptiveColumn(
              children: [
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Padding(
                    padding: const EdgeInsets.symmetric(
                      horizontal: 20,
                      vertical: 40,
                    ),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        Expanded(
                          child: Text(
                            'Good morning',
                            style: context.displaySmall,
                          ),
                        ),
                        const SizedBox(width: 20),
                        const BrightnessToggle(),
                      ],
                    ),
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Column(
                    children: [
                      const HomeHighlight(),
                      LayoutBuilder(
                        builder: (context, constraints) => HomeArtists(
                          artists: artists,
                          constraints: constraints,
                        ),
                      ),
                    ],
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Padding(
                        padding: const EdgeInsets.symmetric(
                          horizontal: 15,
                          vertical: 20,
                        ),
                        child: Text(
                          'Recently played',
                          style: context.headlineSmall,
                        ),
                      ),
                      HomeRecent(
                        playlists: playlists,
                      ),
                    ],
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Padding(
                    padding: const EdgeInsets.all(15),
                    child: Row(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Flexible(
                          flex: 10,
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.start,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Padding(
                                padding:
                                    const EdgeInsets.only(left: 8, bottom: 8),
                                child: Text(
                                  'Top Songs Today',
                                  style: context.titleLarge,
                                ),
                              ),
                              LayoutBuilder(
                                builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: topSongs,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                        const SizedBox(width: 25),
                        Flexible(
                          flex: 10,
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.start,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Padding(
                                padding:
                                    const EdgeInsets.only(left: 8, bottom: 8),
                                child: Text(
                                  'New Releases',
                                  style: context.titleLarge,
                                ),
                              ),
                              LayoutBuilder(
                                builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: newReleases,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),
        );
      },
    );

a3c16fc17be25f6c.pngアダプティブ レイアウトには 2 つのレイアウトが必要です。1 つはモバイル用、もう 1 つは大画面用のレスポンシブ レイアウトです。現在、LayoutBuilder はデスクトップ レイアウトのみを返します。lib/src/features/home/view/home_screen.dart で、TabBarTabBarView の 4 つのタブを含むモバイル レイアウトを作成します。

import 'package:adaptive_components/adaptive_components.dart';
import 'package:flutter/material.dart';

import '../../../shared/classes/classes.dart';
import '../../../shared/extensions.dart';
import '../../../shared/providers/providers.dart';
import '../../../shared/views/views.dart';
import '../../playlists/view/playlist_songs.dart';
import 'view.dart';

class HomeScreen extends StatefulWidget {
 const HomeScreen({Key? key}) : super(key: key);

 @override
 State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
 @override
 Widget build(BuildContext context) {
   final PlaylistsProvider playlistProvider = PlaylistsProvider();
   final List<Playlist> playlists = playlistProvider.playlists;
   final Playlist topSongs = playlistProvider.topSongs;
   final Playlist newReleases = playlistProvider.newReleases;
   final ArtistsProvider artistsProvider = ArtistsProvider();
   final List<Artist> artists = artistsProvider.artists;
   return LayoutBuilder(
     builder: (context, constraints) {
       // Add from here...
       if (constraints.isMobile) {
          return DefaultTabController(
            length: 4,
            child: Scaffold(
              appBar: AppBar(
                centerTitle: false,
                title: const Text('Good morning'),
                actions: const [BrightnessToggle()],
                bottom: const TabBar(
                  isScrollable: true,
                  tabs: [
                    Tab(text: 'Home'),
                    Tab(text: 'Recently Played'),
                    Tab(text: 'New Releases'),
                    Tab(text: 'Top Songs'),
                  ],
                ),
              ),
              body: LayoutBuilder(
                builder: (context, constraints) => TabBarView(
                  children: [
                    SingleChildScrollView(
                      child: Column(
                        children: [
                          const HomeHighlight(),
                          HomeArtists(
                            artists: artists,
                            constraints: constraints,
                          ),
                        ],
                      ),
                    ),
                    HomeRecent(
                      playlists: playlists,
                      axis: Axis.vertical,
                    ),
                    PlaylistSongs(
                      playlist: topSongs,
                      constraints: constraints,
                    ),
                    PlaylistSongs(
                      playlist: newReleases,
                      constraints: constraints,
                    ),
                  ],
                ),
              ),
            ),
          );
        }
       // ... To here.

       return Scaffold(
          body: SingleChildScrollView(
            child: AdaptiveColumn(
              children: [
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Padding(
                    padding: const EdgeInsets.symmetric(
                      horizontal: 20,
                      vertical: 40,
                    ),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        Expanded(
                          child: Text(
                            'Good morning',
                            style: context.displaySmall,
                          ),
                        ),
                        const SizedBox(width: 20),
                        const BrightnessToggle(),
                      ],
                    ),
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Column(
                    children: [
                      const HomeHighlight(),
                      LayoutBuilder(
                        builder: (context, constraints) => HomeArtists(
                          artists: artists,
                          constraints: constraints,
                        ),
                      ),
                    ],
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Padding(
                        padding: const EdgeInsets.symmetric(
                          horizontal: 15,
                          vertical: 20,
                        ),
                        child: Text(
                          'Recently played',
                          style: context.headlineSmall,
                        ),
                      ),
                      HomeRecent(
                        playlists: playlists,
                      ),
                    ],
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Padding(
                    padding: const EdgeInsets.all(15),
                    child: Row(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Flexible(
                          flex: 10,
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.start,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Padding(
                                padding:
                                    const EdgeInsets.only(left: 8, bottom: 8),
                                child: Text(
                                  'Top Songs Today',
                                  style: context.titleLarge,
                                ),
                              ),
                              LayoutBuilder(
                                builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: topSongs,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                        const SizedBox(width: 25),
                        Flexible(
                          flex: 10,
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.start,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Padding(
                                padding:
                                    const EdgeInsets.only(left: 8, bottom: 8),
                                child: Text(
                                  'New Releases',
                                  style: context.titleLarge,
                                ),
                              ),
                              LayoutBuilder(
                                builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: newReleases,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),
        );
     },
   );
 }
}

2e4115a01d76e7ae.png

トラブルシューティング

アプリが正しく実行されていない場合は、次のリンクのコードを確認してから、続きに進んでください。

空白の使用

空白はアプリにとって重要なビジュアル ツールで、セクション間を構成的に区切ることができます。

だだし、空白は必要以上に使用しないほうが良いでしょう。余白に合わせてフォントや視覚的要素のサイズを小さくする場合は、空白を多くしたほうが良いでしょう。

空白がないと、視力に問題がある方にとって非常に見づらいものになります。しかし、空白が多すぎると結束性が低くなり、UI が整然と見えません。たとえば、次のスクリーンショットを見てください。

f50d2fe899e57e42.png

cdf5a34a7658a15e.png

ホーム画面に空白を追加してスペースを広げてみましょう。さらにレイアウトを調整して、間隔を調整します。

a3c16fc17be25f6c.png ウィジェットを Padding オブジェクトでラップして、そのウィジェットの周囲に空白を追加します。現在 lib/src/features/home/view/home_screen.dart にあるすべてのパディング値を 35 に増やします。

Scaffold(
      body: SingleChildScrollView(
        child: AdaptiveColumn(
          children: [
            AdaptiveContainer(
              columnSpan: 12,
              child: Padding(
                padding: const EdgeInsets.all(35), // Modify this line
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    Expanded(
                      child: Text(
                        'Good morning',
                        style: context.displaySmall,
                      ),
                    ),
                    const SizedBox(width: 20),
                    const BrightnessToggle(),
                  ],
                ),
              ),
            ),
            AdaptiveContainer(
              columnSpan: 12,
              child: Column(
                children: [
                  const HomeHighlight(),
                  LayoutBuilder(
                    builder: (context, constraints) => HomeArtists(
                      artists: artists,
                      constraints: constraints,
                    ),
                  ),
                ],
              ),
            ),
            AdaptiveContainer(
              columnSpan: 12,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Padding(
                    padding: const EdgeInsets.all(35), // Modify this line
                    child: Text(
                      'Recently played',
                      style: context.headlineSmall,
                    ),
                  ),
                  HomeRecent(
                    playlists: playlists,
                  ),
                ],
              ),
            ),
            AdaptiveContainer(
              columnSpan: 12,
              child: Padding(
                padding: const EdgeInsets.all(35), // Modify this line
                child: Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Flexible(
                      flex: 10,
                      child: Column(
                        mainAxisAlignment: MainAxisAlignment.start,
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Padding(
                            padding:
                                const EdgeInsets.all(35), // Modify this line
                            child: Text(
                              'Top Songs Today',
                              style: context.titleLarge,
                            ),
                          ),
                          LayoutBuilder(
                            builder: (context, constraints) => PlaylistSongs(
                              playlist: topSongs,
                              constraints: constraints,
                            ),
                          ),
                        ],
                      ),
                    ),
                    // Add spacer between tables
                    Flexible(
                      flex: 10,
                      child: Column(
                        mainAxisAlignment: MainAxisAlignment.start,
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Padding(
                            padding:
                                const EdgeInsets.all(35), // Modify this line
                            child: Text(
                              'New Releases',
                              style: context.titleLarge,
                            ),
                          ),
                          LayoutBuilder(
                            builder: (context, constraints) => PlaylistSongs(
                              playlist: newReleases,
                              constraints: constraints,
                            ),
                          ),
                        ],
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );

a3c16fc17be25f6c.png アプリをホットリロードします。外観は以前と変わりませんが、ウィジェットの間隔が広がりました。パディングは大きくなりましたが、上部のハイライト バナーが端に近すぎます。

a3c16fc17be25f6c.pnglib/src/features/home/view/home_highlight.dart で、バナーのパディングを 35 に変更します。

class HomeHighlight extends StatelessWidget {
  const HomeHighlight({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Expanded(
          child: Padding(
            padding: const EdgeInsets.all(35), // Modify this line
            child: Clickable(
              child: SizedBox(
                height: 275,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(10),
                  child: Image.asset(
                    'assets/images/news/concert.jpeg',
                    fit: BoxFit.cover,
                  ),
                ),
              ),
              onTap: () => launch('https://docs.flutter.dev'),
            ),
          ),
        ),
      ],
    );
  }
}

a3c16fc17be25f6c.png アプリをホットリロードします。下の 2 つのプレイリストの間に空白がなく、同じ表に属しているように見えます。これは問題ですので後で修正しましょう。

df1d9af97d039cc8.png

a3c16fc17be25f6c.png 次のようにプレイリストを含む Row にサイズ ウィジェットを挿入し、プレイリストの間に空白を追加します。lib/src/features/home/view/home_screen.dart に、幅 35 の SizedBox を追加します。

Padding(
  padding: const EdgeInsets.all(35),
  child: Row(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Flexible(
        flex: 10,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.start,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Padding(
              padding: const EdgeInsets.all(35),
              child: Text(
                'Top Songs Today',
                style: context.titleLarge,
              ),
            ),
            PlaylistSongs(
              playlist: topSongs,
              constraints: constraints,
            ),
          ],
        ),
      ),
      const SizedBox(width: 35), // Add this line
      Flexible(
        flex: 10,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.start,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Padding(
              padding: const EdgeInsets.all(35),
              child: Text(
                'New Releases',
                style: context.titleLarge,
              ),
            ),
            PlaylistSongs(
              playlist: newReleases,
              constraints: constraints,
            ),
          ],
        ),
      ),
    ],
  ),
),

a3c16fc17be25f6c.png アプリをホットリロードします。アプリは次のようになります。

89411cc17daf641b.png

ホーム画面のコンテンツを表示するための十分なスペースが確保されましたが、すべてが分離され、セクション間にまとまりがありません。

a3c16fc17be25f6c.pngここまでは、ホーム画面のウィジェットのすべてのパディング(水平と垂直の両方)を EdgeInsets.all(35) で 35 に設定しましたが、各端のパディングを個別に設定することもできます。スペースに合わせてパディングをカスタマイズします。

  • EdgeInsets.LTRB() は、左、上、右、下を個別に設定します。
  • EdgeInsets.symmetric() は、垂直(上下)のパディングを同等に、水平(左右)のパディングを同等に設定します。
  • EdgeInsets.only() は、指定した端のみを設定します。
Scaffold(
  body: SingleChildScrollView(
    child: AdaptiveColumn(
      children: [
        AdaptiveContainer(
           columnSpan: 12,
             child: Padding(
               padding: const EdgeInsets.fromLTRB(20, 25, 20, 10), // Modify this line
               child: Row(
                 mainAxisAlignment: MainAxisAlignment.spaceBetween,
                   children: [
                     Expanded(
                       child: Text(
                         'Good morning',
                          style: context.displaySmall,
                       ),
                     ),
                     const SizedBox(width: 20),
                     const BrightnessToggle(),
                   ],
                 ),
               ),
             ),
             AdaptiveContainer(
               columnSpan: 12,
               child: Column(
                 children: [
                   const HomeHighlight(),
                   LayoutBuilder(
                     builder: (context, constraints) => HomeArtists(
                       artists: artists,
                       constraints: constraints,
                     ),
                   ),
                 ],
               ),
             ),
             AdaptiveContainer(
               columnSpan: 12,
               child: Column(
                 crossAxisAlignment: CrossAxisAlignment.start,
                 children: [
                   Padding(
                     padding: const EdgeInsets.symmetric(
                       horizontal: 15,
                       vertical: 10,
                     ), // Modify this line
                     child: Text(
                       'Recently played',
                       style: context.headlineSmall,
                     ),
                   ),
                   HomeRecent(
                     playlists: playlists,
                   ),
                 ],
               ),
             ),
             AdaptiveContainer(
               columnSpan: 12,
               child: Padding(
                 padding: const EdgeInsets.all(15), // Modify this line
                 child: Row(
                   crossAxisAlignment: CrossAxisAlignment.start,
                   children: [
                     Flexible(
                       flex: 10,
                         child: Column(
                           mainAxisAlignment: MainAxisAlignment.start,
                           crossAxisAlignment: CrossAxisAlignment.start,
                           children: [
                             Padding(
                               padding: const EdgeInsets.only(left: 8, bottom: 8), // Modify this line
                               child: Text(
                                 'Top Songs Today',
                                 style: context.titleLarge,
                               ),
                             ),
                             LayoutBuilder(
                               builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: topSongs,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                    const SizedBox(width: 25),
                    Flexible(
                      flex: 10,
                      child: Column(
                        mainAxisAlignment: MainAxisAlignment.start,
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Padding(
                            padding: const EdgeInsets.only(left: 8, bottom: 8), // Modify this line
                            child: Text(
                              'New Releases',
                               style: context.titleLarge,
                            ),
                          ),
                          LayoutBuilder(
                            builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: newReleases,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),
        );

a3c16fc17be25f6c.pnglib/src/features/home/view/home_highlight.dart で、バナーの左右のパディングを 35 に設定し、上下のパディングを 5 に設定します。

class HomeHighlight extends StatelessWidget {
  const HomeHighlight({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Expanded(
          child: Padding(
            // Modify this line
            padding: const EdgeInsets.symmetric(horizontal: 35, vertical: 5),
            child: Clickable(
              child: SizedBox(
                height: 275,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(10),
                  child: Image.asset(
                    'assets/images/news/concert.jpeg',
                    fit: BoxFit.cover,
                  ),
                ),
              ),
              onTap: () => launch('https://docs.flutter.dev'),
            ),
          ),
        ),
      ],
    );
  }
}

a3c16fc17be25f6c.png アプリをホットリロードします。レイアウトとスペースが大幅に改善されました。最後に、モーションとアニメーションを追加しましょう。

2776abfa6ca738af.png

トラブルシューティング

アプリが正しく実行されていない場合は、次のリンクのコードを確認してから、続きに進んでください。

7. モーションとアニメーションを追加する

モーションとアニメーションを追加すると、アプリの動きがダイナミックになります。また、ユーザーがアプリを操作するときに、フィードバックを表示する場合にも優れた方法です。

画面間のアニメーション

ThemeProvider は、モバイル プラットフォーム(iOS、Android)向けの画面遷移アニメーションで PageTransitionsTheme を定義します。パソコン ユーザーはマウスやトラックパッドのクリックからすでにフィードバックを受け取っているため、ページ遷移アニメーションは必要ありません。

Flutter には、lib/src/shared/providers/theme.dart に示すように、ターゲット プラットフォームに基づいてアプリ用に構成できる画面遷移アニメーションが用意されています。

final pageTransitionsTheme = const PageTransitionsTheme(
  builders: <TargetPlatform, PageTransitionsBuilder>{
    TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
    TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
    TargetPlatform.linux: NoAnimationPageTransitionsBuilder(),
    TargetPlatform.macOS: NoAnimationPageTransitionsBuilder(),
    TargetPlatform.windows: NoAnimationPageTransitionsBuilder(),
  },
);

a3c16fc17be25f6c.png PageTransitionsThemelib/src/shared/providers/theme.dart のライトテーマとダークテーマの両方に渡します。

ThemeData light([Color? targetColor]) {
  final _colors = colors(Brightness.light, targetColor);
  return ThemeData.light().copyWith(
    pageTransitionsTheme: pageTransitionsTheme, // Add this line
    colorScheme: ColorScheme.fromSeed(
      seedColor: source(targetColor),
      brightness: Brightness.light,
    ),
    appBarTheme: appBarTheme(_colors),
    cardTheme: cardTheme(),
    listTileTheme: listTileTheme(),
    tabBarTheme: tabBarTheme(_colors),
    scaffoldBackgroundColor: _colors.background,
  );
}

ThemeData dark([Color? targetColor]) {
  final _colors = colors(Brightness.dark, targetColor);
  return ThemeData.dark().copyWith(
    pageTransitionsTheme: pageTransitionsTheme, // Add this line
    colorScheme: ColorScheme.fromSeed(
      seedColor: source(targetColor),
      brightness: Brightness.dark,
    ),
    appBarTheme: appBarTheme(_colors),
    cardTheme: cardTheme(),
    listTileTheme: listTileTheme(),
    tabBarTheme: tabBarTheme(_colors),
    scaffoldBackgroundColor: _colors.background,
  );
}

iOS でアニメーションを使用しない場合

iOS でアニメーションを使用する場合

トラブルシューティング

アプリが正しく実行されていない場合は、次のリンクのコードを確認してから、続きに進んでください。

マウスオーバーの状態を追加する

デスクトップ アプリにモーションを追加する 1 つの方法は、ユーザーがカーソルを合わせたときに状態(色、形、コンテンツなど)を変更する、マウスオーバー状態を使用する方法です。

デフォルトでは、_OutlinedCardState クラス(最近再生されたプレイリスト タイルに使用)が MouseRegion を返します。これにより、カーソルを合わせたときにカーソル矢印がポインタに変わりますが、さらに視覚的なフィードバックを追加することもできます。

a3c16fc17be25f6c.pnglib/src/shared/views/outlined_card.dart を開き、その内容を次の実装に置き換えて _hovered 状態を導入します。

import 'package:flutter/material.dart';

class OutlinedCard extends StatefulWidget {
  const OutlinedCard({
    Key? key,
    required this.child,
    this.clickable = true,
  }) : super(key: key);
  final Widget child;
  final bool clickable;
  @override
  State<OutlinedCard> createState() => _OutlinedCardState();
}

class _OutlinedCardState extends State<OutlinedCard> {
  bool _hovered = false;

  @override
  Widget build(BuildContext context) {
    final borderRadius = BorderRadius.circular(_hovered ? 20 : 8);
    const animationCurve = Curves.easeInOut;
    return MouseRegion(
      onEnter: (_) {
        if (!widget.clickable) return;
        setState(() {
          _hovered = true;
        });
      },
      onExit: (_) {
        if (!widget.clickable) return;
        setState(() {
          _hovered = false;
        });
      },
      cursor: widget.clickable ? SystemMouseCursors.click : SystemMouseCursors.basic,
      child: AnimatedContainer(
        duration: kThemeAnimationDuration,
        curve: animationCurve,
        decoration: BoxDecoration(
          border: Border.all(
            color: Theme.of(context).colorScheme.outline,
            width: 1,
          ),
          borderRadius: borderRadius,
        ),
        foregroundDecoration: BoxDecoration(
          color: Theme.of(context).colorScheme.onSurface.withOpacity(
                _hovered ? 0.12 : 0,
              ),
          borderRadius: borderRadius,
        ),
        child: TweenAnimationBuilder<BorderRadius>(
          duration: kThemeAnimationDuration,
          curve: animationCurve,
          tween: Tween(begin: BorderRadius.zero, end: borderRadius),
          builder: (context, borderRadius, child) => ClipRRect(
            clipBehavior: Clip.antiAlias,
            borderRadius: borderRadius,
            child: child,
          ),
          child: widget.child,
        ),
      ),
    );
  }
}

a3c16fc17be25f6c.png アプリをホットリロードし、最近再生したプレイリスト タイルの 1 つにカーソルを合わせます。

61c08e46a5926e10.gif

OutlinedCard は不透明度を変更し、角を丸めます。

a3c16fc17be25f6c.png 最後に、lib/src/shared/views/hoverable_song_play_button.dart で定義された HoverableSongPlayButton ウィジェットを使用して、プレイリストの曲番号をアニメーションで動かして、再生ボタンに変更します。lib/src/features/playlists/view/playlist_songs.dart で、Center ウィジェット(曲番号が含まれる)を HoverableSongPlayButton でラップします。

HoverableSongPlayButton(      // Add this line
  hoverMode: HoverMode.overlay, // Add this line
  song: playlist.songs[index],  // Add this line
  child: Center(                // Modify this line
    child: Text(
      (index + 1).toString(),
       textAlign: TextAlign.center,
       ),
    ),
  ),                            // Add this line

a3c16fc17be25f6c.png アプリをホットリロードし、[Top Songs Today] または [New Releases] のプレイリストの曲番号にカーソルを合わせます。

数字をクリックすると、再生ボタンが表示され、クリックすると曲が再生されます。

82587ceb5452eedf.gif

最終版のプロジェクト コードは GitHub をご覧ください。

8. お疲れさまでした

この Codelab は終了です。アプリに組み込んでいくつかの改良を加えることで、見栄えが良く、ローカライズしやすく、複数のプラットフォームに適したアプリになることがわかりました。ここでは次の手法について学習しましたが、この他にも方法はあります。

  • タイポグラフィ: テキストは単なるコミュニケーションの手段ではありません。テキストの表示方法を変えることで、ユーザー エクスペリエンスとアプリの印象が良くなります。
  • テーマ設定: すべてのウィジェットに対して設計上の決定を行わなくても使用できるデザイン システムを確立しましょう。
  • 適応性: ユーザーがアプリを実行しているデバイスやプラットフォームとその機能を確認しましょう。それに合わせて画面サイズとアプリの表示方法を検討する必要があります。
  • モーションとアニメーション: アプリに動きを加えることで、ユーザー エクスペリエンスを改善し、より現実的なフィードバックをユーザーに提供できます。

アプリにわずかな調整を加えるだけで、退屈なアプリから魅力的なアプリに変えることができます。

変更前

変更後

次のステップ

ここでは、Flutter で魅力的なアプリを開発する方法について学習しました。

ここに記載されているヒントやコツをぜひご活用ください。ご不明な点がございましたら、Twitter で @rodydavis または @khanhnwin までお問い合わせください。

また、次のリソースも役に立ちます。

テーマ設定

アダプティブ リソースとレスポンシブ リソース:

一般的なデザイン リソース:

また、Flutter コミュニティとつながることもできます。

見栄えの良い、魅力的なアプリを開発しましょう。