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

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 những ứng dụng đẹp mắt, được biên dịch tự nhiên cho thiết bị di động, web và máy tính chỉ từ một cơ sở mã duy nhất. Flutter hoạt động với mã nguồn 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 và là ngôn ngữ nguồn mở miễn phí.

Trong lớp học lập trình này, bạn sẽ cải thiện một ứng dụng nghe nhạc Flutter để biến ứng dụng này trở nên thật đẹ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 đã 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 dễ sử dụng và đẹp mắt trên các nền tảng.
  • Cách thiết kế văn bản trong ứng dụng của bạn để đảm bảo văn bản bổ sung giá trị cho trải nghiệm người dùng.
  • Cách chọn màu phù hợp, tuỳ chỉnh tiện ích, xây dựng giao diện riêng cũng như triển khai chế độ tối một cách nhanh chóng và dễ dàng.
  • Cách tạo ứng dụng thích ứng trên nhiều nền tảng.
  • Cách xây dựng ứng dụng giao diện đẹp mắt 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ật 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 chưa thì bạn nên tìm hiểu những kiến thức cơ bản trước. Bạn có thể tham khảo những đường liên kết sau đây:

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 cách xây dựng màn hình chính cho một ứng dụng có tên là MyArtist – một ứng dụng phát nhạc giúp người hâm mộ có thể cập nhật thông tin về những nghệ sĩ họ yêu thích. Hướng dẫn này thảo luận cách sửa đổi thiết kế ứng dụng để ứng dụng trông đẹp mắt trên các nền tảng.

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

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

Tôi mới biết đến chủ đề này và tôi muốn có thông tin tổng quan đầy đủ. Tôi đã biết đôi chút về chủ đề này, nhưng tôi muốn ôn lại kiến thức. Tôi đang tìm mã mẫu để sử dụng trong dự án của mình. Tôi muốn được giải thích cụ thể hơn.

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

Bạn cần có 2 phần mềm để hoàn thành phòng thí nghiệm này – Flutter SDKtrình chỉnh sửa.

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

  • Một thiết bị Android hoặc iOS thực kết nối với máy tính của bạn và được đặt ở Chế độ nhà phát triển.
  • Trình mô phỏng iOS (yêu cầu cài đặt 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 (cần có Chrome để gỡ lỗi).
  • Dưới dạng ứng dụng 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 một ứng dụng Windows dành cho máy tính, bạn phải phát triển trên Windows để truy cập vào chuỗi bản dựng phù hợp. Có các yêu cầu cụ thể theo 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

Nhân bản 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ứ hoạt động tốt, hãy chạy ứng dụng Flutter dưới dạng một ứng dụng dành cho máy tính như bên dưới. Ngoài ra, hãy mở dự án này trong IDE rồi sử dụng công cụ của dự án để chạy ứng dụng.

a3c16fc17be25f6c.png Chạy ứng dụng.

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

1e67c60667821082.pngS d1139cde225de452.png

Tìm hiểu mã

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

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

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

import 'package:adaptive_components/adaptive_components.dart';
import 'package:flutter/material.dart';

import '../../../shared/classes/classes.dart';
import '../../../shared/extensions.dart';
import '../../../shared/providers/providers.dart';
import '../../../shared/views/views.dart';
import '../../playlists/view/playlist_songs.dart';
import 'view.dart';

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  @override
  Widget build(BuildContext context) {
    final PlaylistsProvider playlistProvider = PlaylistsProvider();
    final List<Playlist> playlists = playlistProvider.playlists;
    final Playlist topSongs = playlistProvider.topSongs;
    final Playlist newReleases = playlistProvider.newReleases;
    final ArtistsProvider artistsProvider = ArtistsProvider();
    final List<Artist> artists = artistsProvider.artists;
    return LayoutBuilder(
      builder: (context, constraints) {
        // Add conditional mobile layout

        return Scaffold(
          body: SingleChildScrollView(
            child: AdaptiveColumn(
              children: [
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Padding(
                    padding: const EdgeInsets.all(2), // Modify this line
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        Expanded(
                          child: Text(
                            'Good morning',
                            style: context.displaySmall,
                          ),
                        ),
                        const SizedBox(width: 20),
                        const BrightnessToggle(),
                      ],
                    ),
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Column(
                    children: [
                      const HomeHighlight(),
                      LayoutBuilder(
                        builder: (context, constraints) => HomeArtists(
                          artists: artists,
                          constraints: constraints,
                        ),
                      ),
                    ],
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Padding(
                        padding: const EdgeInsets.all(2), // Modify this line
                        child: Text(
                          'Recently played',
                          style: context.headlineSmall,
                        ),
                      ),
                      HomeRecent(
                        playlists: playlists,
                      ),
                    ],
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Padding(
                    padding: const EdgeInsets.all(2), // Modify this line
                    child: Row(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Flexible(
                          flex: 10,
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.start,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Padding(
                                padding:
                                    const EdgeInsets.all(2), // Modify this line
                                child: Text(
                                  'Top Songs Today',
                                  style: context.titleLarge,
                                ),
                              ),
                              LayoutBuilder(
                                builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: topSongs,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                        // Add spacer between tables
                        Flexible(
                          flex: 10,
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.start,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Padding(
                                padding:
                                    const EdgeInsets.all(2), // Modify this line
                                child: Text(
                                  'New Releases',
                                  style: context.titleLarge,
                                ),
                              ),
                              LayoutBuilder(
                                builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: newReleases,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}

Tệp này nhập material.dart và triển khai một tiện ích có trạng thái bằng 2 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 được hiển thị.
  • Phương thức build() của lớp _HomeScreenState tạo gốc của cây tiện ích, điều này ả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 ở khắp mọi nơi. Văn bản là cách hữu ích để giao tiếp với người dùng. Ứng dụng của bạn cần phải thân thiện và thú vị, hay có lẽ là đáng tin cậy và chuyên nghiệp? Vậy thì có lý do là ứng dụng ngân hàng bạn yêu thích không dùng truyện tranh Comic Sans. Cách trình bày văn bản sẽ định hình ấn tượng đầu tiên 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 sao cho thấu đáo hơn.

Minh hoạ nhiều điều

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

86c5f73b3aa5fd35.pngS

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

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

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 sự cố?

Nếu ứng dụng của bạn chạy không đú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 bên dưới để tiếp tục hoạt động.

Chọn phông chữ một cách cân nhắc

Phông chữ xác định cá tính 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ữ, dưới đây là một vài điều cần cân nhắc:

  • Sans-serif hoặc serif: Phông chữ Serif có nét vẽ trang trí hoặc "đuôi" ở cuối chữ cái và được xem là trang trọng hơn. Phông chữ Sans-serif không có các nét trang trí và có xu hướng được coi là không trang trọng hơn. 34bf54e4cad90101.pngs Chữ viết hoa Sans serif T và chữ T viết hoa
  • Tất cả phông chữ viết hoa: Việc sử dụng tất cả chữ viết hoa sẽ thích hợp để thu hút sự chú ý vào một lượng nhỏ văn bản (ví dụ như dòng tiêu đề). Tuy nhiên, nếu bị sử dụng quá nhiều, hành vi này có thể được coi là tiếng hét khiến người dùng hoàn toàn phớt lờ cụm từ đó.
  • Viết hoa chữ cái đầu 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ữ cái viết hoa: viết hoa chữ cái đầu tiên, trong đó chữ cái đầu của mỗi từ được viết hoa ("Đây là chữ viết hoa đầu mỗi từ), sẽ trang trọng hơn. Viết hoa đầu câu: chỉ viết hoa các danh từ riêng và từ đầu tiên trong văn bản ("Đây là tiêu đề viết hoa đầu câu"), mang tính gần gũi và thân mật hơn.
  • 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): Quá nhiều hoặc quá ít trong số này sẽ khiến ứng dụng của bạn khó đọc hơn. Ví dụ: bạn rất dễ bị mất dấu khi đọc một khối văn bản lớn, liên tục.

Hãy lưu ý đến điều này, hãy chuyển đến Google Fonts và chọn phông chữ Sans Serif, chẳng hạn như Montserrat, vì ứng dụng âm nhạc dùng để vui tươi và thú vị.

a3c16fc17be25f6c.png Từ dòng lệnh, hãy lấy gói google_fonts. Thao tác này cũng cập nhật tệp pubspec để thêm phông chữ dưới dạng 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" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <key>com.apple.security.cs.allow-jit</key>
        <true/>
        <!-- Make sure these lines are present from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- To here. -->
        <key>com.apple.security.network.server</key>
        <true/>
</dict>
</plist>

a3c16fc17be25f6c.png 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.

a3c16fc17be25f6c.png Đặt bài Montserrat TextTheme:

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

a3c16fc17be25f6c.png Tải lại nóng 7f9a9e103c7b5e5.pngS để kích hoạt các thay đổi. (Sử dụng nút trong IDE hoặc từ dòng lệnh, hãy nhập r để tải lại nóng.):

1e67c60667821082.pngS

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

Bạn gặp sự cố?

Nếu ứng dụng của bạn chạy không đú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 bên dưới để tiếp tục hoạt động.

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 tập hợp gồm các màu và kiểu văn bản. Giao diện giúp bạn nhanh chóng triển khai giao diện người dùng mà không 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.

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

  • Tạo từng tiện ích tuỳ chỉnh, mỗi tiện ích có giao diện riêng.
  • Tạo giao diện theo phạm vi 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 trên toàn ứ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.value, settings.value.sourceColor.value));
 }

 Color source(Color? target) {
   Color source = settings.value.sourceColor;
   if (target != null) {
     source = blend(target);
   }
   return source;
 }

 ColorScheme colors(Brightness brightness, Color? targetColor) {
   final dynamicPrimary = brightness == Brightness.light
       ? lightDynamic?.primary
       : darkDynamic?.primary;
   return ColorScheme.fromSeed(
     seedColor: dynamicPrimary ?? source(targetColor),
     brightness: brightness,
   );
 }

 ShapeBorder get shapeMedium => RoundedRectangleBorder(
       borderRadius: BorderRadius.circular(8),
     );

 CardTheme cardTheme() {
   return CardTheme(
     elevation: 0,
     shape: shapeMedium,
     clipBehavior: Clip.antiAlias,
   );
 }

 ListTileThemeData listTileTheme(ColorScheme colors) {
   return ListTileThemeData(
     shape: shapeMedium,
     selectedColor: colors.secondary,
   );
 }

 AppBarTheme appBarTheme(ColorScheme colors) {
   return AppBarTheme(
     elevation: 0,
     backgroundColor: colors.surface,
     foregroundColor: colors.onSurface,
   );
 }

 TabBarTheme tabBarTheme(ColorScheme colors) {
   return TabBarTheme(
     labelColor: colors.secondary,
     unselectedLabelColor: colors.onSurfaceVariant,
     indicator: BoxDecoration(
       border: Border(
         bottom: BorderSide(
           color: colors.secondary,
           width: 2,
         ),
       ),
     ),
   );
 }

 BottomAppBarTheme bottomAppBarTheme(ColorScheme colors) {
   return BottomAppBarTheme(
     color: colors.surface,
     elevation: 0,
   );
 }

 BottomNavigationBarThemeData bottomNavigationBarTheme(ColorScheme colors) {
   return BottomNavigationBarThemeData(
     type: BottomNavigationBarType.fixed,
     backgroundColor: colors.surfaceContainerHighest,
     selectedItemColor: colors.onSurface,
     unselectedItemColor: colors.onSurfaceVariant,
     elevation: 0,
     landscapeLayout: BottomNavigationBarLandscapeLayout.centered,
   );
 }

 NavigationRailThemeData navigationRailTheme(ColorScheme colors) {
   return const NavigationRailThemeData();
 }

 DrawerThemeData drawerTheme(ColorScheme colors) {
   return DrawerThemeData(
     backgroundColor: colors.surface,
   );
 }

 ThemeData light([Color? targetColor]) {
   final _colors = colors(Brightness.light, targetColor);
   return ThemeData.light().copyWith(
     pageTransitionsTheme: pageTransitionsTheme,
     colorScheme: _colors,
     appBarTheme: appBarTheme(_colors),
     cardTheme: cardTheme(),
     listTileTheme: listTileTheme(_colors),
     bottomAppBarTheme: bottomAppBarTheme(_colors),
     bottomNavigationBarTheme: bottomNavigationBarTheme(_colors),
     navigationRailTheme: navigationRailTheme(_colors),
     tabBarTheme: tabBarTheme(_colors),
     drawerTheme: drawerTheme(_colors),
     scaffoldBackgroundColor: _colors.background,
     useMaterial3: true,
   );
 }

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

 ThemeMode themeMode() {
   return settings.value.themeMode;
 }

 ThemeData theme(BuildContext context, [Color? targetColor]) {
   final brightness = MediaQuery.of(context).platformBrightness;
   return brightness == Brightness.light
       ? light(targetColor)
       : dark(targetColor);
 }

 static ThemeProvider of(BuildContext context) {
   return context.dependOnInheritedWidgetOfExactType<ThemeProvider>()!;
 }

 @override
 bool updateShouldNotify(covariant ThemeProvider oldWidget) {
   return oldWidget.settings != settings;
 }
}

class ThemeSettings {
 ThemeSettings({
   required this.sourceColor,
   required this.themeMode,
 });

 final Color sourceColor;
 final ThemeMode themeMode;
}

Color randomColor() {
 return Color(Random().nextInt(0xFFFFFFFF));
}

// Custom Colors
const linkColor = CustomColor(
 name: 'Link Color',
 color: Color(0xFF00B0FF),
);

class CustomColor {
 const CustomColor({
   required this.name,
   required this.color,
   this.blend = true,
 });

 final String name;
 final Color color;
 final bool blend;

 Color value(ThemeProvider provider) {
   return provider.custom(this);
 }
}

a3c16fc17be25f6c.pngĐể sử dụng trình cung cấp, hãy tạo một thực thể và truyền thực thể đó vào đối tượng giao diện có phạm vi trong MaterialApp, nằm trong lib/src/shared/app.dart. Mọi đối tượng Theme được lồng sẽ kế thừa thành phần 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, giao diện đã được thiết lập, hãy chọn màu cho ứng dụng.

Việc chọn đúng nhóm màu không phải lúc nào cũng dễ dàng. Bạn có thể có ý tưởng về màu sắc chính, nhưng rất có thể bạn muốn ứng dụng của mình có nhiều hơn chỉ một màu. 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 (Trình tạo giao diện Material) là một công cụ chạy trên nền tảng web (đã ra mắt trong Material 3), giúp bạn chọn một bộ màu bổ sung cho ứng dụng của mình.

a3c16fc17be25f6c.pngĐể 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á nhiều màu sắc trên giao diện người dùng. Điều quan trọng là phải chọn màu sắc phù hợp với thẩm mỹ thương hiệu và/hoặc sở thích cá nhân của bạn.

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

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

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

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

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

a3c16fc17be25f6c.pngĐể sử dụng một màu cụ thể, hãy truy cập vào vai trò của màu sắc trên colorScheme. Chuyển đến lib/src/shared/views/outlined_card.dart rồi 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(
        child: widget.child,
        // Add from here...
        decoration: BoxDecoration(
          border: Border.all(
            color: Theme.of(context).colorScheme.outline,
            width: 1,
          ),
        ),
        // ... To here.
      ),
    );
  }
}

Material 3 ra mắt các vai trò sắc thái của màu sắc bổ sung cho nhau và có thể được sử dụng trên toàn bộ giao diện người dùng để thêm các lớp biểu thức mới. Các vai trò mới này của màu 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, các mã thông báo thiết kế mới hỗ trợ cả giao diện sáng lẫn tối:

7b51703ed96196a4.png.

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

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

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 để trở lại đúng hướng.

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

Nhờ Flutter, bạn có thể tạo ra những ứng dụng chạy được hầu hết mọi nơi, nhưng không có nghĩa là mọi ứng dụng đều được dự kiến sẽ hoạt động như nhau ở mọi nơi. Người dùng mong đợi có các hành vi và tính năng khác nhau từ các nền tảng khác nhau.

Material cung cấp các gói giúp bạn làm việc với bố cục thích ứng dễ dà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 của nền tảng khi tạo ứ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
  • Cỡ 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

a3c16fc17be25f6c.png Tệp lib/src/shared/views/adaptive_navigation.dart chứa một lớp điều hướng để bạn có thể cung cấp danh sách các đích đến và nội dung để kết xuất phần nội dung. Vì bạn dùng bố cục này trên nhiều màn hình, nên sẽ có một bố cục cơ sở dùng chung để truyền vào từng màn hình 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 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 super.child,
  });

  final List<NavigationDestination> destinations;
  final int selectedIndex;
  final void Function(int index) onDestinationSelected;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, dimens) {
        // Tablet Layout
        if (dimens.maxWidth >= 600) { // Add this line
          return Scaffold(
            body: Row(
              children: [
                NavigationRail(
                  extended: dimens.maxWidth >= 800,
                  minExtendedWidth: 180,
                  destinations: destinations
                      .map((e) => NavigationRailDestination(
                            icon: e.icon,
                            label: Text(e.label),
                          ))
                      .toList(),
                  selectedIndex: selectedIndex,
                  onDestinationSelected: onDestinationSelected,
                ),
                Expanded(child: child),
              ],
            ),
          );
        } // Add this line

        // Mobile Layout
        // Add from here...
        return Scaffold(
          body: child,
          bottomNavigationBar: NavigationBar(
            destinations: destinations,
            selectedIndex: selectedIndex,
            onDestinationSelected: onDestinationSelected,
          ),
        );
        // ... To here.
      },
    );
  }
}

a8487a3c4d7890c9.png

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

Để ứng dụng của bạn có khả năng thích ứng, hãy giới thiệu một số đ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 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 dành cho máy tính bị thu gọn, hãy tạo một bố cục riêng cho thiết bị di động, trong đó sử dụng các thẻ để chia nhỏ nội dung. Điều này mang lại cho ứng dụng cảm giác tự nhiên hơn trên thiết bị di động.

Các phương thức tiện ích sau (được xác định trong dự án MyArtist trong lib/src/shared/extensions.dart) là vị trí thích hợp để bắt đầu 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 xem là máy tính bảng. Những thiết bị lớn hơn 1200 pixel được xem là máy tính. Nếu thiết bị 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ạn có thể cân nhắc sử dụng gói adaptive_breakpoints.

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ằng cách sử dụng các gói adaptive_componentsadaptive_breakpoints để triển khai bố cục lưới thích ứng trong Material Design.

return LayoutBuilder(
      builder: (context, constraints) {
        return Scaffold(
          body: SingleChildScrollView(
            child: AdaptiveColumn(
              children: [
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Padding(
                    padding: const EdgeInsets.symmetric(
                      horizontal: 20,
                      vertical: 40,
                    ),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        Expanded(
                          child: Text(
                            'Good morning',
                            style: context.displaySmall,
                          ),
                        ),
                        const SizedBox(width: 20),
                        const BrightnessToggle(),
                      ],
                    ),
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Column(
                    children: [
                      const HomeHighlight(),
                      LayoutBuilder(
                        builder: (context, constraints) => HomeArtists(
                          artists: artists,
                          constraints: constraints,
                        ),
                      ),
                    ],
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Padding(
                        padding: const EdgeInsets.symmetric(
                          horizontal: 15,
                          vertical: 20,
                        ),
                        child: Text(
                          'Recently played',
                          style: context.headlineSmall,
                        ),
                      ),
                      HomeRecent(
                        playlists: playlists,
                      ),
                    ],
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Padding(
                    padding: const EdgeInsets.all(15),
                    child: Row(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Flexible(
                          flex: 10,
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.start,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Padding(
                                padding:
                                    const EdgeInsets.only(left: 8, bottom: 8),
                                child: Text(
                                  'Top Songs Today',
                                  style: context.titleLarge,
                                ),
                              ),
                              LayoutBuilder(
                                builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: topSongs,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                        const SizedBox(width: 25),
                        Flexible(
                          flex: 10,
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.start,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Padding(
                                padding:
                                    const EdgeInsets.only(left: 8, bottom: 8),
                                child: Text(
                                  'New Releases',
                                  style: context.titleLarge,
                                ),
                              ),
                              LayoutBuilder(
                                builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: newReleases,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),
        );
      },
    );

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

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

import 'package:adaptive_components/adaptive_components.dart';
import 'package:flutter/material.dart';

import '../../../shared/classes/classes.dart';
import '../../../shared/extensions.dart';
import '../../../shared/providers/providers.dart';
import '../../../shared/views/views.dart';
import '../../playlists/view/playlist_songs.dart';
import 'view.dart';

class HomeScreen extends StatefulWidget {
 const HomeScreen({super.key});

 @override
 State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
 @override
 Widget build(BuildContext context) {
   final PlaylistsProvider playlistProvider = PlaylistsProvider();
   final List<Playlist> playlists = playlistProvider.playlists;
   final Playlist topSongs = playlistProvider.topSongs;
   final Playlist newReleases = playlistProvider.newReleases;
   final ArtistsProvider artistsProvider = ArtistsProvider();
   final List<Artist> artists = artistsProvider.artists;
   return LayoutBuilder(
     builder: (context, constraints) {
       // Add from here...
       if (constraints.isMobile) {
          return DefaultTabController(
            length: 4,
            child: Scaffold(
              appBar: AppBar(
                centerTitle: false,
                title: const Text('Good morning'),
                actions: const [BrightnessToggle()],
                bottom: const TabBar(
                  isScrollable: true,
                  tabs: [
                    Tab(text: 'Home'),
                    Tab(text: 'Recently Played'),
                    Tab(text: 'New Releases'),
                    Tab(text: 'Top Songs'),
                  ],
                ),
              ),
              body: LayoutBuilder(
                builder: (context, constraints) => TabBarView(
                  children: [
                    SingleChildScrollView(
                      child: Column(
                        children: [
                          const HomeHighlight(),
                          HomeArtists(
                            artists: artists,
                            constraints: constraints,
                          ),
                        ],
                      ),
                    ),
                    HomeRecent(
                      playlists: playlists,
                      axis: Axis.vertical,
                    ),
                    PlaylistSongs(
                      playlist: topSongs,
                      constraints: constraints,
                    ),
                    PlaylistSongs(
                      playlist: newReleases,
                      constraints: constraints,
                    ),
                  ],
                ),
              ),
            ),
          );
        }
       // ... To here.

       return Scaffold(
          body: SingleChildScrollView(
            child: AdaptiveColumn(
              children: [
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Padding(
                    padding: const EdgeInsets.symmetric(
                      horizontal: 20,
                      vertical: 40,
                    ),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        Expanded(
                          child: Text(
                            'Good morning',
                            style: context.displaySmall,
                          ),
                        ),
                        const SizedBox(width: 20),
                        const BrightnessToggle(),
                      ],
                    ),
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Column(
                    children: [
                      const HomeHighlight(),
                      LayoutBuilder(
                        builder: (context, constraints) => HomeArtists(
                          artists: artists,
                          constraints: constraints,
                        ),
                      ),
                    ],
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Padding(
                        padding: const EdgeInsets.symmetric(
                          horizontal: 15,
                          vertical: 20,
                        ),
                        child: Text(
                          'Recently played',
                          style: context.headlineSmall,
                        ),
                      ),
                      HomeRecent(
                        playlists: playlists,
                      ),
                    ],
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Padding(
                    padding: const EdgeInsets.all(15),
                    child: Row(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Flexible(
                          flex: 10,
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.start,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Padding(
                                padding:
                                    const EdgeInsets.only(left: 8, bottom: 8),
                                child: Text(
                                  'Top Songs Today',
                                  style: context.titleLarge,
                                ),
                              ),
                              LayoutBuilder(
                                builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: topSongs,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                        const SizedBox(width: 25),
                        Flexible(
                          flex: 10,
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.start,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Padding(
                                padding:
                                    const EdgeInsets.only(left: 8, bottom: 8),
                                child: Text(
                                  'New Releases',
                                  style: context.titleLarge,
                                ),
                              ),
                              LayoutBuilder(
                                builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: newReleases,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),
        );
     },
   );
 }
}

377cfdda63a9de54.png.

Bạn gặp sự cố?

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 để trở lại đúng hướng.

Sử dụng khoảng trắng

Khoảng trắng là một công cụ trực quan quan trọng đối với ứng dụng của bạn, tạo ra sự ngắt kết nối giữa các phần.

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

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

7f5e3514a7ee1750.pngS

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ẽ tinh chỉnh thêm bố cục để tinh chỉnh khoảng cách.

a3c16fc17be25f6c.png 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ả giá trị khoảng đệm hiện có trong lib/src/features/home/view/home_screen.dart lên 35:

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

Scaffold(
      body: SingleChildScrollView(
        child: AdaptiveColumn(
          children: [
            AdaptiveContainer(
              columnSpan: 12,
              child: Padding(
                padding: const EdgeInsets.all(35), // Modify this line
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    Expanded(
                      child: Text(
                        'Good morning',
                        style: context.displaySmall,
                      ),
                    ),
                    const SizedBox(width: 20),
                    const BrightnessToggle(),
                  ],
                ),
              ),
            ),
            AdaptiveContainer(
              columnSpan: 12,
              child: Column(
                children: [
                  const HomeHighlight(),
                  LayoutBuilder(
                    builder: (context, constraints) => HomeArtists(
                      artists: artists,
                      constraints: constraints,
                    ),
                  ),
                ],
              ),
            ),
            AdaptiveContainer(
              columnSpan: 12,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Padding(
                    padding: const EdgeInsets.all(35), // Modify this line
                    child: Text(
                      'Recently played',
                      style: context.headlineSmall,
                    ),
                  ),
                  HomeRecent(
                    playlists: playlists,
                  ),
                ],
              ),
            ),
            AdaptiveContainer(
              columnSpan: 12,
              child: Padding(
                padding: const EdgeInsets.all(35), // Modify this line
                child: Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Flexible(
                      flex: 10,
                      child: Column(
                        mainAxisAlignment: MainAxisAlignment.start,
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Padding(
                            padding:
                                const EdgeInsets.all(35), // Modify this line
                            child: Text(
                              'Top Songs Today',
                              style: context.titleLarge,
                            ),
                          ),
                          LayoutBuilder(
                            builder: (context, constraints) => PlaylistSongs(
                              playlist: topSongs,
                              constraints: constraints,
                            ),
                          ),
                        ],
                      ),
                    ),
                    // Add spacer between tables
                    Flexible(
                      flex: 10,
                      child: Column(
                        mainAxisAlignment: MainAxisAlignment.start,
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Padding(
                            padding:
                                const EdgeInsets.all(35), // Modify this line
                            child: Text(
                              'New Releases',
                              style: context.titleLarge,
                            ),
                          ),
                          LayoutBuilder(
                            builder: (context, constraints) => PlaylistSongs(
                              playlist: newReleases,
                              constraints: constraints,
                            ),
                          ),
                        ],
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );

a3c16fc17be25f6c.png Vui lòng tải lại ứng dụng. Giao diện sẽ 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 sẽ trông đẹp hơn, nhưng biểu ngữ nổi bật ở trên cùng vẫn quá gần với các cạnh.

a3c16fc17be25f6c.png Trong lib/src/features/home/view/home_highlight.dart, hãy thay đổi khoảng đệm của biểu ngữ thành 35:

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

class HomeHighlight extends StatelessWidget {
  const HomeHighlight({super.key});

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Expanded(
          child: Padding(
            padding: const EdgeInsets.all(35), // Modify this line
            child: Clickable(
              child: SizedBox(
                height: 275,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(10),
                  child: Image.asset(
                    'assets/images/news/concert.jpeg',
                    fit: BoxFit.cover,
                  ),
                ),
              ),
              onTap: () => launch('https://docs.flutter.dev'),
            ),
          ),
        ),
      ],
    );
  }
}

a3c16fc17be25f6c.png Vui lòng tải lại ứng dụng. Hai danh sách phát ở dưới cùng không có khoảng trắng nên trông chúng thuộc cùng một bảng. Không phải vậy và bạn sẽ khắc phục lỗi đó trong bước tiếp theo.

df1d9af97d039cc8.png

a3c16fc17be25f6c.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 SizedBox có chiều rộng là 35:

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

Padding(
  padding: const EdgeInsets.all(35),
  child: Row(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Flexible(
        flex: 10,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.start,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Padding(
              padding: const EdgeInsets.all(35),
              child: Text(
                'Top Songs Today',
                style: context.titleLarge,
              ),
            ),
            PlaylistSongs(
              playlist: topSongs,
              constraints: constraints,
            ),
          ],
        ),
      ),
      const SizedBox(width: 35), // Add this line
      Flexible(
        flex: 10,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.start,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Padding(
              padding: const EdgeInsets.all(35),
              child: Text(
                'New Releases',
                style: context.titleLarge,
              ),
            ),
            PlaylistSongs(
              playlist: newReleases,
              constraints: constraints,
            ),
          ],
        ),
      ),
    ],
  ),
),

a3c16fc17be25f6c.png Vui lòng tải lại ứng dụng. Ứng dụng sẽ có dạng như sau:

d8b2a3d47736dbab.png

Hiện tại, 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ứ có vẻ quá tách biệt và không có sự liên kết giữa các phần.

a3c16fc17be25f6c.png Cho đến nay, bạn đã đặt tất cả khoảng đệm (cả ngang và dọc) cho các tiện ích trên màn hình chính thành 35 bằng EdgeInsets.all(35). Tuy nhiên, 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 cho vừa với không gian hơn.

  • EdgeInsets.LTRB() đặt riêng bên trái, trên cùng, bên phải và dưới cùng
  • EdgeInsets.symmetric() đặt khoảng đệm cho chiều dọc (trên cùng và dưới cùng) là tương đương và ngang (trái và phải) là tương đương
  • EdgeInsets.only() chỉ đặt các cạnh được chỉ định.
Scaffold(
  body: SingleChildScrollView(
    child: AdaptiveColumn(
      children: [
        AdaptiveContainer(
           columnSpan: 12,
             child: Padding(
               padding: const EdgeInsets.fromLTRB(20, 25, 20, 10), // Modify this line
               child: Row(
                 mainAxisAlignment: MainAxisAlignment.spaceBetween,
                   children: [
                     Expanded(
                       child: Text(
                         'Good morning',
                          style: context.displaySmall,
                       ),
                     ),
                     const SizedBox(width: 20),
                     const BrightnessToggle(),
                   ],
                 ),
               ),
             ),
             AdaptiveContainer(
               columnSpan: 12,
               child: Column(
                 children: [
                   const HomeHighlight(),
                   LayoutBuilder(
                     builder: (context, constraints) => HomeArtists(
                       artists: artists,
                       constraints: constraints,
                     ),
                   ),
                 ],
               ),
             ),
             AdaptiveContainer(
               columnSpan: 12,
               child: Column(
                 crossAxisAlignment: CrossAxisAlignment.start,
                 children: [
                   Padding(
                     padding: const EdgeInsets.symmetric(
                       horizontal: 15,
                       vertical: 10,
                     ), // Modify this line
                     child: Text(
                       'Recently played',
                       style: context.headlineSmall,
                     ),
                   ),
                   HomeRecent(
                     playlists: playlists,
                   ),
                 ],
               ),
             ),
             AdaptiveContainer(
               columnSpan: 12,
               child: Padding(
                 padding: const EdgeInsets.all(15), // Modify this line
                 child: Row(
                   crossAxisAlignment: CrossAxisAlignment.start,
                   children: [
                     Flexible(
                       flex: 10,
                         child: Column(
                           mainAxisAlignment: MainAxisAlignment.start,
                           crossAxisAlignment: CrossAxisAlignment.start,
                           children: [
                             Padding(
                               padding: const EdgeInsets.only(left: 8, bottom: 8), // Modify this line
                               child: Text(
                                 'Top Songs Today',
                                 style: context.titleLarge,
                               ),
                             ),
                             LayoutBuilder(
                               builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: topSongs,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                    const SizedBox(width: 25),
                    Flexible(
                      flex: 10,
                      child: Column(
                        mainAxisAlignment: MainAxisAlignment.start,
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Padding(
                            padding: const EdgeInsets.only(left: 8, bottom: 8), // Modify this line
                            child: Text(
                              'New Releases',
                               style: context.titleLarge,
                            ),
                          ),
                          LayoutBuilder(
                            builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: newReleases,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),
        );

a3c16fc17be25f6c.png Trong lib/src/features/home/view/home_highlight.dart, hãy đặt khoảng đệm bên trái và bên phải của biểu ngữ là 35, và khoảng đệm trên cùng và dưới cùng là 5:

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

class HomeHighlight extends StatelessWidget {
  const HomeHighlight({super.key});

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Expanded(
          child: Padding(
            // Modify this line
            padding: const EdgeInsets.symmetric(horizontal: 35, vertical: 5),
            child: Clickable(
              child: SizedBox(
                height: 275,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(10),
                  child: Image.asset(
                    'assets/images/news/concert.jpeg',
                    fit: BoxFit.cover,
                  ),
                ),
              ),
              onTap: () => launch('https://docs.flutter.dev'),
            ),
          ),
        ),
      ],
    );
  }
}

a3c16fc17be25f6c.png Vui lòng tải lại ứ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.pngS

Bạn gặp sự cố?

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 để trở lại đúng hướng.

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

Chuyển động và ảnh độ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 một PageTransitionsTheme có ảnh động chuyển đổi màn hình dành cho nền tảng di động (iOS, Android). Người dùng máy tính đã nhận được phản hồi từ việc nhấp chuột hoặc bàn di chuột, do đó không cần ảnh động chuyển đổi trang.

Flutter cung cấp các ả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(),
  },
);

a3c16fc17be25f6c.png Truyền PageTransitionsTheme vào 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 _colors = colors(Brightness.light, targetColor);
  return ThemeData.light().copyWith(
    pageTransitionsTheme: pageTransitionsTheme, // Add this line
    colorScheme: ColorScheme.fromSeed(
      seedColor: source(targetColor),
      brightness: Brightness.light,
    ),
    appBarTheme: appBarTheme(_colors),
    cardTheme: cardTheme(),
    listTileTheme: listTileTheme(),
    tabBarTheme: tabBarTheme(_colors),
    scaffoldBackgroundColor: _colors.background,
  );
}

ThemeData dark([Color? targetColor]) {
  final _colors = colors(Brightness.dark, targetColor);
  return ThemeData.dark().copyWith(
    pageTransitionsTheme: pageTransitionsTheme, // Add this line
    colorScheme: ColorScheme.fromSeed(
      seedColor: source(targetColor),
      brightness: Brightness.dark,
    ),
    appBarTheme: appBarTheme(_colors),
    cardTheme: cardTheme(),
    listTileTheme: listTileTheme(),
    tabBarTheme: tabBarTheme(_colors),
    scaffoldBackgroundColor: _colors.background,
  );
}

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

Với ảnh động trên iOS

Bạn gặp sự cố?

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 để trở lại đúng hướng.

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, nơi một 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 các ô danh sách phát "đã phát gần đây") sẽ trả về MouseRegion (biến mũi tên con trỏ thành con trỏ) khi di chuột. Tuy nhiên, bạn có thể thêm phản hồi bằng hình ảnh khác.

a3c16fc17be25f6c.png Mở lib/src/shared/views/outlined_card.dart rồi thay thế nội dung trong tệp bằng cách triển khai sau đây để đưa ra 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.withOpacity(
                _hovered ? 0.12 : 0,
              ),
          borderRadius: borderRadius,
        ),
        child: TweenAnimationBuilder<BorderRadius>(
          duration: kThemeAnimationDuration,
          curve: animationCurve,
          tween: Tween(begin: BorderRadius.zero, end: borderRadius),
          builder: (context, borderRadius, child) => ClipRRect(
            clipBehavior: Clip.antiAlias,
            borderRadius: borderRadius,
            child: child,
          ),
          child: widget.child,
        ),
      ),
    );
  }
}

a3c16fc17be25f6c.png Khi tải lại ứng dụng, hãy di chuột qua một ô trong danh sách phát được phát gần đây.

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

a3c16fc17be25f6c.png 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 một HoverableSongPlayButton:

lib/src/features/playlists/view/playlist_songs.dart

HoverableSongPlayButton(        // Add this line
  hoverMode: HoverMode.overlay, // Add this line
  song: playlist.songs[index],  // Add this line
  child: Center(                // Modify this line
    child: Text(
      (index + 1).toString(),
       textAlign: TextAlign.center,
       ),
    ),
  ),                            // Add this line

a3c16fc17be25f6c.pngTải lại ứng dụng rồi di chuột qua số bài hát trên 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ẽ xuất hiện trong nút phát. Nút này phát bài hát khi bạn nhấp vào.

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

8. Xin chúc mừng!

Bạn đã hoàn tất lớp học lập trình này! Bạn biết được rằng có nhiều thay đổi nhỏ mà bạn có thể tích hợp vào một ứng dụng để làm cho ứng dụng bắt mắt hơn, dễ truy cập 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 tác động tích cực đến người dùng trải nghiệm và nhận thức về ứng dụng của bạn.
  • Sắp xếp theo chủ đề: Thiết lập hệ thống thiết kế mà bạn có thể sử dụng đá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: Xem xét thiết bị và nền tảng mà người dùng đang chạy ứng dụng cũng như các tính năng của ứng dụng đó. Xem xét 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 của bạn sẽ 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.

Với một vài điều chỉnh nhỏ, ứng dụng của bạn có thể biến nhàm chán thành đẹp mắt:

Trước

1e67c60667821082.pngS

Sau

Các bước tiếp theo

Chúng tôi hy vọng rằng bạn đã hiểu được thêm về cách tạo các ứ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 của riêng bạn muốn chia sẻ), chúng tôi muốn 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ể thấy các tài nguyên sau hữu ích.

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 đi ra ngoài và làm cho thế giới ứng dụng trở nên tươi đẹp!