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

讓您的 Flutter 應用程式從乏味變得美觀

程式碼研究室簡介

subject上次更新時間:6月 24, 2025
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 中開啟這個專案,然後使用該工具執行應用程式。

flutter run

大功告成!MyArtist 主畫面的範例程式碼應已執行。你應該會看到「MyArtist」主畫面。在電腦上看起來沒問題,但在行動裝置上... 不太好。首先,它不支援瀏覽器的瀏覽器分割線。別擔心,你一定能解決這個問題!

1e67c60667821082.pngd1139cde225de452.png

程式碼導覽

接下來,我們將介紹程式碼。

開啟 lib/src/features/home/view/home_screen.dart,其中包含以下內容:

lib/src/features/home/view/home_screen.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 '../../../utils/adaptive_components.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) {
       
return Scaffold(
         
body: SingleChildScrollView(
           
child: AdaptiveColumn(
             
children: [
               
AdaptiveContainer(
                 
columnSpan: 12,
                 
child: Padding(
                   
padding: const EdgeInsets.all(2),
                   
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),
                       
child: Text(
                         
'Recently played',
                         
style: context.headlineSmall,
                       
),
                     
),
                     
HomeRecent(playlists: playlists),
                   
],
                 
),
               
),
               
AdaptiveContainer(
                 
columnSpan: 12,
                 
child: Padding(
                   
padding: const EdgeInsets.all(2),
                   
child: Row(
                     
crossAxisAlignment: CrossAxisAlignment.start,
                     
children: [
                       
Flexible(
                         
flex: 10,
                         
child: Column(
                           
mainAxisAlignment: MainAxisAlignment.start,
                           
crossAxisAlignment: CrossAxisAlignment.start,
                           
children: [
                             
Padding(
                               
padding: const EdgeInsets.all(2),
                               
child: Text(
                                 
'Top Songs Today',
                                 
style: context.titleLarge,
                               
),
                             
),
                             
LayoutBuilder(
                               
builder: (context, constraints) =>
                                   
PlaylistSongs(
                                     
playlist: topSongs,
                                     
constraints: constraints,
                                   
),
                             
),
                           
],
                         
),
                       
),
                       
Flexible(
                         
flex: 10,
                         
child: Column(
                           
mainAxisAlignment: MainAxisAlignment.start,
                           
crossAxisAlignment: CrossAxisAlignment.start,
                           
children: [
                             
Padding(
                               
padding: const EdgeInsets.all(2),
                               
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

這並沒有幫助,因為使用者仍必須閱讀每個分頁的文字。首先新增視覺化提示,讓使用者只要一瞥前導圖示,就能找到所需分頁。這也有助於本地化和無障礙功能。

lib/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

有問題嗎?

如果應用程式無法正常運作,請檢查是否有錯字。如有需要,請使用下方連結中的程式碼,以便繼續設定。

謹慎選擇字型

字型可設定應用程式的個性,因此選擇正確的字型至關重要。選擇字型時,請考量以下幾點:

  • 無襯線或襯線:襯線字體的字母末端有裝飾性筆觸或「尾巴」,給人較正式的印象。無襯線字型沒有裝飾性筆觸,因此給人的感覺比較隨性。無襯線大寫 T 和襯線大寫 T
  • 全大寫字體:使用全大寫字體適合用於吸引使用者注意的少量文字 (例如標題),但如果過度使用,可能會讓使用者誤以為是對方在咆哮,進而完全忽略。
  • 首字母大寫句首字母大寫:新增標題或標籤時,請考慮如何使用大寫字母:首字母大寫 (每個字詞的第一個字母大寫,例如「This Is a Title Case Title」) 較為正式。句首字母大寫:只會將文字中的專有名詞和第一個字首大寫 (例如「This is a sentence case title」),這種格式比較口語化且非正式。
  • 字間距 (每個字母之間的間距)、行長 (整個文字在螢幕上的寬度) 和行高 (每行文字的高度):如果這些項目過大或過小,都會讓應用程式難以閱讀。舉例來說,閱讀一大段連續的文字時,很難保留閱讀位置。

考量到這點,請前往 Google 字型,選擇 Montserrat 等無襯線字型,因為音樂應用程式旨在提供輕鬆有趣的體驗。

透過指令列,匯入 google_fonts 套件。這也會更新 pubspec.yaml 檔案,將字型新增為應用程式依附元件。

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" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <key>com.apple.security.cs.allow-jit</key>
        <true/>
        <key>com.apple.security.network.server</key>
        <true/>
        <!-- Make sure the following two lines are present -->
        <key>com.apple.security.network.client</key>
        <true/>
</dict>
</plist>

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

lib/src/shared/extensions.dart

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

設定 Montserrat TextTheme:

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

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

1e67c60667821082.png

您應該會看到新的 NavigationRail 圖示,以及以 Montserrat 字型顯示的文字。

有問題嗎?

如果應用程式無法正常運作,請檢查是否有錯字。如有需要,請使用下方連結中的程式碼,以便繼續設定。

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.toARGB32(),
       
settings.value.sourceColor.toARGB32(),
     
),
   
);
 
}

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

 
CardThemeData cardTheme() {
   
return CardThemeData(
     
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,
   
);
 
}

 
TabBarThemeData tabBarTheme(ColorScheme colors) {
   
return TabBarThemeData(
     
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 colorScheme = colors(Brightness.light, targetColor);
   
return ThemeData.light().copyWith(
     
colorScheme: colorScheme,
     
appBarTheme: appBarTheme(colorScheme),
     
cardTheme: cardTheme(),
     
listTileTheme: listTileTheme(colorScheme),
     
bottomAppBarTheme: bottomAppBarTheme(colorScheme),
     
bottomNavigationBarTheme: bottomNavigationBarTheme(colorScheme),
     
navigationRailTheme: navigationRailTheme(colorScheme),
     
tabBarTheme: tabBarTheme(colorScheme),
     
drawerTheme: drawerTheme(colorScheme),
     
scaffoldBackgroundColor: colorScheme.surface,
   
);
 
}

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

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

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

如要使用提供者,請建立例項,並將其傳遞至 MaterialApp 中的限定主題物件,該物件位於 lib/src/shared/app.dart 中。任何巢狀 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 Design 主題設定建構工具是一種網路工具 (在 Material 3 中推出),可協助您為應用程式選取一組互補色。

如要為應用程式選擇來源顏色,請開啟 Material Design 主題設定建構工具,並探索 UI 的不同顏色。請務必選取符合品牌美學或個人偏好的顏色。

建立主題後,請右鍵點選「Primary」顏色氣泡,系統就會開啟對話方塊,其中包含原色的十六進位值。複製這個值。(您也可以使用這個對話方塊設定顏色)。

將主要顏色的十六進位值傳遞至主題提供者。例如,十六進位色彩 #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;

如要使用特定顏色,請在 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(
        // Add from here...
        decoration: BoxDecoration(
          border: Border.all(
            color: Theme.of(context).colorScheme.outline,
            width: 1,
          ),
        ),
        // ... To here.
        child: widget.child,
      ),
    );
  }
}

Material 3 引進了細微的顏色角色,彼此互補,可用於整個 UI 中,以新增更多層次的表情符號。這些新顏色角色包括:

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

此外,新的設計符記可同時支援淺色和深色主題:

7b51703ed96196a4.png

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

使用者可以在裝置的系統設定中設定應用程式亮度。在 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 提供的套件可讓您更輕鬆地使用自適應版面配置,這些 Flutter 套件可在 GitHub 上找到。

建構跨平台自適應應用程式時,請注意下列平台差異:

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

lib/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 this.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) {
       
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.dartMyArtist 專案中定義)。

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 上的說明

主畫面的回應式版面配置會使用 AdaptiveContainerAdaptiveColumn,並以 12 欄格線為基礎。

自動調整式版面配置需要兩種版面配置:一種是行動版,另一種是適用於大螢幕的回應式版面配置。此時,LayoutBuilder 會傳回電腦版版面配置。在 lib/src/features/home/view/home_screen.dart 中,將行動版版面配置建構為具有 4 個分頁的 TabBarTabBarView

lib/src/features/home/view/home_screen.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 '../../../utils/adaptive_components.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.all(2),
                   
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),
                       
child: Text(
                         
'Recently played',
                         
style: context.headlineSmall,
                       
),
                     
),
                     
HomeRecent(playlists: playlists),
                   
],
                 
),
               
),
               
AdaptiveContainer(
                 
columnSpan: 12,
                 
child: Padding(
                   
padding: const EdgeInsets.all(2),
                   
child: Row(
                     
crossAxisAlignment: CrossAxisAlignment.start,
                     
children: [
                       
Flexible(
                         
flex: 10,
                         
child: Column(
                           
mainAxisAlignment: MainAxisAlignment.start,
                           
crossAxisAlignment: CrossAxisAlignment.start,
                           
children: [
                             
Padding(
                               
padding: const EdgeInsets.all(2),
                               
child: Text(
                                 
'Top Songs Today',
                                 
style: context.titleLarge,
                               
),
                             
),
                             
LayoutBuilder(
                               
builder: (context, constraints) =>
                                   
PlaylistSongs(
                                     
playlist: topSongs,
                                     
constraints: constraints,
                                   
),
                             
),
                           
],
                         
),
                       
),
                       
Flexible(
                         
flex: 10,
                         
child: Column(
                           
mainAxisAlignment: MainAxisAlignment.start,
                           
crossAxisAlignment: CrossAxisAlignment.start,
                           
children: [
                             
Padding(
                               
padding: const EdgeInsets.all(2),
                               
child: Text(
                                 
'New Releases',
                                 
style: context.titleLarge,
                               
),
                             
),
                             
LayoutBuilder(
                               
builder: (context, constraints) =>
                                   
PlaylistSongs(
                                     
playlist: newReleases,
                                     
constraints: constraints,
                                   
),
                             
),
                           
],
                         
),
                       
),
                     
],
                   
),
                 
),
               
),
             
],
           
),
         
),
       
);
     
},
   
);
 
}
}

377cfdda63a9de54.png

有問題嗎?

如果應用程式無法正常執行,請使用下方連結中的程式碼,讓應用程式恢復正常運作。

7. 使用空格

空白區域是應用程式的重要視覺工具,可在各個區段之間劃分界線。

空白太多總比太少好。建議增加空白空間,而不是縮小字型或視覺元素的大小,以便在空間中容納更多內容。

缺少空白會讓視力有問題的使用者難以閱讀。空白過多可能會缺乏連貫性,讓使用者介面看起來不夠整齊。例如,請參閱以下螢幕截圖:

7f5e3514a7ee1750.png

d5144a50f5b4142c.png

接下來,您將在主畫面上新增空白區域,以便騰出更多空間。接著,您將進一步調整版面配置,以微調間距。

使用 Padding 物件包裝小工具,以便在小工具周圍加上空白。將 lib/src/features/home/view/home_screen.dart 中的所有邊框值增加至 35:

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

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

熱重新載入應用程式。應用程式應會與先前相同,但小工具之間的空白會變多。加上邊框間距後,畫面看起來更美觀,但頂端的醒目橫幅仍太靠近邊緣。

lib/src/features/home/view/home_highlight.dart 中,將橫幅的邊框間距變更為 15:

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(15),                   // 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: () => launchUrl(Uri.parse('https://docs.flutter.dev')),
            ),
          ),
        ),
      ],
    );
  }
}

重新載入應用程式。底部的兩個播放清單之間沒有空白字元,因此看起來像是屬於同一張表格。實際上並非如此,您將在下一個步驟中修正這個問題。

df1d9af97d039cc8.png

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

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

AdaptiveContainer(
  columnSpan: 12,
  child: 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,
                ),
              ),
              LayoutBuilder(
                builder: (context, constraints) =>
                    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,
                ),
              ),
              LayoutBuilder(
                builder: (context, constraints) =>
                    PlaylistSongs(
                      playlist: newReleases,
                      constraints: constraints,
                    ),
              ),
            ],
          ),
        ),
      ],
    ),
  ),
),

熱重新載入應用程式。應用程式應如下所示:

d8b2a3d47736dbab.png

主畫面內容現在有足夠的空間,但所有內容看起來「太過分散」,各個區塊之間沒有連貫性。

到目前為止,您已使用 EdgeInsets.all(35) 將主畫面上小工具的所有邊框間距 (水平和垂直) 設為 35,但您也可以為各個邊框分別設定邊框間距。自訂邊框間距,以便更適合空間。

  • EdgeInsets.LTRB() 會個別設定左、上、右和下
  • EdgeInsets.symmetric() 會將垂直 (上方和下方) 和水平 (左右) 的邊框間距設為相同
  • EdgeInsets.only() 只會設定指定的邊緣。

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

return 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(             // Modify from here...
                  horizontal: 15,
                  vertical: 10,
                ),                                               // To here.
                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(          // Modify from here...
                          left: 8,
                          bottom: 8,
                        ),                                       // To here.
                        child: Text(
                          'Top Songs Today',
                          style: context.titleLarge,
                        ),
                      ),
                      LayoutBuilder(
                        builder: (context, constraints) =>
                            PlaylistSongs(
                              playlist: topSongs,
                              constraints: constraints,
                            ),
                      ),
                    ],
                  ),
                ),
                const SizedBox(width: 25),                       // Modify this line
                Flexible(
                  flex: 10,
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.start,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Padding(
                        padding: const EdgeInsets.only(          // Modify from here...
                          left: 8,
                          bottom: 8,
                        ),                                       // To here.
                        child: Text(
                          'New Releases',
                          style: context.titleLarge,
                        ),
                      ),
                      LayoutBuilder(
                        builder: (context, constraints) =>
                            PlaylistSongs(
                              playlist: newReleases,
                              constraints: constraints,
                            ),
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ),
      ],
    ),
  ),
);

lib/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 the following 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: () => launchUrl(Uri.parse('https://docs.flutter.dev')),
            ),
          ),
        ),
      ],
    );
  }
}

熱重載應用程式。版面配置和間距看起來更美觀!最後,請加入一些動態效果和動畫。

7f5e3514a7ee1750.png

有問題嗎?

如果應用程式無法正常執行,請使用下方連結中的程式碼,讓應用程式恢復正常運作。

8. 新增動態效果和動畫

動態效果和動畫是引入動作和活力的絕佳方式,可在使用者與應用程式互動時提供意見回饋。

在畫面之間加入動畫效果

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

PageTransitionsTheme 傳遞至 lib/src/shared/providers/theme.dart 中的淺色和深色主題

lib/src/shared/providers/theme.dart

ThemeData light([Color? targetColor]) {
  final colorScheme = colors(Brightness.light, targetColor);
  return ThemeData.light().copyWith(
    pageTransitionsTheme: pageTransitionsTheme,                     // Add this line
    colorScheme: colorScheme,
    appBarTheme: appBarTheme(colorScheme),
    cardTheme: cardTheme(),
    listTileTheme: listTileTheme(colorScheme),
    bottomAppBarTheme: bottomAppBarTheme(colorScheme),
    bottomNavigationBarTheme: bottomNavigationBarTheme(colorScheme),
    navigationRailTheme: navigationRailTheme(colorScheme),
    tabBarTheme: tabBarTheme(colorScheme),
    drawerTheme: drawerTheme(colorScheme),
    scaffoldBackgroundColor: colorScheme.surface,
  );
}

ThemeData dark([Color? targetColor]) {
  final colorScheme = colors(Brightness.dark, targetColor);
  return ThemeData.dark().copyWith(
    pageTransitionsTheme: pageTransitionsTheme,                     // Add this line
    colorScheme: colorScheme,
    appBarTheme: appBarTheme(colorScheme),
    cardTheme: cardTheme(),
    listTileTheme: listTileTheme(colorScheme),
    bottomAppBarTheme: bottomAppBarTheme(colorScheme),
    bottomNavigationBarTheme: bottomNavigationBarTheme(colorScheme),
    navigationRailTheme: navigationRailTheme(colorScheme),
    tabBarTheme: tabBarTheme(colorScheme),
    drawerTheme: drawerTheme(colorScheme),
    scaffoldBackgroundColor: colorScheme.surface,
  );
}

在 iOS 上無動畫

在 iOS 上使用動畫

有問題嗎?

如果應用程式無法正常執行,請使用下方連結中的程式碼,讓應用程式恢復正常運作。

9. 新增懸停狀態

您可以使用懸停狀態,為電腦桌面應用程式新增動畫效果。當使用者將滑鼠游標懸停在小工具上時,小工具會變更狀態 (例如顏色、形狀或內容)。

根據預設,_OutlinedCardState 類別 (用於「最近播放」播放清單圖塊) 會傳回 MouseRegion,在游標懸停時將其轉換為指標,但您可以新增更多視覺回饋。

開啟 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.withAlpha(_hovered ? 30 : 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,
       
),
     
),
   
);
 
}
}

熱重載應用程式,然後將滑鼠游標懸停在最近播放的播放清單圖塊上。

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

最後,請使用 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

rowBuilder: (context, index) => DataRow.byIndex(
  index: index,
  cells: [
    DataCell(
      HoverableSongPlayButton(                                      // Modify from here...
        hoverMode: HoverMode.overlay,
        song: playlist.songs[index],
        child: Center(
          child: Text(
            (index + 1).toString(),
            textAlign: TextAlign.center,
          ),
        ),
      ),                                                            // To here.
    ),
    DataCell(
      Row(
        children: [
          Padding(
            padding: const EdgeInsets.all(2),
            child: ClippedImage(playlist.songs[index].image.image),
          ),
          const SizedBox(width: 10),
          Expanded(child: Text(playlist.songs[index].title)),
        ],
      ),
    ),
    DataCell(Text(playlist.songs[index].length.toHumanizedString())),
  ],
),

熱載應用程式,然後將游標懸停在「今日熱門歌曲」或「新發行歌曲」播放清單的歌曲編號上。

這個數字會以動畫形式轉換成「播放」按鈕,點選這個按鈕即可播放歌曲。

請參閱 GitHub 上的最終專案程式碼

10. 恭喜!

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

  • 字體排版:文字不只是一種溝通工具,使用文字顯示方式,為使用者帶來正面體驗,並提升對應用程式的好感度。
  • 主題設定:建立可靠的設計系統,讓您不必為每個小工具做出設計決策。
  • 適應性:請考量使用者執行應用程式的裝置和平台,以及其功能。請考量螢幕大小和應用程式的顯示方式。
  • 動態效果和動畫:在應用程式中加入動態效果,可為使用者帶來更生動的體驗,並提供更實用的使用者意見回饋。

只要稍微調整一下,就能讓應用程式從乏味變得美觀:

之前

1e67c60667821082.png

使用後

後續步驟

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

如果你採用了本文提及的任何提示或訣竅 (或有自己的訣竅要分享),歡迎與我們分享!歡迎透過 Twitter 與我們聯絡:@rodydavis@khanhnwin

你也可以參考下列實用資源。

主題設定

自動調整和回應式資源:

一般設計資源:

另外,與 Flutter 社群互動

快去打造美麗的應用程式世界吧!