從無聊到精美的 Flutter 應用程式

從無聊到精美的 Flutter 應用程式

程式碼研究室簡介

subject上次更新時間:5月 13, 2024
account_circle作者:The Flutter Team

1. 簡介

Flutter 是 Google 的 UI 工具包,可讓您透過單一程式碼集,建構美觀且以原生方式編譯的應用程式,適用於行動、網頁和電腦。Flutter 能與現有程式碼搭配運作,供世界各地的開發人員和機構使用,不僅是免費的開放原始碼工具。

在本程式碼研究室中,您將強化 Flutter 音樂應用程式,使其從無聊到精美的成品。為達成這個目標,本程式碼研究室會使用 Material 3 中導入的工具和 API。

課程內容

  • 如何編寫可在各平台使用且精美的 Flutter 應用程式。
  • 如何設計應用程式中的文字,確保文字可提升使用者體驗。
  • 如何挑選合適色彩、自訂小工具、建構專屬主題,以及快速輕鬆地實作深色模式。
  • 如何建構跨平台自動調整應用程式。
  • 如何建構在任何螢幕上都能呈現美觀的應用程式。
  • 如何在 Flutter 應用程式中加入動態效果,讓應用程式更吸睛。

需求條件:

本程式碼研究室假設您已具備一些 Flutter 經驗。如果不是,不妨先瞭解基本概念。以下連結相當實用:

建構項目

本程式碼研究室將引導您為名為 MyArtist 的應用程式建立主畫面,這是一款音樂播放器應用程式,讓粉絲能掌握喜愛藝人的最新資訊。本文會說明如何修改應用程式設計,讓各平台的外觀美觀。

以下影片說明應用程式在完成本程式碼研究室後的運作方式:

您希望從本程式碼研究室學到什麼?

2. 設定 Flutter 開發環境

您需要使用兩項軟體:Flutter SDK編輯器

您可以使用下列任一裝置執行程式碼研究室:

  • 將實體 AndroidiOS 裝置接上電腦,並設為開發人員模式。
  • iOS 模擬器 (需要安裝 Xcode 工具)。
  • Android Emulator (需要在 Android Studio 中設定)。
  • 瀏覽器 (必須使用 Chrome 進行偵錯)。
  • 下載 WindowsLinuxmacOS 桌面應用程式。您必須在要部署的平台上進行開發。因此,如果您想要開發 Windows 電腦版應用程式,就必須在 Windows 上進行開發,以便存取適當的建構鏈結。如要進一步瞭解作業系統的特定需求,請參閱 docs.flutter.dev/desktop

3. 取得程式碼研究室的範例應用程式

從 GitHub 複製

如要從 GitHub 複製本程式碼研究室,請執行下列指令:

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

為確保一切正常運作,請將 Flutter 應用程式做為桌面應用程式執行,如下所示。您也可以在 IDE 中開啟這項專案,然後使用專案的工具執行應用程式。

a3c16fc17be25f6c.png 執行應用程式。

大功告成!MyArtist 主畫面的範例程式碼應正在執行。您應該會看到 MyArtist 的主畫面。這可以在電腦上正常顯示,不過行動裝置的效能...不太好。一件事裡,它並沒有遵循凹口。別擔心,就會修正這個問題!

1e67c60667821082.png d1139cde225de452.png

導覽程式碼

接著,我們來瀏覽程式碼導覽

開啟 lib/src/features/home/view/home_screen.dart,其中包含下列項目:

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({super.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,並使用兩個類別實作有狀態小工具:

  • import 陳述式可提供 Material 元件。
  • HomeScreen 類別代表目前顯示的整個網頁。
  • _HomeScreenState 類別的 build() 方法會建立小工具樹狀結構的根目錄,進而影響 UI 中所有小工具的建立方式。

4. 善用字體排版

文字無所不在,文字是與使用者溝通的實用方式。您的應用程式是供友善休閒使用,還是值得信賴且專業?您可能喜愛的銀行應用程式為何未使用 Comic Sans,文字的呈現方式可塑造使用者對應用程式的第一印象。以下介紹一些更貼心的文字運用文字。

影像表達,不必解釋

盡可能顯示「顯示」而不是「述說故事」例如,範例應用程式中的 NavigationRail 會有每條主要路線的分頁標籤,但前置圖示都相同:

86c5f73b3aa5fd35.png

這種做法沒什麼幫助,因為使用者仍必須閱讀每個分頁的文字。請先加入視覺提示,讓使用者快速一眼找出想要的分頁,這也有助於本地化和無障礙功能。

a3c16fc17be25f6c.pnglib/src/shared/router.dart 中,為每個導覽目的地 (首頁、播放清單和人物) 新增不同開頭的圖示:

lib/src/shared/router.dart

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 ASan Serif 大寫 T 和 Serif 大寫 T
  • 全大寫字型:使用全大寫字型可讓注意力集中在少量文字 (例如標題) 上,但過度使用時,可能會被視為大喊,導致使用者完全忽略。
  • 每字字首大寫或句首字母大寫:新增標題或標籤時,請考慮使用大寫字母,也就是「每字字首大寫」,也就是每個字詞的首字母大寫 (「This is a Title Case Title」(這是標題大寫) 較正式。句首字母大寫:只使用大寫字母和文字中的第一個字詞 (「這是一個句首字母大寫名稱」) 則較較自然、較正式。
  • 核心 (每個字母之間的間距)、行長度 (整個文字在畫面上的完整寬度) 和行高 (每行文字的高度):如果這些文字太過太多或太少,會使得應用程式難以閱讀。舉例來說,閱讀雜亂無章的大字體時,很容易失去閱讀進度。

瞭解這一點後,請前往 Google Fonts 並選擇 Sans-Serif 字型,例如 Montserrat,因為這款音樂應用程式兼具趣味和趣味性。

a3c16fc17be25f6c.png 從指令列提取 google_fonts 套件。這樣也會更新 pubspec 檔案,將字型新增為應用程式依附元件。

$ flutter pub add google_fonts

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/>
       
<!-- Make sure these lines are present from here... -->
       
<key>com.apple.security.network.client</key>
       
<true/>
       
<!-- To here. -->
       
<key>com.apple.security.network.server</key>
       
<true/>
</dict>
</plist>

a3c16fc17be25f6c.pnglib/src/shared/extensions.dart 中匯入新套件:

lib/src/shared/extensions.dart

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

a3c16fc17be25f6c.png設定蒙哲臘TextTheme:

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

a3c16fc17be25f6c.png快速重新載入 7f9a9e103c7b5e5.png,即可啟用變更。(使用 IDE 中的按鈕,或在指令列輸入 r 即可熱重載):

1e67c60667821082.png

您應該會看到新的 NavigationRail 圖示,以及以蒙塞拉特字型顯示的文字。

遇到問題嗎?

如果您的應用程式無法正常運作,請檢查是否有錯字。如有需要,請使用以下連結中的程式碼進行後續步驟。

5. 設定主題

「主題」可指定一組色彩和文字樣式系統,使應用程式的設計結構和統一性更加一致。主題可讓您快速實作 UI,而不必擔心一些輕微的細節 (例如為每個小工具指定確切的顏色)。

Flutter 開發人員通常會透過以下其中一種方式建立自訂主題元件:

  • 建立個別的自訂小工具,每個小工具都有各自的主題。
  • 為預設小工具建立限定範圍的主題。

這個範例使用 lib/src/shared/providers/theme.dart 中的主題提供者,在應用程式中建立主題一致的小工具和顏色:

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(
     
{super.key,
     required
this.settings,
     required
this.lightDynamic,
     required
this.darkDynamic,
     required
super.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.surfaceContainerHighest,
     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.dartMaterialApp 中的限定範圍主題物件。任何巢狀 Theme 物件都會沿用這個物件:

lib/src/shared/app.dart

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({super.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 主題建構工具是網頁式工具 (在 Material 3 中推出),可協助您為應用程式選取一系列互補顏色。

a3c16fc17be25f6c.png如要選擇應用程式的來源顏色,請開啟 Material Design 主題設定建構工具,查看 UI 的不同顏色。選擇的顏色務必符合品牌美感和/或個人偏好。

建立主題後,在「主要」顏色泡泡上按一下滑鼠右鍵,系統會開啟含有主要顏色十六進位值的對話方塊。複製這個值。(您也可以使用這個對話方塊設定顏色)。

a3c16fc17be25f6c.png將原色的十六進位值傳送至主題提供者。舉例來說,十六進位顏色 #00cbe6 指定為 Color(0xff00cbe6)ThemeProvider 會產生 ThemeData,其中包含您在 Material Design 主題設定建構工具中預覽的互補色彩組合:

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 加上框線:

lib/src/shared/views/outlined_card.dart

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.
     
),
   
);
 
}
}

Material 3 引進了細微的顏色角色,這些角色可彼此互補,並可用於整個 UI 新增運算式層。這些新的顏色角色包括:

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

此外,新的設計權杖支援淺色和深色主題:

7b51703ed96196a4.png

這些顏色角色可用於指派意義和強調 UI 的不同部分。即使元件不顯眼,也能運用動態色彩。

a3c16fc17be25f6c.png 使用者可以在裝置的系統設定中調整應用程式亮度。在 lib/src/shared/app.dart 中,當裝置設為深色模式時,將深色主題和主題模式傳回 MaterialApp

lib/src/shared/app.dart

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,
);

按一下右上角的月亮圖示,啟用深色模式。

遇到問題嗎?

如果應用程式無法正常運作,請使用以下連結的程式碼,讓裝置恢復正常。

6. 新增自動調整式設計

只要使用 Flutter,您就能在幾乎任何位置執行的應用程式都能運作,但並不表示每個應用程式應「具備」在所有位置運作。使用者將期望各平台的不同行為和功能。

Material 提供的套件可讓您輕鬆處理自動調整式版面配置,您可以在 GitHub 上找到這些 Flutter 套件。

建構跨平台的自動調整式應用程式時,請謹記以下平台差異:

  • 輸入法:滑鼠、觸控或遊戲手把
  • 字型大小、裝置螢幕方向和觀看距離
  • 螢幕大小和板型規格:手機、平板電腦、折疊式裝置、桌機、網站

a3c16fc17be25f6c.pnglib/src/shared/views/adaptive_navigation.dart 檔案包含導覽類別,可讓您提供目的地和內容清單來呈現內文。由於您在多個螢幕上使用這個版面配置,因此也會將共用的基本版面配置傳遞至每個子項。導覽邊欄適用於電腦和大螢幕裝置,但改為在行動裝置上顯示底部導覽列,方便使用者瀏覽版面配置。

lib/src/shared/views/adaptive_navigation.dart

import 'package:flutter/material.dart';

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

 
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.
     
},
   
);
 
}
}

a8487a3c4d7890c9.png

並非所有螢幕尺寸都相同。如果您嘗試在手機上顯示應用程式的電腦版應用程式,就必須透過迴轉和縮放雙管齊下才能看到所有內容。您希望應用程式根據顯示畫面變更外觀。採用回應式設計,可確保應用程式無論在何種尺寸的螢幕上都能正常顯示。

為了讓應用程式回應,請導入一些自動調整中斷點 (不要和偵錯中斷點混淆)。這些中斷點會指定應用程式應變更版面配置的螢幕大小。

小型螢幕無法在未縮小內容的情況下,充分顯示大螢幕。為避免應用程式看起來像已縮小的電腦版應用程式,請為使用分頁區分內容的行動裝置另外建立版面配置。讓應用程式行動體驗更融入行動裝置。

如要針對不同指定目標設計最佳化版面配置,不妨先使用下列擴充方法 (如 lib/src/shared/extensions.dart 的 MyArtist 專案定義)。

lib/src/shared/extensions.dart

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

系統會將大於 730 像素 (最長方向) 但小於 1200 像素的螢幕視為平板電腦。任何超過 1200 像素的影片都會視為桌機。如果裝置不是平板電腦或電腦,則視為行動裝置。如要進一步瞭解自動調整中斷點,請前往 material.io。建議您考慮使用 adaptive_breakpoints 套件。

主畫面的回應式版面配置會使用 adaptive_componentsadaptive_breakpoints 套件,根據 12 欄網格使用 AdaptiveContainerAdaptiveColumn,在 Material Design 中實作回應式格線版面配置。

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自動調整版面配置需要兩個版面配置:一個適用於行動裝置,另一個適用於大型螢幕的回應式版面配置。LayoutBuilder 目前只會傳回電腦版版面配置。在 lib/src/features/home/view/home_screen.dart 中,將行動裝置版面配置建構為 TabBarTabBarView,其中包含 4 個分頁。

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({super.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,
                               
),
                             
),
                           
],
                         
),
                       
),
                     
],
                   
),
                 
),
               
),
             
],
           
),
         
),
       
);
     
},
   
);
 
}
}

377cfdda63a9de54.png

遇到問題嗎?

如果應用程式無法正常運作,請使用以下連結的程式碼,讓裝置恢復正常。

使用空白字元

空白字元是應用程式的重要視覺工具,可讓組織在不同部分之間建立分離的界線。

保留太多空白是好事。增加更多空白字元能讓您縮小字型或視覺元素,讓圖片更加符合空間大小。

缺乏空白字元可能會對有視覺障礙人士造成困擾。過多空白字元可能會缺乏連貫性,使 UI 看起來井然有序。例如,請參考下列螢幕截圖:

7f5e3514a7ee1750.png

d5144a50f5b4142c.png

接下來,請在主畫面新增空白字元,讓空間更大。接著,您將進一步調整版面配置,以微調間距。

a3c16fc17be25f6c.png 使用 Padding 物件納入小工具,在該小工具周圍加入空白字元。將 lib/src/features/home/view/home_screen.dart 中目前的所有邊框間距值提高為 35:

lib/src/features/home/view/home_screen.dart

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:

lib/src/features/home/view/home_highlight.dart

class HomeHighlight extends StatelessWidget {
 
const HomeHighlight({super.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 熱重新載入應用程式。底部的兩個播放清單之間沒有空白,因此看起來是屬於同一個表格。不過,這個問題其實並非如此,您接下來就會修正此問題。

df1d9af97d039cc8.png

a3c16fc17be25f6c.png 在包含大小小工具的 Row 中插入大小小工具,即可在播放清單之間加入空白字元。在 lib/src/features/home/view/home_screen.dart 中新增寬度為 35 的 SizedBox

lib/src/features/home/view/home_screen.dart

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 熱重新載入應用程式。應用程式應如下所示:

d8b2a3d47736dbab.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:

lib/src/features/home/view/home_highlight.dart

class HomeHighlight extends StatelessWidget {
 
const HomeHighlight({super.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 熱重新載入應用程式。版面配置和間距看起來更好!為了進行最後的調整,請加入一些動態和動畫。

7f5e3514a7ee1750.png

遇到問題嗎?

如果應用程式無法正常運作,請使用以下連結的程式碼,讓裝置恢復正常。

7. 加入動態和動畫

動態和動畫很適合用來導入動作和能量,並在使用者與應用程式互動時提供意見回饋。

以動畫呈現畫面

ThemeProvider 會定義 PageTransitionsTheme,其中包含行動裝置平台 (iOS、Android) 的螢幕轉換動畫。電腦使用者現在可透過滑鼠或觸控板點擊取得意見回饋,因此不需要使用頁面轉換動畫。

Flutter 提供可以根據目標平台為應用程式設定的畫面轉換動畫,如 lib/src/shared/providers/theme.dart 所示:

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.pngPageTransitionsTheme 傳遞至 lib/src/shared/providers/theme.dart 中的淺色和深色主題

lib/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 裝置上使用動畫

遇到問題嗎?

如果應用程式無法正常運作,請使用以下連結的程式碼,讓裝置恢復正常。

新增懸停狀態

如要在電腦版應用程式中加入動態效果,其中一種方法是使用懸停狀態,也就是當使用者將遊標懸停在圖示上時,小工具會變更狀態 (例如顏色、形狀或內容)。

根據預設,_OutlinedCardState 類別 (用於「最近播放」的播放清單資訊方塊) 會傳回 MouseRegion,這會將遊標箭頭懸停在遊標懸停時,但您可以新增更多視覺回饋。

a3c16fc17be25f6c.png 開啟 lib/src/shared/views/outlined_card.dart,將其內容替換為下列實作項目,加入 _hovered 狀態。

lib/src/shared/views/outlined_card.dart

import 'package:flutter/material.dart';

class OutlinedCard extends StatefulWidget {
 
const OutlinedCard({
   
super.key,
    required
this.child,
   
this.clickable = true,
 
});
 
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「熱門」重新載入應用程式,然後將滑鼠遊標懸停在最近播放的其中一個播放清單圖塊上。

OutlinedCard 會變更不透明度,並將邊角圓角。

a3c16fc17be25f6c.png 最後,使用 lib/src/shared/views/hoverable_song_play_button.dart 中定義的 HoverableSongPlayButton 小工具,將播放清單上的歌曲編號製作成播放按鈕。在 lib/src/features/playlists/view/playlist_songs.dart 中,使用 HoverableSongPlayButton 納入 Center 小工具 (其中包含歌曲編號):

lib/src/features/playlists/view/playlist_songs.dart

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請重新載入應用程式,然後將滑鼠遊標懸停在「本日熱門歌曲」或「新曲」播放清單中的歌曲編號上。

這個數字以動畫形式呈現「播放」按鈕,按下按鈕即可播放歌曲。

前往 GitHub 查看最終專案程式碼。

8. 恭喜!

您已完成本程式碼研究室!您已經知道有許多小幅變更可以整合至應用程式,讓內容更美觀、更容易使用,也更方便本地化,也更適合多個平台使用。這些技術包括但不限於:

  • 字體排版:文字不只是通訊工具,請運用文字的顯示方式,為使用者帶來正面影響使用者對應用程式的體驗和觀感
  • 主題:建立一個可以放心使用的設計系統,不必針對每個小工具做出設計決定。
  • 適應性:考量使用者執行應用程式的裝置和平台,以及支援的功能。考量螢幕大小和應用程式顯示方式。
  • 動態和動畫:在應用程式中加入動作可增加使用者體驗,並能更實用地為使用者提供回饋。

只要稍微調整一下應用程式,就能讓看起來有點無趣:

之前

1e67c60667821082.png

使用後

後續步驟

希望您已進一步瞭解在 Flutter 中建構精美的應用程式!

如果您運用了上述任何提示或技巧 (或有自己的秘訣可以分享),我們非常樂於傾聽您的心聲!歡迎透過 Twitter 帳戶 @rodydavis@khanhnwin 與我們聯絡!

此外,以下資源可能對您有幫助。

主題設定

自動調整和回應式資源:

一般設計資源:

你也可以與 Flutter 社群互動交流

現在就開始打造精美的應用程式吧!