1. บทนำ
Flutter คือชุดเครื่องมือ UI ของ Google สำหรับการสร้างแอปพลิเคชันที่สวยงามซึ่งรวบรวมไว้ภายในอุปกรณ์เคลื่อนที่ เว็บ และเดสก์ท็อปจากฐานของโค้ดเดียว Flutter ใช้งานได้กับโค้ดที่มีอยู่ ซึ่งเป็นโค้ดที่นักพัฒนาซอฟต์แวร์และองค์กรทั่วโลกใช้ โดยเป็นโค้ดฟรีและเป็นโอเพนซอร์ส
ใน Codelab นี้ คุณจะได้ปรับปรุงแอปพลิเคชันเพลง Flutter โดยเปลี่ยนจากสิ่งที่น่าเบื่อให้กลายเป็นความสวยงาม Codelab นี้ใช้เครื่องมือและ API ที่นำเสนอใน Material 3 เพื่อให้บรรลุเป้าหมายนี้
สิ่งที่คุณจะได้เรียนรู้
- วิธีเขียนแอป Flutter ให้ใช้งานได้และสวยงามในแพลตฟอร์มต่างๆ
- วิธีออกแบบข้อความในแอปเพื่อให้แน่ใจว่าข้อความจะมีประโยชน์ต่อประสบการณ์ของผู้ใช้
- วิธีเลือกสีที่เหมาะสม ปรับแต่งวิดเจ็ต สร้างธีมของคุณเอง และใช้งานโหมดมืดได้อย่างรวดเร็วและง่ายดาย
- วิธีสร้างแอปที่ปรับเปลี่ยนได้ข้ามแพลตฟอร์ม
- วิธีสร้างแอปที่ดูดีบนหน้าจอใดก็ได้
- วิธีเพิ่มการเคลื่อนไหวให้แอป Flutter เพื่อทำให้แอปโดดเด่น
สิ่งที่ต้องมีก่อน
Codelab นี้จะสมมติว่าคุณมีประสบการณ์การใช้งาน Flutter อยู่บ้าง หากไม่ คุณอาจต้องเรียนรู้ข้อมูลเบื้องต้นก่อน ลิงก์ต่อไปนี้มีประโยชน์
- ชมทัวร์ชมเฟรมเวิร์กวิดเจ็ต Flutter
- ลองใช้ Codelab ของ Write Your First Flutter App ตอนที่ 1
สิ่งที่คุณจะสร้าง
Codelab นี้จะแนะนำขั้นตอนการสร้างหน้าจอหลักสำหรับแอปพลิเคชันชื่อ MyArtist ซึ่งเป็นแอปโปรแกรมเล่นเพลงที่แฟนๆ สามารถติดตามข่าวสารเกี่ยวกับศิลปินคนโปรด โดยจะกล่าวถึงวิธีที่คุณสามารถปรับแต่งการออกแบบแอปให้ดูสวยงามในแพลตฟอร์มต่างๆ
วิดีโอต่อไปนี้แสดงวิธีการทำงานของแอปเมื่อ Codelab เสร็จสมบูรณ์
คุณต้องการเรียนรู้อะไรจาก Codelab นี้
2. ตั้งค่าสภาพแวดล้อมในการพัฒนาซอฟต์แวร์ Flutter
ห้องทดลองนี้ต้องมีซอฟต์แวร์ 2 ประเภท ได้แก่ Flutter SDK และเครื่องมือแก้ไข
คุณเรียกใช้ Codelab ได้โดยใช้อุปกรณ์ต่อไปนี้
- อุปกรณ์ Android หรือ iOS ที่เชื่อมต่อกับคอมพิวเตอร์และตั้งค่าเป็นโหมดนักพัฒนาซอฟต์แวร์
- เครื่องมือจำลอง iOS (ต้องติดตั้งเครื่องมือ Xcode)
- โปรแกรมจำลอง Android (ต้องตั้งค่าใน Android Studio)
- เบราว์เซอร์ (การแก้ไขข้อบกพร่องต้องใช้ Chrome)
- เป็นแอปพลิเคชัน Windows, Linux หรือ macOS บนเดสก์ท็อป คุณต้องพัฒนาบนแพลตฟอร์มที่คุณวางแผนจะทำให้ใช้งานได้ ดังนั้นหากต้องการพัฒนาแอป Windows บนเดสก์ท็อป คุณต้องพัฒนาบน Windows เพื่อเข้าถึงเชนบิลด์ที่เหมาะสม มีข้อกำหนดเฉพาะระบบปฏิบัติการที่ครอบคลุมรายละเอียดใน docs.flutter.dev/desktop
3. ดาวน์โหลดแอปเริ่มต้นสำหรับ Codelab
โคลนไฟล์จาก GitHub
หากต้องการโคลน Codelab นี้จาก GitHub ให้เรียกใช้คำสั่งต่อไปนี้
git clone https://github.com/flutter/codelabs.git cd codelabs/boring_to_beautiful/step_01/
เพื่อให้แน่ใจว่าทุกอย่างทำงานได้ดี ให้เรียกใช้แอปพลิเคชัน Flutter เป็นแอปพลิเคชันเดสก์ท็อปดังที่แสดงด้านล่าง อีกทางเลือกหนึ่งคือเปิดโปรเจ็กต์นี้ใน IDE แล้วใช้เครื่องมือของโปรเจ็กต์เพื่อเรียกใช้แอปพลิเคชัน
สำเร็จ! รหัสเริ่มต้นสำหรับหน้าจอหลักของ MyArtist ควรทำงานอยู่ คุณควรเห็นหน้าจอหลักของ MyArtist แม้จะดูดีในเดสก์ท็อป แต่อุปกรณ์เคลื่อนที่กลับ... ไม่ค่อยดี อย่างหนึ่งก็คือ มันไม่ได้ให้รอยบากนั้นหรอก ไม่ต้องกังวล คุณจะแก้ไขปัญหานี้ได้
ทัวร์ชมโค้ด
ต่อไป ทัวร์ชมโค้ด
เปิด lib/src/features/home/view/home_screen.dart
ซึ่งจะมีข้อมูลต่อไปนี้
lib/src/features/home/view/home_screen.dart
import 'package:adaptive_components/adaptive_components.dart';
import 'package:flutter/material.dart';
import '../../../shared/classes/classes.dart';
import '../../../shared/extensions.dart';
import '../../../shared/providers/providers.dart';
import '../../../shared/views/views.dart';
import '../../playlists/view/playlist_songs.dart';
import 'view.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
final PlaylistsProvider playlistProvider = PlaylistsProvider();
final List<Playlist> playlists = playlistProvider.playlists;
final Playlist topSongs = playlistProvider.topSongs;
final Playlist newReleases = playlistProvider.newReleases;
final ArtistsProvider artistsProvider = ArtistsProvider();
final List<Artist> artists = artistsProvider.artists;
return LayoutBuilder(
builder: (context, constraints) {
// Add conditional mobile layout
return Scaffold(
body: SingleChildScrollView(
child: AdaptiveColumn(
children: [
AdaptiveContainer(
columnSpan: 12,
child: Padding(
padding: const EdgeInsets.all(2), // Modify this line
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'Good morning',
style: context.displaySmall,
),
),
const SizedBox(width: 20),
const BrightnessToggle(),
],
),
),
),
AdaptiveContainer(
columnSpan: 12,
child: Column(
children: [
const HomeHighlight(),
LayoutBuilder(
builder: (context, constraints) => HomeArtists(
artists: artists,
constraints: constraints,
),
),
],
),
),
AdaptiveContainer(
columnSpan: 12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(2), // Modify this line
child: Text(
'Recently played',
style: context.headlineSmall,
),
),
HomeRecent(
playlists: playlists,
),
],
),
),
AdaptiveContainer(
columnSpan: 12,
child: Padding(
padding: const EdgeInsets.all(2), // Modify this line
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
flex: 10,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding:
const EdgeInsets.all(2), // Modify this line
child: Text(
'Top Songs Today',
style: context.titleLarge,
),
),
LayoutBuilder(
builder: (context, constraints) =>
PlaylistSongs(
playlist: topSongs,
constraints: constraints,
),
),
],
),
),
// Add spacer between tables
Flexible(
flex: 10,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding:
const EdgeInsets.all(2), // Modify this line
child: Text(
'New Releases',
style: context.titleLarge,
),
),
LayoutBuilder(
builder: (context, constraints) =>
PlaylistSongs(
playlist: newReleases,
constraints: constraints,
),
),
],
),
),
],
),
),
),
],
),
),
);
},
);
}
}
ไฟล์นี้นำเข้า material.dart
และใช้วิดเจ็ตการเก็บสถานะโดยใช้ 2 คลาส ดังนี้
- คำสั่ง
import
แสดงคอมโพเนนต์เนื้อหาที่พร้อมใช้งาน - คลาส
HomeScreen
จะแสดงหน้าเว็บที่ปรากฏทั้งหน้า - เมธอด
build()
ของคลาส_HomeScreenState
จะสร้างรูทของโครงสร้างวิดเจ็ต ซึ่งจะส่งผลต่อวิธีสร้างวิดเจ็ตทั้งหมดใน UI
4. ใช้ประโยชน์จากการพิมพ์
ข้อความปรากฏอยู่ทุกที่ ข้อความเป็นวิธีที่มีประโยชน์ในการสื่อสารกับผู้ใช้ แอปของคุณมีเจตนาให้ใช้งานง่ายและสนุก หรืออาจเชื่อถือได้และเป็นมืออาชีพหรือไม่ มีเหตุผลว่าทำไมแอปธนาคารที่คุณชื่นชอบถึงไม่ใช้ Comic Sans ลักษณะการนำเสนอข้อความจะเป็นตัวกำหนดความประทับใจแรกของผู้ใช้ต่อแอปของคุณ ต่อไปนี้คือวิธีการใช้ข้อความอย่างรอบคอบ
แสดงให้เห็นแทนการบอก
"แสดง" หากเป็นไปได้ แทนที่จะใช้ "บอก" ตัวอย่างเช่น NavigationRail
ในแอปเริ่มต้นมีแท็บสำหรับเส้นทางหลักแต่ละเส้นทาง แต่ไอคอนนำหน้าจะเหมือนกัน ดังนี้
กรณีนี้ไม่มีประโยชน์เนื่องจากผู้ใช้ยังคงต้องอ่านข้อความของแต่ละแท็บ เริ่มด้วยการเพิ่มสัญลักษณ์ที่เป็นภาพ เพื่อให้ผู้ใช้สามารถมองผ่านไอคอนนำเพื่อค้นหาแท็บที่ต้องการได้อย่างรวดเร็ว ซึ่งจะช่วยในด้านการแปลและการช่วยเหลือพิเศษด้วย
ใน 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',
),
];
หากพบปัญหา
หากแอปทำงานไม่ปกติ ให้มองหาการพิมพ์ผิด หากจำเป็น ให้ใช้โค้ดในลิงก์ต่อไปนี้เพื่อกลับสู่เส้นทาง
เลือกแบบอักษรอย่างรอบคอบ
แบบอักษรจะเป็นตัวกำหนดลักษณะเฉพาะของแอปพลิเคชันของคุณ ดังนั้นการเลือกแบบอักษรที่เหมาะสมจึงเป็นสิ่งสำคัญ สิ่งที่ควรคำนึงถึงเมื่อเลือกแบบอักษรมีดังนี้
- Sans-serif หรือ serif: แบบอักษร Serif มีเส้นตกแต่งหรือ "tail" ที่ส่วนท้ายของตัวอักษรและมองว่าเป็นทางการมากขึ้น แบบอักษร Sans Serif ไม่มีลายเส้นตกแต่งและมักมองว่าเป็นแบบไม่เป็นทางการมากกว่า A Sans Serif ตัวพิมพ์ใหญ่ T และ Serif อักษรตัวพิมพ์ใหญ่ T
- แบบอักษรตัวพิมพ์ใหญ่ทั้งหมด: การใช้ตัวพิมพ์ใหญ่ทั้งหมดเหมาะสำหรับการดึงดูดความสนใจไปยังข้อความจำนวนน้อย (นึกถึงบรรทัดแรก) แต่หากใช้มากเกินไป ระบบอาจมองว่าเป็นการตะโกนที่ทำให้ผู้ใช้ไม่ต้องสนใจข้อความนั้น
- ลักษณะตัวพิมพ์ของชื่อเรื่องหรือประโยคตัวอย่าง: เมื่อเพิ่มชื่อหรือป้ายกำกับ ให้พิจารณาวิธีใช้ตัวพิมพ์ใหญ่: ลักษณะตัวพิมพ์ของชื่อเรื่อง ซึ่งอักษรตัวแรกของแต่ละคำเป็นตัวพิมพ์ใหญ่ ("This Is a Title Case Title") เป็นทางการมากขึ้น ขึ้นต้นประโยคด้วยตัวพิมพ์ใหญ่ ซึ่งจะใช้อักษรตัวพิมพ์ใหญ่เฉพาะคำนามและคำแรกในข้อความ ("This is a sentence case title") มีความเป็นบทสนทนาและไม่เป็นทางการมากกว่า
- การจัดบรรทัด (ระยะห่างระหว่างตัวอักษรแต่ละตัวอักษร) ความยาวบรรทัด (ความกว้างของข้อความเต็มทั้งหน้าจอ) และความสูงของบรรทัด (ความสูงของข้อความแต่ละบรรทัด): ข้อความเหล่านี้มากเกินไปหรือน้อยเกินไปจะทำให้แอปอ่านน้อยลง เช่น คุณอาจเสียตำแหน่งเมื่ออ่านบล็อกข้อความขนาดใหญ่ที่อ่านไม่แตกได้ง่าย
ดังนั้นไปที่ Google Fonts และเลือกแบบอักษร Sans-Serif เช่น Montserrat เนื่องจากแอปเพลงมีวัตถุประสงค์เพื่อสร้างความบันเทิงและสนุกสนาน
ดึงข้อมูลแพ็กเกจ google_fonts
จากบรรทัดคำสั่ง ซึ่งจะอัปเดตไฟล์ pubspec เพื่อเพิ่มแบบอักษรเป็นทรัพยากร 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" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- Make sure these lines are present from here... -->
<key>com.apple.security.network.client</key>
<true/>
<!-- To here. -->
<key>com.apple.security.network.server</key>
<true/>
</dict>
</plist>
ใน lib/src/shared/extensions.dart
ให้นำเข้าแพ็กเกจใหม่ดังนี้
lib/src/shared/extensions.dart
import 'package:google_fonts/google_fonts.dart'; // Add this line.
ตั้งค่ามอนต์เซอร์รัต TextTheme:
TextTheme get textTheme => GoogleFonts.montserratTextTheme(theme.textTheme); // Modify this line
รีโหลดร้อน เพื่อเปิดใช้งานการเปลี่ยนแปลง (ใช้ปุ่มใน IDE หรือ จากบรรทัดคำสั่ง ให้ป้อน r
เพื่อ Hotโหลดซ้ำ):
คุณจะเห็นไอคอน NavigationRail
ใหม่ พร้อมกับข้อความที่แสดงในแบบอักษร Montserrat
หากพบปัญหา
หากแอปทำงานไม่ปกติ ให้มองหาการพิมพ์ผิด หากจำเป็น ให้ใช้โค้ดในลิงก์ต่อไปนี้เพื่อกลับสู่เส้นทาง
5. กำหนดธีม
ธีมช่วยให้แอปมีการออกแบบที่มีโครงสร้างและเป็นแบบเดียวกันด้วยการระบุระบบชุดสีและรูปแบบข้อความ ธีมช่วยให้คุณติดตั้ง UI ได้อย่างรวดเร็วโดยไม่ต้องเน้นรายละเอียดเล็กๆ น้อยๆ เช่น การระบุสีที่แน่ชัดสำหรับวิดเจ็ตทุกรายการ
โดยปกติแล้วนักพัฒนาแอป Flutter จะสร้างคอมโพเนนต์ที่มีธีมที่กำหนดเองด้วย 1 ใน 2 วิธีต่อไปนี้
- สร้างวิดเจ็ตที่กำหนดเองโดยแต่ละวิดเจ็ตมีธีมของตัวเอง
- สร้างธีมที่กำหนดขอบเขตสำหรับวิดเจ็ตเริ่มต้น
ตัวอย่างนี้ใช้ผู้ให้บริการธีมที่อยู่ใน lib/src/shared/providers/theme.dart
เพื่อสร้างวิดเจ็ตและสีที่มีธีมสอดคล้องกันทั่วทั้งแอป
lib/src/shared/providers/theme.dart
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:material_color_utilities/material_color_utilities.dart';
class NoAnimationPageTransitionsBuilder extends PageTransitionsBuilder {
const NoAnimationPageTransitionsBuilder();
@override
Widget buildTransitions<T>(
PageRoute<T> route,
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return child;
}
}
class ThemeSettingChange extends Notification {
ThemeSettingChange({required this.settings});
final ThemeSettings settings;
}
class ThemeProvider extends InheritedWidget {
const ThemeProvider(
{super.key,
required this.settings,
required this.lightDynamic,
required this.darkDynamic,
required super.child});
final ValueNotifier<ThemeSettings> settings;
final ColorScheme? lightDynamic;
final ColorScheme? darkDynamic;
final pageTransitionsTheme = const PageTransitionsTheme(
builders: <TargetPlatform, PageTransitionsBuilder>{
TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.linux: NoAnimationPageTransitionsBuilder(),
TargetPlatform.macOS: NoAnimationPageTransitionsBuilder(),
TargetPlatform.windows: NoAnimationPageTransitionsBuilder(),
},
);
Color custom(CustomColor custom) {
if (custom.blend) {
return blend(custom.color);
} else {
return custom.color;
}
}
Color blend(Color targetColor) {
return Color(
Blend.harmonize(targetColor.value, settings.value.sourceColor.value));
}
Color source(Color? target) {
Color source = settings.value.sourceColor;
if (target != null) {
source = blend(target);
}
return source;
}
ColorScheme colors(Brightness brightness, Color? targetColor) {
final dynamicPrimary = brightness == Brightness.light
? lightDynamic?.primary
: darkDynamic?.primary;
return ColorScheme.fromSeed(
seedColor: dynamicPrimary ?? source(targetColor),
brightness: brightness,
);
}
ShapeBorder get shapeMedium => RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
);
CardTheme cardTheme() {
return CardTheme(
elevation: 0,
shape: shapeMedium,
clipBehavior: Clip.antiAlias,
);
}
ListTileThemeData listTileTheme(ColorScheme colors) {
return ListTileThemeData(
shape: shapeMedium,
selectedColor: colors.secondary,
);
}
AppBarTheme appBarTheme(ColorScheme colors) {
return AppBarTheme(
elevation: 0,
backgroundColor: colors.surface,
foregroundColor: colors.onSurface,
);
}
TabBarTheme tabBarTheme(ColorScheme colors) {
return TabBarTheme(
labelColor: colors.secondary,
unselectedLabelColor: colors.onSurfaceVariant,
indicator: BoxDecoration(
border: Border(
bottom: BorderSide(
color: colors.secondary,
width: 2,
),
),
),
);
}
BottomAppBarTheme bottomAppBarTheme(ColorScheme colors) {
return BottomAppBarTheme(
color: colors.surface,
elevation: 0,
);
}
BottomNavigationBarThemeData bottomNavigationBarTheme(ColorScheme colors) {
return BottomNavigationBarThemeData(
type: BottomNavigationBarType.fixed,
backgroundColor: colors.surfaceContainerHighest,
selectedItemColor: colors.onSurface,
unselectedItemColor: colors.onSurfaceVariant,
elevation: 0,
landscapeLayout: BottomNavigationBarLandscapeLayout.centered,
);
}
NavigationRailThemeData navigationRailTheme(ColorScheme colors) {
return const NavigationRailThemeData();
}
DrawerThemeData drawerTheme(ColorScheme colors) {
return DrawerThemeData(
backgroundColor: colors.surface,
);
}
ThemeData light([Color? targetColor]) {
final _colors = colors(Brightness.light, targetColor);
return ThemeData.light().copyWith(
pageTransitionsTheme: pageTransitionsTheme,
colorScheme: _colors,
appBarTheme: appBarTheme(_colors),
cardTheme: cardTheme(),
listTileTheme: listTileTheme(_colors),
bottomAppBarTheme: bottomAppBarTheme(_colors),
bottomNavigationBarTheme: bottomNavigationBarTheme(_colors),
navigationRailTheme: navigationRailTheme(_colors),
tabBarTheme: tabBarTheme(_colors),
drawerTheme: drawerTheme(_colors),
scaffoldBackgroundColor: _colors.background,
useMaterial3: true,
);
}
ThemeData dark([Color? targetColor]) {
final _colors = colors(Brightness.dark, targetColor);
return ThemeData.dark().copyWith(
pageTransitionsTheme: pageTransitionsTheme,
colorScheme: _colors,
appBarTheme: appBarTheme(_colors),
cardTheme: cardTheme(),
listTileTheme: listTileTheme(_colors),
bottomAppBarTheme: bottomAppBarTheme(_colors),
bottomNavigationBarTheme: bottomNavigationBarTheme(_colors),
navigationRailTheme: navigationRailTheme(_colors),
tabBarTheme: tabBarTheme(_colors),
drawerTheme: drawerTheme(_colors),
scaffoldBackgroundColor: _colors.background,
useMaterial3: true,
);
}
ThemeMode themeMode() {
return settings.value.themeMode;
}
ThemeData theme(BuildContext context, [Color? targetColor]) {
final brightness = MediaQuery.of(context).platformBrightness;
return brightness == Brightness.light
? light(targetColor)
: dark(targetColor);
}
static ThemeProvider of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ThemeProvider>()!;
}
@override
bool updateShouldNotify(covariant ThemeProvider oldWidget) {
return oldWidget.settings != settings;
}
}
class ThemeSettings {
ThemeSettings({
required this.sourceColor,
required this.themeMode,
});
final Color sourceColor;
final ThemeMode themeMode;
}
Color randomColor() {
return Color(Random().nextInt(0xFFFFFFFF));
}
// Custom Colors
const linkColor = CustomColor(
name: 'Link Color',
color: Color(0xFF00B0FF),
);
class CustomColor {
const CustomColor({
required this.name,
required this.color,
this.blend = true,
});
final String name;
final Color color;
final bool blend;
Color value(ThemeProvider provider) {
return provider.custom(this);
}
}
หากต้องการใช้ผู้ให้บริการ ให้สร้างอินสแตนซ์และส่งไปยังออบเจ็กต์ธีมที่กำหนดขอบเขตใน 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 Theme Builder เป็นเครื่องมือบนเว็บ (เปิดตัวใน Material 3) ซึ่งจะช่วยให้คุณเลือกชุดสีเสริมสำหรับแอปได้
หากต้องการเลือกสีต้นฉบับของแอปพลิเคชัน ให้เปิด Material Theme Builder และสำรวจสีต่างๆ ของ UI สิ่งสำคัญคือการเลือกสีที่เหมาะกับความสวยงามของแบรนด์และ/หรือความชอบส่วนตัวของคุณ
หลังจากสร้างธีมแล้ว ให้คลิกขวาลูกโป่งสีหลัก ซึ่งจะเปิดกล่องโต้ตอบที่มีค่าเลขฐาน 16 ของสีหลัก คัดลอกค่านี้ (คุณสามารถตั้งค่าสีโดยใช้กล่องโต้ตอบนี้)
ส่งค่าเลขฐานสิบหกของสีหลักไปยังผู้ให้บริการธีม ตัวอย่างเช่น สีแบบเลขฐาน 16 #00cbe6
จะระบุเป็น Color(0xff00cbe6)
ThemeProvider
จะสร้าง ThemeData
ที่มีชุดสีเสริมที่คุณดูตัวอย่างใน Material Theme Builder:
final settings = ValueNotifier(ThemeSettings(
sourceColor: Color(0xff00cbe6), // Replace this color
themeMode: ThemeMode.system,
));
Hot รีสตาร์ทแอป แอปจะเริ่มสื่ออารมณ์มากขึ้นเมื่อใส่สีหลักเรียบร้อยแล้ว เข้าถึงสีใหม่ทั้งหมดโดยอ้างอิงธีมในบริบทและเลือก 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(
child: widget.child,
// Add from here...
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 1,
),
),
// ... To here.
),
);
}
}
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
นอกจากนี้ โทเค็นการออกแบบใหม่ยังรองรับทั้งธีมสว่างและธีมมืดอีกด้วย
โดยจะใช้บทบาทสีเหล่านี้เพื่อกําหนดความหมายและเน้นส่วนต่างๆ ของ 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 super.child,
});
final List<NavigationDestination> destinations;
final int selectedIndex;
final void Function(int index) onDestinationSelected;
final Widget child;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, dimens) {
// Tablet Layout
if (dimens.maxWidth >= 600) { // Add this line
return Scaffold(
body: Row(
children: [
NavigationRail(
extended: dimens.maxWidth >= 800,
minExtendedWidth: 180,
destinations: destinations
.map((e) => NavigationRailDestination(
icon: e.icon,
label: Text(e.label),
))
.toList(),
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
),
Expanded(child: child),
],
),
);
} // Add this line
// Mobile Layout
// Add from here...
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
destinations: destinations,
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
),
);
// ... To here.
},
);
}
}
หน้าจอแต่ละหน้าจอไม่ได้มีขนาดเท่ากัน หากพยายามแสดงแอปเวอร์ชันเดสก์ท็อปในโทรศัพท์ คุณต้องหรี่ตาและซูมหน้าจอร่วมกันจึงจะเห็นทุกอย่าง คุณต้องการให้แอปเปลี่ยนวิธีแสดงผลตามหน้าจอที่แสดงแอป การออกแบบที่ปรับเปลี่ยนตามอุปกรณ์จะช่วยให้แอปดูดีในหน้าจอทุกขนาด
ในการทำให้แอปตอบสนองตามอุปกรณ์ ให้ใช้เบรกพอยท์ที่ปรับเปลี่ยนได้ 2-3 จุด (อย่าสับสนกับเบรกพอยท์การแก้ไขข้อบกพร่อง) เบรกพอยท์เหล่านี้จะระบุขนาดหน้าจอที่แอปของคุณควรเปลี่ยนเลย์เอาต์
หน้าจอขนาดเล็กไม่สามารถแสดงได้มากเท่ากับหน้าจอขนาดใหญ่ หากไม่ย่อเนื้อหาลง หากต้องการป้องกันไม่ให้แอปดูเหมือนแอปบนเดสก์ท็อปที่ลดขนาดลง ให้สร้างเลย์เอาต์แยกสำหรับอุปกรณ์เคลื่อนที่ซึ่งใช้แท็บในการแบ่งเนื้อหา วิธีนี้ทำให้แอปดูกลมกลืนกับการใช้งานบนอุปกรณ์เคลื่อนที่มากขึ้น
การใช้วิธีการขยายเวลาต่อไปนี้ (ตามที่กำหนดไว้ในโปรเจ็กต์ 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 คุณอาจลองใช้แพ็กเกจ adaptive_breakpoints
เลย์เอาต์ที่ปรับเปลี่ยนตามอุปกรณ์ของหน้าจอหลักใช้ AdaptiveContainer
และ AdaptiveColumn
ตามตารางกริดแบบ 12 คอลัมน์ โดยใช้แพ็กเกจ adaptive_components และ adaptive_breakpoints เพื่อใช้เลย์เอาต์ตารางกริดที่ปรับเปลี่ยนตามอุปกรณ์ในดีไซน์ Material
return LayoutBuilder(
builder: (context, constraints) {
return Scaffold(
body: SingleChildScrollView(
child: AdaptiveColumn(
children: [
AdaptiveContainer(
columnSpan: 12,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 40,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'Good morning',
style: context.displaySmall,
),
),
const SizedBox(width: 20),
const BrightnessToggle(),
],
),
),
),
AdaptiveContainer(
columnSpan: 12,
child: Column(
children: [
const HomeHighlight(),
LayoutBuilder(
builder: (context, constraints) => HomeArtists(
artists: artists,
constraints: constraints,
),
),
],
),
),
AdaptiveContainer(
columnSpan: 12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 15,
vertical: 20,
),
child: Text(
'Recently played',
style: context.headlineSmall,
),
),
HomeRecent(
playlists: playlists,
),
],
),
),
AdaptiveContainer(
columnSpan: 12,
child: Padding(
padding: const EdgeInsets.all(15),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
flex: 10,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding:
const EdgeInsets.only(left: 8, bottom: 8),
child: Text(
'Top Songs Today',
style: context.titleLarge,
),
),
LayoutBuilder(
builder: (context, constraints) =>
PlaylistSongs(
playlist: topSongs,
constraints: constraints,
),
),
],
),
),
const SizedBox(width: 25),
Flexible(
flex: 10,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding:
const EdgeInsets.only(left: 8, bottom: 8),
child: Text(
'New Releases',
style: context.titleLarge,
),
),
LayoutBuilder(
builder: (context, constraints) =>
PlaylistSongs(
playlist: newReleases,
constraints: constraints,
),
),
],
),
),
],
),
),
),
],
),
),
);
},
);
เลย์เอาต์แบบปรับอัตโนมัติต้องใช้ 2 เลย์เอาต์ ได้แก่ เลย์เอาต์สำหรับอุปกรณ์เคลื่อนที่ และเลย์เอาต์ที่ปรับเปลี่ยนตามอุปกรณ์สำหรับหน้าจอขนาดใหญ่ ปัจจุบัน LayoutBuilder
แสดงผลเฉพาะเลย์เอาต์บนเดสก์ท็อปเท่านั้น ใน lib/src/features/home/view/home_screen.dart
ให้สร้างเลย์เอาต์อุปกรณ์เคลื่อนที่เป็น TabBar
และ TabBarView
โดยมี 4 แท็บ
lib/src/features/home/view/home_screen.dart
import 'package:adaptive_components/adaptive_components.dart';
import 'package:flutter/material.dart';
import '../../../shared/classes/classes.dart';
import '../../../shared/extensions.dart';
import '../../../shared/providers/providers.dart';
import '../../../shared/views/views.dart';
import '../../playlists/view/playlist_songs.dart';
import 'view.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
final PlaylistsProvider playlistProvider = PlaylistsProvider();
final List<Playlist> playlists = playlistProvider.playlists;
final Playlist topSongs = playlistProvider.topSongs;
final Playlist newReleases = playlistProvider.newReleases;
final ArtistsProvider artistsProvider = ArtistsProvider();
final List<Artist> artists = artistsProvider.artists;
return LayoutBuilder(
builder: (context, constraints) {
// Add from here...
if (constraints.isMobile) {
return DefaultTabController(
length: 4,
child: Scaffold(
appBar: AppBar(
centerTitle: false,
title: const Text('Good morning'),
actions: const [BrightnessToggle()],
bottom: const TabBar(
isScrollable: true,
tabs: [
Tab(text: 'Home'),
Tab(text: 'Recently Played'),
Tab(text: 'New Releases'),
Tab(text: 'Top Songs'),
],
),
),
body: LayoutBuilder(
builder: (context, constraints) => TabBarView(
children: [
SingleChildScrollView(
child: Column(
children: [
const HomeHighlight(),
HomeArtists(
artists: artists,
constraints: constraints,
),
],
),
),
HomeRecent(
playlists: playlists,
axis: Axis.vertical,
),
PlaylistSongs(
playlist: topSongs,
constraints: constraints,
),
PlaylistSongs(
playlist: newReleases,
constraints: constraints,
),
],
),
),
),
);
}
// ... To here.
return Scaffold(
body: SingleChildScrollView(
child: AdaptiveColumn(
children: [
AdaptiveContainer(
columnSpan: 12,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 40,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'Good morning',
style: context.displaySmall,
),
),
const SizedBox(width: 20),
const BrightnessToggle(),
],
),
),
),
AdaptiveContainer(
columnSpan: 12,
child: Column(
children: [
const HomeHighlight(),
LayoutBuilder(
builder: (context, constraints) => HomeArtists(
artists: artists,
constraints: constraints,
),
),
],
),
),
AdaptiveContainer(
columnSpan: 12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 15,
vertical: 20,
),
child: Text(
'Recently played',
style: context.headlineSmall,
),
),
HomeRecent(
playlists: playlists,
),
],
),
),
AdaptiveContainer(
columnSpan: 12,
child: Padding(
padding: const EdgeInsets.all(15),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
flex: 10,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding:
const EdgeInsets.only(left: 8, bottom: 8),
child: Text(
'Top Songs Today',
style: context.titleLarge,
),
),
LayoutBuilder(
builder: (context, constraints) =>
PlaylistSongs(
playlist: topSongs,
constraints: constraints,
),
),
],
),
),
const SizedBox(width: 25),
Flexible(
flex: 10,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding:
const EdgeInsets.only(left: 8, bottom: 8),
child: Text(
'New Releases',
style: context.titleLarge,
),
),
LayoutBuilder(
builder: (context, constraints) =>
PlaylistSongs(
playlist: newReleases,
constraints: constraints,
),
),
],
),
),
],
),
),
),
],
),
),
);
},
);
}
}
หากพบปัญหา
หากแอปของคุณทำงานไม่ถูกต้อง ให้ใช้โค้ดที่ลิงก์ต่อไปนี้เพื่อกลับสู่การทำงาน
ใช้ช่องว่าง
ช่องว่างคือเครื่องมือแสดงข้อมูลทางภาพที่สำคัญของแอป โดยจะช่วยสร้างตัวแบ่งส่วนต่างๆ ในองค์กร
ควรมีพื้นที่ว่างมากเกินไป ขอแนะนำให้เพิ่มช่องว่างเพื่อลดขนาดของแบบอักษรหรือองค์ประกอบภาพเพื่อให้เข้ากับพื้นที่ว่างมากขึ้น
การขาดช่องว่างอาจเป็นความยากลำบากสำหรับผู้ที่มีปัญหาเกี่ยวกับการมองเห็น ช่องว่างมากเกินไปอาจทำให้ไม่สอดคล้องกัน และทำให้ UI ดูไม่เป็นระเบียบ ตัวอย่างเช่น โปรดดูภาพหน้าจอต่อไปนี้
ถัดไป คุณจะเพิ่มช่องว่างในหน้าจอหลักเพื่อเพิ่มพื้นที่ จากนั้นคุณจะปรับแต่งเลย์เอาต์เพิ่มเติมเพื่อปรับแต่งระยะห่าง
รวมวิดเจ็ตด้วยออบเจ็กต์ Padding
เพื่อเพิ่มช่องว่างรอบๆ วิดเจ็ตนั้น เพิ่มค่า Padding ทั้งหมดที่ตอนนี้อยู่ใน lib/src/features/home/view/home_screen.dart
เป็น 35 ดังนี้
lib/src/features/home/view/home_screen.dart
Scaffold(
body: SingleChildScrollView(
child: AdaptiveColumn(
children: [
AdaptiveContainer(
columnSpan: 12,
child: Padding(
padding: const EdgeInsets.all(35), // Modify this line
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'Good morning',
style: context.displaySmall,
),
),
const SizedBox(width: 20),
const BrightnessToggle(),
],
),
),
),
AdaptiveContainer(
columnSpan: 12,
child: Column(
children: [
const HomeHighlight(),
LayoutBuilder(
builder: (context, constraints) => HomeArtists(
artists: artists,
constraints: constraints,
),
),
],
),
),
AdaptiveContainer(
columnSpan: 12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(35), // Modify this line
child: Text(
'Recently played',
style: context.headlineSmall,
),
),
HomeRecent(
playlists: playlists,
),
],
),
),
AdaptiveContainer(
columnSpan: 12,
child: Padding(
padding: const EdgeInsets.all(35), // Modify this line
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
flex: 10,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding:
const EdgeInsets.all(35), // Modify this line
child: Text(
'Top Songs Today',
style: context.titleLarge,
),
),
LayoutBuilder(
builder: (context, constraints) => PlaylistSongs(
playlist: topSongs,
constraints: constraints,
),
),
],
),
),
// Add spacer between tables
Flexible(
flex: 10,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding:
const EdgeInsets.all(35), // Modify this line
child: Text(
'New Releases',
style: context.titleLarge,
),
),
LayoutBuilder(
builder: (context, constraints) => PlaylistSongs(
playlist: newReleases,
constraints: constraints,
),
),
],
),
),
],
),
),
),
],
),
),
);
Hot โหลดแอปซ้ำ ซึ่งควรมีลักษณะเหมือนเดิม แต่มีช่องว่างระหว่างวิดเจ็ตมากขึ้น ระยะห่างจากขอบเพิ่มเติมดูดีขึ้น แต่แบนเนอร์ไฮไลต์ที่ด้านบนยังคงอยู่ใกล้กับขอบมากเกินไป
ใน lib/src/features/home/view/home_highlight.dart
ให้เปลี่ยนระยะห่างจากขอบในแบนเนอร์เป็น 35 ดังนี้
lib/src/features/home/view/home_highlight.dart
class HomeHighlight extends StatelessWidget {
const HomeHighlight({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(35), // Modify this line
child: Clickable(
child: SizedBox(
height: 275,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.asset(
'assets/images/news/concert.jpeg',
fit: BoxFit.cover,
),
),
),
onTap: () => launch('https://docs.flutter.dev'),
),
),
),
],
);
}
}
Hot โหลดแอปซ้ำ เพลย์ลิสต์ 2 รายการด้านล่างไม่มีช่องว่างระหว่างเพลย์ลิสต์ จึงดูเหมือนว่าจะอยู่ในตารางเดียวกัน ไม่ได้เป็นเช่นนั้นและจะดำเนินการแก้ไขในขั้นตอนต่อไป
เพิ่มช่องว่างระหว่างเพลย์ลิสต์โดยแทรกวิดเจ็ตขนาดลงใน Row
ที่มีวิดเจ็ตเหล่านั้น ใน lib/src/features/home/view/home_screen.dart
ให้เพิ่ม SizedBox
ที่มีความกว้าง 35 ดังนี้
lib/src/features/home/view/home_screen.dart
Padding(
padding: const EdgeInsets.all(35),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
flex: 10,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(35),
child: Text(
'Top Songs Today',
style: context.titleLarge,
),
),
PlaylistSongs(
playlist: topSongs,
constraints: constraints,
),
],
),
),
const SizedBox(width: 35), // Add this line
Flexible(
flex: 10,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(35),
child: Text(
'New Releases',
style: context.titleLarge,
),
),
PlaylistSongs(
playlist: newReleases,
constraints: constraints,
),
],
),
),
],
),
),
Hot โหลดแอปซ้ำ แอปควรมีลักษณะดังต่อไปนี้
ตอนนี้มีพื้นที่มากมายสำหรับเนื้อหาในหน้าจอหลัก แต่ทุกอย่างดูแยกส่วนเกินไปและส่วนต่างๆ ไม่กลมกลืนกัน
จนถึงตอนนี้ คุณตั้งค่าระยะห่างจากขอบทั้งหมด (ทั้งแนวนอนและแนวตั้ง) สำหรับวิดเจ็ตบนหน้าจอหลักเป็น 35 ด้วย EdgeInsets.all(35)
แต่คุณก็ตั้งค่าระยะห่างจากขอบของแต่ละขอบแยกกันได้ด้วย ปรับแต่งระยะห่างจากขอบให้พอดีกับพื้นที่มากขึ้น
EdgeInsets.LTRB()
ตั้งค่าด้านซ้าย บน ขวา และด้านล่างแยกกันEdgeInsets.symmetric()
กำหนดระยะห่างจากขอบสำหรับแนวตั้ง (ด้านบนและด้านล่าง) ให้เท่ากัน และแนวนอน (ซ้ายและขวา) ให้เท่ากันEdgeInsets.only()
จะตั้งค่าขอบที่ระบุเท่านั้น
Scaffold(
body: SingleChildScrollView(
child: AdaptiveColumn(
children: [
AdaptiveContainer(
columnSpan: 12,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 25, 20, 10), // Modify this line
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'Good morning',
style: context.displaySmall,
),
),
const SizedBox(width: 20),
const BrightnessToggle(),
],
),
),
),
AdaptiveContainer(
columnSpan: 12,
child: Column(
children: [
const HomeHighlight(),
LayoutBuilder(
builder: (context, constraints) => HomeArtists(
artists: artists,
constraints: constraints,
),
),
],
),
),
AdaptiveContainer(
columnSpan: 12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 15,
vertical: 10,
), // Modify this line
child: Text(
'Recently played',
style: context.headlineSmall,
),
),
HomeRecent(
playlists: playlists,
),
],
),
),
AdaptiveContainer(
columnSpan: 12,
child: Padding(
padding: const EdgeInsets.all(15), // Modify this line
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
flex: 10,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 8, bottom: 8), // Modify this line
child: Text(
'Top Songs Today',
style: context.titleLarge,
),
),
LayoutBuilder(
builder: (context, constraints) =>
PlaylistSongs(
playlist: topSongs,
constraints: constraints,
),
),
],
),
),
const SizedBox(width: 25),
Flexible(
flex: 10,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 8, bottom: 8), // Modify this line
child: Text(
'New Releases',
style: context.titleLarge,
),
),
LayoutBuilder(
builder: (context, constraints) =>
PlaylistSongs(
playlist: newReleases,
constraints: constraints,
),
),
],
),
),
],
),
),
),
],
),
),
);
ใน 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 this line
padding: const EdgeInsets.symmetric(horizontal: 35, vertical: 5),
child: Clickable(
child: SizedBox(
height: 275,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.asset(
'assets/images/news/concert.jpeg',
fit: BoxFit.cover,
),
),
),
onTap: () => launch('https://docs.flutter.dev'),
),
),
),
],
);
}
}
Hot โหลดแอปซ้ำ การจัดวางและระยะห่างจะดูดีขึ้นมาก เพิ่มการเคลื่อนไหวและภาพเคลื่อนไหวลงไปสักเล็กน้อยเพื่อให้ขั้นตอนเสร็จสมบูรณ์
หากพบปัญหา
หากแอปของคุณทำงานไม่ถูกต้อง ให้ใช้โค้ดที่ลิงก์ต่อไปนี้เพื่อกลับสู่การทำงาน
7. เพิ่มการเคลื่อนไหวและภาพเคลื่อนไหว
การเคลื่อนไหวและภาพเคลื่อนไหวเป็นวิธีที่ดีในการนำเสนอการเคลื่อนไหวและการให้พลังงาน และเป็นการให้ข้อเสนอแนะเมื่อผู้ใช้โต้ตอบกับแอป
เคลื่อนไหวระหว่างหน้าจอ
ThemeProvider
กำหนด PageTransitionsTheme
ด้วยภาพเคลื่อนไหวการเปลี่ยนหน้าจอสำหรับแพลตฟอร์มอุปกรณ์เคลื่อนที่ (iOS, Android) ผู้ใช้เดสก์ท็อปได้รับความคิดเห็นจากการคลิกเมาส์หรือแทร็กแพดอยู่แล้ว จึงไม่จำเป็นต้องใช้ภาพเคลื่อนไหวการเปลี่ยนหน้า
Flutter มีภาพเคลื่อนไหวการเปลี่ยนหน้าจอที่คุณกําหนดค่าสําหรับแอปตามแพลตฟอร์มเป้าหมายได้ ดังที่แสดงใน lib/src/shared/providers/theme.dart
lib/src/shared/providers/theme.dart
final pageTransitionsTheme = const PageTransitionsTheme(
builders: <TargetPlatform, PageTransitionsBuilder>{
TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.linux: NoAnimationPageTransitionsBuilder(),
TargetPlatform.macOS: NoAnimationPageTransitionsBuilder(),
TargetPlatform.windows: NoAnimationPageTransitionsBuilder(),
},
);
ส่ง PageTransitionsTheme
ไปยังทั้งธีมสว่างและธีมมืดใน lib/src/shared/providers/theme.dart
lib/src/shared/providers/theme.dart
ThemeData light([Color? targetColor]) {
final _colors = colors(Brightness.light, targetColor);
return ThemeData.light().copyWith(
pageTransitionsTheme: pageTransitionsTheme, // Add this line
colorScheme: ColorScheme.fromSeed(
seedColor: source(targetColor),
brightness: Brightness.light,
),
appBarTheme: appBarTheme(_colors),
cardTheme: cardTheme(),
listTileTheme: listTileTheme(),
tabBarTheme: tabBarTheme(_colors),
scaffoldBackgroundColor: _colors.background,
);
}
ThemeData dark([Color? targetColor]) {
final _colors = colors(Brightness.dark, targetColor);
return ThemeData.dark().copyWith(
pageTransitionsTheme: pageTransitionsTheme, // Add this line
colorScheme: ColorScheme.fromSeed(
seedColor: source(targetColor),
brightness: Brightness.dark,
),
appBarTheme: appBarTheme(_colors),
cardTheme: cardTheme(),
listTileTheme: listTileTheme(),
tabBarTheme: tabBarTheme(_colors),
scaffoldBackgroundColor: _colors.background,
);
}
ไม่มีภาพเคลื่อนไหวใน iOS
มีภาพเคลื่อนไหวใน iOS
หากพบปัญหา
หากแอปของคุณทำงานไม่ถูกต้อง ให้ใช้โค้ดที่ลิงก์ต่อไปนี้เพื่อกลับสู่การทำงาน
เพิ่มสถานะการวางเมาส์เหนือ
วิธีหนึ่งในการเพิ่มการเคลื่อนไหวให้กับแอปบนเดสก์ท็อปคือการใช้สถานะเมื่อชี้เมาส์ ซึ่งวิดเจ็ตจะเปลี่ยนสถานะ (เช่น สี รูปร่าง หรือเนื้อหา) เมื่อผู้ใช้วางเคอร์เซอร์เหนือวิดเจ็ต
โดยค่าเริ่มต้น คลาส _OutlinedCardState
(ใช้สำหรับการ์ดเพลย์ลิสต์ "เล่นล่าสุด") จะแสดง MouseRegion
ซึ่งจะเปลี่ยนลูกศรของเคอร์เซอร์ให้เป็นตัวชี้เมื่อวางเมาส์ไว้ด้านบน แต่คุณสามารถเพิ่มความคิดเห็นที่เป็นภาพเพิ่มเติมได้
เปิด lib/src/shared/views/outlined_card.dart และแทนที่เนื้อหาด้วยการใช้งานต่อไปนี้เพื่อเริ่มใช้สถานะ _hovered
lib/src/shared/views/outlined_card.dart
import 'package:flutter/material.dart';
class OutlinedCard extends StatefulWidget {
const OutlinedCard({
super.key,
required this.child,
this.clickable = true,
});
final Widget child;
final bool clickable;
@override
State<OutlinedCard> createState() => _OutlinedCardState();
}
class _OutlinedCardState extends State<OutlinedCard> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final borderRadius = BorderRadius.circular(_hovered ? 20 : 8);
const animationCurve = Curves.easeInOut;
return MouseRegion(
onEnter: (_) {
if (!widget.clickable) return;
setState(() {
_hovered = true;
});
},
onExit: (_) {
if (!widget.clickable) return;
setState(() {
_hovered = false;
});
},
cursor: widget.clickable ? SystemMouseCursors.click : SystemMouseCursors.basic,
child: AnimatedContainer(
duration: kThemeAnimationDuration,
curve: animationCurve,
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 1,
),
borderRadius: borderRadius,
),
foregroundDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.onSurface.withOpacity(
_hovered ? 0.12 : 0,
),
borderRadius: borderRadius,
),
child: TweenAnimationBuilder<BorderRadius>(
duration: kThemeAnimationDuration,
curve: animationCurve,
tween: Tween(begin: BorderRadius.zero, end: borderRadius),
builder: (context, borderRadius, child) => ClipRRect(
clipBehavior: Clip.antiAlias,
borderRadius: borderRadius,
child: child,
),
child: widget.child,
),
),
);
}
}
โหลดแอปซ้ำโดยด่วน จากนั้นวางเมาส์เหนือการ์ดเพลย์ลิสต์ที่เล่นล่าสุด
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
HoverableSongPlayButton( // Add this line
hoverMode: HoverMode.overlay, // Add this line
song: playlist.songs[index], // Add this line
child: Center( // Modify this line
child: Text(
(index + 1).toString(),
textAlign: TextAlign.center,
),
),
), // Add this line
โหลดแอปซ้ำทันที จากนั้นวางเคอร์เซอร์เหนือหมายเลขเพลงบนเพลงยอดนิยมวันนี้หรือเพลย์ลิสต์มาใหม่
ตัวเลขจะเคลื่อนไหวไปที่ปุ่มเล่น ซึ่งจะเล่นเพลงเมื่อคุณคลิก
ดูรหัสโปรเจ็กต์สุดท้ายใน GitHub
8. ยินดีด้วย
คุณดำเนินการ Codelab นี้เสร็จแล้ว คุณได้เรียนรู้ว่ามีการเปลี่ยนแปลงเล็กๆ น้อยๆ มากมายที่คุณสามารถผสานรวมเข้ากับแอปเพื่อทำให้แอปดูสวยงามยิ่งขึ้น เข้าถึงได้มากขึ้น แปลได้มากขึ้น และเหมาะสำหรับหลายแพลตฟอร์มมากขึ้น เทคนิคเหล่านี้รวมถึงแต่ไม่จำกัดเพียงรายการต่อไปนี้
- การออกแบบตัวอักษร: ข้อความเป็นมากกว่าเครื่องมือสื่อสาร ใช้วิธีการแสดงข้อความเพื่อสร้างผลกระทบเชิงบวกต่อผู้ใช้ ประสบการณ์และการรับรู้แอปของคุณ
- การกำหนดธีม: สร้างระบบการออกแบบที่คุณสามารถใช้ได้อย่างน่าเชื่อถือ โดยไม่ต้องตัดสินใจเรื่องการออกแบบสำหรับวิดเจ็ตทั้งหมด
- การปรับตัว: พิจารณาอุปกรณ์และแพลตฟอร์มที่ผู้ใช้กำลังใช้แอปและความสามารถของแอป พิจารณาขนาดหน้าจอและลักษณะการแสดงผลแอป
- การเคลื่อนไหวและภาพเคลื่อนไหว: การเพิ่มการเคลื่อนไหวลงในแอปจะเพิ่มพลังให้กับประสบการณ์ของผู้ใช้และจะให้ความคิดเห็นแก่ผู้ใช้ในทางปฏิบัติมากขึ้น
เพียงปรับเปลี่ยนเล็กๆ น้อยๆ แอปของคุณก็สามารถเปลี่ยนจากสิ่งที่น่าเบื่อให้กลายเป็นความสวยงามได้ ดังนี้
ก่อน
หลัง
ขั้นตอนถัดไป
เราหวังว่าคุณได้เรียนรู้เกี่ยวกับการสร้างแอปที่สวยงามใน Flutter มากขึ้นแล้ว
หากคุณนำกลเม็ดเคล็ดลับใดๆ ที่กล่าวถึงในที่นี้ (หรือมีเคล็ดลับของคุณเองเพื่อแบ่งปัน) เราก็ยินดีรับฟังความคิดเห็นจากคุณ ติดต่อเราได้ทาง Twitter ที่ @rodydavis และ @khanhnwin
แหล่งข้อมูลต่อไปนี้อาจเป็นประโยชน์สำหรับคุณ
ธีม
- เครื่องมือสร้างธีม Material (เครื่องมือ)
ทรัพยากรแบบปรับได้และปรับเปลี่ยนตามอุปกรณ์
- การถอดรหัส Flutter ในโหมดปรับอัตโนมัติและปรับเปลี่ยนตามอุปกรณ์ (วิดีโอ)
- เลย์เอาต์แบบปรับอัตโนมัติ (วิดีโอจาก The Boring Flutter Development Show)
- การสร้างแอปที่ปรับเปลี่ยนตามอุปกรณ์และปรับเปลี่ยนได้ (flutter.dev)
- คอมโพเนนต์ Adaptive Material สำหรับ Flutter (คลังใน GitHub)
- 5 สิ่งที่คุณทำได้เพื่อเตรียมแอปให้พร้อมสำหรับหน้าจอขนาดใหญ่ (วิดีโอจากการประชุม Google I/O ปี 2021)
แหล่งข้อมูลเกี่ยวกับการออกแบบทั่วไป
- สิ่งเล็กๆ น้อยๆ: การเป็นนักพัฒนาซอฟต์แวร์ในตำนาน (วิดีโอจาก Flutter Engage)
- ดีไซน์ Material 3 สำหรับอุปกรณ์แบบพับได้ (material.io)
และเชื่อมต่อกับชุมชน Flutter ด้วย
ออกไปปรับแต่งแอปให้สวยงาม