נהנים מאפליקציית Flutter משעממת ליפה

1. מבוא

Flutter היא ערכת הכלים לבניית ממשק משתמש של Google, שנועדה לבנות אפליקציות יפות ומותאמות לניידים, לאינטרנט ולמחשבים מתוך בסיס קוד יחיד. Flutter עובדת עם קוד קיים, משמשת מפתחים וארגונים ברחבי העולם, והיא חינמית וקוד פתוח.

ב-Codelab הזה, תשפרו אפליקציה של Flutter של מוזיקה, ותעלו אותה מ'משעמם' ל'יפה'. כדי לעשות זאת, ה-Codelab הזה משתמש בכלים ובממשקי API שנוספו ב-Material 3.

מה תלמדו

  • איך לכתוב אפליקציית Flutter שניתנת לשימוש ויפה בפלטפורמות שונות.
  • איך לעצב טקסט באפליקציה כדי לשפר את חוויית המשתמש?
  • איך בוחרים את הצבעים הנכונים, מתאימים אישית ווידג'טים, בונים עיצוב משלכם ומטמיעים מצב כהה במהירות ובקלות.
  • איך ליצור אפליקציות מותאמות בפלטפורמות שונות.
  • איך ליצור אפליקציות שנראות טוב בכל מסך.
  • איך להוסיף תנועה לאפליקציית Flutter כדי שבאמת יבלטו.

דרישות מוקדמות:

ה-Codelab הזה מניח שיש לך ניסיון ב-Flutter. אם לא, מומלץ ללמוד קודם את היסודות. הקישורים הבאים יעזרו לך:

מה תפַתחו

ה-Codelab הזה ידריך אותך איך לבנות את מסך הבית של אפליקציה בשם MyArtist, אפליקציית נגן מוזיקה שבה המעריצים יכולים להתעדכן בתכנים של האומנים האהובים עליהם. נסביר איך לשנות את העיצוב של האפליקציה כך שייראה יפה בפלטפורמות שונות.

בסרטונים הבאים אפשר לראות איך האפליקציה עובדת בסיום ה-Codelab הזה:

מה היית רוצה ללמוד מ-Codelab הזה?

הנושא חדש ורציתי לקבל סקירה כללית טובה. יש לי מושג בנושא הזה, אבל אני רוצה לרענן את הידע שלי. אני רוצה קוד לדוגמה לשימוש בפרויקט שלי. אני רוצה הסבר למשהו ספציפי.

2. הגדרת סביבת הפיתוח של Flutter

כדי להשלים את שיעור ה-Lab הזה אתם צריכים שתי תוכנות: Flutter SDK וכלי עריכה.

אפשר להריץ את Codelab באמצעות כל אחד מהמכשירים הבאים:

  • מכשיר פיזי שמשמש ל-Android או ל-iOS שמחובר למחשב ומוגדר ל'מצב פיתוח'.
  • הסימולטור של iOS (צריך להתקין כלים של Xcode).
  • האמולטור של Android (נדרשת הגדרה ב-Android Studio).
  • דפדפן (Chrome נדרש לניפוי באגים).
  • בתור אפליקציית Windows , Linux או macOS למחשב. צריך לפתח בפלטפורמה שבה אתם מתכננים לפרוס. לכן, כדי לפתח אפליקציה למחשב של Windows, צריך לפתח את האפליקציה ב-Windows כדי לגשת לשרשרת ה-build המתאימה. יש דרישות ספציפיות למערכת ההפעלה שמפורטות בהרחבה בכתובת docs.flutter.dev/desktop.

3. הורדת האפליקציה לתחילת העבודה של Codelab

שכפול מ-GitHub

כדי לשכפל את ה-Codelab הזה מ-GitHub, מריצים את הפקודות הבאות:

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

כדי לוודא שהכול עובד, מריצים את האפליקציה Flutter כאפליקציה למחשב, כפי שמוצג בהמשך. לחלופין, אפשר לפתוח את הפרויקט בסביבת הפיתוח המשולבת (IDE) ולהשתמש בכלים שלו כדי להריץ את האפליקציה.

a3c16fc17be25f6c.png הפעלת האפליקציה.

הצלחת! קוד הסימן לתחילת הפעולה במסך הבית של MyArtist אמור לפעול. אתם אמורים לראות את מסך הבית של MyArtist. העיצוב נראה תקין במחשב, אבל בנייד... לא משהו. קודם כל, הכלי לא מכבד. אין מה לדאוג, הבעיה תטופל!

1e67c60667821082.png d1139cde225de452.png

סיור בקוד

לאחר מכן, סיור בקוד.

פותחים את lib/src/features/home/view/home_screen.dart, ובו:

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

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

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

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

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

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

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

הקובץ הזה מייבא את material.dart ומיישם ווידג'ט עם שמירת מצב באמצעות שתי מחלקות:

  • ההצהרה import הופכת את רכיבי החומר לזמינים.
  • המחלקה HomeScreen מייצגת את כל הדף שמוצג.
  • השיטה build() של המחלקה _HomeScreenState יוצרת את הרמה הבסיסית (root) של עץ הווידג'ט, והיא משפיעה על האופן שבו כל הווידג'טים נוצרים בממשק המשתמש.

4. נצלו את הטיפוגרפיה

הטקסט מופיע בכל מקום. טקסט הוא דרך יעילה לתקשר עם המשתמש. האם האפליקציה אמורה להיות ידידותית ומהנה, או אולי אמינה ומקצועית? יש סיבה לכך שבאפליקציה של הבנק המועדף עליך לא נעשה שימוש ב-Comic Sans. אופן הצגת הטקסט משפיע על הרושם הראשוני של המשתמש על האפליקציה. הנה כמה דרכים לשימוש חכם יותר בטקסט.

בלי לגלות

כשהדבר אפשרי, יש לבצע 'הצגה' במקום "Tell". לדוגמה, לNavigationRail באפליקציה לתחילת הפעולה יש כרטיסיות לכל מסלול ראשי, אבל הסמלים המובילים זהים:

86c5f73b3aa5fd35.png

זה לא מועיל כי המשתמש עדיין צריך לקרוא את הטקסט של כל כרטיסייה. בתור התחלה, מוסיפים סימנים ויזואליים כדי שהמשתמשים יוכלו להסתכל במהירות בסמלים המובילים כדי למצוא את הכרטיסייה הרצויה. הוא עוזר גם בהתאמה לשוק המקומי ולנגישות.

a3c16fc17be25f6c.png בlib/src/shared/router.dart, מוסיפים סמלים מובילים ייחודיים לכל יעד ניווט (בית, פלייליסט ואנשים):

lib/src/shared/router.dart

const List<NavigationDestination> destinations = [
  NavigationDestination(
    label: 'Home',
    icon: Icon(Icons.home), // Modify this line
    route: '/',
  ),
  NavigationDestination(
    label: 'Playlists',
    icon: Icon(Icons.playlist_add_check), // Modify this line
    route: '/playlists',
  ),
  NavigationDestination(
    label: 'Artists',
    icon: Icon(Icons.people), // Modify this line
    route: '/artists',
  ),
];

23278e4f4610fbf4.png

בעיות?

אם האפליקציה שלכם לא פועלת בצורה תקינה, כדאי לבדוק אם יש שגיאות הקלדה. במקרה הצורך, אפשר להשתמש בקוד שמופיע בקישורים הבאים כדי לחזור לעניינים.

בחירת גופנים בקפידה

גופנים מגדירים את האישיות של האפליקציה שלכם, ולכן חשוב מאוד לבחור את הגופן הנכון. יש כמה דברים שחשוב להביא בחשבון כשבוחרים את הגופן:

  • Sans-serif או serif: לגופנים מסוג Serif יש קווים דקורטיביים או "זנבות" בסוף האותיות וכתוצאה מכך הן נתפסות כרשמיות יותר. גופן Sans-serif אינו כולל את הקווים הדקורטיביים והם נתפסים כתוכן לא רשמי יותר. 34bf54e4cad90101.png אותיות רישיות מסוג Sans Serif T ואות T גדולה מסוג serif
  • שימוש באותיות גדולות בלבד: שימוש באותיות גדולות בלבד מתאים למשיכת תשומת לב לכמויות קטנות של טקסט (למשל, כותרות). אם משתמשים יותר מדי, הדבר עלול להיתפס כצעקה שגורמת למשתמש להתעלם ממנו לגמרי.
  • אותיות רישיות בתחילת כל מילה: כשמוסיפים כותרות או תוויות, כדאי להשתמש באותיות רישיות: האות הראשונה של כל מילה באנגלית היא רשמית יותר. אותיות רישיות במשפט, שנעשה בהן שימוש רק בשמות עצם פרטיים ובמילה הראשונה בטקסט ("זהו כותרת של אותיות במשפט"), יותר בשפה פשוטה ולא רשמית.
  • קישור בין כל אות או אות, אורך שורה (רוחב הטקסט המלא על פני המסך) וגובה שורה (הגובה של כל שורת טקסט): יותר מדי או מעט מדי הערכים האלה הופכים את האפליקציה ללא קריאה פחות. לדוגמה, קל לאבד את המקום שבו נמצאים כשקוראים קטע טקסט גדול ולא שבור.

עם זאת, תוכלו לעבור אל Google Fonts ולבחור בגופן Sans Serat, כמו Montserrat, כי אפליקציית המוזיקה נועדה להיות משעשעת ומהנה.

a3c16fc17be25f6c.png משורת הפקודה, מכניסים את החבילה google_fonts. הפעולה הזו גם מעדכנת את הקובץ pubspec כך שיוסיף את הגופנים כתלות באפליקציה.

$ 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 ב-lib/src/shared/extensions.dart, מייבאים את החבילה החדשה:

lib/src/shared/extensions.dart

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

a3c16fc17be25f6c.png הגדרת TextTheme: של מונטסראט

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

a3c16fc17be25f6c.png טעינה מהירה מחדש של 7f9a9e103c7b5e5.png כדי להפעיל את השינויים. (יש להשתמש בלחצן בסביבת הפיתוח המשולבת (IDE) או, משורת הפקודה, להזין r כדי לטעון מחדש מתוך הזיכרון):

1e67c60667821082.png

הסמלים החדשים של NavigationRail אמורים להופיע בגופן מונטסראט.

בעיות?

אם האפליקציה שלכם לא פועלת בצורה תקינה, כדאי לבדוק אם יש שגיאות הקלדה. במקרה הצורך, אפשר להשתמש בקוד שמופיע בקישורים הבאים כדי לחזור לעניינים.

5. הגדרת העיצוב

עיצובים עוזרים ליצור עיצוב מובנה ואחיד באפליקציה על ידי ציון מערכת של צבעים וסגנונות טקסט. עיצובים מאפשרים להטמיע במהירות ממשק משתמש בלי להדגיש פרטים קטנים, כמו ציון הצבע המדויק לכל ווידג'ט.

בדרך כלל מפתחי Flutter יוצרים רכיבים בעיצוב מותאם אישית, באחת משתי דרכים:

  • יצירת ווידג'טים נפרדים בהתאמה אישית, כל אחד עם עיצוב משלו.
  • יצירת עיצובים בהיקף לווידג'טים שמוגדרים כברירת מחדל.

בדוגמה הזו משתמשים בספק עיצוב שנמצא ב-lib/src/shared/providers/theme.dart כדי ליצור ווידג'טים וצבעים עם נושא עקבי בכל האפליקציה:

lib/src/shared/providers/theme.dart

import 'dart:math';

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

class NoAnimationPageTransitionsBuilder extends PageTransitionsBuilder {
 const NoAnimationPageTransitionsBuilder();

 @override
 Widget buildTransitions<T>(
   PageRoute<T> route,
   BuildContext context,
   Animation<double> animation,
   Animation<double> secondaryAnimation,
   Widget child,
 ) {
   return child;
 }
}

class ThemeSettingChange extends Notification {
 ThemeSettingChange({required this.settings});
 final ThemeSettings settings;
}

class ThemeProvider extends InheritedWidget {
 const ThemeProvider(
     {super.key,
     required this.settings,
     required this.lightDynamic,
     required this.darkDynamic,
     required super.child});

 final ValueNotifier<ThemeSettings> settings;
 final ColorScheme? lightDynamic;
 final ColorScheme? darkDynamic;

 final pageTransitionsTheme = const PageTransitionsTheme(
   builders: <TargetPlatform, PageTransitionsBuilder>{
     TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
     TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
     TargetPlatform.linux: NoAnimationPageTransitionsBuilder(),
     TargetPlatform.macOS: NoAnimationPageTransitionsBuilder(),
     TargetPlatform.windows: NoAnimationPageTransitionsBuilder(),
   },
 );

 Color custom(CustomColor custom) {
   if (custom.blend) {
     return blend(custom.color);
   } else {
     return custom.color;
   }
 }

 Color blend(Color targetColor) {
   return Color(
       Blend.harmonize(targetColor.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כדי להשתמש בספק, צריך ליצור מכונה ולהעביר אותה לאובייקט העיצוב בהיקף ב-MaterialApp, שנמצא ב-lib/src/shared/app.dart. היא תעבור בירושה לכל האובייקטים מסוג Theme שהוצבו בו:

lib/src/shared/app.dart

import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import 'playback/bloc/bloc.dart';
import 'providers/theme.dart';
import 'router.dart';

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

 @override
 State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
 final settings = ValueNotifier(ThemeSettings(
   sourceColor:  Colors.pink,
   themeMode: ThemeMode.system,
 ));
 @override
 Widget build(BuildContext context) {
   return BlocProvider<PlaybackBloc>(
     create: (context) => PlaybackBloc(),
     child: DynamicColorBuilder(
       builder: (lightDynamic, darkDynamic) => ThemeProvider(
           lightDynamic: lightDynamic,
           darkDynamic: darkDynamic,
           settings: settings,
           child: NotificationListener<ThemeSettingChange>(
             onNotification: (notification) {
               settings.value = notification.settings;
               return true;
             },
             child: ValueListenableBuilder<ThemeSettings>(
               valueListenable: settings,
               builder: (context, value, _) {
                 final theme = ThemeProvider.of(context); // Add this line
                 return MaterialApp.router(
                   debugShowCheckedModeBanner: false,
                   title: 'Flutter Demo',
                   theme: theme.light(settings.value.sourceColor), // Add this line
                   routeInformationParser: appRouter.routeInformationParser,
                   routerDelegate: appRouter.routerDelegate,
                 );
               },
             ),
           )),
     ),
   );
 }
}

כעת, לאחר שהעיצוב מוגדר, בוחרים צבעים לאפליקציה.

לא תמיד קל לבחור את קבוצת הצבעים הנכונה. יכול להיות שיש לכם מושג לגבי הצבע הראשי, אבל סביר להניח שתרצו שלאפליקציה יהיה יותר מצבע אחד. באיזה צבע הטקסט צריך להיות? שם הספר? תוכן? קישורים? מה לגבי צבע הרקע? הכלי Material Design הוא כלי מבוסס-אינטרנט (הושק ב-Material 3) שעוזר לבחור קבוצה של צבעים משלימים לאפליקציה.

a3c16fc17be25f6c.pngכדי לבחור צבע מקור לאפליקציה, פותחים את הכלי ליצירת עיצובים מהותיים ובודקים צבעים שונים של ממשק המשתמש. חשוב לבחור צבע שמתאים לאסתטיקה של המותג ו/או להעדפות האישיות שלכם.

אחרי שיוצרים עיצוב, לוחצים לחיצה ימנית על בועת הצבע הראשי – תיפתח תיבת דו-שיח עם הערך ההקסדצימלי של הצבע הראשי. מעתיקים את הערך הזה. (תוכלו גם להגדיר את הצבע באמצעות תיבת הדו-שיח הזו.)

a3c16fc17be25f6c.pngמעבירים את הערך ההקסדצימלי של הצבע הראשי לספק העיצוב. לדוגמה, קוד הצבע ההקסדצימלי #00cbe6 מצוין כ-Color(0xff00cbe6). הרכיב ThemeProvider יוצר ThemeData שמכיל את קבוצת הצבעים המשלימים שהוצגה בתצוגה מקדימה בכלי Material Design:

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

מומלץ להפעיל מחדש את האפליקציה. אחרי הוספת הצבע הראשי, האפליקציה מתחילה להרגיש יותר אקספרסיביים. כדי לגשת לכל הצבעים החדשים, להפנות אל העיצוב בהקשר ולוחצים על ColorScheme:

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

a3c16fc17be25f6c.pngכדי להשתמש בצבע מסוים, צריך לגשת לתפקיד צבע ב-colorScheme. צריך לעבור אל lib/src/shared/views/outlined_card.dart ולתת לגבול OutlinedCard גבול:

lib/src/shared/views/outlined_card.dart

class _OutlinedCardState extends State<OutlinedCard> {
  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      cursor: widget.clickable
          ? SystemMouseCursors.click
          : SystemMouseCursors.basic,
      child: Container(
        child: widget.child,
        // Add from here...
        decoration: BoxDecoration(
          border: Border.all(
            color: Theme.of(context).colorScheme.outline,
            width: 1,
          ),
        ),
        // ... To here.
      ),
    );
  }
}

חומר 3 כולל תפקידי צבע ייחודיים שמשלימים זה את זה, ואפשר להשתמש בהם בכל ממשק המשתמש כדי להוסיף שכבות חדשות של ביטוי. תפקידי הצבע החדשים האלה כוללים:

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

בנוסף, אסימוני עיצוב חדשים תומכים גם בעיצוב הבהיר וגם בעיצוב הכהה:

7b51703ed96196a4.png

אפשר להשתמש בתפקידי הצבעים האלה כדי להקצות משמעות והדגשה לחלקים שונים בממשק המשתמש. גם אם רכיב מסוים לא בולט, הוא עדיין יכול לנצל את היתרונות של צבע דינמי.

a3c16fc17be25f6c.png המשתמש יכול להגדיר את בהירות האפליקציה בהגדרות המערכת של המכשיר. ב-lib/src/shared/app.dart, כשהמכשיר מוגדר למצב כהה, מחזירים את העיצוב הכהה ואת מצב העיצוב אל MaterialApp.

lib/src/shared/app.dart

return MaterialApp.router(
  debugShowCheckedModeBanner: false,
  title: 'Flutter Demo',
  theme: theme.light(settings.value.sourceColor),
  darkTheme: theme.dark(settings.value.sourceColor), // Add this line
  themeMode: theme.themeMode(), // Add this line
  routeInformationParser: appRouter.routeInformationParser,
  routerDelegate: appRouter.routerDelegate,
);

לוחצים על סמל הירח בפינה השמאלית העליונה כדי להפעיל את המצב הכהה.

בעיות?

אם האפליקציה לא פועלת בצורה תקינה, אפשר להשתמש בקוד שבקישור הבא כדי לחזור לעניינים.

6. הוספת עיצוב דינמי

בעזרת Flutter אפשר לבנות אפליקציות שעובדות כמעט בכל מקום, אבל זה לא אומר שמצופה מכל אפליקציה להתנהג באותו אופן בכל מקום. משתמשים צפויים לצפות להתנהגויות ולתכונות שונות מפלטפורמות שונות.

Material מציע חבילות שמקלות על העבודה עם פריסות מותאמות - אפשר למצוא את חבילות Flutter האלה ב-GitHub.

חשוב לזכור את ההבדלים הבאים בין הפלטפורמות כשיוצרים אפליקציה מותאמת לפלטפורמות שונות:

  • שיטת קלט: עכבר, מסך מגע או בקר משחקים
  • גודל הגופן, כיוון המכשיר ומרחק הצפייה
  • גודל המסך וגורם צורה: טלפון, טאבלט, מתקפל, מחשב, אינטרנט

a3c16fc17be25f6c.png הקובץ lib/src/shared/views/adaptive_navigation.dart מכיל מחלקת ניווט שבה אפשר לספק רשימה של יעדים ותוכן להצגה בגוף ההודעה. בגלל שהפריסה הזו מבוססת על כמה מסכים, יש פריסה בסיסית משותפת שאפשר להעביר לכל אחד מהילדים. מסילות ניווט מתאימות למחשבים ולמסכים גדולים, אבל הפריסה שלהן ידידותית לנייד על ידי הצגת סרגל ניווט תחתון בנייד.

lib/src/shared/views/adaptive_navigation.dart

import 'package:flutter/material.dart';

class AdaptiveNavigation extends StatelessWidget {
  const AdaptiveNavigation({
    super.key,
    required this.destinations,
    required this.selectedIndex,
    required this.onDestinationSelected,
    required super.child,
  });

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

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

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

a8487a3c4d7890c9.png

לא כל המסכים הם בגודל זהה. אם ניסיתם להציג בטלפון את הגרסה למחשב של האפליקציה, הייתם צריכים לבצע שילוב של צמצום התצוגה ושינוי מרחק התצוגה כדי לראות הכול. רוצים שהאפליקציה תשנה את המראה שלה בהתאם למסך שבו היא מוצגת. עיצוב רספונסיבי מבטיח שהאפליקציה תיראה מעולה על מסכים בכל הגדלים.

כדי שהאפליקציה תהיה רספונסיבית, כדאי להוסיף כמה נקודות עצירה (breakpoint) מותאמות (אל תתבלבלו בין נקודות עצירה לניפוי באגים). נקודות העצירה (breakpoint) האלה מציינות את גודל המסכים שבהם האפליקציה צריכה לשנות את הפריסה.

במסכים קטנים יותר לא ניתן להציג מסכים גדולים בלי לכווץ את התוכן. כדי שהאפליקציה לא תיראה כמו אפליקציה שכווצה למחשב, צריך ליצור פריסה נפרדת לנייד עם כרטיסיות לפיצול התוכן. כך ניתן להעניק לאפליקציה תחושה טבעית יותר בנייד.

שיטות התוספים הבאות (מוגדרות בפרויקט MyArtist ב-lib/src/shared/extensions.dart) הן מקום טוב להתחיל בו כשמעצבים פריסות שעברו אופטימיזציה ליעדים שונים.

lib/src/shared/extensions.dart

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

מסך שגדול מ-730 פיקסלים (בכיוון הארוך ביותר), אך קטן מ-1,200 פיקסלים, נחשב לטאבלט. כל פריט שגדול מ-1,200 פיקסלים נחשב למחשב שולחני. אם מכשיר מסוים הוא לא טאבלט או מחשב, הוא נחשב לנייד. אפשר למצוא מידע נוסף על נקודות עצירה מותאמות ב-material.io. כדאי לשקול את האפשרות להשתמש בחבילה adaptive_breakpoints.

הפריסה הרספונסיבית של מסך הבית משתמשת ב-AdaptiveContainer וב-AdaptiveColumn על סמך הרשת של 12 עמודות, תוך שימוש בחבילות adaptive_components ו-adaptive_breakpoints כדי להטמיע פריסת רשת רספונסיבית ב-Material 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.pngפריסה מותאמת צריכות שתי פריסות: אחת לנייד ופריסה רספונסיבית למסכים גדולים יותר. בשלב הזה, LayoutBuilder מחזיר רק פריסה של שולחן עבודה. ב-lib/src/features/home/view/home_screen.dart יוצרים את הפריסה לנייד כ-TabBar ו-TabBarView עם 4 כרטיסיות.

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

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

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

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

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

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

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

377cfdda63a9de54.png

בעיות?

אם האפליקציה לא פועלת בצורה תקינה, אפשר להשתמש בקוד שבקישור הבא כדי לחזור לעניינים.

שימוש ברווח לבן

רווח לבן הוא כלי חזותי חשוב לאפליקציה, שיוצר הפסקה ארגונית בין קטעים.

עדיף שיהיה יותר מדי רווח לבן מאשר לא מספיק. עדיף להוסיף רווחים לבנים כדי להקטין את גודל הגופן או הרכיבים החזותיים כדי שיתאימו יותר לשטח.

היעדר רווחים לבנים יכול להקשות על אנשים עם בעיות ראייה. רווחים לבנים רבים מדי עלולים לא להיות אחידים ולגרום לממשק המשתמש להיראות לא מאורגן. לדוגמה, ראו את צילומי המסך הבאים:

7f5e3514a7ee1750.png

d5144a50f5b4142c.png

בשלב הבא מוסיפים רווחים לבנים למסך הבית כדי שיהיה לו יותר מקום. לאחר מכן תבצע כוונונים נוספים של הפריסה כדי לכוונן את הריווח.

a3c16fc17be25f6c.png צריך לגרור ווידג'ט עם אובייקט Padding כדי להוסיף רווחים מסביב לווידג'ט. הגדלת כל ערכי המרווח הפנימי כרגע ב-lib/src/features/home/view/home_screen.dart ל-35:

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

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

a3c16fc17be25f6c.png טעינה מחדש של האפליקציה. הוא אמור להיראות כמו קודם, אבל עם יותר רווחים בין הווידג'טים. המרווח הפנימי הנוסף נראה טוב יותר, אבל מודעת הבאנר של ההדגשה בחלק העליון עדיין קרובה מדי לקצוות.

a3c16fc17be25f6c.png ב-lib/src/features/home/view/home_highlight.dart, משנים את המרווח הפנימי בבאנר ל-35:

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

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

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

a3c16fc17be25f6c.png טעינה מחדש של האפליקציה. בשני הפלייליסטים שבתחתית המסך אין רווחים לבנים, ולכן הם נראים כאילו הם שייכים לאותה טבלה. זה לא המצב, ונתקן זאת בשלב הבא.

df1d9af97d039cc8.png

a3c16fc17be25f6c.png יש להוסיף רווח לבן בין הפלייליסטים על ידי הוספת ווידג'ט בגודל של Row שמכיל אותם. ב-lib/src/features/home/view/home_screen.dart, מוסיפים SizedBox ברוחב 35:

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

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

a3c16fc17be25f6c.png טעינה מחדש של האפליקציה. האפליקציה אמורה להיראות כך:

d8b2a3d47736dbab.png

עכשיו יש הרבה מקום לתוכן במסך הבית, אבל הכול נראה גם נפרד ואין לכידות בין הקטעים.

a3c16fc17be25f6c.png עד כה הגדרת את כל המרווח הפנימי (אופקי ואנכי) לווידג'טים במסך הבית ל-35 באמצעות EdgeInsets.all(35), אבל ניתן גם להגדיר את המרווח הפנימי של כל אחד מהקצוות בנפרד. כדאי להתאים אישית את המרווח הפנימי כך שיתאים טוב יותר לשטח.

  • EdgeInsets.LTRB() מגדיר בנפרד שמאלה, למעלה, ימינה ולמטה
  • EdgeInsets.symmetric() מגדיר את המרווח הפנימי בפורמט אנכי (למעלה ולמטה) להיות שווה ערך, והמרווח האופקי (שמאל וימין) שווה ערך.
  • EdgeInsets.only() מגדיר רק את הקצוות שצוינו.
Scaffold(
  body: SingleChildScrollView(
    child: AdaptiveColumn(
      children: [
        AdaptiveContainer(
           columnSpan: 12,
             child: Padding(
               padding: const EdgeInsets.fromLTRB(20, 25, 20, 10), // Modify this line
               child: Row(
                 mainAxisAlignment: MainAxisAlignment.spaceBetween,
                   children: [
                     Expanded(
                       child: Text(
                         'Good morning',
                          style: context.displaySmall,
                       ),
                     ),
                     const SizedBox(width: 20),
                     const BrightnessToggle(),
                   ],
                 ),
               ),
             ),
             AdaptiveContainer(
               columnSpan: 12,
               child: Column(
                 children: [
                   const HomeHighlight(),
                   LayoutBuilder(
                     builder: (context, constraints) => HomeArtists(
                       artists: artists,
                       constraints: constraints,
                     ),
                   ),
                 ],
               ),
             ),
             AdaptiveContainer(
               columnSpan: 12,
               child: Column(
                 crossAxisAlignment: CrossAxisAlignment.start,
                 children: [
                   Padding(
                     padding: const EdgeInsets.symmetric(
                       horizontal: 15,
                       vertical: 10,
                     ), // Modify this line
                     child: Text(
                       'Recently played',
                       style: context.headlineSmall,
                     ),
                   ),
                   HomeRecent(
                     playlists: playlists,
                   ),
                 ],
               ),
             ),
             AdaptiveContainer(
               columnSpan: 12,
               child: Padding(
                 padding: const EdgeInsets.all(15), // Modify this line
                 child: Row(
                   crossAxisAlignment: CrossAxisAlignment.start,
                   children: [
                     Flexible(
                       flex: 10,
                         child: Column(
                           mainAxisAlignment: MainAxisAlignment.start,
                           crossAxisAlignment: CrossAxisAlignment.start,
                           children: [
                             Padding(
                               padding: const EdgeInsets.only(left: 8, bottom: 8), // Modify this line
                               child: Text(
                                 'Top Songs Today',
                                 style: context.titleLarge,
                               ),
                             ),
                             LayoutBuilder(
                               builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: topSongs,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                    const SizedBox(width: 25),
                    Flexible(
                      flex: 10,
                      child: Column(
                        mainAxisAlignment: MainAxisAlignment.start,
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Padding(
                            padding: const EdgeInsets.only(left: 8, bottom: 8), // Modify this line
                            child: Text(
                              'New Releases',
                               style: context.titleLarge,
                            ),
                          ),
                          LayoutBuilder(
                            builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: newReleases,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),
        );

a3c16fc17be25f6c.png ב-lib/src/features/home/view/home_highlight.dart, מגדירים את המרווח הפנימי הימני והשמאלי בבאנר ל-35, ואת המרווח הפנימי העליון והתחתון ל-5:

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

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

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

a3c16fc17be25f6c.png טעינה מחדש של האפליקציה. הפריסה והריווח נראים הרבה יותר טובים! כדי לסיים, מוסיפים קצת תנועה ואנימציה.

7f5e3514a7ee1750.png

בעיות?

אם האפליקציה לא פועלת בצורה תקינה, אפשר להשתמש בקוד שבקישור הבא כדי לחזור לעניינים.

7. הוספת תנועה ואנימציה

תנועה ואנימציה הן דרכים מעולות להוסיף תנועה ואנרגיה ולספק משוב במהלך אינטראקציה של המשתמש עם האפליקציה.

אנימציה בין מסכים

בThemeProvider מוגדר PageTransitionsTheme עם אנימציות למעבר בין מסכים לפלטפורמות ניידות (iOS או Android). משתמשים במחשבים כבר מקבלים משוב מהקליקים על העכבר או על משטח המגע, כך שאין צורך באנימציה של מעבר דף.

Flutter מספקת את האנימציות למעבר בין המסך שאפשר להגדיר לאפליקציה בהתאם לפלטפורמת היעד, כפי שמוצג ב-lib/src/shared/providers/theme.dart:

lib/src/shared/providers/theme.dart

final pageTransitionsTheme = const PageTransitionsTheme(
  builders: <TargetPlatform, PageTransitionsBuilder>{
    TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
    TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
    TargetPlatform.linux: NoAnimationPageTransitionsBuilder(),
    TargetPlatform.macOS: NoAnimationPageTransitionsBuilder(),
    TargetPlatform.windows: NoAnimationPageTransitionsBuilder(),
  },
);

a3c16fc17be25f6c.png מעבירים את PageTransitionsTheme גם לעיצוב הבהיר וגם לעיצוב הכהה ב-lib/src/shared/providers/theme.dart

lib/src/shared/providers/theme.dart

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

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

ללא אנימציה ב-iOS

עם אנימציה ב-iOS

בעיות?

אם האפליקציה לא פועלת בצורה תקינה, אפשר להשתמש בקוד שבקישור הבא כדי לחזור לעניינים.

הוספת מצבים של העברת העכבר

אחת מהדרכים להוסיף תנועה לאפליקציה למחשב היא באמצעות מצבי העברת עכבר, שבהם ווידג'ט משנה את המצב שלו (למשל צבע, צורה או תוכן), כשהמשתמש מעביר את העכבר מעליו.

כברירת מחדל, הכיתה _OutlinedCardState (משמשת למשבצות של הפלייליסט 'הופעלו לאחרונה'), מחזירה MouseRegion – שהופכת את החץ של הסמן לסמן כשמעבירים את העכבר מעליו – אבל אפשר להוסיף עוד משוב חזותי.

a3c16fc17be25f6c.png פותחים את lib/src/shared/views/outlined_card.dart ומחליפים את התוכן שלו בהטמעה הבאה כדי ליצור מצב _hovered.

lib/src/shared/views/outlined_card.dart

import 'package:flutter/material.dart';

class OutlinedCard extends StatefulWidget {
  const OutlinedCard({
    super.key,
    required this.child,
    this.clickable = true,
  });
  final Widget child;
  final bool clickable;
  @override
  State<OutlinedCard> createState() => _OutlinedCardState();
}

class _OutlinedCardState extends State<OutlinedCard> {
  bool _hovered = false;

  @override
  Widget build(BuildContext context) {
    final borderRadius = BorderRadius.circular(_hovered ? 20 : 8);
    const animationCurve = Curves.easeInOut;
    return MouseRegion(
      onEnter: (_) {
        if (!widget.clickable) return;
        setState(() {
          _hovered = true;
        });
      },
      onExit: (_) {
        if (!widget.clickable) return;
        setState(() {
          _hovered = false;
        });
      },
      cursor: widget.clickable ? SystemMouseCursors.click : SystemMouseCursors.basic,
      child: AnimatedContainer(
        duration: kThemeAnimationDuration,
        curve: animationCurve,
        decoration: BoxDecoration(
          border: Border.all(
            color: Theme.of(context).colorScheme.outline,
            width: 1,
          ),
          borderRadius: borderRadius,
        ),
        foregroundDecoration: BoxDecoration(
          color: Theme.of(context).colorScheme.onSurface.withOpacity(
                _hovered ? 0.12 : 0,
              ),
          borderRadius: borderRadius,
        ),
        child: TweenAnimationBuilder<BorderRadius>(
          duration: kThemeAnimationDuration,
          curve: animationCurve,
          tween: Tween(begin: BorderRadius.zero, end: borderRadius),
          builder: (context, borderRadius, child) => ClipRRect(
            clipBehavior: Clip.antiAlias,
            borderRadius: borderRadius,
            child: child,
          ),
          child: widget.child,
        ),
      ),
    );
  }
}

a3c16fc17be25f6c.png לטעון מחדש את האפליקציה במהירות, ואז להעביר את העכבר מעל אחת ממשבצות הפלייליסט שהושמעו לאחרונה.

הסימון OutlinedCard משנה את האטימות ומעגל את הפינות.

a3c16fc17be25f6c.png לסיום, יוצרים אנימציה של מספר השיר בפלייליסט לתוך לחצן הפעלה באמצעות הווידג'ט HoverableSongPlayButton שמוגדר ב-lib/src/shared/views/hoverable_song_play_button.dart. בתוך lib/src/features/playlists/view/playlist_songs.dart, מסביב לווידג'ט Center (שמכיל את מספר השיר) בתוך HoverableSongPlayButton:

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

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

a3c16fc17be25f6c.pngטוענים מחדש את האפליקציה במהירות ואז מציבים את הסמן מעל מספר השיר בפלייליסט השירים המובילים היום או בפלייליסט שירים חדשים.

המספר כולל אנימציה של לחצן הפעלה שמשמיע את השיר כשלוחצים עליו.

קוד הפרויקט הסופי מוצג ב-GitHub.

8. מעולה!

סיימת את ה-Codelab הזה! גיליתם שיש הרבה שינויים קטנים שאפשר לשלב באפליקציה כדי לשפר את היופי שלה, שהיא גם נגישה יותר, מתאימה יותר לשוק המקומי והיא מתאימה יותר לכמה פלטפורמות. הטכניקות האלה כוללות, בין היתר:

  • טיפוגרפיה: טקסט הוא יותר מכלי תקשורת. להשתמש בדרך שבה הטקסט מוצג כדי להשפיע לטובה על המשתמשים ואת האופן שבו המשתמשים חושבים על האפליקציה שלכם.
  • שינוי עיצוב: הגדירו מערכת עיצוב שאפשר להשתמש בה בצורה אמינה, בלי שתצטרכו לקבל החלטות עיצוב לגבי כל ווידג'ט.
  • התאמה: כדאי להביא בחשבון את המכשיר והפלטפורמה שבהם המשתמש מפעיל את האפליקציה ואת היכולות שלה. כדאי להביא בחשבון את גודל המסך ואת האופן שבו האפליקציה מוצגת.
  • תנועה ואנימציה: הוספת תנועה לאפליקציה מוסיפה אנרגיה לחוויית המשתמש, ובאופן מעשי, מספקת משוב למשתמשים.

כמה כוונונים קלים שהאפליקציה יכולה להפוך משעממת ליפה:

לפני

1e67c60667821082.png

אחרי

השלבים הבאים

אנחנו מקווים שלמדת עוד יותר על פיתוח אפליקציות יפהפיות ב-Flutter!

אם יישמת אחד מהטיפים או הטריקים שמופיעים כאן (או שיש לך טיפ משלך), נשמח לשמוע ממך! אפשר ליצור איתנו קשר ב-Twitter בכתובות @rodydavis וב-@khanhnwin!

בנוסף, אפשר להיעזר במקורות המידע הבאים.

בחירת עיצוב

משאבים רספונסיביים ורספונסיביים:

משאבי עיצוב כלליים:

בנוסף, תוכלו ליצור קשר עם קהילת Flutter!

הגיע הזמן ליצור את עולם האפליקציות יפהפה!