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

איך הופכים אפליקציה ב-Flutter משעממת ליפיפית

מידע על Codelab זה

subjectהעדכון האחרון: יוני 24, 2025
account_circleנכתב על ידי The Flutter Team

1.‏ מבוא

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

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

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

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

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

מה תפַתחו

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

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

מה היית רוצה ללמוד מהקודלאב הזה?

2.‏ הגדרת סביבת הפיתוח ב-Flutter

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

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

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

3.‏ הורדת האפליקציה למתחילים ב-Codelab

יצירת עותקים (cloning) מ-GitHub

כדי להעתיק (clone) את סדנת הקוד הזו מ-GitHub, מריצים את הפקודות הבאות:

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

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

flutter run

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

1e67c60667821082.pngd1139cde225de452.png

סיור בקוד

בשלב הבא נבצע סיור בקוד.

פותחים את הקובץ lib/src/features/home/view/home_screen.dart, שמכיל את הפרטים הבאים:

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

import 'package:flutter/material.dart';

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

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

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

class _HomeScreenState extends State<HomeScreen> {
 
@override
 
Widget build(BuildContext context) {
   
final PlaylistsProvider playlistProvider = PlaylistsProvider();
   
final List<Playlist> playlists = playlistProvider.playlists;
   
final Playlist topSongs = playlistProvider.topSongs;
   
final Playlist newReleases = playlistProvider.newReleases;
   
final ArtistsProvider artistsProvider = ArtistsProvider();
   
final List<Artist> artists = artistsProvider.artists;
   
return LayoutBuilder(
     
builder: (context, constraints) {
       
return Scaffold(
         
body: SingleChildScrollView(
           
child: AdaptiveColumn(
             
children: [
               
AdaptiveContainer(
                 
columnSpan: 12,
                 
child: Padding(
                   
padding: const EdgeInsets.all(2),
                   
child: Row(
                     
mainAxisAlignment: MainAxisAlignment.spaceBetween,
                     
children: [
                       
Expanded(
                         
child: Text(
                           
'Good morning',
                           
style: context.displaySmall,
                         
),
                       
),
                       
const SizedBox(width: 20),
                       
const BrightnessToggle(),
                     
],
                   
),
                 
),
               
),
               
AdaptiveContainer(
                 
columnSpan: 12,
                 
child: Column(
                   
children: [
                     
const HomeHighlight(),
                     
LayoutBuilder(
                       
builder: (context, constraints) => HomeArtists(
                         
artists: artists,
                         
constraints: constraints,
                       
),
                     
),
                   
],
                 
),
               
),
               
AdaptiveContainer(
                 
columnSpan: 12,
                 
child: Column(
                   
crossAxisAlignment: CrossAxisAlignment.start,
                   
children: [
                     
Padding(
                       
padding: const EdgeInsets.all(2),
                       
child: Text(
                         
'Recently played',
                         
style: context.headlineSmall,
                       
),
                     
),
                     
HomeRecent(playlists: playlists),
                   
],
                 
),
               
),
               
AdaptiveContainer(
                 
columnSpan: 12,
                 
child: Padding(
                   
padding: const EdgeInsets.all(2),
                   
child: Row(
                     
crossAxisAlignment: CrossAxisAlignment.start,
                     
children: [
                       
Flexible(
                         
flex: 10,
                         
child: Column(
                           
mainAxisAlignment: MainAxisAlignment.start,
                           
crossAxisAlignment: CrossAxisAlignment.start,
                           
children: [
                             
Padding(
                               
padding: const EdgeInsets.all(2),
                               
child: Text(
                                 
'Top Songs Today',
                                 
style: context.titleLarge,
                               
),
                             
),
                             
LayoutBuilder(
                               
builder: (context, constraints) =>
                                   
PlaylistSongs(
                                     
playlist: topSongs,
                                     
constraints: constraints,
                                   
),
                             
),
                           
],
                         
),
                       
),
                       
Flexible(
                         
flex: 10,
                         
child: Column(
                           
mainAxisAlignment: MainAxisAlignment.start,
                           
crossAxisAlignment: CrossAxisAlignment.start,
                           
children: [
                             
Padding(
                               
padding: const EdgeInsets.all(2),
                               
child: Text(
                                 
'New Releases',
                                 
style: context.titleLarge,
                               
),
                             
),
                             
LayoutBuilder(
                               
builder: (context, constraints) =>
                                   
PlaylistSongs(
                                     
playlist: newReleases,
                                     
constraints: constraints,
                                   
),
                             
),
                           
],
                         
),
                       
),
                     
],
                   
),
                 
),
               
),
             
],
           
),
         
),
       
);
     
},
   
);
 
}
}

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

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

4.‏ שימוש בטיפוגרפיה

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

הסברים ויזואליים יעילים יותר מהסברים מילוליים

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

86c5f73b3aa5fd35.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 יש קווים דקורטיביים או "זנבות" בסוף האותיות, והם נחשבים לפורמליים יותר. בגופנים ללא serif אין קווים דקורטיביים, והם נתפסים בדרך כלל כפחות רשמיים. האות T רישית בסגנון סנס סריפ והאות T רישית בסגנון סריפ
  • גופנים באותיות גדולות בלבד: שימוש באותיות גדולות בלבד מתאים כדי למשוך תשומת לב לכמויות קטנות של טקסט (למשל כותרות), אבל אם משתמשים בהן יותר מדי, הן עלולות להיחשב כצעקה שגורמת למשתמש להתעלם מהן לגמרי.
  • אותיות רישיות בכותרות או אותיות רישיות בתחילת משפט: כשאתם מוסיפים כותרות או תוויות, כדאי להחליט איך להשתמש באותיות רישיות: אותיות רישיות בכותרות, שבהן האות הראשונה בכל מילה היא אות רישית ("This Is a Title Case Title"), הן רשמיות יותר. אותיות רישיות בתחילת משפט, שבהן רק שמות עצם ומילה ראשונה בטקסט מודגשים באותיות רישיות ('This is a sentence case title'), הן פחות רשמיות ויותר מדוברות.
  • מרווחי גופן (מרווח בין כל אות), אורך שורה (רוחב הטקסט המלא במסך) וגובה שורה (גובה כל שורה של טקסט): אם מרווחי הגופן גדולים מדי או קטנים מדי, או אם אורך השורה או גובה השורה גדולים מדי או קטנים מדי, קשה יותר לקרוא את האפליקציה. לדוגמה, יכול להיות שיהיה קשה לשמור על המיקום בקריאה של קטע טקסט גדול בלי הפסקות.

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

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

flutter pub add google_fonts

macos/Runner/DebugProfile.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <key>com.apple.security.cs.allow-jit</key>
        <true/>
        <key>com.apple.security.network.server</key>
        <true/>
        <!-- Make sure the following two lines are present -->
        <key>com.apple.security.network.client</key>
        <true/>
</dict>
</plist>

ב-lib/src/shared/extensions.dart, מייבאים את החבילה החדשה:

lib/src/shared/extensions.dart

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

הגדרת TextTheme: של Montserrat

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

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

1e67c60667821082.png

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

בעיות?

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

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.toARGB32(),
       
settings.value.sourceColor.toARGB32(),
     
),
   
);
 
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

 
final Color sourceColor;
 
final ThemeMode themeMode;
}

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

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

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

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

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

כדי להשתמש בספק, יוצרים מכונה ומעבירים אותה לאובייקט העיצוב ברמת ההיקף ב-MaterialApp, שנמצא ב-lib/src/shared/app.dart. הוא יועבר בירושה לכל אובייקטי Theme בתצוגת עץ:

lib/src/shared/app.dart

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

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

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

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

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

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

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

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

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

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

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

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

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

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

lib/src/shared/views/outlined_card.dart

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

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

  • 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

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

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

lib/src/shared/app.dart

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

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

בעיות?

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

6.‏ הוספת עיצוב רספונסיבי

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

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

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

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

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

lib/src/shared/views/adaptive_navigation.dart

import 'package:flutter/material.dart';

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

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

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

a8487a3c4d7890c9.png

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

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

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

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

lib/src/shared/extensions.dart

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

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

בפריסה הרספונסיבית של מסך הבית נעשה שימוש ב-AdaptiveContainer וב-AdaptiveColumn על סמך רשת של 12 עמודות.

כדי ליצור פריסה דינמית, צריך ליצור שתי פריסות: אחת לנייד ופרסה רספונסיבית למסכים גדולים יותר. בשלב הזה, LayoutBuilder מחזירה פריסת מחשב. ב-lib/src/features/home/view/home_screen.dart, יוצרים את הפריסה לנייד כ-TabBar ו-TabBarView עם 4 כרטיסיות.

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

import 'package:flutter/material.dart';

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

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

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

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

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

377cfdda63a9de54.png

בעיות?

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

7.‏ שימוש ברווחים לבנים

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

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

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

7f5e3514a7ee1750.png

d5144a50f5b4142c.png

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

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

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

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

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

ב-lib/src/features/home/view/home_highlight.dart, משנים את הערך של הרווח בבאנר ל-15:

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

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

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

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

df1d9af97d039cc8.png

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

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

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

מעלים מחדש את האפליקציה. האפליקציה אמורה להיראות כך:

d8b2a3d47736dbab.png

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

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

  • EdgeInsets.LTRB() מגדיר את הצדדים השמאלי, העליון, הימני והתחתון בנפרד
  • EdgeInsets.symmetric() מגדיר את המרווח הפנימי האנכי (למעלה ולמטה) כזהה, ואת המרווח הפנימי האופקי (ימינה ושמאלה) כזהה
  • EdgeInsets.only() מגדיר רק את הקצוות שצוינו.

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

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

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

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

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

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

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

7f5e3514a7ee1750.png

בעיות?

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

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

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

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

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

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

lib/src/shared/providers/theme.dart

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

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

lib/src/shared/providers/theme.dart

ThemeData light([Color? targetColor]) {
  final colorScheme = colors(Brightness.light, targetColor);
  return ThemeData.light().copyWith(
    pageTransitionsTheme: pageTransitionsTheme,                     // Add this line
    colorScheme: colorScheme,
    appBarTheme: appBarTheme(colorScheme),
    cardTheme: cardTheme(),
    listTileTheme: listTileTheme(colorScheme),
    bottomAppBarTheme: bottomAppBarTheme(colorScheme),
    bottomNavigationBarTheme: bottomNavigationBarTheme(colorScheme),
    navigationRailTheme: navigationRailTheme(colorScheme),
    tabBarTheme: tabBarTheme(colorScheme),
    drawerTheme: drawerTheme(colorScheme),
    scaffoldBackgroundColor: colorScheme.surface,
  );
}

ThemeData dark([Color? targetColor]) {
  final colorScheme = colors(Brightness.dark, targetColor);
  return ThemeData.dark().copyWith(
    pageTransitionsTheme: pageTransitionsTheme,                     // Add this line
    colorScheme: colorScheme,
    appBarTheme: appBarTheme(colorScheme),
    cardTheme: cardTheme(),
    listTileTheme: listTileTheme(colorScheme),
    bottomAppBarTheme: bottomAppBarTheme(colorScheme),
    bottomNavigationBarTheme: bottomNavigationBarTheme(colorScheme),
    navigationRailTheme: navigationRailTheme(colorScheme),
    tabBarTheme: tabBarTheme(colorScheme),
    drawerTheme: drawerTheme(colorScheme),
    scaffoldBackgroundColor: colorScheme.surface,
  );
}

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

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

בעיות?

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

9.‏ הוספת מצבי מעבר

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

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

פותחים את הקובץ lib/src/shared/views/outlined_card.dart ומחליפים את התוכן שלו בהטמעה הבאה כדי להוסיף את המצב _hovered.

lib/src/shared/views/outlined_card.dart

import 'package:flutter/material.dart';

class OutlinedCard extends StatefulWidget {
 
const OutlinedCard({super.key, required this.child, this.clickable = true});

 
final Widget child;
 
final bool clickable;

 
@override
 
State<OutlinedCard> createState() => _OutlinedCardState();
}

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

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

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

ה-OutlinedCard משנה את רמת השקיפות ומעגל את הפינות.

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

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

rowBuilder: (context, index) => DataRow.byIndex(
  index: index,
  cells: [
    DataCell(
      HoverableSongPlayButton(                                      // Modify from here...
        hoverMode: HoverMode.overlay,
        song: playlist.songs[index],
        child: Center(
          child: Text(
            (index + 1).toString(),
            textAlign: TextAlign.center,
          ),
        ),
      ),                                                            // To here.
    ),
    DataCell(
      Row(
        children: [
          Padding(
            padding: const EdgeInsets.all(2),
            child: ClippedImage(playlist.songs[index].image.image),
          ),
          const SizedBox(width: 10),
          Expanded(child: Text(playlist.songs[index].title)),
        ],
      ),
    ),
    DataCell(Text(playlist.songs[index].length.toHumanizedString())),
  ],
),

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

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

קוד הפרויקט הסופי ב-GitHub

10.‏ מעולה!

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

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

בעזרת כמה שינויים קטנים, האפליקציה שלכם יכולה להפוך ממשעממת ליפה:

לפני

1e67c60667821082.png

אחרי

השלבים הבאים

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

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

מקורות המידע הבאים עשויים לעזור לך.

בחירת עיצוב

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

מקורות מידע כלליים בנושא עיצוב:

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

קדימה, תהיו יפים!