Cải thiện ứng dụng Flutter

Chuyển đổi ứng dụng Flutter từ nhàm chán sang đẹp mắt

Thông tin về lớp học lập trình này

subjectLần cập nhật gần đây nhất: thg 6 24, 2025
account_circleTác giả: The Flutter Team

1. Giới thiệu

Flutter là bộ công cụ giao diện người dùng của Google để xây dựng các ứng dụng gốc đẹp mắt cho thiết bị di động, nền tảng web và máy tính để bàn chỉ từ một bộ mã cơ sở. Flutter hoạt động với mã hiện có, được các nhà phát triển và tổ chức trên khắp thế giới sử dụng, đồng thời là mã nguồn mở và miễn phí.

Trong lớp học lập trình này, bạn sẽ nâng cao một ứng dụng âm nhạc Flutter, biến ứng dụng đó từ nhàm chán trở nên đẹp mắt. Để thực hiện việc này, lớp học lập trình này sử dụng các công cụ và API được giới thiệu trong Material 3.

Kiến thức bạn sẽ học được

  • Cách viết một ứng dụng Flutter hữu ích và đẹp mắt trên nhiều nền tảng.
  • Cách thiết kế văn bản trong ứng dụng để đảm bảo văn bản đó góp phần nâng cao trải nghiệm người dùng.
  • Cách chọn màu phù hợp, tuỳ chỉnh tiện ích, tạo giao diện của riêng bạn và nhanh chóng triển khai chế độ tối.
  • Cách xây dựng ứng dụng thích ứng trên nhiều nền tảng.
  • Cách tạo ứng dụng trông đẹp trên mọi màn hình.
  • Cách thêm chuyển động vào ứng dụng Flutter để ứng dụng thực sự nổi bật.

Điều kiện tiên quyết

Lớp học lập trình này giả định rằng bạn đã có một số kinh nghiệm về Flutter. Nếu không, bạn nên tìm hiểu các kiến thức cơ bản trước. Các đường liên kết sau đây sẽ hữu ích:

Sản phẩm bạn sẽ tạo ra

Lớp học lập trình này sẽ hướng dẫn bạn xây dựng màn hình chính cho một ứng dụng có tên MyArtist. Đây là ứng dụng phát nhạc, nơi người hâm mộ có thể cập nhật thông tin về các nghệ sĩ mà họ yêu thích. Bài viết này thảo luận về cách bạn có thể sửa đổi thiết kế ứng dụng để trông đẹp mắt trên nhiều nền tảng.

Các video sau đây cho thấy cách hoạt động của ứng dụng khi bạn hoàn thành lớp học lập trình này:

Bạn muốn tìm hiểu điều gì qua lớp học lập trình này?

2. Thiết lập môi trường phát triển Flutter

Bạn cần có hai phần mềm để hoàn thành lớp học này – SDK Fluttermột trình chỉnh sửa.

Bạn có thể chạy lớp học lập trình này bằng bất kỳ thiết bị nào sau đây:

  • Thiết bị Android hoặc iOS thực được kết nối với máy tính và đặt thành Chế độ nhà phát triển.
  • Trình mô phỏng iOS (yêu cầu cài đặt các công cụ Xcode).
  • Trình mô phỏng Android (yêu cầu thiết lập trong Android Studio).
  • Trình duyệt (bạn cần có Chrome để gỡ lỗi).
  • Dưới dạng ứng dụng máy tính Windows, Linux hoặc macOS. Bạn phải phát triển trên nền tảng mà bạn dự định triển khai. Vì vậy, nếu muốn phát triển ứng dụng dành cho máy tính Windows, bạn phải phát triển trên Windows để truy cập vào chuỗi xây dựng thích hợp. Có các yêu cầu dành riêng cho hệ điều hành được đề cập chi tiết trên docs.flutter.dev/desktop.

3. Tải ứng dụng khởi đầu của lớp học lập trình

Sao chép từ GitHub

Để sao chép lớp học lập trình này từ GitHub, hãy chạy các lệnh sau:

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

Để đảm bảo mọi thứ đều hoạt động, hãy chạy ứng dụng Flutter dưới dạng ứng dụng máy tính như minh hoạ dưới đây. Ngoài ra, bạn có thể mở dự án này trong IDE và sử dụng công cụ của dự án để chạy ứng dụng.

flutter run

Thành công! Mã khởi động cho màn hình chính của MyArtist sẽ chạy. Bạn sẽ thấy màn hình chính MyArtist. Giao diện này trông ổn trên máy tính, nhưng trên thiết bị di động thì... Không tốt lắm. Một điều là nó không tuân theo phần notch. Đừng lo, bạn sẽ khắc phục được vấn đề này!

1e67c60667821082.pngd1139cde225de452.png

Tham quan mã

Tiếp theo, hãy tìm hiểu mã.

Mở lib/src/features/home/view/home_screen.dart, chứa nội dung sau:

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

Tệp này nhập material.dart và triển khai một tiện ích có trạng thái bằng cách sử dụng hai lớp:

  • Câu lệnh import cung cấp các thành phần Material.
  • Lớp HomeScreen đại diện cho toàn bộ trang hiển thị.
  • Phương thức build() của lớp _HomeScreenState tạo gốc của cây tiện ích, ảnh hưởng đến cách tạo tất cả tiện ích trong giao diện người dùng.

4. Tận dụng kiểu chữ

Văn bản có ở mọi nơi. Văn bản là một cách hữu ích để giao tiếp với người dùng. Ứng dụng của bạn hướng đến sự thân thiện và thú vị hay đáng tin cậy và chuyên nghiệp? Có lý do khiến ứng dụng ngân hàng yêu thích của bạn không sử dụng phông chữ Comic Sans. Cách trình bày văn bản sẽ tạo ấn tượng ban đầu của người dùng về ứng dụng của bạn. Dưới đây là một số cách sử dụng văn bản một cách chu đáo hơn.

Dùng hình ảnh nhiều hơn lời nói

Bất cứ khi nào có thể, hãy "cho thấy" thay vì "cho biết". Ví dụ: NavigationRail trong ứng dụng khởi động có các thẻ cho từng tuyến chính, nhưng các biểu tượng hàng đầu giống hệt nhau:

86c5f73b3aa5fd35.png

Điều này không hữu ích vì người dùng vẫn phải đọc văn bản của từng thẻ. Bắt đầu bằng cách thêm các tín hiệu hình ảnh để người dùng có thể nhanh chóng xem các biểu tượng hàng đầu để tìm thẻ bạn muốn. Điều này cũng giúp ích cho việc bản địa hoá và hỗ trợ tiếp cận.

Trong lib/src/shared/router.dart, hãy thêm các biểu tượng đầu trang riêng biệt cho từng đích đến điều hướng (trang chủ, danh sách phát và người dùng):

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

Bạn gặp vấn đề?

Nếu ứng dụng của bạn không chạy đúng cách, hãy tìm lỗi chính tả. Nếu cần, hãy sử dụng mã tại các đường liên kết sau để tiếp tục.

Chọn phông chữ một cách thận trọng

Phông chữ tạo nên bản sắc của ứng dụng, vì vậy, việc chọn phông chữ phù hợp là rất quan trọng. Khi chọn phông chữ, bạn cần cân nhắc một số điều sau:

  • Sans-serif hoặc serif: Phông chữ serif có các nét trang trí hoặc "đuôi" ở cuối chữ cái và được coi là trang trọng hơn. Phông chữ sans-serif không có nét trang trí và thường được coi là không trang trọng. Chữ T viết hoa sans serif và chữ T viết hoa serif
  • Phông chữ viết hoa toàn bộ: Việc sử dụng chữ viết hoa toàn bộ phù hợp để thu hút sự chú ý đến một lượng nhỏ văn bản (như dòng tiêu đề), nhưng khi sử dụng quá mức, người dùng có thể coi đó là hành động la hét và bỏ qua hoàn toàn.
  • Viết hoa chữ cái đầu tiên hoặc viết hoa đầu câu: Khi thêm tiêu đề hoặc nhãn, hãy cân nhắc cách bạn sử dụng chữ hoa: viết hoa chữ cái đầu tiên, trong đó chữ cái đầu tiên của mỗi từ được viết hoa ("This Is a Title Case Title"), mang tính trang trọng hơn. Viết hoa đầu câu (chỉ viết hoa danh từ riêng và từ đầu tiên trong văn bản ("This is a sentence case title")) mang tính trò chuyện và không trang trọng hơn.
  • Kerning (khoảng cách giữa các chữ cái), độ dài dòng (chiều rộng của toàn bộ văn bản trên màn hình) và chiều cao dòng (chiều cao của mỗi dòng văn bản): Nếu quá nhiều hoặc quá ít bất kỳ yếu tố nào trong số này, ứng dụng của bạn sẽ khó đọc hơn. Ví dụ: bạn có thể khó giữ chỗ khi đọc một khối văn bản lớn, không bị ngắt quãng.

Do đó, hãy truy cập vào Google Fonts và chọn một phông chữ sans-serif, chẳng hạn như Montserrat, vì ứng dụng âm nhạc này mang tính chất vui nhộn và thú vị.

Từ dòng lệnh, hãy kéo gói google_fonts. Thao tác này cũng cập nhật tệp pubspec.yaml để thêm phông chữ làm phần phụ thuộc ứng dụng.

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>

Trong lib/src/shared/extensions.dart, hãy nhập gói mới:

lib/src/shared/extensions.dart

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

Thiết lập TextTheme: Montserrat

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

Tải lại nhanh 7f9a9e103c7b5e5.png để kích hoạt các thay đổi. (Sử dụng nút trong IDE hoặc nhập r vào dòng lệnh để tải lại nhanh.):

1e67c60667821082.png

Bạn sẽ thấy các biểu tượng NavigationRail mới cùng với văn bản hiển thị bằng phông chữ Montserrat.

Bạn gặp vấn đề?

Nếu ứng dụng của bạn không chạy đúng cách, hãy tìm lỗi chính tả. Nếu cần, hãy sử dụng mã tại các đường liên kết sau để tiếp tục.

5. Đặt giao diện

Giao diện giúp mang lại thiết kế có cấu trúc và tính đồng nhất cho ứng dụng bằng cách chỉ định một hệ thống màu sắc và kiểu văn bản. Giao diện cho phép bạn nhanh chóng triển khai giao diện người dùng mà không cần phải lo lắng về các chi tiết nhỏ như chỉ định màu chính xác cho từng tiện ích.

Nhà phát triển Flutter thường tạo các thành phần theo chủ đề tuỳ chỉnh theo một trong hai cách sau:

  • Tạo các tiện ích tuỳ chỉnh riêng lẻ, mỗi tiện ích có một giao diện riêng.
  • Tạo giao diện có giới hạn cho các tiện ích mặc định.

Ví dụ này sử dụng một trình cung cấp giao diện nằm trong lib/src/shared/providers/theme.dart để tạo các tiện ích và màu sắc theo chủ đề nhất quán trong toàn bộ ứng dụng:

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

Để sử dụng trình cung cấp, hãy tạo một thực thể và truyền thực thể đó đến đối tượng giao diện có giới hạn trong MaterialApp, nằm trong lib/src/shared/app.dart. Mọi đối tượng Theme lồng nhau đều sẽ kế thừa thuộc tính này:

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

Giờ đây, khi giao diện đã được thiết lập, hãy chọn màu cho ứng dụng.

Việc chọn bộ màu phù hợp có thể rất khó. Bạn có thể đã có ý tưởng về màu chính, nhưng có thể bạn muốn ứng dụng của mình có nhiều màu hơn. Văn bản nên có màu gì? Tiêu đề? Nội dung? Đường liên kết? Còn màu nền thì sao? Material Theme Builder là một công cụ dựa trên web (được giới thiệu trong Material 3), giúp bạn chọn một bộ màu bổ sung cho ứng dụng của mình.

Để chọn màu nguồn cho ứng dụng, hãy mở Material Theme Builder (Trình tạo giao diện Material) và khám phá các màu sắc khác nhau cho giao diện người dùng. Điều quan trọng là bạn phải chọn màu phù hợp với tính thẩm mỹ của thương hiệu hoặc sở thích cá nhân.

Sau khi tạo giao diện, hãy nhấp chuột phải vào bong bóng màu Primary (Cơ bản). Thao tác này sẽ mở ra một hộp thoại chứa giá trị hex của màu cơ bản. Sao chép giá trị này. (Bạn cũng có thể đặt màu bằng hộp thoại này.)

Truyền giá trị hex của màu chính đến trình cung cấp giao diện. Ví dụ: màu hex #00cbe6 được chỉ định là Color(0xff00cbe6). ThemeProvider tạo một ThemeData chứa tập hợp các màu bổ sung mà bạn đã xem trước trong Material Theme Builder:

final settings = ValueNotifier(ThemeSettings(
   sourceColor:  Color(0xff00cbe6), // Replace this color
   themeMode: ThemeMode.system,
 ));

Khởi động lại nóng ứng dụng. Khi có màu chính, ứng dụng sẽ bắt đầu trở nên biểu cảm hơn. Truy cập vào tất cả màu mới bằng cách tham chiếu giao diện trong ngữ cảnh và lấy ColorScheme:

final colors = Theme.of(context).colorScheme;

Để sử dụng một màu cụ thể, hãy truy cập vào vai trò màu trên colorScheme. Chuyển đến lib/src/shared/views/outlined_card.dart và tạo đường viền cho 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 giới thiệu các vai trò màu sắc tinh tế bổ sung cho nhau và có thể được sử dụng trong toàn bộ giao diện người dùng để thêm các lớp biểu đạt mới. Các vai trò màu mới này bao gồm:

  • 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

Ngoài ra, mã thông báo thiết kế mới hỗ trợ cả giao diện sáng và tối:

7b51703ed96196a4.png

Bạn có thể sử dụng các vai trò màu sắc này để chỉ định ý nghĩa và điểm nhấn cho các phần khác nhau của giao diện người dùng. Ngay cả khi không nổi bật, một thành phần vẫn có thể tận dụng màu động.

Người dùng có thể đặt độ sáng ứng dụng trong phần cài đặt hệ thống của thiết bị. Trong lib/src/shared/app.dart, khi thiết bị được đặt thành chế độ tối, hãy trả về giao diện tối và chế độ giao diện cho 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,
);

Nhấp vào biểu tượng mặt trăng ở góc trên cùng bên phải để bật chế độ tối.

Bạn gặp vấn đề?

Nếu ứng dụng của bạn không chạy đúng cách, hãy sử dụng mã tại đường liên kết sau để khắc phục.

6. Thêm thiết kế thích ứng

Với Flutter, bạn có thể xây dựng các ứng dụng chạy ở hầu hết mọi nơi, nhưng điều đó không có nghĩa là mọi ứng dụng đều hoạt động giống nhau ở mọi nơi. Người dùng đã bắt đầu mong đợi các hành vi và tính năng khác nhau trên các nền tảng.

Material cung cấp các gói giúp bạn dễ dàng làm việc với bố cục thích ứng hơn. Bạn có thể tìm thấy các gói Flutter này trên GitHub.

Hãy lưu ý những điểm khác biệt sau đây về nền tảng khi xây dựng một ứng dụng thích ứng, đa nền tảng:

  • Phương thức nhập: chuột, cảm ứng hoặc tay điều khiển trò chơi
  • Kích thước phông chữ, hướng thiết bị và khoảng cách xem
  • Kích thước màn hình và kiểu dáng: điện thoại, máy tính bảng, thiết bị có thể gập lại, máy tính, web

Tệp lib/src/shared/views/adaptive_navigation.dart chứa một lớp điều hướng, trong đó bạn có thể cung cấp danh sách các đích đến và nội dung để hiển thị phần nội dung. Vì bạn sử dụng bố cục này trên nhiều màn hình, nên có một bố cục cơ sở dùng chung để truyền vào từng phần tử con. Dải điều hướng phù hợp với máy tính và màn hình lớn, nhưng hãy làm cho bố cục trở nên thân thiện với thiết bị di động bằng cách hiển thị thanh điều hướng dưới cùng trên thiết bị di động.

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

Không phải màn hình nào cũng có cùng kích thước. Nếu cố gắng hiển thị phiên bản máy tính của ứng dụng trên điện thoại, bạn sẽ phải nheo mắt và phóng to để xem mọi thứ. Bạn muốn ứng dụng thay đổi giao diện dựa trên màn hình hiển thị ứng dụng. Với thiết kế thích ứng, bạn có thể đảm bảo ứng dụng của mình trông đẹp trên màn hình ở mọi kích thước.

Để ứng dụng của bạn thích ứng, hãy giới thiệu một vài điểm ngắt thích ứng (đừng nhầm với điểm ngắt gỡ lỗi). Các điểm ngắt này chỉ định kích thước màn hình mà ứng dụng của bạn sẽ thay đổi bố cục.

Màn hình nhỏ hơn không thể hiển thị nhiều nội dung như màn hình lớn hơn nếu không thu nhỏ nội dung. Để ứng dụng không trông giống như một ứng dụng máy tính đã bị thu nhỏ, hãy tạo một bố cục riêng cho thiết bị di động sử dụng các thẻ để chia nội dung. Điều này giúp ứng dụng có giao diện gốc hơn trên thiết bị di động.

Các phương thức mở rộng sau đây (được xác định trong dự án MyArtist trong lib/src/shared/extensions.dart) là điểm khởi đầu phù hợp khi thiết kế bố cục được tối ưu hoá cho nhiều mục tiêu.

lib/src/shared/extensions.dart

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

Màn hình lớn hơn 730 pixel (theo hướng dài nhất) nhưng nhỏ hơn 1200 pixel được coi là máy tính bảng. Mọi kích thước lớn hơn 1200 pixel đều được coi là máy tính. Nếu không phải là máy tính bảng hay máy tính để bàn, thì thiết bị đó được coi là thiết bị di động. Bạn có thể tìm hiểu thêm về điểm ngắt thích ứng trên material.io.

Bố cục thích ứng của màn hình chính sử dụng AdaptiveContainerAdaptiveColumn dựa trên lưới 12 cột.

Bố cục thích ứng cần có hai bố cục: một bố cục cho thiết bị di động và một bố cục thích ứng cho màn hình lớn hơn. Tại thời điểm này, LayoutBuilder sẽ trả về một bố cục máy tính. Trong lib/src/features/home/view/home_screen.dart, hãy tạo bố cục dành cho thiết bị di động dưới dạng TabBarTabBarView với 4 thẻ.

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

Bạn gặp vấn đề?

Nếu ứng dụng của bạn không chạy đúng cách, hãy sử dụng mã tại đường liên kết sau để khắc phục.

7. Sử dụng khoảng trắng

Khoảng trắng là một công cụ trực quan quan trọng cho ứng dụng của bạn, tạo ra một khoảng nghỉ giữa các phần.

Tốt hơn là có quá nhiều khoảng trắng còn hơn là không đủ. Bạn nên thêm khoảng trắng thay vì giảm kích thước phông chữ hoặc các thành phần hình ảnh để vừa với không gian hơn.

Việc thiếu khoảng trắng có thể gây khó khăn cho những người gặp vấn đề về thị lực. Quá nhiều khoảng trắng có thể làm mất tính liên kết và khiến giao diện người dùng của bạn trông lộn xộn. Ví dụ: hãy xem các ảnh chụp màn hình sau:

7f5e3514a7ee1750.png

d5144a50f5b4142c.png

Tiếp theo, bạn sẽ thêm khoảng trắng vào màn hình chính để có thêm không gian. Sau đó, bạn sẽ điều chỉnh thêm bố cục để tinh chỉnh khoảng cách.

Gói một tiện ích bằng đối tượng Padding để thêm khoảng trắng xung quanh tiện ích đó. Tăng tất cả các giá trị khoảng đệm trong lib/src/features/home/view/home_screen.dart lên 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,
                            ),
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ),
      ],
    ),
  ),
);

Tải lại nóng ứng dụng. Ứng dụng sẽ trông giống như trước, nhưng có nhiều khoảng trắng hơn giữa các tiện ích. Khoảng đệm bổ sung trông đẹp hơn, nhưng biểu ngữ nổi bật ở trên cùng vẫn quá gần các cạnh.

Trong lib/src/features/home/view/home_highlight.dart, hãy thay đổi khoảng đệm trên biểu ngữ thành 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')),
            ),
          ),
        ),
      ],
    );
  }
}

Tải lại nóng ứng dụng. Hai danh sách phát ở dưới cùng không có khoảng trắng giữa các danh sách phát, vì vậy, chúng trông giống như thuộc cùng một bảng. Không phải vậy, bạn sẽ khắc phục vấn đề này trong bước tiếp theo.

df1d9af97d039cc8.png

Thêm khoảng trắng giữa các danh sách phát bằng cách chèn một tiện ích kích thước vào Row chứa các danh sách phát đó. Trong lib/src/features/home/view/home_screen.dart, hãy thêm một SizedBox có chiều rộng là 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,
                    ),
              ),
            ],
          ),
        ),
      ],
    ),
  ),
),

Tải lại nóng ứng dụng. Ứng dụng sẽ có dạng như sau:

d8b2a3d47736dbab.png

Giờ đây, có rất nhiều không gian cho nội dung trên màn hình chính, nhưng mọi thứ trông quá tách biệt và không có sự gắn kết giữa các phần.

Cho đến nay, bạn đã đặt tất cả khoảng đệm (cả chiều ngang và chiều dọc) cho các tiện ích trên màn hình chính thành 35 bằng EdgeInsets.all(35), nhưng bạn cũng có thể đặt khoảng đệm cho từng cạnh một cách độc lập. Tuỳ chỉnh khoảng đệm để phù hợp hơn với không gian.

  • EdgeInsets.LTRB() đặt riêng bên trái, trên cùng, bên phải và bên dưới
  • EdgeInsets.symmetric() đặt khoảng đệm cho chiều dọc (trên cùng và dưới cùng) tương đương và chiều ngang (bên trái và bên phải) tương đương
  • EdgeInsets.only() chỉ đặt các cạnh được chỉ định.

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

Trong lib/src/features/home/view/home_highlight.dart, hãy đặt khoảng đệm bên trái và bên phải trên biểu ngữ thành 35, đồng thời đặt khoảng đệm trên cùng và dưới cùng thành 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')),
            ),
          ),
        ),
      ],
    );
  }
}

Tải lại nóng ứng dụng. Bố cục và khoảng cách trông đẹp hơn nhiều! Để hoàn thiện, hãy thêm một số chuyển động và ảnh động.

7f5e3514a7ee1750.png

Bạn gặp vấn đề?

Nếu ứng dụng của bạn không chạy đúng cách, hãy sử dụng mã tại đường liên kết sau để khắc phục.

8. Thêm chuyển động và ảnh động

Ảnh động và chuyển động là những cách tuyệt vời để giới thiệu chuyển động và năng lượng, đồng thời cung cấp phản hồi khi người dùng tương tác với ứng dụng.

Tạo ảnh động giữa các màn hình

ThemeProvider xác định PageTransitionsTheme bằng ảnh động chuyển đổi màn hình cho các nền tảng di động (iOS, Android). Người dùng máy tính đã nhận được phản hồi từ thao tác nhấp chuột hoặc bàn di chuột, vì vậy, không cần ảnh động chuyển đổi trang.

Flutter cung cấp ảnh động chuyển đổi màn hình mà bạn có thể định cấu hình cho ứng dụng dựa trên nền tảng mục tiêu như trong 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(),
  },
);

Truyền PageTransitionsTheme cho cả giao diện sáng và tối trong 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,
  );
}

Không có ảnh động trên iOS

Có ảnh động trên iOS

Bạn gặp vấn đề?

Nếu ứng dụng của bạn không chạy đúng cách, hãy sử dụng mã tại đường liên kết sau để khắc phục.

9. Thêm trạng thái di chuột

Một cách để thêm chuyển động vào ứng dụng dành cho máy tính là sử dụng trạng thái di chuột, trong đó tiện ích thay đổi trạng thái (chẳng hạn như màu sắc, hình dạng hoặc nội dung) khi người dùng di chuột qua tiện ích đó.

Theo mặc định, lớp _OutlinedCardState (dùng cho thẻ thông tin danh sách phát "đã phát gần đây") sẽ trả về một MouseRegion – biến mũi tên con trỏ thành con trỏ khi di chuột qua – nhưng bạn có thể thêm phản hồi trực quan khác.

Mở lib/src/shared/views/outlined_card.dart và thay thế nội dung của tệp này bằng cách triển khai sau để giới thiệu trạng thái _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,
       
),
     
),
   
);
 
}
}

Tải lại nóng ứng dụng rồi di chuột qua một trong các thẻ danh sách phát đã phát gần đây.

OutlinedCard thay đổi độ mờ và làm tròn các góc.

Cuối cùng, hãy tạo ảnh động cho số bài hát trên danh sách phát thành nút phát bằng tiện ích HoverableSongPlayButton được xác định trong lib/src/shared/views/hoverable_song_play_button.dart. Trong lib/src/features/playlists/view/playlist_songs.dart, hãy gói tiện ích Center (chứa số bài hát) bằng 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())),
  ],
),

Tải lại nóng ứng dụng rồi di chuột qua số bài hát trong danh sách phát Bài hát hàng đầu hôm nay hoặc Bản phát hành mới.

Số này sẽ chuyển thành ảnh động nút phát để phát bài hát khi bạn nhấp vào.

Xem mã dự án hoàn thiện trên GitHub.

10. Xin chúc mừng!

Bạn đã hoàn thành lớp học lập trình này! Bạn đã tìm hiểu rằng có nhiều thay đổi nhỏ mà bạn có thể tích hợp vào ứng dụng để ứng dụng đó trở nên đẹp mắt hơn, dễ tiếp cận hơn, dễ bản địa hoá hơn và phù hợp hơn với nhiều nền tảng. Những kỹ thuật này bao gồm nhưng không giới hạn ở:

  • Kiểu chữ: Văn bản không chỉ là một công cụ giao tiếp. Sử dụng cách hiển thị văn bản để tạo ra tác động tích cực đến trải nghiệm và cảm nhận của người dùng về ứng dụng.
  • Thiết kế giao diện: Thiết lập một hệ thống thiết kế mà bạn có thể sử dụng một cách đáng tin cậy mà không cần phải đưa ra quyết định thiết kế cho mọi tiện ích.
  • Khả năng thích ứng: Hãy cân nhắc thiết bị và nền tảng mà người dùng đang chạy ứng dụng của bạn cũng như các tính năng của thiết bị và nền tảng đó. Cân nhắc kích thước màn hình và cách ứng dụng của bạn hiển thị.
  • Chuyển động và ảnh động: Việc thêm chuyển động vào ứng dụng sẽ làm tăng năng lượng cho trải nghiệm người dùng và thực tế hơn là cung cấp phản hồi cho người dùng.

Chỉ cần một vài điều chỉnh nhỏ, ứng dụng của bạn có thể thay đổi từ nhàm chán thành đẹp mắt:

Trước

1e67c60667821082.png

Sau

Các bước tiếp theo

Chúng tôi hy vọng bạn đã tìm hiểu thêm về cách tạo ứng dụng đẹp mắt trong Flutter!

Nếu bạn áp dụng bất kỳ mẹo hoặc thủ thuật nào được đề cập ở đây (hoặc có mẹo riêng muốn chia sẻ), chúng tôi rất mong được nghe ý kiến của bạn! Hãy liên hệ với chúng tôi trên Twitter theo địa chỉ @rodydavis@khanhnwin!

Bạn cũng có thể tham khảo các tài nguyên sau đây.

Giao diện

Tài nguyên thích ứng và thích ứng:

Tài nguyên thiết kế chung:

Ngoài ra, hãy kết nối với cộng đồng Flutter!

Hãy tiếp tục và làm cho thế giới ứng dụng trở nên đẹp đẽ!