1. Введение
Flutter — это набор инструментов Google для создания пользовательских интерфейсов мобильных, веб- и настольных приложений из единой кодовой базы. В этом практическом занятии вы создадите следующее приложение 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
- Android
- Windows
- macOS
- Linux
- веб
Однако, как правило, принято выбирать одну операционную систему, для которой будет осуществляться основная разработка. Это ваша «целевая система разработки» — операционная система, на которой будет работать ваше приложение во время разработки.

Например, предположим, вы используете ноутбук с Windows для разработки приложения Flutter. Если вы выберете Android в качестве целевой платформы разработки, вы обычно подключаете устройство Android к своему ноутбуку с Windows с помощью USB-кабеля, и разрабатываемое приложение будет работать на этом подключенном устройстве Android. Но вы также можете выбрать Windows в качестве целевой платформы разработки, что означает, что разрабатываемое приложение будет работать как приложение Windows параллельно с вашим редактором.
Выбор веб-среды в качестве целевой платформы разработки может показаться заманчивым. Недостаток такого выбора заключается в потере одной из самых полезных функций Flutter: горячей перезагрузки с сохранением состояния (Stateful Hot Reload). 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? - Как мне поступить, если инструмент
sdkmanagerв Android не найден? - Как мне справиться с ошибкой "отсутствует компонент
cmdline-tools"? - Как запустить CocoaPods на Apple Silicon (M1)?
- Как отключить автоматическое форматирование при сохранении в VS Code?
3. Создайте проект
Создайте свой первый проект Flutter
Запустите Visual Studio Code и откройте палитру команд (нажав F1 , Ctrl+Shift+P или Shift+Cmd+P ). Начните вводить "flutter new". Выберите команду "Flutter: Новый проект ".
Далее выберите «Приложение» , а затем папку, в которой будет создан ваш проект. Это может быть ваша домашняя директория или что-то вроде 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 с сохранением состояния. Горячая перезагрузка запускается при сохранении изменений в исходном файле.
Часто задаваемые вопросы
- Что делать, если функция «Горячая перезагрузка» не работает в VSCode?
- Нужно ли нажимать клавишу 'r' для горячей перезагрузки в VSCode?
- Работает ли функция «Горячая перезагрузка» в веб-версии?
- Как убрать баннер "Отладка"?
Добавление кнопки
Далее добавьте кнопку внизу 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+.(Windows/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 , откройте меню «Refactor» и выберите «Wrap with widget...» .
Это позволяет указать родительский виджет. Введите "Card" и нажмите 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 для всего приложения, в отличие от жестко заданных значений.
Текстовая тема
У карточки по-прежнему есть проблема: текст слишком мелкий, а его цвет трудночитаемый. Чтобы это исправить, внесите следующие изменения в метод 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 . В документации кdisplayMediumговорится, что «стили display зарезервированы для короткого, важного текста» — именно то, что нам нужно. - Теоретически свойство
displayMediumтемы может быть равноnull. Dart, язык программирования, на котором вы пишете это приложение, является null-безопасным, поэтому он не позволит вам вызывать методы объектов, которые потенциально могут бытьnull. Однако в этом случае вы можете использовать оператор!(оператор восклицательного знака), чтобы заверить Dart, что вы знаете, что делаете. (В этом случаеdisplayMediumточно не равен null. Причина, по которой мы это знаем, выходит за рамки этого практического занятия.) - Вызов метода
copyWith()дляdisplayMediumвозвращает копию стиля текста с заданными вами изменениями. В данном случае вы изменяете только цвет текста. - Чтобы получить новый цвет, вам снова нужно получить доступ к теме приложения. Свойство
onPrimaryцветовой схемы определяет цвет, который хорошо подходит для использования в качестве основного цвета приложения.
Теперь приложение должно выглядеть примерно так:

Если захотите, можете дополнительно изменить карту. Вот несколько идей:
-
copyWith()позволяет изменять гораздо больше параметров стиля текста, чем просто цвет. Чтобы получить полный список изменяемых свойств, поместите курсор в любое место внутри скобок функцииcopyWith()и нажмитеCtrl+Shift+Space(Windows/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 , вызовите меню «Рефакторинг» (с помощью Ctrl+. или Cmd+. ) и выберите «Перенос по центру» .
Теперь приложение должно выглядеть примерно так:

При желании вы можете еще немного подкорректировать это.
- Вы можете удалить виджет
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>[], используя обобщения . Это помогает сделать ваше приложение более надежным — Dart даже не запустит ваше приложение, если вы попытаетесь добавить в него что-либо, кромеWordPair. В свою очередь, вы можете использовать списокfavoritesзная, что в нем никогда не будет никаких нежелательных объектов (например,null).
- Вы также добавили новый метод
toggleFavorite(), который либо удаляет текущую пару слов из списка избранных (если она уже там есть), либо добавляет её (если её ещё нет). В любом случае, после этого код вызываетnotifyListeners();.
Добавить кнопку
Разобравшись с "бизнес-логикой", пора снова заняться пользовательским интерфейсом. Чтобы разместить кнопку "Нравится" слева от кнопки "Далее", потребуется виджет Row ". Виджет Row — это горизонтальный аналог Column , который вы видели ранее.
Сначала оберните существующую кнопку в Row . Перейдите к методу build() класса MyHomePage , наведите курсор на ElevatedButton , вызовите меню «Рефакторинг» с помощью Ctrl+. или Cmd+. и выберите «Обернуть строкой» .
После сохранения вы заметите, что 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 на 2 отдельных виджета.
Выделите весь раздел 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. SelectedIndex, равный нулю, выбирает первый пункт назначения, selectedIndex, равный единице, выбирает второй пункт назначения и так далее. На данный момент он жестко задан равным нулю. - Панель навигации также определяет, что происходит, когда пользователь выбирает один из пунктов назначения с помощью
onDestinationSelected. В данный момент приложение просто выводит запрошенное значение индекса с помощьюprint(). - Вторым дочерним элементом элемента
Rowявляется виджетExpanded. Виджеты Expanded чрезвычайно полезны в строках и столбцах — они позволяют создавать макеты, где некоторые дочерние элементы занимают ровно столько места, сколько им нужно (в данном случаеSafeArea), а другие виджеты должны занимать как можно больше оставшегося пространства (в данном случаеExpanded). ВиджетыExpandedможно охарактеризовать как «жадные». Чтобы лучше понять роль этого виджета, попробуйте обернуть виджетSafeAreaдругимExpanded. В результате макет будет выглядеть примерно так:

- Два виджета
Expandedделят между собой всё доступное горизонтальное пространство, хотя навигационной панели на самом деле нужен был лишь небольшой участок слева. - Внутри виджета
Expandedнаходится цветнойContainer, а внутри контейнера —GeneratorPage.
Виджеты без состояния и виджеты с состоянием
До сих пор MyAppState покрывал все ваши потребности в управлении состоянием. Именно поэтому все созданные вами виджеты являются бессостоятельными . Они не содержат собственного изменяемого состояния. Ни один из виджетов не может изменить себя — они должны передавать данные через MyAppState .
Ситуация вот-вот изменится.
Вам нужен способ хранить значение selectedIndex в навигационной панели. Также вам необходимо иметь возможность изменять это значение из обработчика события onDestinationSelected .
Можно добавить selectedIndex в качестве еще одного свойства класса MyAppState . И это сработает. Но представьте, что состояние приложения быстро разрастется до немыслимых размеров, если каждый виджет будет хранить в нем свои значения.

Некоторые состояния актуальны только для одного виджета, поэтому они должны оставаться привязанными к этому виджету.
Представляем StatefulWidget — тип виджета, обладающий State . Сначала преобразуем MyHomePage в виджет с состоянием.
Установите курсор на первую строку кода MyHomePage (ту, которая начинается с class MyHomePage... ) и вызовите меню «Рефакторинг» с помощью Ctrl+. или Cmd+. Затем выберите «Преобразовать в 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 для определения того, какой экран отображается.
Используйте выбранный индекс
Разместите следующий код в начале метода 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 после внесения этого единственного оставшегося изменения:
lib/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 provides several widgets that help you make your apps automatically responsive. For example, Wrap is a widget similar to Row or Column that automatically wraps children to the next "line" (called "run") when there isn't enough vertical or horizontal space. There's FittedBox , a widget that automatically fits its child into available space according to your specifications.
But NavigationRail doesn't automatically show labels when there's enough space because it can't know what is enough space in every context. It's up to you, the developer, to make that call.
Say you decide to show labels only if MyHomePage is at least 600 pixels wide.
The widget to use, in this case, is LayoutBuilder . It lets you change your widget tree depending on how much available space you have.
Once again, use Flutter's Refactor menu in VS Code to make the required changes. This time, though, it's a little more complicated:
- Inside
_MyHomePageState'sbuildmethod, put your cursor onScaffold. - Call up the Refactor menu with
Ctrl+.(Windows/Linux) orCmd+.(Mac). - Select Wrap with Builder and press Enter .
- Modify the name of the newly added
BuildertoLayoutBuilder. - Modify the callback parameter list from
(context)to(context, constraints).
LayoutBuilder 's builder callback is called every time the constraints change. This happens when, for example:
- The user resizes the app's window
- The user rotates their phone from portrait mode to landscape mode, or back
- Some widget next to
MyHomePagegrows in size, makingMyHomePage's constraints smaller
Now your code can decide whether to show the label by querying the current constraints . Make the following single-line change to _MyHomePageState 's build method:
lib/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,
),
),
],
),
);
});
}
}
// ...
Now, your app responds to its environment, such as screen size, orientation, and platform! In other words, it's responsive!.
The only work that remains is to replace that Placeholder with an actual Favorites screen. That's covered in the next section.
8. Add a new page
Remember the Placeholder widget we used instead of the Favorites page?
Пора это исправить.
If you feel adventurous, try to do this step by yourself. Your goal is to show the list of favorites in a new stateless widget, FavoritesPage , and then show that widget instead of the Placeholder .
Here are a few pointers:
- When you want a
Columnthat scrolls, use theListViewwidget. - Remember, access the
MyAppStateinstance from any widget usingcontext.watch<MyAppState>(). - If you also want to try a new widget,
ListTilehas properties liketitle(generally for text),leading(for icons or avatars) andonTap(for interactions). However, you can achieve similar effects with the widgets you already know. - Dart allows using
forloops inside collection literals. For example, ifmessagescontains a list of strings, you can have code like the following:

On the other hand, if you're more familiar with functional programming, Dart also lets you write code like messages.map((m) => Text(m)).toList() . And, of course, you can always create a list of widgets and imperatively add to it inside the build method.
The advantage of adding the Favorites page yourself is that you learn more by making your own decisions. The disadvantage is that you might run into trouble that you aren't yet able to solve by yourself. Remember: failing is okay, and is one of the most important elements of learning. Nobody expects you to nail Flutter development in your first hour, and neither should you.

What follows is just one way to implement the favorites page. How it's implemented will (hopefully) inspire you to play with the code—improve the UI and make it your own.
Here's the new FavoritesPage class:
lib/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),
),
],
);
}
}
Here's what the widget does:
- It gets the current state of the app.
- If the list of favorites is empty, it shows a centered message: No favorites yet.
- Otherwise, it shows a (scrollable) list.
- The list starts with a summary (for example, You have 5 favorites. ).
- The code then iterates through all the favorites, and constructs a
ListTilewidget for each one.
All that remains now is to replace the Placeholder widget with a FavoritesPage . And voilá!
You can get the final code of this app in the codelab repo on GitHub.
9. Next steps
Поздравляем!
Look at you! You took a non-functional scaffold with a Column and two Text widgets, and made it into a responsive, delightful little app.

Что мы рассмотрели
- The basics of how Flutter works
- Creating layouts in Flutter
- Connecting user interactions (like button presses) to app behavior
- Keeping your Flutter code organized
- Making your app responsive
- Achieving a consistent look & feel of your app
Что дальше?
- Experiment more with the app you wrote during this lab.
- Look at the code of this advanced version of the same app, to see how you can add animated lists, gradients, cross-fades, and more.
- Follow your learning journey by going to flutter.dev/learn .