Превратите свое приложение Flutter из скучного в красивое

1. Введение

Flutter — это набор инструментов пользовательского интерфейса Google для создания красивых, скомпилированных в собственном коде приложений для мобильных устройств, Интернета и настольных компьютеров из единой базы кода. Flutter работает с существующим кодом, используется разработчиками и организациями по всему миру, он бесплатен и имеет открытый исходный код.

В этой кодовой лаборатории вы улучшите музыкальное приложение Flutter, превратив его из скучного в красивое. Для этого в этой лаборатории кода используются инструменты и API, представленные в Материале 3 .

Что вы узнаете

  • Как написать приложение Flutter, которое будет удобным и красивым на разных платформах.
  • Как разработать текст в своем приложении, чтобы он повышал удобство использования.
  • Как выбрать правильные цвета, настроить виджеты, создать собственную тему и быстро и легко реализовать темный режим.
  • Как создавать кроссплатформенные адаптивные приложения.
  • Как создавать приложения, которые хорошо выглядят на любом экране.
  • Как добавить движение в ваше приложение Flutter, чтобы оно стало по-настоящему популярным.

Предпосылки:

В этой кодовой лаборатории предполагается, что у вас есть некоторый опыт работы с Flutter. Если нет, возможно, вы захотите сначала изучить основы. Следующие ссылки полезны:

Что ты построишь

Эта лаборатория кода поможет вам создать главный экран для приложения MyArtist — музыкального проигрывателя, с помощью которого фанаты могут быть в курсе событий своих любимых исполнителей. В нем обсуждается, как можно изменить дизайн своего приложения, чтобы оно выглядело красиво на разных платформах.

В следующих видеороликах показано, как приложение работает после завершения этой лаборатории кода:

Что бы вы хотели узнать из этой кодовой лаборатории?

Я новичок в этой теме, и мне нужен хороший обзор. Я кое-что знаю по этой теме, но хочу освежить знания. Я ищу пример кода для использования в моем проекте. Я ищу объяснение чего-то конкретного.

2. Настройте среду разработки Flutter.

Для выполнения этой лабораторной работы вам понадобятся два программного обеспечения — Flutter SDK и редактор .

Вы можете запустить кодовую лабораторию, используя любое из этих устройств:

  • Физическое устройство Android или iOS , подключенное к вашему компьютеру и переведенное в режим разработчика.
  • Симулятор iOS (требуется установка инструментов Xcode).
  • Эмулятор Android (требуется установка в Android Studio).
  • Браузер (для отладки необходим Chrome).
  • В качестве настольного приложения для Windows , Linux или macOS . Вы должны разрабатывать на платформе, на которой планируете развернуть. Итак, если вы хотите разработать классическое приложение для Windows, вам необходимо разработать его в Windows, чтобы получить доступ к соответствующей цепочке сборки. Существуют требования, специфичные для операционной системы, которые подробно описаны на docs.flutter.dev/desktop .

3. Загрузите стартовое приложение Codelab.

Клонируйте его с GitHub

Чтобы клонировать эту кодовую лабораторию из GitHub, выполните следующие команды:

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

Чтобы убедиться, что все работает, запустите приложение Flutter как настольное приложение, как показано ниже. Альтернативно откройте этот проект в своей IDE и используйте его инструменты для запуска приложения.

a3c16fc17be25f6c.png Запустите приложение.

Успех! Стартовый код для главного экрана MyArtist должен быть запущен. Вы должны увидеть главный экран MyArtist. На настольном компьютере он выглядит нормально, но на мобильных устройствах... Не очень хорошо. Во-первых, это не соответствует отметке. Не волнуйся, ты это исправишь!

1e67c60667821082.pngd1139cde225de452.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 создает корень дерева виджетов, который влияет на то, как создаются все виджеты в пользовательском интерфейсе.

4. Воспользуйтесь преимуществами типографики

Текст повсюду. Текст — полезный способ общения с пользователем. Ваше приложение должно быть дружелюбным и веселым или, возможно, заслуживающим доверия и профессиональным? Есть причина, по которой ваше любимое банковское приложение не использует Comic Sans. То, как представлен текст, формирует первое впечатление пользователя о вашем приложении. Вот несколько способов более продуманного использования текста.

Покажи, а не рассказывай

Везде, где это возможно, «покажите», а не «расскажите». Например, 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

Проблемы?

Если ваше приложение работает неправильно, поищите опечатки. При необходимости используйте код по следующим ссылкам, чтобы вернуться в нужное русло.

Выбирайте шрифты вдумчиво

Шрифты определяют индивидуальность вашего приложения, поэтому выбор правильного шрифта имеет решающее значение. При выборе шрифта следует учитывать несколько факторов:

  • Без засечек или с засечками : шрифты с засечками имеют декоративные штрихи или «хвостики» на концах букв и воспринимаются как более формальные. Шрифты без засечек не имеют декоративных штрихов и обычно воспринимаются как более неформальные. 34bf54e4cad90101.png Заглавная буква Т без засечек и заглавная буква Т.
  • Шрифты с заглавными буквами . Использование заглавных букв подходит для привлечения внимания к небольшим объемам текста (например, к заголовкам), но при чрезмерном использовании это может быть воспринято как крик, заставляющий пользователя полностью его игнорировать.
  • Регистр названия или регистр предложения . При добавлении заголовков или меток учитывайте, как вы используете заглавные буквы: регистр заголовков , где первая буква каждого слова пишется с заглавной буквы («Это название регистра названия»), является более формальным. Падеж предложения , в котором с заглавной буквы пишутся только имена собственные и первое слово в тексте («Это заголовок падежа предложения»), является более разговорным и неформальным.
  • Кернинг (интервал между буквами), длина строки (ширина всего текста на экране) и высота строки (высота каждой строки текста) : слишком много или слишком мало любого из этих элементов делает ваше приложение менее читабельным. Например, легко потерять место, читая большой непрерывный блок текста.

Имея это в виду, зайдите в Google Fonts и выберите шрифт без засечек, например 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 вместе с текстом, отображаемым шрифтом 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.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 Theme Builder — это веб-инструмент (представленный в Материале 3), который помогает вам выбрать набор дополнительных цветов для вашего приложения.

a3c16fc17be25f6c.png Чтобы выбрать исходный цвет для приложения, откройте конструктор тем материалов и изучите различные цвета для пользовательского интерфейса. Важно выбрать цвет, который соответствует эстетике бренда и/или вашим личным предпочтениям.

После создания темы щелкните правой кнопкой мыши пузырь основного цвета — откроется диалоговое окно, содержащее шестнадцатеричное значение основного цвета. Скопируйте это значение. (Вы также можете установить цвет с помощью этого диалогового окна.)

a3c16fc17be25f6c.png Передайте шестнадцатеричное значение основного цвета поставщику темы. Например, шестнадцатеричный цвет #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;

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

В Material 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

Не все экраны одинакового размера. Если бы вы попытались отобразить настольную версию своего приложения на своем телефоне, вам пришлось бы щуриться и масштабировать изображение, чтобы увидеть все. Вы хотите, чтобы ваше приложение меняло внешний вид в зависимости от экрана, на котором оно отображается. Благодаря адаптивному дизайну ваше приложение будет отлично выглядеть на экранах любого размера.

Чтобы сделать ваше приложение отзывчивым, добавьте несколько адаптивных точек останова (не путать с точками останова отладки). Эти точки останова определяют размеры экрана, при которых ваше приложение должно изменить свой макет.

Меньшие экраны не могут отображать столько же, сколько большие экраны, без уменьшения содержимого. Чтобы приложение не выглядело как уменьшенное настольное приложение, создайте отдельный макет для мобильных устройств, в котором для разделения содержимого используются вкладки. Это придает приложению более приятный вид на мобильных устройствах.

Следующие методы расширения (определенные в проекте 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 пикселей (в самом длинном направлении), но менее 1200 пикселей. Все, что больше 1200 пикселей, считается рабочим столом. Если устройство не является ни планшетом, ни настольным компьютером, то оно считается мобильным. Вы можете узнать больше об адаптивных точках останова на сайте Material.io . Вы можете рассмотреть возможность использования пакета Adaptive_breakpoints .

Адаптивный макет главного экрана использует AdaptiveContainer и AdaptiveColumn на основе сетки из 12 столбцов с использованием пакетов Adaptive_comComponents и 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. Поздравляем!

Вы завершили эту кодовую работу! Вы узнали, что существует множество небольших изменений, которые вы можете интегрировать в приложение, чтобы сделать его более красивым, а также более доступным, более локализуемым и более подходящим для нескольких платформ. Эти методы включают, помимо прочего:

  • Типографика: Текст — это больше, чем просто инструмент коммуникации. Используйте способ отображения текста, чтобы оказать положительное влияние на восприятие пользователями вашего приложения.
  • Тематика: создайте систему дизайна, которую вы сможете надежно использовать без необходимости принимать дизайнерские решения для каждого виджета.
  • Адаптивность. Учитывайте устройство и платформу, на которой пользователь запускает ваше приложение, и его возможности. Учитывайте размер экрана и способ отображения вашего приложения.
  • Движение и анимация. Добавление движения в ваше приложение добавляет энергии пользовательскому опыту и, что более практично, обеспечивает обратную связь для пользователей.

С помощью нескольких небольших настроек ваше приложение может превратиться из скучного в красивое:

До

1e67c60667821082.png

После

Следующие шаги

Мы надеемся, что вы узнали больше о создании красивых приложений во Flutter!

Если вы примените какой-либо из советов или приемов, упомянутых здесь (или у вас есть собственный совет), мы будем рады услышать ваше мнение! Свяжитесь с нами в Твиттере по адресу @rodydavis и @kanhnwin !

Вам также могут оказаться полезными следующие ресурсы.

Тематика

Адаптивные и отзывчивые ресурсы:

Общие ресурсы дизайна:

Также присоединяйтесь к сообществу Flutter !

Идите вперед и сделайте мир приложений красивым!