เกี่ยวกับ Codelab นี้
1 บทนำ
Flutter เป็นชุดเครื่องมือ UI ของ Google สำหรับสร้างแอปพลิเคชันที่สวยงามและคอมไพล์แบบเนทีฟสำหรับอุปกรณ์เคลื่อนที่ เว็บ และเดสก์ท็อปจากฐานของโค้ดรายการเดียว Flutter ทำงานร่วมกับโค้ดที่มีอยู่ นักพัฒนาซอฟต์แวร์และองค์กรทั่วโลกใช้ Flutter และ Flutter เป็นโอเพนซอร์สที่ใช้งานได้ฟรี
ใน Codelab นี้ คุณจะปรับปรุงแอปพลิเคชันเพลง Flutter ให้น่าใช้งานมากขึ้น ด้วยเหตุนี้ Codelab นี้จึงใช้เครื่องมือและ API ที่เปิดตัวใน Material 3
สิ่งที่คุณจะได้เรียนรู้
- วิธีเขียนแอป Flutter ที่ใช้งานได้และสวยงามในทุกแพลตฟอร์ม
- วิธีออกแบบข้อความในแอปเพื่อให้แน่ใจว่าข้อความจะช่วยเพิ่มประสบการณ์ของผู้ใช้
- วิธีเลือกสีที่เหมาะสม ปรับแต่งวิดเจ็ต สร้างธีมของคุณเอง และเปิดใช้โหมดมืดได้อย่างรวดเร็ว
- วิธีสร้างแอปที่ปรับให้เหมาะกับอุปกรณ์ทุกแพลตฟอร์ม
- วิธีสร้างแอปที่ดูดีบนหน้าจอทุกขนาด
- วิธีเพิ่มการเคลื่อนไหวในแอป Flutter เพื่อให้โดดเด่น
ข้อกำหนดเบื้องต้น
โค้ดแล็บนี้ถือว่าคุณมีประสบการณ์การใช้งาน Flutter อยู่บ้าง หากไม่ คุณอาจต้องเรียนรู้พื้นฐานก่อน ลิงก์ต่อไปนี้มีประโยชน์
- ดูการสร้างอินเทอร์เฟซผู้ใช้ด้วย Flutter
- ลองใช้ Codelab แอป 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 โฆษณาดูดีบนเดสก์ท็อป แต่อุปกรณ์เคลื่อนที่... ไม่ค่อยดี เหตุผลหนึ่งคือไม่รองรับรอยบาก ไม่ต้องกังวล คุณจะแก้ไขปัญหานี้ได้
ทัวร์ชมโค้ด
ถัดไป ให้ดูตัวอย่างโค้ด
เปิด 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
ในแอปเริ่มต้นจะมีแท็บสำหรับเส้นทางหลักแต่ละเส้นทาง แต่ไอคอนนำหน้าจะเหมือนกัน
ซึ่งไม่เป็นประโยชน์เนื่องจากผู้ใช้ยังคงต้องอ่านข้อความของแต่ละแท็บ เริ่มต้นด้วยการเพิ่มสิ่งบอกใบ้ที่มองเห็นได้เพื่อให้ผู้ใช้มองไปที่ไอคอนนำหน้าได้อย่างรวดเร็วเพื่อค้นหาแท็บที่ต้องการ ทั้งยังช่วยในการแปลและความสามารถในการเข้าถึงด้วย
ใน 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',
),
];
หากพบปัญหา
หากแอปทำงานไม่ถูกต้อง ให้ตรวจหาการพิมพ์ผิด หากจำเป็น ให้ใช้รหัสในลิงก์ต่อไปนี้เพื่อดำเนินการต่อ
เลือกแบบอักษรอย่างรอบคอบ
แบบอักษรจะกำหนดบุคลิกของแอปพลิเคชัน ดังนั้นการเลือกแบบอักษรที่เหมาะสมจึงสำคัญอย่างยิ่ง สิ่งที่ควรพิจารณาเมื่อเลือกแบบอักษรมีดังนี้
- แบบไม่มีขีดหรือแบบมีขีด: แบบมีขีดจะมีเส้นตกแต่งหรือ "หาง" ที่ส่วนท้ายของตัวอักษร และดูเป็นทางการมากกว่า แบบอักษรที่ไม่มี Serif จะไม่มีเส้นตกแต่งและมักจะให้ความรู้สึกไม่เป็นทางการมากกว่า
- แบบอักษรตัวพิมพ์ใหญ่ทั้งหมด: การใช้ตัวพิมพ์ใหญ่ทั้งหมดเหมาะสําหรับการดึงดูดความสนใจไปยังข้อความจํานวนไม่มาก (เช่น บรรทัดแรก) แต่หากใช้มากเกินไปอาจทําให้ผู้ใช้รู้สึกว่าเป็นการตะโกน ซึ่งทําให้ผู้ใช้ไม่สนใจข้อความนั้นเลย
- ขึ้นต้นคำด้วยตัวพิมพ์ใหญ่หรือขึ้นต้นประโยคด้วยตัวพิมพ์ใหญ่: เมื่อเพิ่มชื่อหรือป้ายกํากับ ให้พิจารณาวิธีใช้อักษรตัวพิมพ์ใหญ่ โดยขึ้นต้นคำด้วยตัวพิมพ์ใหญ่ซึ่งตัวอักษรตัวแรกของทุกคำเป็นตัวพิมพ์ใหญ่ ("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
โหลด ซ้ำขณะทำงานเพื่อเปิดใช้งานการเปลี่ยนแปลง (ใช้ปุ่มใน IDE หรือจากบรรทัดคำสั่ง ให้ป้อน
r
เพื่อโหลดซ้ำแบบ Hot Reload)
คุณควรเห็นไอคอน 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
นอกจากนี้ โทเค็นการออกแบบใหม่ยังรองรับทั้งธีมสว่างและธีมมืด
บทบาทของสีเหล่านี้สามารถใช้เพื่อกำหนดความหมายและเน้นส่วนต่างๆ ของ 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.
},
);
}
}
หน้าจอแต่ละขนาดมีขนาดไม่เท่ากัน หากพยายามแสดงแอปเวอร์ชันเดสก์ท็อปในโทรศัพท์ คุณอาจต้องเพ่งมองและซูมเพื่อดูทุกอย่าง คุณต้องการให้แอปเปลี่ยนลักษณะที่ปรากฏตามหน้าจอที่แสดง การออกแบบที่ปรับเปลี่ยนตามอุปกรณ์ช่วยให้มั่นใจได้ว่าแอปของคุณจะดูดีบนหน้าจอทุกขนาด
หากต้องการให้แอปตอบสนอง ให้ใส่จุดหยุดพักที่ปรับเปลี่ยนได้ (อย่าสับสนกับจุดหยุดพักสำหรับการแก้ไขข้อบกพร่อง) เบรกพอยต์เหล่านี้ระบุขนาดหน้าจอที่แอปควรเปลี่ยนเลย์เอาต์
หน้าจอขนาดเล็กจะแสดงเนื้อหาได้น้อยกว่าหน้าจอขนาดใหญ่โดยไม่ต้องย่อเนื้อหา เพื่อป้องกันไม่ให้แอปดูเหมือนแอปบนเดสก์ท็อปที่ย่อขนาดลง ให้สร้างเลย์เอาต์แยกต่างหากสำหรับอุปกรณ์เคลื่อนที่ที่ใช้แท็บเพื่อแบ่งเนื้อหา วิธีนี้ช่วยให้แอปดูกลมกลืนกับอุปกรณ์เคลื่อนที่มากขึ้น
วิธีการขยายต่อไปนี้ (ที่กําหนดไว้ในโปรเจ็กต์ 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,
),
),
],
),
),
],
),
),
),
],
),
),
);
},
);
}
}
หากพบปัญหา
หากแอปทำงานไม่ถูกต้อง ให้ใช้รหัสในลิงก์ต่อไปนี้เพื่อแก้ไขปัญหา
7 ใช้การเว้นวรรค
พื้นที่ว่างเป็นเครื่องมือภาพที่สำคัญสำหรับแอปของคุณ ซึ่งช่วยจัดระเบียบส่วนต่างๆ ไว้ด้วยกัน
การมีช่องว่างมากเกินไปดีกว่ามีไม่เพียงพอ การเพิ่มพื้นที่ว่างมากกว่าการลดขนาดแบบอักษรหรือองค์ประกอบภาพเพื่อให้พอดีกับพื้นที่มากขึ้น
การไม่มีพื้นที่ว่างอาจทำให้ผู้ที่มีภาวะสายตามีปัญหา ช่องว่างมากเกินไปอาจทำให้ขาดความสอดคล้องกันและทำให้ UI ดูไม่เป็นระเบียบ ตัวอย่างเช่น ดูภาพหน้าจอต่อไปนี้
ต่อไป คุณจะต้องเพิ่มพื้นที่ว่างในหน้าจอหลักเพื่อให้มีพื้นที่มากขึ้น จากนั้นคุณปรับเลย์เอาต์เพิ่มเติมเพื่อปรับระยะห่างให้ละเอียดยิ่งขึ้น
ตัดขึ้นบรรทัดใหม่ของวิดเจ็ตด้วยออบเจ็กต์ 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 รายการที่ด้านล่างไม่มีเว้นวรรคระหว่างกัน จึงดูเหมือนว่าอยู่ในตารางเดียวกัน ไม่ได้เป็นเช่นนั้น และคุณจะแก้ไขในลำดับถัดไป
เพิ่มเว้นวรรคระหว่างเพลย์ลิสต์โดยแทรกวิดเจ็ตขนาดลงใน 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,
),
),
],
),
),
],
),
),
),
โหลดแอปซ้ำขณะทำงาน แอปควรมีลักษณะดังต่อไปนี้
ตอนนี้มีพื้นที่เหลือเฟือสำหรับเนื้อหาในหน้าจอหลัก แต่ทุกอย่างดูแยกกันเกินไปและไม่มีความสอดคล้องกันระหว่างส่วนต่างๆ
จนถึงตอนนี้ คุณได้ตั้งค่าระยะห่างจากขอบทั้งหมด (ทั้งแนวนอนและแนวตั้ง) สำหรับวิดเจ็ตในหน้าจอหลักเป็น 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')),
),
),
),
],
);
}
}
โหลดแอปซ้ำขณะทำงาน เลย์เอาต์และระยะห่างดูดีขึ้นมาก เพิ่มการเคลื่อนไหวและภาพเคลื่อนไหวเพื่อปิดท้าย
หากพบปัญหา
หากแอปทำงานไม่ถูกต้อง ให้ใช้รหัสในลิงก์ต่อไปนี้เพื่อแก้ไขปัญหา
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 แล้ววางเคอร์เซอร์เหนือหมายเลขเพลงในเพลย์ลิสต์เพลงยอดนิยมวันนี้หรือเพลงใหม่
ตัวเลขจะเคลื่อนไหวเป็นปุ่มเล่นที่จะเล่นเพลงเมื่อคุณคลิก
10 ยินดีด้วย
คุณทำ Codelab นี้เสร็จแล้ว คุณได้เรียนรู้แล้วว่าการเปลี่ยนแปลงเล็กๆ น้อยๆ มากมายสามารถผสานรวมเข้ากับแอปเพื่อให้แอปดูสวยงามขึ้น รวมถึงเข้าถึงได้ง่ายขึ้น แปลได้ง่ายขึ้น และเหมาะกับแพลตฟอร์มต่างๆ มากขึ้น เทคนิคเหล่านี้รวมถึงแต่ไม่จำกัดเพียงรายการต่อไปนี้
- การจัดรูปแบบตัวอักษร: ข้อความเป็นมากกว่าเครื่องมือสื่อสาร ใช้วิธีแสดงข้อความเพื่อให้เกิดผลลัพธ์เชิงบวกต่อประสบการณ์และมุมมองของผู้ใช้เกี่ยวกับแอป
- ธีม: สร้างระบบการออกแบบที่คุณใช้ได้อย่างน่าเชื่อถือโดยไม่ต้องตัดสินใจเกี่ยวกับการออกแบบวิดเจ็ตแต่ละรายการ
- ความสามารถในการปรับตัว: พิจารณาอุปกรณ์และแพลตฟอร์มที่ผู้ใช้เรียกใช้แอปของคุณ รวมถึงความสามารถของอุปกรณ์และแพลตฟอร์ม พิจารณาขนาดหน้าจอและลักษณะที่แอปแสดง
- การเคลื่อนไหวและภาพเคลื่อนไหว: การเพิ่มการเคลื่อนไหวในแอปจะช่วยเพิ่มประสบการณ์ของผู้ใช้ และที่สำคัญคือแสดงความคิดเห็นให้ผู้ใช้ทราบ
การปรับแต่งเล็กๆ น้อยๆ เพียงไม่กี่อย่างก็สามารถเปลี่ยนแอปที่น่าเบื่อให้ดูสวยงามได้
ก่อน
หลัง
ขั้นตอนถัดไป
เราหวังว่าคุณจะได้เรียนรู้เพิ่มเติมเกี่ยวกับการสร้างแอปที่สวยงามใน Flutter
หากใช้เคล็ดลับที่กล่าวถึงที่นี่ (หรือมีเคล็ดลับของคุณเองที่จะแชร์) เรายินดีรับฟังจากคุณ ติดต่อเราได้ที่ @rodydavis และ @khanhnwin บน Twitter
แหล่งข้อมูลต่อไปนี้อาจเป็นประโยชน์สำหรับคุณ
ธีม
- เครื่องมือสร้างธีม Material (เครื่องมือ)
แหล่งข้อมูลที่ปรับเปลี่ยนและตอบสนอง
- การถอดรหัส Flutter เกี่ยวกับแบบปรับเปลี่ยนได้กับแบบตอบสนอง (วิดีโอ)
- เลย์เอาต์แบบปรับอัตโนมัติ (วิดีโอจาก The Boring Flutter Development Show)
- การสร้างแอปที่ปรับเปลี่ยนตามอุปกรณ์และปรับขนาดได้ (flutter.dev)
- คอมโพเนนต์ Material แบบปรับเปลี่ยนได้สำหรับ Flutter (ไลบรารีใน GitHub)
- 5 สิ่งที่คุณทำได้เพื่อเตรียมแอปให้พร้อมใช้งานบนหน้าจอขนาดใหญ่ (วิดีโอจาก Google I/O 2021)
แหล่งข้อมูลการออกแบบทั่วไป
- สิ่งเล็กๆ น้อยๆ: การเป็นดีไซเนอร์ที่เป็นนักพัฒนาซอฟต์แวร์ในตำนาน (วิดีโอจาก Flutter Engage)
- Material Design 3 สำหรับอุปกรณ์แบบพับได้ (material.io)
นอกจากนี้ โปรดเชื่อมต่อกับชุมชน Flutter
ออกไปสร้างโลกแอปให้สวยงาม