เปลี่ยนแอป Flutter ได้ง่ายๆ ให้เป็นแอปที่สวยงาม

1. บทนำ

Flutter เป็นชุดเครื่องมือ UI ของ Google สำหรับสร้างแอปพลิเคชันที่สวยงามและคอมไพล์แบบเนทีฟสำหรับอุปกรณ์เคลื่อนที่ เว็บ และเดสก์ท็อปจากฐานของโค้ดรายการเดียว Flutter ทำงานร่วมกับโค้ดที่มีอยู่ นักพัฒนาซอฟต์แวร์และองค์กรทั่วโลกใช้ Flutter และ Flutter เป็นโอเพนซอร์สที่ใช้งานได้ฟรี

ใน Codelab นี้ คุณจะปรับปรุงแอปพลิเคชันเพลง Flutter ให้น่าใช้งานมากขึ้น ด้วยเหตุนี้ Codelab นี้จึงใช้เครื่องมือและ API ที่เปิดตัวใน Material 3

สิ่งที่คุณจะได้เรียนรู้

  • วิธีเขียนแอป Flutter ที่ใช้งานได้และสวยงามในทุกแพลตฟอร์ม
  • วิธีออกแบบข้อความในแอปเพื่อให้แน่ใจว่าข้อความจะช่วยเพิ่มประสบการณ์ของผู้ใช้
  • วิธีเลือกสีที่เหมาะสม ปรับแต่งวิดเจ็ต สร้างธีมของคุณเอง และเปิดใช้โหมดมืดได้อย่างรวดเร็ว
  • วิธีสร้างแอปที่ปรับให้เหมาะกับอุปกรณ์ทุกแพลตฟอร์ม
  • วิธีสร้างแอปที่ดูดีบนหน้าจอทุกขนาด
  • วิธีเพิ่มการเคลื่อนไหวในแอป Flutter เพื่อให้โดดเด่น

ข้อกำหนดเบื้องต้น

โค้ดแล็บนี้ถือว่าคุณมีประสบการณ์การใช้งาน Flutter อยู่บ้าง หากไม่ คุณอาจต้องเรียนรู้พื้นฐานก่อน ลิงก์ต่อไปนี้มีประโยชน์

สิ่งที่คุณจะสร้าง

Codelab นี้จะแนะนำการสร้างหน้าจอหลักสําหรับแอปพลิเคชันชื่อ MyArtist ซึ่งเป็นแอปเพลเยอร์เพลงที่แฟนๆ สามารถติดตามข่าวสารเกี่ยวกับศิลปินคนโปรดได้ ซึ่งจะอธิบายวิธีแก้ไขการออกแบบแอปให้ดูสวยงามในแพลตฟอร์มต่างๆ

วิดีโอต่อไปนี้แสดงวิธีการทำงานของแอปเมื่อทำ Codelab นี้เสร็จสมบูรณ์

คุณต้องการเรียนรู้อะไรจาก Codelab นี้

ฉันเพิ่งเริ่มศึกษาหัวข้อนี้และต้องการภาพรวมที่ดี ฉันพอจะรู้เกี่ยวกับหัวข้อนี้ แต่อยากทบทวนข้อมูล ฉันต้องการโค้ดตัวอย่างเพื่อใช้ในโปรเจ็กต์ ฉันต้องการคำอธิบายเกี่ยวกับบางสิ่ง

2. ตั้งค่าสภาพแวดล้อมการพัฒนา Flutter

คุณต้องใช้ซอฟต์แวร์ 2 อย่างในการฝึกนี้ ได้แก่ Flutter SDK และเครื่องมือแก้ไข

คุณเรียกใช้โค้ดแล็บได้โดยใช้อุปกรณ์ต่อไปนี้

  • อุปกรณ์ Android หรือ iOS จริงที่เชื่อมต่อกับคอมพิวเตอร์และตั้งค่าเป็นโหมดนักพัฒนาซอฟต์แวร์
  • โปรแกรมจำลอง iOS (ต้องติดตั้งเครื่องมือ Xcode)
  • โปรแกรมจำลอง Android (ต้องมีการตั้งค่าใน Android Studio)
  • เบราว์เซอร์ (ต้องใช้ Chrome สำหรับการแก้ไขข้อบกพร่อง)
  • เป็นแอปพลิเคชันเดสก์ท็อป Windows, Linux หรือ macOS คุณต้องพัฒนาในแพลตฟอร์มที่วางแผนจะติดตั้งใช้งาน ดังนั้น หากต้องการพัฒนาแอปเดสก์ท็อป Windows คุณต้องพัฒนาใน Windows เพื่อเข้าถึงเชนการบิลด์ที่เหมาะสม มีข้อกำหนดเฉพาะสำหรับระบบปฏิบัติการที่อธิบายไว้อย่างละเอียดใน docs.flutter.dev/desktop

3. ดาวน์โหลดแอปเริ่มต้นใช้งาน Codelab

โคลนจาก GitHub

หากต้องการโคลนโค้ดแล็บนี้จาก GitHub ให้เรียกใช้คำสั่งต่อไปนี้

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

ตรวจสอบว่าทุกอย่างทำงานได้ตามปกติโดยเรียกใช้แอปพลิเคชัน Flutter เป็นแอปพลิเคชันเดสก์ท็อปดังที่แสดงด้านล่าง หรือจะเปิดโปรเจ็กต์นี้ใน IDE และใช้เครื่องมือของ 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 และใช้วิดเจ็ตที่มีสถานะโดยใช้ 2 คลาส ได้แก่

  • คำสั่ง import จะทำให้ Material Components พร้อมใช้งาน
  • Class HomeScreen แสดงถึงทั้งหน้าที่แสดง
  • เมธอด build() ของคลาส _HomeScreenState จะสร้างรูทของต้นไม้วิดเจ็ต ซึ่งส่งผลต่อวิธีสร้างวิดเจ็ตทั้งหมดใน 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

หากพบปัญหา

หากแอปทำงานไม่ถูกต้อง ให้ตรวจหาการพิมพ์ผิด หากจำเป็น ให้ใช้รหัสในลิงก์ต่อไปนี้เพื่อดำเนินการต่อ

เลือกแบบอักษรอย่างรอบคอบ

แบบอักษรจะกำหนดบุคลิกของแอปพลิเคชัน ดังนั้นการเลือกแบบอักษรที่เหมาะสมจึงสำคัญอย่างยิ่ง สิ่งที่ควรพิจารณาเมื่อเลือกแบบอักษรมีดังนี้

  • แบบไม่มีขีดหรือแบบมีขีด: แบบมีขีดจะมีเส้นตกแต่งหรือ "หาง" ที่ส่วนท้ายของตัวอักษร และดูเป็นทางการมากกว่า แบบอักษรที่ไม่มี Serif จะไม่มีเส้นตกแต่งและมักจะให้ความรู้สึกไม่เป็นทางการมากกว่า ตัวพิมพ์ใหญ่ T แบบไม่มีขีดหนาและตัวพิมพ์ใหญ่ T แบบมีขีดหนา
  • แบบอักษรตัวพิมพ์ใหญ่ทั้งหมด: การใช้ตัวพิมพ์ใหญ่ทั้งหมดเหมาะสําหรับการดึงดูดความสนใจไปยังข้อความจํานวนไม่มาก (เช่น บรรทัดแรก) แต่หากใช้มากเกินไปอาจทําให้ผู้ใช้รู้สึกว่าเป็นการตะโกน ซึ่งทําให้ผู้ใช้ไม่สนใจข้อความนั้นเลย
  • ขึ้นต้นคำด้วยตัวพิมพ์ใหญ่หรือขึ้นต้นประโยคด้วยตัวพิมพ์ใหญ่: เมื่อเพิ่มชื่อหรือป้ายกํากับ ให้พิจารณาวิธีใช้อักษรตัวพิมพ์ใหญ่ โดยขึ้นต้นคำด้วยตัวพิมพ์ใหญ่ซึ่งตัวอักษรตัวแรกของทุกคำเป็นตัวพิมพ์ใหญ่ ("This Is a Title Case Title") จะดูเป็นทางการมากกว่า ขึ้นต้นประโยคด้วยตัวพิมพ์ใหญ่ ซึ่งจะใช้ตัวพิมพ์ใหญ่เฉพาะกับคำนามเฉพาะและคำแรกในข้อความ ("This is a sentence case title") จะให้ความรู้สึกเป็นกันเองและไม่เป็นทางการมากกว่า
  • การเว้นวรรค (ระยะห่างระหว่างแต่ละตัวอักษร), ความยาวบรรทัด (ความกว้างของข้อความทั้งหมดบนหน้าจอ) และความสูงบรรทัด (ความสูงของข้อความแต่ละบรรทัด): ระยะห่างเหล่านี้มากหรือน้อยเกินไปจะทำให้แอปอ่านยาก เช่น การอ่านข้อความขนาดใหญ่ที่ต่อเนื่องกันอาจทำให้คุณจดจําตําแหน่งอ่านได้ยาก

ด้วยเหตุนี้ โปรดไปที่ Google Fonts แล้วเลือกแบบอักษรแบบ Sans Serif เช่น Montserrat เนื่องจากแอปเพลงมีไว้เพื่อความสนุกสนาน

ดึงข้อมูลแพ็กเกจ google_fonts จากบรรทัดคำสั่ง ซึ่งจะอัปเดตไฟล์ pubspec.yaml เพื่อเพิ่มแบบอักษรเป็นทรัพยากร Dependency ของแอปด้วย

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 เพื่อโหลดซ้ำแบบ Hot Reload)

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

เมื่อตั้งค่าธีมแล้ว ให้เลือกสีสำหรับแอปพลิเคชัน

การเลือกชุดสีที่เหมาะสมอาจเป็นเรื่องยาก คุณอาจทราบสีหลักแล้ว แต่อาจต้องการใช้สีมากกว่า 1 สีในแอป ข้อความควรเป็นสีอะไร ชื่อ เนื้อหา ลิงก์ แล้วสีพื้นหลังล่ะ ตัวสร้างธีม Material เป็นเครื่องมือบนเว็บ (เปิดตัวใน Material 3) ที่ช่วยคุณเลือกชุดสีที่ส่งเสริมกันสำหรับแอป

หากต้องการเลือกสีต้นทางสำหรับแอปพลิเคชัน ให้เปิดเครื่องมือสร้างธีม Material แล้วสำรวจสีต่างๆ สำหรับ UI การเลือกสีที่เข้ากับภาพลักษณ์ของแบรนด์หรือความชอบส่วนตัวเป็นสิ่งสำคัญ

หลังจากสร้างธีมแล้ว ให้คลิกขวาลูกโป่งสีหลัก ซึ่งจะเปิดกล่องโต้ตอบที่มีค่าสีฐานสิบหกของสีหลัก คัดลอกค่านี้ (คุณตั้งค่าสีโดยใช้กล่องโต้ตอบนี้ได้ด้วย)

ส่งค่าเลขฐานสิบหกของสีหลักไปยังผู้ให้บริการธีม เช่น ระบุสีฐาน 16 #00cbe6 เป็น Color(0xff00cbe6) ThemeProvider จะสร้าง ThemeData ที่มีชุดสีเสริมซึ่งคุณดูตัวอย่างได้ในเครื่องมือสร้างธีม Material

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 เพื่อเพิ่มระดับการแสดงออกใหม่ๆ บทบาทสีใหม่เหล่านี้มีดังนี้

  • Primary, OnPrimary, PrimaryContainer, OnPrimaryContainer
  • Secondary, OnSecondary, SecondaryContainer, OnSecondaryContainer
  • Tertiary, OnTertiary, TertiaryContainer, OnTertiaryContainer
  • Error, OnError, ErrorContainer, OnErrorContainer
  • Background, OnBackground
  • Surface, OnSurface, SurfaceVariant, OnSurfaceVariant
  • Shadow, Outline, InversePrimary

นอกจากนี้ โทเค็นการออกแบบใหม่ยังรองรับทั้งธีมสว่างและธีมมืด

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

หน้าจอแต่ละขนาดมีขนาดไม่เท่ากัน หากพยายามแสดงแอปเวอร์ชันเดสก์ท็อปในโทรศัพท์ คุณอาจต้องเพ่งมองและซูมเพื่อดูทุกอย่าง คุณต้องการให้แอปเปลี่ยนลักษณะที่ปรากฏตามหน้าจอที่แสดง การออกแบบที่ปรับเปลี่ยนตามอุปกรณ์ช่วยให้มั่นใจได้ว่าแอปของคุณจะดูดีบนหน้าจอทุกขนาด

หากต้องการให้แอปตอบสนอง ให้ใส่จุดหยุดพักที่ปรับเปลี่ยนได้ (อย่าสับสนกับจุดหยุดพักสำหรับการแก้ไขข้อบกพร่อง) เบรกพอยต์เหล่านี้ระบุขนาดหน้าจอที่แอปควรเปลี่ยนเลย์เอาต์

หน้าจอขนาดเล็กจะแสดงเนื้อหาได้น้อยกว่าหน้าจอขนาดใหญ่โดยไม่ต้องย่อเนื้อหา เพื่อป้องกันไม่ให้แอปดูเหมือนแอปบนเดสก์ท็อปที่ย่อขนาดลง ให้สร้างเลย์เอาต์แยกต่างหากสำหรับอุปกรณ์เคลื่อนที่ที่ใช้แท็บเพื่อแบ่งเนื้อหา วิธีนี้ช่วยให้แอปดูกลมกลืนกับอุปกรณ์เคลื่อนที่มากขึ้น

วิธีการขยายต่อไปนี้ (ที่กําหนดไว้ในโปรเจ็กต์ MyArtist ใน lib/src/shared/extensions.dart) เป็นจุดเริ่มต้นที่ดีเมื่อออกแบบเลย์เอาต์ที่เพิ่มประสิทธิภาพสําหรับเป้าหมายต่างๆ

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 พิกเซล (ในแนวยาวที่สุด) แต่เล็กกว่า 1,200 พิกเซลจะถือว่าใช้กับแท็บเล็ต รูปภาพที่มีขนาดใหญ่กว่า 1,200 พิกเซลจะถือว่ามีขนาดสำหรับเดสก์ท็อป หากอุปกรณ์ไม่ใช่แท็บเล็ตหรือเดสก์ท็อป ระบบจะถือว่าอุปกรณ์นั้นเป็นอุปกรณ์เคลื่อนที่ ดูข้อมูลเพิ่มเติมเกี่ยวกับจุดพักแบบปรับได้ใน material.io

เลย์เอาต์ที่ปรับเปลี่ยนตามอุปกรณ์ของหน้าจอหลักใช้ AdaptiveContainer และ AdaptiveColumn โดยอิงตามตารางกริด 12 คอลัมน์

เลย์เอาต์ที่ปรับเปลี่ยนตามอุปกรณ์ต้องมี 2 เลย์เอาต์ ได้แก่ เลย์เอาต์สำหรับอุปกรณ์เคลื่อนที่และเลย์เอาต์ที่ปรับเปลี่ยนตามอุปกรณ์สำหรับหน้าจอขนาดใหญ่ เมื่อถึงจุดนี้ LayoutBuilder จะแสดงเลย์เอาต์เดสก์ท็อป ใน lib/src/features/home/view/home_screen.dart ให้สร้างเลย์เอาต์สำหรับอุปกรณ์เคลื่อนที่เป็น TabBar และ TabBarView ที่มี 4 แท็บ

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. ใช้การเว้นวรรค

พื้นที่ว่างเป็นเครื่องมือภาพที่สำคัญสำหรับแอปของคุณ ซึ่งช่วยจัดระเบียบส่วนต่างๆ ไว้ด้วยกัน

การมีช่องว่างมากเกินไปดีกว่ามีไม่เพียงพอ การเพิ่มพื้นที่ว่างมากกว่าการลดขนาดแบบอักษรหรือองค์ประกอบภาพเพื่อให้พอดีกับพื้นที่มากขึ้น

การไม่มีพื้นที่ว่างอาจทำให้ผู้ที่มีภาวะสายตามีปัญหา ช่องว่างมากเกินไปอาจทำให้ขาดความสอดคล้องกันและทำให้ UI ดูไม่เป็นระเบียบ ตัวอย่างเช่น ดูภาพหน้าจอต่อไปนี้

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

โหลดแอปซ้ำแบบ Hot Reload เพลย์ลิสต์ 2 รายการที่ด้านล่างไม่มีเว้นวรรคระหว่างกัน จึงดูเหมือนว่าอยู่ในตารางเดียวกัน ไม่ได้เป็นเช่นนั้น และคุณจะแก้ไขในลำดับถัดไป

df1d9af97d039cc8.png

เพิ่มเว้นวรรคระหว่างเพลย์ลิสต์โดยแทรกวิดเจ็ตขนาดลงใน Row ที่มีเพลย์ลิสต์ ใน lib/src/features/home/view/home_screen.dart ให้เพิ่ม SizedBox ที่มีความกว้าง 35 ดังนี้

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

ตอนนี้มีพื้นที่เหลือเฟือสำหรับเนื้อหาในหน้าจอหลัก แต่ทุกอย่างดูแยกกันเกินไปและไม่มีความสอดคล้องกันระหว่างส่วนต่างๆ

จนถึงตอนนี้ คุณได้ตั้งค่าระยะห่างจากขอบทั้งหมด (ทั้งแนวนอนและแนวตั้ง) สำหรับวิดเจ็ตในหน้าจอหลักเป็น 35 ด้วย EdgeInsets.all(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,         ),       ),     );   } } 

โหลดแอปซ้ำแบบ Hot Reload แล้ววางเมาส์เหนือการ์ดเพลย์ลิสต์ที่เล่นล่าสุด

OutlinedCard จะเปลี่ยนความทึบแสงและปัดมุม

สุดท้าย ให้สร้างภาพเคลื่อนไหวของหมายเลขเพลงในเพลย์ลิสต์ให้กลายเป็นปุ่มเล่นโดยใช้วิดเจ็ต HoverableSongPlayButton ที่กําหนดไว้ใน lib/src/shared/views/hoverable_song_play_button.dart ใน lib/src/features/playlists/view/playlist_songs.dart ให้ใส่วิดเจ็ต Center (ซึ่งมีหมายเลขเพลง) ไว้ใน HoverableSongPlayButton ดังนี้

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())),   ], ), 

โหลดแอปซ้ำแบบ Hot Reload แล้ววางเคอร์เซอร์เหนือหมายเลขเพลงในเพลย์ลิสต์เพลงยอดนิยมวันนี้หรือเพลงใหม่

ตัวเลขจะเคลื่อนไหวเป็นปุ่มเล่นที่จะเล่นเพลงเมื่อคุณคลิก

ดูโค้ดโปรเจ็กต์เวอร์ชันสุดท้ายบน GitHub

10. ยินดีด้วย

คุณทำ Codelab นี้เสร็จแล้ว คุณได้เรียนรู้แล้วว่าการเปลี่ยนแปลงเล็กๆ น้อยๆ มากมายสามารถผสานรวมเข้ากับแอปเพื่อให้แอปดูสวยงามขึ้น รวมถึงเข้าถึงได้ง่ายขึ้น แปลได้ง่ายขึ้น และเหมาะกับแพลตฟอร์มต่างๆ มากขึ้น เทคนิคเหล่านี้รวมถึงแต่ไม่จำกัดเพียงรายการต่อไปนี้

  • การจัดรูปแบบตัวอักษร: ข้อความเป็นมากกว่าเครื่องมือสื่อสาร ใช้วิธีแสดงข้อความเพื่อให้เกิดผลลัพธ์เชิงบวกต่อประสบการณ์และมุมมองของผู้ใช้เกี่ยวกับแอป
  • ธีม: สร้างระบบการออกแบบที่คุณใช้ได้อย่างน่าเชื่อถือโดยไม่ต้องตัดสินใจเกี่ยวกับการออกแบบวิดเจ็ตแต่ละรายการ
  • ความสามารถในการปรับตัว: พิจารณาอุปกรณ์และแพลตฟอร์มที่ผู้ใช้เรียกใช้แอปของคุณ รวมถึงความสามารถของอุปกรณ์และแพลตฟอร์ม พิจารณาขนาดหน้าจอและลักษณะที่แอปแสดง
  • การเคลื่อนไหวและภาพเคลื่อนไหว: การเพิ่มการเคลื่อนไหวในแอปจะช่วยเพิ่มประสบการณ์ของผู้ใช้ และที่สำคัญคือแสดงความคิดเห็นให้ผู้ใช้ทราบ

การปรับแต่งเล็กๆ น้อยๆ เพียงไม่กี่อย่างก็สามารถเปลี่ยนแอปที่น่าเบื่อให้ดูสวยงามได้

ก่อน

1e67c60667821082.png

หลัง

ขั้นตอนถัดไป

เราหวังว่าคุณจะได้เรียนรู้เพิ่มเติมเกี่ยวกับการสร้างแอปที่สวยงามใน Flutter

หากใช้เคล็ดลับที่กล่าวถึงที่นี่ (หรือมีเคล็ดลับของคุณเองที่จะแชร์) เรายินดีรับฟังจากคุณ ติดต่อเราได้ที่ @rodydavis และ @khanhnwin บน Twitter

แหล่งข้อมูลต่อไปนี้อาจเป็นประโยชน์สำหรับคุณ

ธีม

แหล่งข้อมูลที่ปรับเปลี่ยนและตอบสนอง

แหล่งข้อมูลการออกแบบทั่วไป

นอกจากนี้ โปรดเชื่อมต่อกับชุมชน Flutter

ออกไปสร้างโลกแอปให้สวยงาม