1. Введение
Flutter — это набор инструментов Google для разработки UI-приложений для мобильных устройств, веб-приложений и настольных компьютеров на основе единой кодовой базы. В этой лабораторной работе вам предстоит создать следующее приложение Flutter:
Приложение генерирует интересные названия, например, «newstay», «lightstream», «mainbrake» или «graypine». Пользователь может запросить следующее название, добавить текущее в избранное и просмотреть список избранных названий на отдельной странице. Приложение адаптировано для экранов разных размеров.
Чему вы научитесь
- Основы работы Flutter
- Создание макетов во Flutter
- Связывание взаимодействий пользователя (например, нажатия кнопок) с поведением приложения
- Поддержание порядка в коде Flutter
- Сделайте ваше приложение адаптивным (для разных экранов)
- Достижение единого внешнего вида и поведения вашего приложения
Вы начнете с базового каркаса, чтобы иметь возможность сразу перейти к интересным частям.
А вот Филипп проведет вас через всю лабораторную работу!
Нажмите «Далее», чтобы начать лабораторную работу.
2. Настройте среду Flutter
Редактор
Чтобы сделать эту практикум максимально понятной, мы предполагаем, что вы будете использовать среду разработки Visual Studio Code (VS Code). Она бесплатна и работает на всех основных платформах.
Конечно, вы можете использовать любой редактор, который вам нравится: Android Studio, другие IDE IntelliJ, Emacs, Vim или Notepad++. Все они работают с Flutter.
Мы рекомендуем использовать VS Code для этой практической работы, поскольку в инструкциях по умолчанию используются сочетания клавиш, характерные для VS Code. Проще сказать что-то вроде «щёлкните здесь» или «нажмите эту клавишу», чем «выполните соответствующее действие в редакторе, чтобы сделать X».
Выберите цель развития
Flutter — это кроссплатформенный инструментарий. Ваше приложение может работать на любой из следующих операционных систем:
- iOS
- Андроид
- Окна
- macOS
- Линукс
- веб
Однако обычно выбирают одну операционную систему, для которой вы в первую очередь будете разрабатывать. Это ваша «целевая платформа разработки» — операционная система, в которой будет работать ваше приложение во время разработки.
Например, вы используете ноутбук с Windows для разработки приложения Flutter. Если вы выбираете Android в качестве целевой платформы для разработки, обычно вы подключаете устройство Android к ноутбуку с Windows с помощью USB-кабеля, и ваше разрабатываемое приложение запускается на этом подключенном устройстве Android. Но вы также можете выбрать Windows в качестве целевой платформы для разработки, что означает, что ваше разрабатываемое приложение будет работать как приложение Windows вместе с редактором.
Может возникнуть соблазн выбрать веб в качестве целевой платформы для разработки. Недостаток этого выбора заключается в потере одной из самых полезных функций Flutter: горячей перезагрузки с сохранением состояния. Flutter не поддерживает горячую перезагрузку веб-приложений.
Сделайте свой выбор сейчас. Помните: вы всегда сможете запустить своё приложение на других операционных системах позже. Просто чёткое понимание цели разработки делает следующий шаг более плавным.
Установить Флаттер
Самые актуальные инструкции по установке Flutter SDK всегда можно найти на docs.flutter.dev .
Инструкции на сайте Flutter охватывают не только установку самого SDK, но и инструментов для разработки, а также плагинов для редактора. Помните, что для этой практической работы вам потребуется установить только следующее:
- Flutter SDK
- Visual Studio Code с плагином Flutter
- Программное обеспечение, необходимое для выбранной вами целевой платформы разработки (например: Visual Studio для Windows или Xcode для macOS)
В следующем разделе вы создадите свой первый проект Flutter.
Если у вас уже возникли проблемы, возможно, некоторые из этих вопросов и ответов (со StackOverflow) будут вам полезны для устранения неполадок.
Часто задаваемые вопросы
- Как найти путь к Flutter SDK?
- Что делать, если команда Flutter не найдена?
- Как исправить ошибку «Ожидание другой команды flutter для снятия блокировки запуска»?
- Как сообщить Flutter, где находится моя установка Android SDK?
- Как бороться с ошибкой Java при запуске
flutter doctor --android-licenses
? - Что делать, если инструмент Android
sdkmanager
не найден? - Как бороться с ошибкой «Компонент
cmdline-tools
отсутствует»? - Как запустить CocoaPods на Apple Silicon (M1)?
- Как отключить автоформатирование при сохранении в VS Code?
3. Создать проект
Создайте свой первый проект Flutter
Запустите Visual Studio Code и откройте палитру команд (сочетанием клавиш F1
, Ctrl+Shift+P
или Shift+Cmd+P
). Начните вводить «flutter new». Выберите команду «Flutter: New Project» .
Затем выберите «Приложение» и укажите папку, в которой будет создан проект. Это может быть ваш домашний каталог или что-то вроде C:\src\
.
Наконец, дайте название своему проекту. Например, namer_app
или my_awesome_namer
.
Теперь Flutter создаст папку вашего проекта, а VS Code откроет ее.
Теперь вам нужно перезаписать содержимое 3 файлов базовой структурой приложения.
Скопируйте и вставьте исходное приложение
На левой панели VS Code убедитесь, что выбран Проводник , и откройте файл pubspec.yaml
.
Замените содержимое этого файла следующим:
pubspec.yaml
name: namer_app
description: "A new Flutter project."
publish_to: "none"
version: 0.1.0
environment:
sdk: ^3.9.0
dependencies:
flutter:
sdk: flutter
english_words: ^4.0.0
provider: ^6.1.5
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
uses-material-design: true
Файл pubspec.yaml
содержит основную информацию о вашем приложении, такую как его текущая версия, его зависимости и ресурсы, с которыми оно будет поставляться.
Затем откройте в проекте еще один файл конфигурации — analysis_options.yaml
.
Замените его содержимое следующим:
analysis_options.yaml
include: package:flutter_lints/flutter.yaml
linter:
rules:
avoid_print: false
prefer_const_constructors_in_immutables: false
prefer_const_constructors: false
prefer_const_literals_to_create_immutables: false
prefer_final_fields: false
unnecessary_breaks: true
use_key_in_widget_constructors: false
Этот файл определяет, насколько строгим должен быть Flutter при анализе вашего кода. Поскольку это ваше первое знакомство с Flutter, вы указываете анализатору, что нужно быть осторожнее. Вы всегда сможете настроить это позже. Более того, по мере приближения к публикации реального приложения в продакшене вы почти наверняка захотите сделать анализаторы более строгими.
Наконец, откройте файл main.dart
в каталоге lib/
.
Замените содержимое этого файла следующим:
lib/main.dart
import 'package:english_words/english_words.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => MyAppState(),
child: MaterialApp(
title: 'Namer App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
),
home: MyHomePage(),
),
);
}
}
class MyAppState extends ChangeNotifier {
var current = WordPair.random();
}
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
return Scaffold(
body: Column(
children: [Text('A random idea:'), Text(appState.current.asLowerCase)],
),
);
}
}
Эти 50 строк кода на данный момент составляют всю работу приложения.
В следующем разделе запустите приложение в режиме отладки и приступайте к разработке.
4. Добавить кнопку
На этом шаге добавляется кнопка «Далее» для создания новой пары слов.
Запустить приложение
Сначала откройте lib/main.dart
и убедитесь, что выбрано целевое устройство. В правом нижнем углу VS Code вы найдёте кнопку, которая показывает текущее целевое устройство. Нажмите, чтобы изменить его.
Пока открыт lib/main.dart
, найдите «play» кнопку в правом верхнем углу окна VS Code и щелкните ее.
Примерно через минуту ваше приложение запустится в режиме отладки. Пока что это выглядит не очень впечатляюще:
Первая горячая перезагрузка
В конце lib/main.dart
добавьте что-нибудь к строке в первом объекте Text
и сохраните файл (нажатием Ctrl+S
или Cmd+S
). Например:
lib/main.dart
// ...
return Scaffold(
body: Column(
children: [
Text('A random AWESOME idea:'), // ← Example change.
Text(appState.current.asLowerCase),
],
),
);
// ...
Обратите внимание, как приложение мгновенно меняется, но случайное слово остаётся прежним. Это знаменитая функция Flutter Hot Reload с сохранением состояния. Горячая перезагрузка запускается при сохранении изменений в исходном файле.
Часто задаваемые вопросы
- Что делать, если горячая перезагрузка не работает в VSCode?
- Нужно ли нажимать «r» для горячей перезагрузки в VSCode?
- Работает ли функция Hot Reload в Интернете?
- Как убрать баннер «Отладка»?
Добавление кнопки
Затем добавьте кнопку внизу Column
, прямо под вторым экземпляром Text
.
lib/main.dart
// ...
return Scaffold(
body: Column(
children: [
Text('A random AWESOME idea:'),
Text(appState.current.asLowerCase),
// ↓ Add this.
ElevatedButton(
onPressed: () {
print('button pressed!');
},
child: Text('Next'),
),
],
),
);
// ...
После сохранения изменений приложение снова обновится: появится кнопка, и при нажатии на нее в консоли отладки в VS Code отобразится сообщение « Кнопка нажата!» .
Экспресс-курс Flutter за 5 минут
Как бы ни было интересно наблюдать за консолью отладки , хотелось бы, чтобы кнопка выполняла что-то более значимое. Но прежде чем перейти к этому, внимательно изучите код в lib/main.dart
, чтобы понять, как он работает.
lib/main.dart
// ...
void main() {
runApp(MyApp());
}
// ...
В самом верху файла вы найдёте функцию main()
. В её текущем виде она лишь указывает Flutter запустить приложение, определённое в MyApp
.
lib/main.dart
// ...
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => MyAppState(),
child: MaterialApp(
title: 'Namer App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
),
home: MyHomePage(),
),
);
}
}
// ...
Класс MyApp
расширяет StatelessWidget
. Виджеты — это элементы, из которых строится каждое приложение Flutter. Как видите, даже само приложение является виджетом.
Код в MyApp
настраивает всё приложение. Он создаёт состояние для всего приложения (подробнее об этом позже), присваивает ему имя, определяет визуальную тему и устанавливает виджет «Домой» — отправную точку вашего приложения.
lib/main.dart
// ...
class MyAppState extends ChangeNotifier {
var current = WordPair.random();
}
// ...
Далее, класс MyAppState
определяет... ну... состояние приложения. Это ваше первое знакомство с Flutter, поэтому эта лабораторная работа будет простой и понятной. Во Flutter существует множество эффективных способов управления состоянием приложения. Один из самых простых для объяснения — ChangeNotifier
, подход, используемый в этом приложении.
-
MyAppState
определяет данные, необходимые приложению для работы. Сейчас он содержит только одну переменную с текущей случайной парой слов. Вы дополните это позже. - Класс состояния расширяет
ChangeNotifier
, что означает, что он может уведомлять другие о своих изменениях . Например, если текущая пара слов изменяется, некоторые виджеты в приложении должны знать об этом. - Состояние создаётся и предоставляется всему приложению с помощью
ChangeNotifierProvider
(см. код выше вMyApp
). Это позволяет любому виджету в приложении получать доступ к состоянию.
lib/main.dart
// ...
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) { // ← 1
var appState = context.watch<MyAppState>(); // ← 2
return Scaffold( // ← 3
body: Column( // ← 4
children: [
Text('A random AWESOME idea:'), // ← 5
Text(appState.current.asLowerCase), // ← 6
ElevatedButton(
onPressed: () {
print('button pressed!');
},
child: Text('Next'),
),
], // ← 7
),
);
}
}
// ...
И наконец, MyHomePage
— виджет, который вы уже изменили. Каждая пронумерованная строка ниже соответствует комментарию с номером строки в коде выше:
- Каждый виджет определяет метод
build()
, который автоматически вызывается каждый раз при изменении обстоятельств виджета, чтобы виджет всегда был актуальным. -
MyHomePage
отслеживает изменения текущего состояния приложения с помощью методаwatch
. - Каждый метод
build
должен возвращать виджет или (чаще) вложенное дерево виджетов. В данном случае виджет верхнего уровня —Scaffold
. В этой практической работе вы не будете работать соScaffold
, но это полезный виджет, который встречается в подавляющем большинстве реальных приложений Flutter. -
Column
— один из самых базовых виджетов макета во Flutter. Он принимает любое количество дочерних элементов и размещает их в столбце сверху вниз. По умолчанию дочерние элементы столбца визуально располагаются наверху. Скоро вы измените это, чтобы столбец был выровнен по центру. - Вы изменили этот
Text
виджет на первом этапе. - Этот второй виджет
Text
принимаетappState
и обращается к единственному члену этого класса,current
(который являетсяWordPair
).WordPair
предоставляет несколько полезных геттеров, таких какasPascalCase
илиasSnakeCase
. Здесь мы используемasLowerCase
, но вы можете изменить это сейчас, если предпочитаете один из альтернативных вариантов. - Обратите внимание, как активно код Flutter использует запятые в конце. Эта запятая здесь не нужна, поскольку
children
— последний (и единственный ) элемент этого списка параметровColumn
. Тем не менее, обычно рекомендуется использовать запятые в конце: они упрощают добавление элементов и также служат подсказкой для автоформатировщика Dart о необходимости добавить новую строку. Подробнее см. в разделе Форматирование кода .
Далее вам нужно будет подключить кнопку к состоянию.
Ваше первое поведение
Прокрутите до MyAppState
и добавьте метод getNext
.
lib/main.dart
// ...
class MyAppState extends ChangeNotifier {
var current = WordPair.random();
// ↓ Add this.
void getNext() {
current = WordPair.random();
notifyListeners();
}
}
// ...
Новый метод getNext()
переназначает current
новой случайной WordPair
. Он также вызывает notifyListeners()
(метод ChangeNotifier)
, который гарантирует уведомление всех, кто наблюдает MyAppState
.
Осталось только вызвать метод getNext
из обратного вызова кнопки.
lib/main.dart
// ...
ElevatedButton(
onPressed: () {
appState.getNext(); // ← This instead of print().
},
child: Text('Next'),
),
// ...
Сохраните и попробуйте приложение прямо сейчас. Оно будет генерировать новую случайную пару слов каждый раз, когда вы нажимаете кнопку «Далее» .
В следующем разделе вы сделаете пользовательский интерфейс более красивым.
5. Сделайте приложение красивее
Вот как приложение выглядит на данный момент.
Не очень. Центральная часть приложения — случайно сгенерированная пара слов — должна быть заметнее. В конце концов, это главная причина, по которой наши пользователи используют это приложение! Кроме того, содержимое приложения странно смещено по центру, и всё приложение скучное, чёрно-белое.
В этом разделе мы рассмотрим эти проблемы, работая над дизайном приложения. Конечная цель этого раздела примерно следующая:
Извлечь виджет
Строка, отвечающая за отображение текущей пары слов, теперь выглядит так: Text(appState.current.asLowerCase)
. Чтобы сделать её более сложной, рекомендуется выделить эту строку в отдельный виджет. Наличие отдельных виджетов для отдельных логических частей пользовательского интерфейса — важный способ управления сложностью во Flutter.
Flutter предоставляет инструмент для рефакторинга, позволяющий извлекать виджеты, но перед его использованием убедитесь, что извлекаемая строка обращается только к необходимым ей функциям. Сейчас строка обращается appState
, но на самом деле ей нужно знать только текущую пару слов.
По этой причине перепишите виджет MyHomePage
следующим образом:
lib/main.dart
// ...
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
var pair = appState.current; // ← Add this.
return Scaffold(
body: Column(
children: [
Text('A random AWESOME idea:'),
Text(pair.asLowerCase), // ← Change to this.
ElevatedButton(
onPressed: () {
appState.getNext();
},
child: Text('Next'),
),
],
),
);
}
}
// ...
Отлично. Виджет Text
больше не ссылается на всё appState
.
Теперь вызовите меню «Рефакторинг» . В VS Code это можно сделать двумя способами:
- Щелкните правой кнопкой мыши фрагмент кода, который вы хотите рефакторить (в данном случае
Text
), и выберите Рефакторинг... в раскрывающемся меню.
ИЛИ
- Переместите курсор на фрагмент кода, который вы хотите рефакторить (в данном случае
Text
), и нажмитеCtrl+.
(Win/Linux) илиCmd+.
(Mac).
В меню «Рефакторинг» выберите «Извлечь виджет» . Присвойте имя, например, BigCard , и нажмите Enter
.
Это автоматически создаст новый класс BigCard
в конце текущего файла. Класс выглядит примерно так:
lib/main.dart
// ...
class BigCard extends StatelessWidget {
const BigCard({super.key, required this.pair});
final WordPair pair;
@override
Widget build(BuildContext context) {
return Text(pair.asLowerCase);
}
}
// ...
Обратите внимание, как приложение продолжает работать даже после этого рефакторинга.
Добавить карту
Теперь пришло время превратить этот новый виджет в яркий элемент пользовательского интерфейса, который мы задумали в начале этого раздела.
Найдите класс BigCard
и метод build()
в нём. Как и раньше, вызовите меню «Рефакторинг» в виджете Text
. Однако на этот раз вам не придётся извлекать виджет.
Вместо этого выберите «Обтекание с отступом» . Это создаст новый родительский виджет вокруг виджета Text
с названием Padding
. После сохранения вы увидите, что у случайного слова уже больше свободного пространства.
Увеличьте значение отступа по умолчанию, равное 8.0
. Например, для большего отступа используйте значение 20
.
Затем поднимитесь на уровень выше. Наведите курсор на виджет « Padding
, откройте меню «Рефакторинг» и выберите «Обернуть виджетом...» .
Это позволит вам указать родительский виджет. Введите «Карта» и нажмите Enter .
Это обернет виджет Padding
(и, следовательно, также и Text
) в виджет Card
.
lib/main.dart
// ...
class BigCard extends StatelessWidget {
const BigCard({super.key, required this.pair});
final WordPair pair;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Text(pair.asLowerCase),
),
);
}
}
// ...
Теперь приложение будет выглядеть примерно так:
Тема и стиль
Чтобы сделать открытку более заметной, выберите более насыщенный цвет. А поскольку всегда важно придерживаться единой цветовой гаммы, выберите цвет Theme
приложения.
Внесите следующие изменения в метод build()
BigCard
.
lib/main.dart
// ...
@override
Widget build(BuildContext context) {
final theme = Theme.of(context); // ← Add this.
return Card(
color: theme.colorScheme.primary, // ← And also this.
child: Padding(
padding: const EdgeInsets.all(20),
child: Text(pair.asLowerCase),
),
);
}
// ...
Эти две новые строки выполняют большую работу:
- Сначала код запрашивает текущую тему приложения с помощью
Theme.of(context)
. - Затем код определяет цвет карточки, совпадающий со свойством
colorScheme
темы. Цветовая схема содержит множество цветов, иprimary
— самый заметный, определяющий цвет приложения.
Теперь карточка окрашена в основной цвет приложения:
Вы можете изменить этот цвет и цветовую схему всего приложения, прокрутив страницу до MyApp
и изменив там начальный цвет для ColorScheme
.
Обратите внимание, как плавно анимируется цвет. Это называется неявной анимацией . Многие виджеты Flutter плавно интерполируют значения, чтобы пользовательский интерфейс не «прыгал» между состояниями.
Кнопка под карточкой также меняет цвет. В этом и заключается преимущество использования Theme
общей для всего приложения, а не жёстко заданных значений.
TextTheme
Проблема с карточкой всё ещё актуальна: текст слишком мелкий, а его цвет трудночитаем. Чтобы исправить это, внесите следующие изменения в метод build()
объекта BigCard
.
lib/main.dart
// ...
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// ↓ Add this.
final style = theme.textTheme.displayMedium!.copyWith(
color: theme.colorScheme.onPrimary,
);
return Card(
color: theme.colorScheme.primary,
child: Padding(
padding: const EdgeInsets.all(20),
// ↓ Change this line.
child: Text(pair.asLowerCase, style: style),
),
);
}
// ...
Что стоит за этим изменением:
- Используя
theme.textTheme,
вы получаете доступ к теме шрифта приложения. Этот класс включает такие члены, какbodyMedium
(для стандартного текста среднего размера),caption
(для подписей к изображениям) илиheadlineLarge
(для крупных заголовков). - Свойство
displayMedium
— это большой стиль, предназначенный для отображения текста. Слово display здесь используется в типографском смысле, например, в display typeface . В документации кdisplayMedium
говорится, что «стили отображения зарезервированы для короткого, важного текста» — как раз наш случай. - Свойство темы
displayMedium
теоретически может бытьnull
. Dart, язык программирования, на котором вы пишете это приложение, является null-безопасным, поэтому он не позволит вам вызывать методы объектов, которые потенциальноnull
. В этом случае, однако, вы можете использовать оператор!
(«оператор восклицания»), чтобы убедиться, что Dart понимает, что делает. (В данном случаеdisplayMedium
определённо не равен null. Однако то, почему мы это знаем, выходит за рамки данной практической работы.) - Вызов
copyWith()
наdisplayMedium
возвращает копию стиля текста с определёнными вами изменениями. В данном случае вы меняете только цвет текста. - Чтобы получить новый цвет, вам снова нужно обратиться к теме приложения. Свойство
onPrimary
цветовой схемы определяет цвет, который лучше всего подходит для использования в качестве основного цвета приложения.
Теперь приложение должно выглядеть примерно так:
Если хотите, можете изменить открытку ещё больше. Вот несколько идей:
-
copyWith()
позволяет изменять не только цвет текста, но и другие параметры. Чтобы получить полный список доступных для изменения свойств, поместите курсор в любое место внутри скобок функцииcopyWith()
и нажмитеCtrl+Shift+Space
(Win/Linux) илиCmd+Shift+Space
(Mac). - Аналогичным образом вы можете изменить и другие параметры виджета
Card
. Например, можно увеличить тень карты, увеличив значение параметраelevation
. - Попробуйте поэкспериментировать с цветами. Помимо
theme.colorScheme.primary
, существуют также.secondary
,.surface
и множество других. У всех этих цветов есть эквивалентыonPrimary
.
Улучшить доступность
Flutter делает приложения доступными по умолчанию. Например, каждое приложение Flutter корректно отображает весь текст и интерактивные элементы в программах экранного доступа, таких как TalkBack и VoiceOver.
Однако иногда требуется определённая работа. В случае с этим приложением у программы чтения с экрана могут возникнуть проблемы с произношением некоторых сгенерированных пар слов. Хотя люди без проблем распознают два слова в слове cheaphead , программа чтения с экрана может произнести звук ph в середине слова как f .
Решение — заменить pair.asLowerCase
на "${pair.first} ${pair.second}"
. В последнем случае используется интерполяция строк для создания строки (например, "cheap head"
) из двух слов, содержащихся в pair
. Использование двух отдельных слов вместо составного гарантирует их корректную идентификацию программами чтения с экрана и обеспечивает более удобный интерфейс для пользователей с нарушениями зрения.
Однако, возможно, вы захотите сохранить визуальную простоту pair.asLowerCase
. Используйте свойство semanticsLabel
объекта Text
, чтобы переопределить визуальное содержимое текстового виджета семантическим содержимым, более подходящим для программ чтения с экрана:
lib/main.dart
// ...
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final style = theme.textTheme.displayMedium!.copyWith(
color: theme.colorScheme.onPrimary,
);
return Card(
color: theme.colorScheme.primary,
child: Padding(
padding: const EdgeInsets.all(20),
// ↓ Make the following change.
child: Text(
pair.asLowerCase,
style: style,
semanticsLabel: "${pair.first} ${pair.second}",
),
),
);
}
// ...
Теперь программы чтения с экрана правильно произносят каждую сгенерированную пару слов, но интерфейс остаётся прежним. Попробуйте это на практике , используя программу чтения с экрана на своём устройстве .
Центрировать пользовательский интерфейс
Теперь, когда случайная пара слов достаточно визуально привлекательна, пришло время поместить ее в центр окна/экрана приложения.
Во-первых, помните, что BigCard
является частью Column
. По умолчанию столбцы смещают свои дочерние элементы вверх, но это можно переопределить. Перейдите к методу build()
объекта MyHomePage
и внесите следующее изменение:
lib/main.dart
// ...
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
var pair = appState.current;
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.center, // ← Add this.
children: [
Text('A random AWESOME idea:'),
BigCard(pair: pair),
ElevatedButton(
onPressed: () {
appState.getNext();
},
child: Text('Next'),
),
],
),
);
}
}
// ...
Это центрирует дочерние элементы внутри Column
вдоль ее главной (вертикальной) оси.
Дочерние элементы уже отцентрированы по поперечной оси колонны (то есть, по горизонтали). Но сама Column
не отцентрирована внутри Scaffold
. Мы можем убедиться в этом с помощью инспектора виджетов .
Сам инспектор виджетов выходит за рамки этой практической работы, но вы можете видеть, что при выделении Column
он не занимает всю ширину приложения. Он занимает ровно столько горизонтального пространства, сколько требуется его дочерним элементам.
Вы можете просто отцентрировать сам столбец. Наведите курсор на Column
, вызовите меню Refactor (нажатием Ctrl+.
или Cmd+.
) и выберите Wrap with Center .
Теперь приложение должно выглядеть примерно так:
Если хотите, можете еще немного это подправить.
- Вы можете удалить виджет
Text
надBigCard
. Можно утверждать, что описательный текст («Случайная ОТЛИЧНАЯ идея:») больше не нужен, поскольку интерфейс и без него понятен. И он становится чище. - Вы также можете добавить виджет
SizedBox(height: 10)
междуBigCard
иElevatedButton
. Это позволит немного увеличить расстояние между двумя виджетами. ВиджетSizedBox
просто занимает место и ничего не отображает сам по себе. Он часто используется для создания визуальных «зазоров».
С учетом необязательных изменений MyHomePage
содержит следующий код:
lib/main.dart
// ...
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
var pair = appState.current;
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
BigCard(pair: pair),
SizedBox(height: 10),
ElevatedButton(
onPressed: () {
appState.getNext();
},
child: Text('Next'),
),
],
),
),
);
}
}
// ...
А приложение выглядит так:
В следующем разделе вы добавите возможность добавлять в избранное (или ставить отметку «Нравится») сгенерированные слова.
6. Добавьте функциональность
Приложение работает и иногда даже предлагает интересные пары слов. Но каждый раз, когда пользователь нажимает «Далее» , каждая пара слов исчезает навсегда. Было бы неплохо иметь способ «запоминать» лучшие предложения, например, кнопку «Нравится».
Добавьте бизнес-логику
Прокрутите до MyAppState
и добавьте следующий код:
lib/main.dart
// ...
class MyAppState extends ChangeNotifier {
var current = WordPair.random();
void getNext() {
current = WordPair.random();
notifyListeners();
}
// ↓ Add the code below.
var favorites = <WordPair>[];
void toggleFavorite() {
if (favorites.contains(current)) {
favorites.remove(current);
} else {
favorites.add(current);
}
notifyListeners();
}
}
// ...
Изучите изменения:
- Вы добавили новое свойство в
MyAppState
под названиемfavorites
. Это свойство инициализируется пустым списком:[]
. - Вы также указали, что список может содержать только пары слов:
<WordPair>[]
, используя generics . Это помогает сделать ваше приложение более надёжным — Dart отказывается даже запускать его, если вы пытаетесь добавить в него что-либо, кромеWordPair
. В свою очередь, вы можете использовать списокfavorites
, зная, что в нём никогда не будет скрытых нежелательных объектов (например,null
).
- Вы также добавили новый метод
toggleFavorite()
, который либо удаляет текущую пару слов из списка избранного (если она уже там), либо добавляет её (если её там ещё нет). В любом случае код впоследствии вызываетnotifyListeners();
;.
Добавить кнопку
Разобравшись с «бизнес-логикой», пора снова заняться пользовательским интерфейсом. Для размещения кнопки «Нравится» слева от кнопки «Далее» требуется виджет « Row
. Виджет Row
— это горизонтальный аналог Column
, который вы видели ранее.
Сначала оберните существующую кнопку в Row
. Перейдите к методу build()
объекта MyHomePage
, наведите курсор на ElevatedButton
, вызовите меню «Рефакторинг» сочетанием Ctrl+.
или Cmd+.
и выберите «Обернуть в Row » .
После сохранения вы заметите, что Row
действует аналогично Column
— по умолчанию он смещает свои дочерние элементы влево. ( Column
смещает свои дочерние элементы вверх.) Чтобы исправить это, можно использовать тот же подход, что и раньше, но с mainAxisAlignment
. Однако в дидактических (обучающих) целях используйте mainAxisSize
. Это указывает Row
не занимать всё доступное горизонтальное пространство.
Внесите следующие изменения:
lib/main.dart
// ...
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
var pair = appState.current;
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
BigCard(pair: pair),
SizedBox(height: 10),
Row(
mainAxisSize: MainAxisSize.min, // ← Add this.
children: [
ElevatedButton(
onPressed: () {
appState.getNext();
},
child: Text('Next'),
),
],
),
],
),
),
);
}
}
// ...
Пользовательский интерфейс вернулся туда, где он был раньше.
Затем добавьте кнопку «Нравится» и подключите её к toggleFavorite()
. В качестве эксперимента попробуйте сделать это самостоятельно, не глядя на приведённый ниже блок кода.
Ничего страшного, если вы не сделаете всё точно так же, как показано ниже. На самом деле, не беспокойтесь о значке сердца, если только не хотите серьёзных испытаний.
Потерпеть неудачу — это совершенно нормально, ведь это ваш первый час работы с Flutter.
Вот один из способов добавить вторую кнопку на MyHomePage
. На этот раз используйте конструктор ElevatedButton.icon()
для создания кнопки со значком. В начале метода build
выберите подходящий значок в зависимости от того, есть ли текущая пара слов в избранном. Также обратите внимание на использование SizedBox
, чтобы расположить две кнопки немного порознь.
lib/main.dart
// ...
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
var pair = appState.current;
// ↓ Add this.
IconData icon;
if (appState.favorites.contains(pair)) {
icon = Icons.favorite;
} else {
icon = Icons.favorite_border;
}
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
BigCard(pair: pair),
SizedBox(height: 10),
Row(
mainAxisSize: MainAxisSize.min,
children: [
// ↓ And this.
ElevatedButton.icon(
onPressed: () {
appState.toggleFavorite();
},
icon: Icon(icon),
label: Text('Like'),
),
SizedBox(width: 10),
ElevatedButton(
onPressed: () {
appState.getNext();
},
child: Text('Next'),
),
],
),
],
),
),
);
}
}
// ...
Приложение должно выглядеть следующим образом:
К сожалению, пользователь не видит избранное. Пришло время добавить в наше приложение отдельный экран. До встречи в следующем разделе!
7. Добавить навигационную панель
Большинство приложений не могут уместить всё на одном экране. Это конкретное приложение, вероятно, сможет, но в учебных целях мы создадим отдельный экран для избранного контента пользователя. Для переключения между двумя экранами мы реализуем свой первый StatefulWidget
.
Чтобы как можно быстрее перейти к сути этого шага, разделите MyHomePage
на два отдельных виджета.
Выберите весь MyHomePage
, удалите его и замените следующим кодом:
lib/main.dart
// ...
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(
children: [
SafeArea(
child: NavigationRail(
extended: false,
destinations: [
NavigationRailDestination(
icon: Icon(Icons.home),
label: Text('Home'),
),
NavigationRailDestination(
icon: Icon(Icons.favorite),
label: Text('Favorites'),
),
],
selectedIndex: 0,
onDestinationSelected: (value) {
print('selected: $value');
},
),
),
Expanded(
child: Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: GeneratorPage(),
),
),
],
),
);
}
}
class GeneratorPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
var pair = appState.current;
IconData icon;
if (appState.favorites.contains(pair)) {
icon = Icons.favorite;
} else {
icon = Icons.favorite_border;
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
BigCard(pair: pair),
SizedBox(height: 10),
Row(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton.icon(
onPressed: () {
appState.toggleFavorite();
},
icon: Icon(icon),
label: Text('Like'),
),
SizedBox(width: 10),
ElevatedButton(
onPressed: () {
appState.getNext();
},
child: Text('Next'),
),
],
),
],
),
);
}
}
// ...
После сохранения вы увидите, что визуальная часть интерфейса готова, но она не работает. Нажатие на ♥︎ (сердце) на панели навигации ничего не даёт.
Изучите изменения.
- Во-первых, обратите внимание, что всё содержимое
MyHomePage
извлекается в новый виджетGeneratorPage
. Единственная часть старого виджетаMyHomePage
, которая не была извлечена, — этоScaffold
. - Новый
MyHomePage
содержитRow
с двумя дочерними виджетами. Первый виджет —SafeArea
, а второй —Expanded
. -
SafeArea
гарантирует, что её дочерний элемент не будет перекрыт аппаратным вырезом или строкой состояния. В этом приложении виджет обтекаетNavigationRail
, чтобы кнопки навигации не перекрывались, например, строкой состояния мобильного устройства. - Вы можете изменить значение строки
extended: false
вNavigationRail
наtrue
. Это отобразит подписи рядом со значками. В следующем шаге вы узнаете, как сделать это автоматически, когда в приложении достаточно места по горизонтали. - Навигационная панель имеет два пункта назначения ( «Домой» и «Избранное ») с соответствующими значками и метками. Она также определяет текущий индекс
selectedIndex
. Выбранный индекс, равный нулю, выбирает первый пункт назначения, выбранный индекс, равный единице, выбирает второй пункт назначения и так далее. На данный момент он жёстко запрограммирован на ноль. - Навигационная панель также определяет, что происходит, когда пользователь выбирает один из пунктов назначения с помощью
onDestinationSelected
. Сейчас приложение просто выводит запрошенное значение индекса с помощьюprint()
. - Вторым дочерним элементом
Row
является виджетExpanded
. Развернутые виджеты чрезвычайно полезны в строках и столбцах — они позволяют создавать макеты, в которых одни дочерние элементы занимают ровно столько места, сколько им нужно (в данном случаеSafeArea
), а другие должны занимать как можно больше оставшегося пространства (в данном случаеExpanded
).Expanded
виджеты можно считать «жадными». Чтобы лучше понять роль этого виджета, попробуйте обернуть виджетSafeArea
другимExpanded
. Результирующий макет выглядит примерно так:
- Два
Expanded
виджета разделили между собой все доступное горизонтальное пространство, хотя навигационной панели на самом деле требовался лишь небольшой фрагмент слева. - Внутри
Expanded
виджета находится цветнойContainer
, а внутри контейнера —GeneratorPage
.
Виджеты без состояния и с состоянием
До сих пор MyAppState
обеспечивал все ваши потребности в состоянии. Именно поэтому все виджеты, которые вы написали до сих пор, не имеют состояния. Они не содержат собственного изменяемого состояния. Ни один из виджетов не может измениться сам — они должны пройти через MyAppState
.
Но скоро ситуация изменится.
Вам необходимо каким-то образом сохранить значение selectedIndex
навигационной панели. Также необходимо иметь возможность изменять это значение из обратного вызова onDestinationSelected
.
Можно добавить selectedIndex
как ещё одно свойство MyAppState
. И это бы работало. Но представьте, что состояние приложения быстро разрослось бы до невероятных размеров, если бы каждый виджет хранил в нём свои значения.
Некоторые состояния относятся только к одному виджету, поэтому они должны оставаться с этим виджетом.
Знакомьтесь с StatefulWidget
— типом виджета с State
. Сначала преобразуйте MyHomePage
в виджет с отслеживанием состояния.
Поместите курсор на первую строку MyHomePage
(ту, которая начинается с class MyHomePage...
) и вызовите меню Refactor с помощью Ctrl+.
или Cmd+.
. Затем выберите Convert to StatefulWidget .
IDE создаёт для вас новый класс _MyHomePageState
. Этот класс расширяет State
и, следовательно, может управлять собственными значениями. (Он может изменяться .) Также обратите внимание, что метод build
из старого виджета без сохранения состояния перенесён в _MyHomePageState
(вместо того, чтобы остаться в виджете). Он был перенесён дословно — внутри метода build
ничего не изменилось. Теперь он просто находится в другом месте.
setState
Новый виджет с отслеживанием состояния должен отслеживать только одну переменную: selectedIndex
. Внесите следующие 3 изменения в _MyHomePageState
:
lib/main.dart
// ...
class _MyHomePageState extends State<MyHomePage> {
var selectedIndex = 0; // ← Add this property.
@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(
children: [
SafeArea(
child: NavigationRail(
extended: false,
destinations: [
NavigationRailDestination(
icon: Icon(Icons.home),
label: Text('Home'),
),
NavigationRailDestination(
icon: Icon(Icons.favorite),
label: Text('Favorites'),
),
],
selectedIndex: selectedIndex, // ← Change to this.
onDestinationSelected: (value) {
// ↓ Replace print with this.
setState(() {
selectedIndex = value;
});
},
),
),
Expanded(
child: Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: GeneratorPage(),
),
),
],
),
);
}
}
// ...
Изучите изменения:
- Вы вводите новую переменную
selectedIndex
и инициализируете ее значением0
. - Эту новую переменную вы используете в определении
NavigationRail
вместо жестко запрограммированного значения0
, которое было там до сих пор. - При вызове обратного вызова
onDestinationSelected
вместо того, чтобы просто вывести новое значение на консоль, вы присваиваете егоselectedIndex
внутри вызоваsetState()
. Этот вызов аналогичен методуnotifyListeners()
который использовался ранее, — он обеспечивает обновление пользовательского интерфейса.
Панель навигации теперь реагирует на взаимодействие с пользователем. Но расширенная область справа остаётся прежней. Это связано с тем, что код не использует selectedIndex
для определения отображаемого экрана.
Использовать selectedIndex
Поместите следующий код в начало метода build
_MyHomePageState
, непосредственно перед return Scaffold
:
lib/main.dart
// ...
Widget page;
switch (selectedIndex) {
case 0:
page = GeneratorPage();
break;
case 1:
page = Placeholder();
break;
default:
throw UnimplementedError('no widget for $selectedIndex');
}
// ...
Изучите этот фрагмент кода:
- Код объявляет новую переменную
page
типаWidget
. - Затем оператор switch назначает экран
page
в соответствии с текущим значением вselectedIndex
. - Поскольку
FavoritesPage
пока нет, используйтеPlaceholder
; удобный виджет, который рисует перечеркнутый прямоугольник, где бы вы его ни разместили, отмечая эту часть пользовательского интерфейса как незавершенную.
- Применяя принцип отказоустойчивости , оператор switch также гарантирует выдачу ошибки, если
selectedIndex
не равен ни 0, ни 1. Это помогает предотвратить ошибки в дальнейшем. Если вы когда-нибудь добавите новый пункт назначения в навигационную панель и забудете обновить этот код, программа выйдет из строя в процессе разработки (в отличие от того, чтобы позволить вам догадаться, почему что-то не работает, или позволить вам опубликовать ошибочный код в производство).
Теперь, когда page
содержит виджет, который вы хотите отобразить справа, вы, вероятно, можете догадаться, какие еще изменения необходимы.
Вот _MyHomePageState
после этого единственного оставшегося изменения:
библиотека/main.dart
// ...
class _MyHomePageState extends State<MyHomePage> {
var selectedIndex = 0;
@override
Widget build(BuildContext context) {
Widget page;
switch (selectedIndex) {
case 0:
page = GeneratorPage();
break;
case 1:
page = Placeholder();
break;
default:
throw UnimplementedError('no widget for $selectedIndex');
}
return Scaffold(
body: Row(
children: [
SafeArea(
child: NavigationRail(
extended: false,
destinations: [
NavigationRailDestination(
icon: Icon(Icons.home),
label: Text('Home'),
),
NavigationRailDestination(
icon: Icon(Icons.favorite),
label: Text('Favorites'),
),
],
selectedIndex: selectedIndex,
onDestinationSelected: (value) {
setState(() {
selectedIndex = value;
});
},
),
),
Expanded(
child: Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: page, // ← Here.
),
),
],
),
);
}
}
// ...
Приложение теперь переключается между нашей GeneratorPage
и заполнителем, который вскоре станет страницей избранного .
Отзывчивость
Затем сделайте навигационную рейку отзывчивой. То есть сделать так, чтобы метки автоматически отображались (используя extended: true
), когда для них достаточно места.
Flutter предоставляет несколько виджетов, которые помогут вам автоматически реагировать на ваши приложения. Например, Wrap
— это виджет, похожий на Row
или Column
, который автоматически переносит дочерние элементы на следующую «строку» (называемую «run»), когда недостаточно вертикального или горизонтального пространства. Существует FittedBox
, виджет, который автоматически помещает дочерний элемент в доступное пространство в соответствии с вашими требованиями.
Но NavigationRail
не отображает метки автоматически , когда достаточно места, поскольку не может знать, сколько места достаточно в каждом контексте. Это решение зависит от вас, разработчика.
Допустим, вы решили показывать метки только в том случае, если ширина MyHomePage
составляет не менее 600 пикселей.
В данном случае используется виджет LayoutBuilder
. Он позволяет вам изменять дерево виджетов в зависимости от того, сколько у вас свободного места.
Еще раз используйте меню рефакторинга Flutter в VS Code, чтобы внести необходимые изменения. Однако на этот раз все немного сложнее:
- Внутри метода
build
_MyHomePageState
поместите курсор наScaffold
. - Вызовите меню «Рефакторинг» с помощью
Ctrl+.
(Windows/Linux) илиCmd+.
(Мак). - Выберите «Обтекание с помощью Builder» и нажмите Enter .
- Измените имя только что добавленного
Builder
наLayoutBuilder
. - Измените список параметров обратного вызова с
(context)
на(context, constraints)
.
Обратный вызов builder
LayoutBuilder
вызывается каждый раз, когда изменяются ограничения. Это происходит, когда, например:
- Пользователь изменяет размер окна приложения
- Пользователь поворачивает свой телефон из портретного режима в альбомный или обратно.
- Некоторые виджеты рядом с
MyHomePage
увеличиваются в размерах, уменьшая ограниченияMyHomePage
.
Теперь ваш код может решить, показывать ли метку, запрашивая текущие constraints
. Внесите следующее однострочное изменение в метод build
_MyHomePageState
:
библиотека/main.dart
// ...
class _MyHomePageState extends State<MyHomePage> {
var selectedIndex = 0;
@override
Widget build(BuildContext context) {
Widget page;
switch (selectedIndex) {
case 0:
page = GeneratorPage();
break;
case 1:
page = Placeholder();
break;
default:
throw UnimplementedError('no widget for $selectedIndex');
}
return LayoutBuilder(builder: (context, constraints) {
return Scaffold(
body: Row(
children: [
SafeArea(
child: NavigationRail(
extended: constraints.maxWidth >= 600, // ← Here.
destinations: [
NavigationRailDestination(
icon: Icon(Icons.home),
label: Text('Home'),
),
NavigationRailDestination(
icon: Icon(Icons.favorite),
label: Text('Favorites'),
),
],
selectedIndex: selectedIndex,
onDestinationSelected: (value) {
setState(() {
selectedIndex = value;
});
},
),
),
Expanded(
child: Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: page,
),
),
],
),
);
});
}
}
// ...
Теперь ваше приложение реагирует на окружающую среду, например размер экрана, ориентацию и платформу! Другими словами, он отзывчив!
Остается только заменить этот Placeholder
реальным экраном избранного . Это описано в следующем разделе.
8. Добавьте новую страницу
Помните виджет « Placeholder
, который мы использовали вместо страницы «Избранное» ?
Пришло время это исправить.
Если вы любите приключения, попробуйте сделать этот шаг самостоятельно. Ваша цель — показать список favorites
в новом виджете без сохранения состояния FavoritesPage
, а затем показать этот виджет вместо Placeholder
.
Вот несколько указателей:
- Если вам нужен прокручиваемый
Column
, используйте виджетListView
. - Помните, что доступ к экземпляру
MyAppState
из любого виджета осуществляется с помощьюcontext.watch<MyAppState>()
. - Если вы также хотите попробовать новый виджет,
ListTile
есть такие свойства, какtitle
(обычно для текста),leading
(для значков или аватаров) иonTap
(для взаимодействия). Однако вы можете добиться аналогичного эффекта с помощью уже знакомых вам виджетов. - Dart позволяет использовать циклы
for
внутри литералов коллекции. Например, еслиmessages
содержат список строк, вы можете использовать следующий код:
С другой стороны, если вы более знакомы с функциональным программированием, Dart также позволяет вам писать код типа messages.map((m) => Text(m)).toList()
. И, конечно же, вы всегда можете создать список виджетов и обязательно дополнять его внутри метода build
.
Преимущество самостоятельного добавления страницы «Избранное» заключается в том, что вы узнаете больше, принимая собственные решения. Недостаток в том, что вы можете столкнуться с проблемами, которые пока не сможете решить самостоятельно. Помните: неудачи – это нормально, и это один из наиболее важных элементов обучения. Никто не ожидает, что вы справитесь с разработкой Flutter в первый же час, и вам не следует этого делать.
Далее следует лишь один из способов реализации страницы избранного. То, как это реализовано (надеюсь), вдохновит вас поиграть с кодом — улучшить пользовательский интерфейс и сделать его своим.
Вот новый класс FavoritesPage
:
библиотека/main.dart
// ...
class FavoritesPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
if (appState.favorites.isEmpty) {
return Center(
child: Text('No favorites yet.'),
);
}
return ListView(
children: [
Padding(
padding: const EdgeInsets.all(20),
child: Text('You have '
'${appState.favorites.length} favorites:'),
),
for (var pair in appState.favorites)
ListTile(
leading: Icon(Icons.favorite),
title: Text(pair.asLowerCase),
),
],
);
}
}
Вот что делает виджет:
- Он получает текущее состояние приложения.
- Если список избранного пуст, в центре отображается сообщение: Избранного пока нет.
- В противном случае отображается (прокручиваемый) список.
- Список начинается с краткой информации (например, У вас 5 избранных ).
- Затем код перебирает все избранные и создает виджет
ListTile
для каждого из них.
Теперь остается только заменить виджет Placeholder
на FavoritesPage
. И вуаля!
Вы можете получить окончательный код этого приложения в репозитории codelab на GitHub.
9. Следующие шаги
Поздравляю!
Посмотри на себя! Вы взяли нефункциональный каркас со Column
и двумя виджетами Text
и превратили его в отзывчивое, восхитительное маленькое приложение.
Что мы рассмотрели
- Основы работы Flutter
- Создание макетов во Flutter
- Связывание действий пользователя (например, нажатий кнопок) с поведением приложения.
- Поддержание порядка в коде Flutter
- Делаем ваше приложение адаптивным
- Достижение единообразного внешнего вида вашего приложения.
Что дальше?
- Больше экспериментируйте с приложением, которое вы написали во время этой лабораторной работы.
- Посмотрите код этой расширенной версии того же приложения, чтобы узнать, как можно добавлять анимированные списки, градиенты, плавные переходы и многое другое.
- Следите за своим учебным процессом, перейдя на flutter.dev/learn .