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

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

О практической работе

subjectПоследнее обновление: июн. 24, 2025
account_circleАвторы: The Flutter Team

1. Введение

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

В этой кодовой лаборатории вы улучшите музыкальное приложение Flutter, превратив его из скучного в прекрасное. Для этого в этой кодовой лаборатории используются инструменты и API, представленные в Material 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 и используйте его инструменты для запуска приложения.

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 делает компоненты материалов доступными.
  • Класс 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

Проблемы?

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

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

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

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

Имея это в виду, перейдите в Google Fonts и выберите шрифт без засечек, например 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:

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

Горячая перезагрузка 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.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 Theme Builder — это веб-инструмент (представленный в 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 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

Эти цветовые роли можно использовать для назначения значения и акцента на различных частях пользовательского интерфейса. Даже если компонент не выделяется, он все равно может использовать преимущества динамического цвета.

Пользователь может установить яркость приложения в системных настройках устройства. В 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 пикселей (в самом длинном направлении), но меньше 1200 пикселей, считается планшетом. Все, что больше 1200 пикселей, считается настольным компьютером. Если устройство не является ни планшетом, ни настольным компьютером, то оно считается мобильным. Вы можете узнать больше об адаптивных контрольных точках на 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 , чтобы добавить пустое пространство вокруг этого виджета. Увеличьте все значения 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,
                            ),
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ),
      ],
    ),
  ),
);

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

В 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. Поздравляю!

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

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

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

До

1e67c60667821082.png

После

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

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

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

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

Тематика

Адаптивные и реагирующие ресурсы:

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

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

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