Создание пользовательских интерфейсов нового поколения во Flutter

Создание пользовательских интерфейсов нового поколения во Flutter

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

subjectПоследнее обновление: мая 13, 2024
account_circleАвторы: Brett Morgan

1. Прежде чем начать

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

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

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

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

На следующих снимках экрана показано приложение, которое вы создадите для трех поддерживаемых настольных операционных систем: Windows, Linux и macOS. Для полноты представлена ​​версия для веб-браузера (также поддерживается). Анимации и фрагментные шейдеры повсюду!

Готовое приложение, работающее на Windows

Готовое приложение, работающее в браузере Chrome.

Готовое приложение, работающее на Linux

Готовое приложение, работающее на macOS

Предварительные условия

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

Что вам понадобится

2. Начать

Загрузите стартовый код

  1. Перейдите в этот репозиторий GitHub .
  2. Нажмите «Код» > «Загрузить zip» , чтобы загрузить весь код для этой лаборатории кода.
  3. Разархивируйте загруженный zip-файл, чтобы распаковать codelabs-main . Вам нужен только подкаталог next-gen-ui/ , который содержит папки step_01 до step_06 , в которых содержится исходный код, на основе которого вы строите каждый шаг этой лаборатории кода.

Загрузите зависимости проекта

  1. В VS Code нажмите «Файл» > «Открыть папку» > «codelabs-main» > «next-gen-uis» > «step_01», чтобы открыть стартовый проект.
  2. Если вы увидите диалоговое окно VS Code, в котором вам будет предложено загрузить необходимые пакеты для начального приложения, нажмите « Получить пакеты» .

Диалоговое окно VS Code, предлагающее загрузить необходимые пакеты для начального приложения.

  1. Если вы не видите диалоговое окно VS Code, предлагающее загрузить необходимые пакеты для начального приложения, откройте терминал, а затем перейдите к папке step_01 и запустите команду flutter pub get .

Запустите стартовое приложение

  1. В VS Code выберите либо операционную систему для настольного компьютера, либо Chrome, если вы хотите протестировать свое приложение в веб-браузере.

Например, вот что вы видите, когда используете macOS в качестве цели развертывания:

Оформление строки состояния VSCode, показывающее, что целью Flutter является macOS (Дарвин)

Вот что вы видите, когда используете Chrome в качестве цели развертывания:

Оформление строки состояния VSCode, показывающее, что целью Flutter является Chrome (веб-javascript).

  1. Откройте файл lib/main.dart и нажмите Кнопка Play из VSCode Запустите отладку . Приложение запускается в операционной системе вашего настольного компьютера или в браузере Chrome.

Изучите стартовое приложение

В стартовом приложении обратите внимание на следующее:

  • Пользовательский интерфейс готов к созданию.
  • В каталоге assets находятся художественные ресурсы и два фрагментных шейдера, которые вы будете использовать.
  • В файле pubspec.yaml уже перечислены ресурсы и набор пакетов pub, которые вы будете использовать.
  • Каталог lib содержит обязательный файл main.dart , файл assets.dart , в котором указан путь к художественным ресурсам и фрагментным шейдерам, а также файл styles.dart , в котором перечислены TextStyles и Colors, которые вы будете использовать.
  • Каталог lib также содержит common каталог, содержащий несколько полезных утилит, которые вы будете использовать в этой лаборатории, и каталог orb_shader , содержащий Widget , который будет использоваться для отображения сферы с помощью вершинного шейдера.

Вот что вы увидите после запуска приложения.

Приложение Codelab, работающее под названием «Вставьте сюда пользовательский интерфейс следующего поколения...»

3. Раскрась сцену

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

Добавьте ресурсы в сцену

  1. Создайте каталог title_screen в каталоге lib , а затем добавьте файл title_screen.dart . Добавьте в файл следующее содержимое:

lib/title_screen/title_screen.dart

import 'package:flutter/material.dart';

import '../assets.dart';

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

 
@override
 
Widget build(BuildContext context) {
   
return Scaffold(
      backgroundColor
: Colors.black,
      body
: Center(
        child
: Stack(
          children
: [
           
/// Bg-Base
           
Image.asset(AssetPaths.titleBgBase),

           
/// Bg-Receive
           
Image.asset(AssetPaths.titleBgReceive),

           
/// Mg-Base
           
Image.asset(AssetPaths.titleMgBase),

           
/// Mg-Receive
           
Image.asset(AssetPaths.titleMgReceive),

           
/// Mg-Emit
           
Image.asset(AssetPaths.titleMgEmit),

           
/// Fg-Rocks
           
Image.asset(AssetPaths.titleFgBase),

           
/// Fg-Receive
           
Image.asset(AssetPaths.titleFgReceive),

           
/// Fg-Emit
           
Image.asset(AssetPaths.titleFgEmit),
         
],
       
),
     
),
   
);
 
}
}

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

  1. В файл main.dart добавьте следующее содержимое:

библиотека/main.dart

import 'dart:io' show Platform;

import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:window_size/window_size.dart';
                                                         
// Remove 'styles.dart' import
import 'title_screen/title_screen.dart';                  // Add this import


void main() {
 
if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
   
WidgetsFlutterBinding.ensureInitialized();
    setWindowMinSize
(const Size(800, 500));
 
}
  runApp
(const NextGenApp());
}

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

 
@override
 
Widget build(BuildContext context) {
   
return MaterialApp(
      themeMode
: ThemeMode.dark,
      darkTheme
: ThemeData(brightness: Brightness.dark),
      home
: const TitleScreen(),                          // Replace with this widget
   
);
 
}
}

При этом пользовательский интерфейс приложения заменяется монохромной сценой, созданной художественными ресурсами. Далее вы раскрашиваете каждый слой.

Приложение Codelab работает только с графическими ресурсами фона, среднего и переднего плана, отображаемыми в монохромном режиме.

Добавьте утилиту для раскрашивания изображений

Добавьте утилиту раскраски изображений, добавив в файл title_screen.dart следующее содержимое:

lib/title_screen/title_screen.dart

import 'package:flutter/material.dart';

import '../assets.dart';

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

 
@override
 
Widget build(BuildContext context) {
   
return Scaffold(
      backgroundColor
: Colors.black,
      body
: Center(
        child
: Stack(
          children
: [
           
/// Bg-Base
           
Image.asset(AssetPaths.titleBgBase),

           
/// Bg-Receive
           
Image.asset(AssetPaths.titleBgReceive),

           
/// Mg-Base
           
Image.asset(AssetPaths.titleMgBase),

           
/// Mg-Receive
           
Image.asset(AssetPaths.titleMgReceive),

           
/// Mg-Emit
           
Image.asset(AssetPaths.titleMgEmit),

           
/// Fg-Rocks
           
Image.asset(AssetPaths.titleFgBase),

           
/// Fg-Receive
           
Image.asset(AssetPaths.titleFgReceive),

           
/// Fg-Emit
           
Image.asset(AssetPaths.titleFgEmit),
         
],
       
),
     
),
   
);
 
}
}

class _LitImage extends StatelessWidget {                 // Add from here...
 
const _LitImage({
    required
this.color,
    required
this.imgSrc,
    required
this.lightAmt,
 
});
 
final Color color;
 
final String imgSrc;
 
final double lightAmt;

 
@override
 
Widget build(BuildContext context) {
   
final hsl = HSLColor.fromColor(color);
   
return Image.asset(
      imgSrc
,
      color
: hsl.withLightness(hsl.lightness * lightAmt).toColor(),
      colorBlendMode
: BlendMode.modulate,
   
);
 
}
}                                                         // to here.

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

Краска в цвет

Раскрасьте цветом, изменив файл title_screen.dart следующим образом:

lib/title_screen/title_screen.dart

import 'package:flutter/material.dart';

import '../assets.dart';
import '../styles.dart';                                  // Add this import

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

 
final _finalReceiveLightAmt = 0.7;                      // Add this attribute
 
final _finalEmitLightAmt = 0.5;                         // And this attribute

 
@override
 
Widget build(BuildContext context) {
   
final orbColor = AppColors.orbColors[0];              // Add this final variable
   
final emitColor = AppColors.emitColors[0];            // And this one

   
return Scaffold(
      backgroundColor
: Colors.black,
      body
: Center(
        child
: Stack(
          children
: [
           
/// Bg-Base
           
Image.asset(AssetPaths.titleBgBase),

           
/// Bg-Receive
            _LitImage
(                                    // Modify from here...
              color
: orbColor,
              imgSrc
: AssetPaths.titleBgReceive,
              lightAmt
: _finalReceiveLightAmt,
           
),                                            // to here.

           
/// Mg-Base
            _LitImage
(                                    // Modify from here...
              imgSrc
: AssetPaths.titleMgBase,
              color
: orbColor,
              lightAmt
: _finalReceiveLightAmt,
           
),                                            // to here.

           
/// Mg-Receive
            _LitImage
(                                    // Modify from here...
              imgSrc
: AssetPaths.titleMgReceive,
              color
: orbColor,
              lightAmt
: _finalReceiveLightAmt,
           
),                                            // to here.

           
/// Mg-Emit
            _LitImage
(                                    // Modify from here...
              imgSrc
: AssetPaths.titleMgEmit,
              color
: emitColor,
              lightAmt
: _finalEmitLightAmt,
           
),                                            // to here.

           
/// Fg-Rocks
           
Image.asset(AssetPaths.titleFgBase),

           
/// Fg-Receive
            _LitImage
(                                    // Modify from here...
              imgSrc
: AssetPaths.titleFgReceive,
              color
: orbColor,
              lightAmt
: _finalReceiveLightAmt,
           
),                                            // to here.

           
/// Fg-Emit
            _LitImage
(                                    // Modify from here...
              imgSrc
: AssetPaths.titleFgEmit,
              color
: emitColor,
              lightAmt
: _finalEmitLightAmt,
           
),                                            // to here.
         
],
       
),
     
),
   
);
 
}
}

class _LitImage extends StatelessWidget {
 
const _LitImage({
    required
this.color,
    required
this.imgSrc,
    required
this.lightAmt,
 
});
 
final Color color;
 
final String imgSrc;
 
final double lightAmt;

 
@override
 
Widget build(BuildContext context) {
   
final hsl = HSLColor.fromColor(color);
   
return Image.asset(
      imgSrc
,
      color
: hsl.withLightness(hsl.lightness * lightAmt).toColor(),
      colorBlendMode
: BlendMode.modulate,
   
);
 
}
}

Вот снова приложение, на этот раз с художественными ресурсами, окрашенными в зеленый цвет.

Приложение Codelab, работающее с художественными ресурсами, окрашенными в зеленый цвет.

4. Добавить пользовательский интерфейс

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

Добавить заголовок

  1. Создайте файл title_screen_ui.dart в каталоге lib/title_screen и добавьте в него следующее содержимое:

lib/title_screen/title_screen_ui.dart

import 'package:extra_alignments/extra_alignments.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';

import '../assets.dart';
import '../common/ui_scaler.dart';
import '../styles.dart';

class TitleScreenUi extends StatelessWidget {
 
const TitleScreenUi({
   
super.key,
 
});
 
@override
 
Widget build(BuildContext context) {
   
return const Padding(
      padding
: EdgeInsets.symmetric(vertical: 40, horizontal: 50),
      child
: Stack(
        children
: [
         
/// Title Text
         
TopLeft(
            child
: UiScaler(
              alignment
: Alignment.topLeft,
              child
: _TitleText(),
           
),
         
),
       
],
     
),
   
);
 
}
}

class _TitleText extends StatelessWidget {
 
const _TitleText();

 
@override
 
Widget build(BuildContext context) {
   
return Column(
      mainAxisSize
: MainAxisSize.min,
      crossAxisAlignment
: CrossAxisAlignment.start,
      children
: [
       
const Gap(20),
       
Row(
          mainAxisSize
: MainAxisSize.min,
          children
: [
           
Transform.translate(
              offset
: Offset(-(TextStyles.h1.letterSpacing! * .5), 0),
              child
: Text('OUTPOST', style: TextStyles.h1),
           
),
           
Image.asset(AssetPaths.titleSelectedLeft, height: 65),
           
Text('57', style: TextStyles.h2),
           
Image.asset(AssetPaths.titleSelectedRight, height: 65),
         
],
       
),
       
Text('INTO THE UNKNOWN', style: TextStyles.h3),
     
],
   
);
 
}
}

Этот виджет содержит заголовок и все кнопки, составляющие пользовательский интерфейс этого приложения.

  1. Обновите файл lib/title_screen/title_screen.dart следующим образом:

lib/title_screen/title_screen.dart

import 'package:flutter/material.dart';

import '../assets.dart';
import '../styles.dart';
import 'title_screen_ui.dart';                            // Add this import

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

 
final _finalReceiveLightAmt = 0.7;
 
final _finalEmitLightAmt = 0.5;

 
@override
 
Widget build(BuildContext context) {
   
final orbColor = AppColors.orbColors[0];
   
final emitColor = AppColors.emitColors[0];

   
return Scaffold(
      backgroundColor
: Colors.black,
      body
: Center(
        child
: Stack(
          children
: [
           
/// Bg-Base
           
Image.asset(AssetPaths.titleBgBase),

           
/// Bg-Receive
            _LitImage
(
              color
: orbColor,
              imgSrc
: AssetPaths.titleBgReceive,
              lightAmt
: _finalReceiveLightAmt,
           
),

           
/// Mg-Base
            _LitImage
(
              imgSrc
: AssetPaths.titleMgBase,
              color
: orbColor,
              lightAmt
: _finalReceiveLightAmt,
           
),

           
/// Mg-Receive
            _LitImage
(
              imgSrc
: AssetPaths.titleMgReceive,
              color
: orbColor,
              lightAmt
: _finalReceiveLightAmt,
           
),

           
/// Mg-Emit
            _LitImage
(
              imgSrc
: AssetPaths.titleMgEmit,
              color
: emitColor,
              lightAmt
: _finalEmitLightAmt,
           
),

           
/// Fg-Rocks
           
Image.asset(AssetPaths.titleFgBase),

           
/// Fg-Receive
            _LitImage
(
              imgSrc
: AssetPaths.titleFgReceive,
              color
: orbColor,
              lightAmt
: _finalReceiveLightAmt,
           
),

           
/// Fg-Emit
            _LitImage
(
              imgSrc
: AssetPaths.titleFgEmit,
              color
: emitColor,
              lightAmt
: _finalEmitLightAmt,
           
),

           
/// UI
           
const Positioned.fill(                        // Add from here...
              child
: TitleScreenUi(),
           
),                                            // to here.
         
],
       
),
     
),
   
);
 
}
}

class _LitImage extends StatelessWidget {
 
const _LitImage({
    required
this.color,
    required
this.imgSrc,
    required
this.lightAmt,
 
});
 
final Color color;
 
final String imgSrc;
 
final double lightAmt;

 
@override
 
Widget build(BuildContext context) {
   
final hsl = HSLColor.fromColor(color);
   
return Image.asset(
      imgSrc
,
      color
: hsl.withLightness(hsl.lightness * lightAmt).toColor(),
      colorBlendMode
: BlendMode.modulate,
   
);
 
}
}

Запуск этого кода показывает заголовок, который является началом пользовательского интерфейса.

Приложение Codelab работает с заголовком «Outpost [57] В неизвестность».

Добавьте кнопки сложности.

  1. Обновите title_screen_ui.dart , добавив новый импорт для пакета focusable_control_builder :

lib/title_screen/title_screen_ui.dart

import 'package:extra_alignments/extra_alignments.dart';
import 'package:flutter/material.dart';
import 'package:focusable_control_builder/focusable_control_builder.dart'; // Add import
import 'package:gap/gap.dart';

import '../assets.dart';
import '../common/ui_scaler.dart';
import '../styles.dart';
  1. В виджет TitleScreenUi добавьте следующее:

lib/title_screen/title_screen_ui.dart

class TitleScreenUi extends StatelessWidget {
 
const TitleScreenUi({
   
super.key,
    required
this.difficulty,                            // Edit from here...
    required
this.onDifficultyPressed,
    required
this.onDifficultyFocused,
 
});

 
final int difficulty;
 
final void Function(int difficulty) onDifficultyPressed;
 
final void Function(int? difficulty) onDifficultyFocused; // to here.

 
@override
 
Widget build(BuildContext context) {
   
return Padding(                                      // Move this const...
      padding
: const EdgeInsets.symmetric(vertical: 40, horizontal: 50), // to here.
      child
: Stack(
        children
: [
         
/// Title Text
         
const TopLeft(                                 // Add a const here, as well
            child
: UiScaler(
              alignment
: Alignment.topLeft,
              child
: _TitleText(),
           
),
         
),

         
/// Difficulty Btns
         
BottomLeft(                                    // Add from here...
            child
: UiScaler(
              alignment
: Alignment.bottomLeft,
              child
: _DifficultyBtns(
                difficulty
: difficulty,
                onDifficultyPressed
: onDifficultyPressed,
                onDifficultyFocused
: onDifficultyFocused,
             
),
           
),
         
),                                             // to here.
       
],
     
),
   
);
 
}
}
  1. Добавьте следующие два виджета для реализации кнопок сложности:

lib/title_screen/title_screen_ui.dart

class _DifficultyBtns extends StatelessWidget {
 
const _DifficultyBtns({
    required
this.difficulty,
    required
this.onDifficultyPressed,
    required
this.onDifficultyFocused,
 
});

 
final int difficulty;
 
final void Function(int difficulty) onDifficultyPressed;
 
final void Function(int? difficulty) onDifficultyFocused;

 
@override
 
Widget build(BuildContext context) {
   
return Column(
      mainAxisSize
: MainAxisSize.min,
      children
: [
        _DifficultyBtn
(
          label
: 'Casual',
          selected
: difficulty == 0,
          onPressed
: () => onDifficultyPressed(0),
          onHover
: (over) => onDifficultyFocused(over ? 0 : null),
       
),
        _DifficultyBtn
(
          label
: 'Normal',
          selected
: difficulty == 1,
          onPressed
: () => onDifficultyPressed(1),
          onHover
: (over) => onDifficultyFocused(over ? 1 : null),
       
),
        _DifficultyBtn
(
          label
: 'Hardcore',
          selected
: difficulty == 2,
          onPressed
: () => onDifficultyPressed(2),
          onHover
: (over) => onDifficultyFocused(over ? 2 : null),
       
),
       
const Gap(20),
     
],
   
);
 
}
}

class _DifficultyBtn extends StatelessWidget {
 
const _DifficultyBtn({
    required
this.selected,
    required
this.onPressed,
    required
this.onHover,
    required
this.label,
 
});
 
final String label;
 
final bool selected;
 
final VoidCallback onPressed;
 
final void Function(bool hasFocus) onHover;

 
@override
 
Widget build(BuildContext context) {
   
return FocusableControlBuilder(
      onPressed
: onPressed,
      onHoverChanged
: (_, state) => onHover.call(state.isHovered),
      builder
: (_, state) {
       
return Padding(
          padding
: const EdgeInsets.all(8.0),
          child
: SizedBox(
            width
: 250,
            height
: 60,
            child
: Stack(
              children
: [
               
/// Bg with fill and outline
               
Container(
                  decoration
: BoxDecoration(
                    color
: const Color(0xFF00D1FF).withOpacity(.1),
                    border
: Border.all(color: Colors.white, width: 5),
                 
),
               
),

               
if (state.isHovered || state.isFocused) ...[
                 
Container(
                    decoration
: BoxDecoration(
                      color
: const Color(0xFF00D1FF).withOpacity(.1),
                   
),
                 
),
               
],

               
/// cross-hairs (selected state)
               
if (selected) ...[
                 
CenterLeft(
                    child
: Image.asset(AssetPaths.titleSelectedLeft),
                 
),
                 
CenterRight(
                    child
: Image.asset(AssetPaths.titleSelectedRight),
                 
),
               
],

               
/// Label
               
Center(
                  child
: Text(label.toUpperCase(), style: TextStyles.btn),
               
),
             
],
           
),
         
),
       
);
     
},
   
);
 
}
}
  1. Преобразуйте виджет TitleScreen из состояния без сохранения состояния в состояние с сохранением состояния и добавьте состояние, чтобы можно было изменять цветовую схему в зависимости от сложности:

lib/title_screen/title_screen.dart

import 'package:flutter/material.dart';

import '../assets.dart';
import '../styles.dart';
import 'title_screen_ui.dart';

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

 
@override
 
State<TitleScreen> createState() => _TitleScreenState();
}

class _TitleScreenState extends State<TitleScreen> {
 
Color get _emitColor =>
     
AppColors.emitColors[_difficultyOverride ?? _difficulty];
 
Color get _orbColor =>
     
AppColors.orbColors[_difficultyOverride ?? _difficulty];

 
/// Currently selected difficulty
 
int _difficulty = 0;

 
/// Currently focused difficulty (if any)
 
int? _difficultyOverride;

 
void _handleDifficultyPressed(int value) {
    setState
(() => _difficulty = value);
 
}

 
void _handleDifficultyFocused(int? value) {
    setState
(() => _difficultyOverride = value);
 
}

 
final _finalReceiveLightAmt = 0.7;
 
final _finalEmitLightAmt = 0.5;

 
@override
 
Widget build(BuildContext context) {
   
return Scaffold(
      backgroundColor
: Colors.black,
      body
: Center(
        child
: Stack(
          children
: [
           
/// Bg-Base
           
Image.asset(AssetPaths.titleBgBase),

           
/// Bg-Receive
            _LitImage
(
              color
: _orbColor,
              imgSrc
: AssetPaths.titleBgReceive,
              lightAmt
: _finalReceiveLightAmt,
           
),

           
/// Mg-Base
            _LitImage
(
              imgSrc
: AssetPaths.titleMgBase,
              color
: _orbColor,
              lightAmt
: _finalReceiveLightAmt,
           
),

           
/// Mg-Receive
            _LitImage
(
              imgSrc
: AssetPaths.titleMgReceive,
              color
: _orbColor,
              lightAmt
: _finalReceiveLightAmt,
           
),

           
/// Mg-Emit
            _LitImage
(
              imgSrc
: AssetPaths.titleMgEmit,
              color
: _emitColor,
              lightAmt
: _finalEmitLightAmt,
           
),

           
/// Fg-Rocks
           
Image.asset(AssetPaths.titleFgBase),

           
/// Fg-Receive
            _LitImage
(
              imgSrc
: AssetPaths.titleFgReceive,
              color
: _orbColor,
              lightAmt
: _finalReceiveLightAmt,
           
),

           
/// Fg-Emit
            _LitImage
(
              imgSrc
: AssetPaths.titleFgEmit,
              color
: _emitColor,
              lightAmt
: _finalEmitLightAmt,
           
),

           
/// UI
           
Positioned.fill(
              child
: TitleScreenUi(
                difficulty
: _difficulty,
                onDifficultyFocused
: _handleDifficultyFocused,
                onDifficultyPressed
: _handleDifficultyPressed,
             
),
           
),
         
],
       
),
     
),
   
);
 
}
}

class _LitImage extends StatelessWidget {
 
const _LitImage({
    required
this.color,
    required
this.imgSrc,
    required
this.lightAmt,
 
});
 
final Color color;
 
final String imgSrc;
 
final double lightAmt;

 
@override
 
Widget build(BuildContext context) {
   
final hsl = HSLColor.fromColor(color);
   
return Image.asset(
      imgSrc
,
      color
: hsl.withLightness(hsl.lightness * lightAmt).toColor(),
      colorBlendMode
: BlendMode.modulate,
   
);
 
}
}

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

Приложение Codelab с выбранной нормальной сложностью, в котором ресурсы изображений окрашены в фиолетовый и голубой цвета.

Приложение Codelab с выбранным уровнем сложности «Hardcore», в котором ресурсы изображения окрашены в огненно-оранжевый цвет.

Добавьте кнопку «Пуск»

  1. Обновите файл title_screen_ui.dart . В виджет TitleScreenUi добавьте следующее:

lib/title_screen/title_screen_ui.dart

class TitleScreenUi extends StatelessWidget {
 
const TitleScreenUi({
   
super.key,
    required
this.difficulty,
    required
this.onDifficultyPressed,
    required
this.onDifficultyFocused,
 
});

 
final int difficulty;
 
final void Function(int difficulty) onDifficultyPressed;
 
final void Function(int? difficulty) onDifficultyFocused;

 
@override
 
Widget build(BuildContext context) {
   
return Padding(
      padding
: const EdgeInsets.symmetric(vertical: 40, horizontal: 50),
      child
: Stack(
        children
: [
         
/// Title Text
         
const TopLeft(
            child
: UiScaler(
              alignment
: Alignment.topLeft,
              child
: _TitleText(),
           
),
         
),

         
/// Difficulty Btns
         
BottomLeft(
            child
: UiScaler(
              alignment
: Alignment.bottomLeft,
              child
: _DifficultyBtns(
                difficulty
: difficulty,
                onDifficultyPressed
: onDifficultyPressed,
                onDifficultyFocused
: onDifficultyFocused,
             
),
           
),
         
),

         
/// StartBtn
         
BottomRight(                                    // Add from here...
            child
: UiScaler(
              alignment
: Alignment.bottomRight,
              child
: Padding(
                padding
: const EdgeInsets.only(bottom: 20, right: 40),
                child
: _StartBtn(onPressed: () {}),
             
),
           
),
         
),                                              // to here.
       
],
     
),
   
);
 
}
}
  1. Добавьте следующий виджет для реализации кнопки «Пуск»:

lib/title_screen/title_screen_ui.dart

class _StartBtn extends StatefulWidget {
 
const _StartBtn({required this.onPressed});
 
final VoidCallback onPressed;

 
@override
 
State<_StartBtn> createState() => _StartBtnState();
}

class _StartBtnState extends State<_StartBtn> {
 
AnimationController? _btnAnim;
 
bool _wasHovered = false;

 
@override
 
Widget build(BuildContext context) {
   
return FocusableControlBuilder(
      cursor
: SystemMouseCursors.click,
      onPressed
: widget.onPressed,
      builder
: (_, state) {
       
if ((state.isHovered || state.isFocused) &&
           
!_wasHovered &&
            _btnAnim
?.status != AnimationStatus.forward) {
          _btnAnim
?.forward(from: 0);
       
}
        _wasHovered
= (state.isHovered || state.isFocused);
       
return SizedBox(
          width
: 520,
          height
: 100,
          child
: Stack(
            children
: [
             
Positioned.fill(child: Image.asset(AssetPaths.titleStartBtn)),
             
if (state.isHovered || state.isFocused) ...[
               
Positioned.fill(
                    child
: Image.asset(AssetPaths.titleStartBtnHover)),
             
],
             
Center(
                child
: Row(
                  mainAxisAlignment
: MainAxisAlignment.end,
                  children
: [
                   
Text('START MISSION',
                        style
: TextStyles.btn
                           
.copyWith(fontSize: 24, letterSpacing: 18)),
                 
],
               
),
             
),
           
],
         
),
       
);
     
},
   
);
 
}
}

А вот приложение работает с полной коллекцией кнопок.

Приложение Codelab с выбранной нормальной сложностью, отображающее заголовок, кнопки сложности и кнопку запуска.

5. Добавить анимацию

На этом этапе вы анимируете пользовательский интерфейс и цветовые переходы для графических ресурсов.

Исчезновение в названии

На этом этапе вы используете несколько подходов для анимации приложения Flutter. Один из подходов — использовать flutter_animate . Анимации, созданные на основе этого пакета, могут автоматически воспроизводиться при каждой горячей перезагрузке приложения, чтобы ускорить итерации разработки.

  1. Измените код в lib/main.dart следующим образом:

библиотека/main.dart

import 'dart:io' show Platform;

import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';   // Add this import
import 'package:window_size/window_size.dart';

import 'title_screen/title_screen.dart';

void main() {
 
if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
   
WidgetsFlutterBinding.ensureInitialized();
    setWindowMinSize
(const Size(800, 500));
 
}
 
Animate.restartOnHotReload = true;                     // Add this line
  runApp
(const NextGenApp());
}

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

 
@override
 
Widget build(BuildContext context) {
   
return MaterialApp(
      themeMode
: ThemeMode.dark,
      darkTheme
: ThemeData(brightness: Brightness.dark),
      home
: const TitleScreen(),
   
);
 
}
}
  1. Чтобы воспользоваться преимуществами пакета flutter_animate , вам необходимо его импортировать. Добавьте импорт в lib/title_screen/title_screen_ui.dart следующим образом:

lib/title_screen/title_screen_ui.dart

import 'package:extra_alignments/extra_alignments.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';   // Add this import
import 'package:focusable_control_builder/focusable_control_builder.dart';
import 'package:gap/gap.dart';

import '../assets.dart';
import '../common/ui_scaler.dart';
import '../styles.dart';

class TitleScreenUi extends StatelessWidget {
  1. Добавьте анимацию к заголовку, отредактировав виджет _TitleText следующим образом:

lib/title_screen/title_screen_ui.dart

class _TitleText extends StatelessWidget {
 
const _TitleText();

 
@override
 
Widget build(BuildContext context) {
   
return Column(
      mainAxisSize
: MainAxisSize.min,
      crossAxisAlignment
: CrossAxisAlignment.start,
      children
: [
       
const Gap(20),
       
Row(
          mainAxisSize
: MainAxisSize.min,
          children
: [
           
Transform.translate(
              offset
: Offset(-(TextStyles.h1.letterSpacing! * .5), 0),
              child
: Text('OUTPOST', style: TextStyles.h1),
           
),
           
Image.asset(AssetPaths.titleSelectedLeft, height: 65),
           
Text('57', style: TextStyles.h2),
           
Image.asset(AssetPaths.titleSelectedRight, height: 65),
         
],                                             // Edit from here...
       
).animate().fadeIn(delay: .8.seconds, duration: .7.seconds),
       
Text('INTO THE UNKNOWN', style: TextStyles.h3)
           
.animate()
           
.fadeIn(delay: 1.seconds, duration: .7.seconds),
     
],                                                 // to here.
   
);
 
}
}
  1. Нажмите «Обновить» , чтобы увидеть, как заголовок исчезает.

Постепенное исчезновение кнопок сложности.

  1. Добавьте анимацию к первоначальному виду кнопок сложности, отредактировав виджет _DifficultyBtns следующим образом:

lib/title_screen/title_screen_ui.dart

class _DifficultyBtns extends StatelessWidget {
 
const _DifficultyBtns({
    required
this.difficulty,
    required
this.onDifficultyPressed,
    required
this.onDifficultyFocused,
 
});

 
final int difficulty;
 
final void Function(int difficulty) onDifficultyPressed;
 
final void Function(int? difficulty) onDifficultyFocused;

 
@override
 
Widget build(BuildContext context) {
   
return Column(
      mainAxisSize
: MainAxisSize.min,
      children
: [
        _DifficultyBtn
(
          label
: 'Casual',
          selected
: difficulty == 0,
          onPressed
: () => onDifficultyPressed(0),
          onHover
: (over) => onDifficultyFocused(over ? 0 : null),
       
)                                                // Add from here...
           
.animate()
           
.fadeIn(delay: 1.3.seconds, duration: .35.seconds)
           
.slide(begin: const Offset(0, .2)),          // to here
        _DifficultyBtn
(
          label
: 'Normal',
          selected
: difficulty == 1,
          onPressed
: () => onDifficultyPressed(1),
          onHover
: (over) => onDifficultyFocused(over ? 1 : null),
       
)                                                // Add from here...
           
.animate()
           
.fadeIn(delay: 1.5.seconds, duration: .35.seconds)
           
.slide(begin: const Offset(0, .2)),          // to here
        _DifficultyBtn
(
          label
: 'Hardcore',
          selected
: difficulty == 2,
          onPressed
: () => onDifficultyPressed(2),
          onHover
: (over) => onDifficultyFocused(over ? 2 : null),
       
)                                                // Add from here...
           
.animate()
           
.fadeIn(delay: 1.7.seconds, duration: .35.seconds)
           
.slide(begin: const Offset(0, .2)),          // to here
       
const Gap(20),
     
],
   
);
 
}
}
  1. Нажмите «Перезагрузить» , чтобы увидеть, как кнопки сложности появляются по порядку, а в качестве бонуса — плавное скольжение вверх.

Затухание кнопки «Пуск»

  1. Добавьте анимацию к кнопке «Пуск», отредактировав класс состояния _StartBtnState следующим образом:

lib/title_screen/title_screen_ui.dart

class _StartBtnState extends State<_StartBtn> {
 
AnimationController? _btnAnim;
 
bool _wasHovered = false;

 
@override
 
Widget build(BuildContext context) {
   
return FocusableControlBuilder(
      cursor
: SystemMouseCursors.click,
      onPressed
: widget.onPressed,
      builder
: (_, state) {
       
if ((state.isHovered || state.isFocused) &&
           
!_wasHovered &&
            _btnAnim
?.status != AnimationStatus.forward) {
          _btnAnim
?.forward(from: 0);
       
}
        _wasHovered
= (state.isHovered || state.isFocused);
       
return SizedBox(
          width
: 520,
          height
: 100,
          child
: Stack(
            children
: [
             
Positioned.fill(child: Image.asset(AssetPaths.titleStartBtn)),
             
if (state.isHovered || state.isFocused) ...[
               
Positioned.fill(
                    child
: Image.asset(AssetPaths.titleStartBtnHover)),
             
],
             
Center(
                child
: Row(
                  mainAxisAlignment
: MainAxisAlignment.end,
                  children
: [
                   
Text('START MISSION',
                        style
: TextStyles.btn
                           
.copyWith(fontSize: 24, letterSpacing: 18)),
                 
],
               
),
             
),
           
],
         
)                                              // Edit from here...
             
.animate(autoPlay: false, onInit: (c) => _btnAnim = c)
             
.shimmer(duration: .7.seconds, color: Colors.black),
       
)
           
.animate()
           
.fadeIn(delay: 2.3.seconds)
           
.slide(begin: const Offset(0, .2));
     
},                                                 // to here.
   
);
 
}
}
  1. Нажмите «Перезагрузить» , чтобы увидеть, как кнопки сложности появляются по порядку, а в качестве бонуса — плавное скольжение вверх.

Анимация эффекта наведения сложности

Добавьте анимацию к состоянию наведения кнопок сложности, отредактировав класс состояния _DifficultyBtn следующим образом:

lib/title_screen/title_screen_ui.dart

class _DifficultyBtn extends StatelessWidget {
 
const _DifficultyBtn({
    required
this.selected,
    required
this.onPressed,
    required
this.onHover,
    required
this.label,
 
});
 
final String label;
 
final bool selected;
 
final VoidCallback onPressed;
 
final void Function(bool hasFocus) onHover;

 
@override
 
Widget build(BuildContext context) {
   
return FocusableControlBuilder(
      onPressed
: onPressed,
      onHoverChanged
: (_, state) => onHover.call(state.isHovered),
      builder
: (_, state) {
       
return Padding(
          padding
: const EdgeInsets.all(8.0),
          child
: SizedBox(
            width
: 250,
            height
: 60,
            child
: Stack(
              children
: [
               
/// Bg with fill and outline
               
AnimatedOpacity(                         // Edit from here
                  opacity
: (!selected && (state.isHovered || state.isFocused))
                     
? 1
                     
: 0,
                  duration
: .3.seconds,
                  child
: Container(
                    decoration
: BoxDecoration(
                      color
: const Color(0xFF00D1FF).withOpacity(.1),
                      border
: Border.all(color: Colors.white, width: 5),
                   
),
                 
),
               
),                                       // to here.

               
if (state.isHovered || state.isFocused) ...[
                 
Container(
                    decoration
: BoxDecoration(
                      color
: const Color(0xFF00D1FF).withOpacity(.1),
                   
),
                 
),
               
],

               
/// cross-hairs (selected state)
               
if (selected) ...[
                 
CenterLeft(
                    child
: Image.asset(AssetPaths.titleSelectedLeft),
                 
),
                 
CenterRight(
                    child
: Image.asset(AssetPaths.titleSelectedRight),
                 
),
               
],

               
/// Label
               
Center(
                  child
: Text(label.toUpperCase(), style: TextStyles.btn),
               
),
             
],
           
),
         
),
       
);
     
},
   
);
 
}
}

На кнопках сложности теперь отображается BoxDecoration , когда указатель мыши наводится на кнопку, которая не была выбрана.

Анимация изменения цвета

  1. Изменение цвета фона происходит мгновенно и резко. Лучше анимировать освещенные изображения между цветовыми схемами. Добавьте flutter_animate в lib/title_screen/title_screen.dart :

lib/title_screen/title_screen.dart

import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';    // Add this import

import '../assets.dart';
import '../styles.dart';
import 'title_screen_ui.dart';

class TitleScreen extends StatefulWidget {
  1. Добавьте виджет _AnimatedColors в lib/title_screen/title_screen.dart :

lib/title_screen/title_screen.dart

class _AnimatedColors extends StatelessWidget {
 
const _AnimatedColors({
    required
this.emitColor,
    required
this.orbColor,
    required
this.builder,
 
});

 
final Color emitColor;
 
final Color orbColor;

 
final Widget Function(BuildContext context, Color orbColor, Color emitColor)
      builder
;

 
@override
 
Widget build(BuildContext context) {
   
final duration = .5.seconds;
   
return TweenAnimationBuilder(
      tween
: ColorTween(begin: emitColor, end: emitColor),
      duration
: duration,
      builder
: (_, emitColor, __) {
       
return TweenAnimationBuilder(
          tween
: ColorTween(begin: orbColor, end: orbColor),
          duration
: duration,
          builder
: (context, orbColor, __) {
           
return builder(context, orbColor!, emitColor!);
         
},
       
);
     
},
   
);
 
}
}
  1. Используйте только что созданный виджет для анимации цветов освещенных изображений, обновив метод build в _TitleScreenState следующим образом:

lib/title_screen/title_screen.dart

class _TitleScreenState extends State<TitleScreen> {
 
Color get _emitColor =>
     
AppColors.emitColors[_difficultyOverride ?? _difficulty];
 
Color get _orbColor =>
     
AppColors.orbColors[_difficultyOverride ?? _difficulty];

 
/// Currently selected difficulty
 
int _difficulty = 0;

 
/// Currently focused difficulty (if any)
 
int? _difficultyOverride;

 
void _handleDifficultyPressed(int value) {
    setState
(() => _difficulty = value);
 
}

 
void _handleDifficultyFocused(int? value) {
    setState
(() => _difficultyOverride = value);
 
}

 
final _finalReceiveLightAmt = 0.7;
 
final _finalEmitLightAmt = 0.5;

 
@override
 
Widget build(BuildContext context) {
   
return Scaffold(
      backgroundColor
: Colors.black,
      body
: Center(
        child
: _AnimatedColors(                           // Edit from here...
          orbColor
: _orbColor,
          emitColor
: _emitColor,
          builder
: (_, orbColor, emitColor) {
           
return Stack(
              children
: [
               
/// Bg-Base
               
Image.asset(AssetPaths.titleBgBase),

               
/// Bg-Receive
                _LitImage
(
                  color
: orbColor,
                  imgSrc
: AssetPaths.titleBgReceive,
                  lightAmt
: _finalReceiveLightAmt,
               
),

               
/// Mg-Base
                _LitImage
(
                  imgSrc
: AssetPaths.titleMgBase,
                  color
: orbColor,
                  lightAmt
: _finalReceiveLightAmt,
               
),

               
/// Mg-Receive
                _LitImage
(
                  imgSrc
: AssetPaths.titleMgReceive,
                  color
: orbColor,
                  lightAmt
: _finalReceiveLightAmt,
               
),

               
/// Mg-Emit
                _LitImage
(
                  imgSrc
: AssetPaths.titleMgEmit,
                  color
: emitColor,
                  lightAmt
: _finalEmitLightAmt,
               
),

               
/// Fg-Rocks
               
Image.asset(AssetPaths.titleFgBase),

               
/// Fg-Receive
                _LitImage
(
                  imgSrc
: AssetPaths.titleFgReceive,
                  color
: orbColor,
                  lightAmt
: _finalReceiveLightAmt,
               
),

               
/// Fg-Emit
                _LitImage
(
                  imgSrc
: AssetPaths.titleFgEmit,
                  color
: emitColor,
                  lightAmt
: _finalEmitLightAmt,
               
),

               
/// UI
               
Positioned.fill(
                  child
: TitleScreenUi(
                    difficulty
: _difficulty,
                    onDifficultyFocused
: _handleDifficultyFocused,
                    onDifficultyPressed
: _handleDifficultyPressed,
                 
),
               
),
             
],
           
).animate().fadeIn(duration: 1.seconds, delay: .3.seconds);
         
},
       
),                                                // to here.
     
),
   
);
 
}
}

В этом последнем редактировании вы добавили анимацию к каждому элементу на экране, и это выглядит намного лучше!

6. Добавить фрагментные шейдеры

На этом этапе вы добавите в приложение фрагментные шейдеры. Сначала вы используете шейдер, чтобы изменить заголовок, чтобы придать ему более мрачный вид. Затем вы добавляете второй шейдер, чтобы создать сферу, которая будет служить центральной точкой страницы.

Искажение заголовка с помощью фрагментного шейдера

Благодаря этому изменению вы представляете пакет provider , который позволяет передавать скомпилированные шейдеры вниз по дереву виджетов. Если вас интересует, как загружаются шейдеры, см. реализацию в lib/assets.dart .

  1. Измените код в lib/main.dart следующим образом:

библиотека/main.dart

import 'dart:io' show Platform;

import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';                 // Add this import
import 'package:window_size/window_size.dart';

import 'assets.dart';                                    // Add this import
import 'title_screen/title_screen.dart';

void main() {
 
if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
   
WidgetsFlutterBinding.ensureInitialized();
    setWindowMinSize
(const Size(800, 500));
 
}
 
Animate.restartOnHotReload = true;
  runApp
(                                                // Edit from here...
   
FutureProvider<FragmentPrograms?>(
      create
: (context) => loadFragmentPrograms(),
      initialData
: null,
      child
: const NextGenApp(),
   
),
 
);                                                     // to here.
}

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

 
@override
 
Widget build(BuildContext context) {
   
return MaterialApp(
      themeMode
: ThemeMode.dark,
      darkTheme
: ThemeData(brightness: Brightness.dark),
      home
: const TitleScreen(),
   
);
 
}
}
  1. Чтобы воспользоваться пакетом provider и утилитами шейдеров, включенными в step_01 , вам необходимо их импортировать. Добавьте новый импорт в lib/title_screen/title_screen_ui.dart следующим образом:

lib/title_screen/title_screen_ui.dart

import 'package:extra_alignments/extra_alignments.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:focusable_control_builder/focusable_control_builder.dart';
import 'package:gap/gap.dart';
import 'package:provider/provider.dart';                 // Add this import

import '../assets.dart';
import '../common/shader_effect.dart';                   // And this import
import '../common/ticking_builder.dart';                 // And this import
import '../common/ui_scaler.dart';
import '../styles.dart';

class TitleScreenUi extends StatelessWidget {
  1. Искажьте заголовок с помощью шейдера, отредактировав виджет _TitleText следующим образом:

lib/title_screen/title_screen_ui.dart

class _TitleText extends StatelessWidget {
 
const _TitleText();

 
@override
 
Widget build(BuildContext context) {
   
Widget content = Column(                             // Modify this line
      mainAxisSize
: MainAxisSize.min,
      crossAxisAlignment
: CrossAxisAlignment.start,
      children
: [
       
const Gap(20),
       
Row(
          mainAxisSize
: MainAxisSize.min,
          children
: [
           
Transform.translate(
              offset
: Offset(-(TextStyles.h1.letterSpacing! * .5), 0),
              child
: Text('OUTPOST', style: TextStyles.h1),
           
),
           
Image.asset(AssetPaths.titleSelectedLeft, height: 65),
           
Text('57', style: TextStyles.h2),
           
Image.asset(AssetPaths.titleSelectedRight, height: 65),
         
],
       
).animate().fadeIn(delay: .8.seconds, duration: .7.seconds),
       
Text('INTO THE UNKNOWN', style: TextStyles.h3)
           
.animate()
           
.fadeIn(delay: 1.seconds, duration: .7.seconds),
     
],
   
);
   
return Consumer<FragmentPrograms?>(                  // Add from here...
      builder
: (context, fragmentPrograms, _) {
       
if (fragmentPrograms == null) return content;
       
return TickingBuilder(
          builder
: (context, time) {
           
return AnimatedSampler(
             
(image, size, canvas) {
               
const double overdrawPx = 30;
               
final shader = fragmentPrograms.ui.fragmentShader();
                shader
                 
..setFloat(0, size.width)
                 
..setFloat(1, size.height)
                 
..setFloat(2, time)
                 
..setImageSampler(0, image);
               
Rect rect = Rect.fromLTWH(-overdrawPx, -overdrawPx,
                    size
.width + overdrawPx, size.height + overdrawPx);
                canvas
.drawRect(rect, Paint()..shader = shader);
             
},
              child
: content,
           
);
         
},
       
);
     
},
   
);                                                   // to here.
 
}
}

Вы должны увидеть искажение названия — как и следовало ожидать в антиутопическом будущем.

Добавьте сферу

Теперь добавьте шар в центре окна. Вам нужно добавить обратный вызов onPressed к кнопке «Пуск».

  1. В lib/title_screen/title_screen_ui.dart измените TitleScreenUi следующим образом:

lib/title_screen/title_screen_ui.dart

class TitleScreenUi extends StatelessWidget {
 
const TitleScreenUi({
   
super.key,
    required
this.difficulty,
    required
this.onDifficultyPressed,
    required
this.onDifficultyFocused,
    required
this.onStartPressed,                         // Add this argument
 
});

 
final int difficulty;
 
final void Function(int difficulty) onDifficultyPressed;
 
final void Function(int? difficulty) onDifficultyFocused;
 
final VoidCallback onStartPressed;                      // Add this attribute

 
@override
 
Widget build(BuildContext context) {
   
return Padding(
      padding
: const EdgeInsets.symmetric(vertical: 40, horizontal: 50),
      child
: Stack(
        children
: [
         
/// Title Text
         
const TopLeft(
            child
: UiScaler(
              alignment
: Alignment.topLeft,
              child
: _TitleText(),
           
),
         
),

         
/// Difficulty Btns
         
BottomLeft(
            child
: UiScaler(
              alignment
: Alignment.bottomLeft,
              child
: _DifficultyBtns(
                difficulty
: difficulty,
                onDifficultyPressed
: onDifficultyPressed,
                onDifficultyFocused
: onDifficultyFocused,
             
),
           
),
         
),

         
/// StartBtn
         
BottomRight(
            child
: UiScaler(
              alignment
: Alignment.bottomRight,
              child
: Padding(
                padding
: const EdgeInsets.only(bottom: 20, right: 40),
                child
: _StartBtn(onPressed: onStartPressed),  // Edit this line
             
),
           
),
         
),
       
],
     
),
   
);
 
}
}

Теперь, когда вы изменили кнопку «Пуск» с помощью обратного вызова, вам необходимо внести значительные изменения в файл lib/title_screen/title_screen.dart .

  1. Измените импорт следующим образом:

lib/title_screen/title_screen.dart

import 'dart:math';                                       // Add this import
import 'dart:ui';                                         // And this import

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';                   // Add this import
import 'package:flutter_animate/flutter_animate.dart';

import '../assets.dart';
import '../orb_shader/orb_shader_config.dart';            // And this import
import '../orb_shader/orb_shader_widget.dart';            // And this import too
import '../styles.dart';
import 'title_screen_ui.dart';

class TitleScreen extends StatefulWidget {
  1. Измените _TitleScreenState , чтобы он соответствовал следующему. Почти каждая часть класса тем или иным образом модифицирована.

lib/title_screen/title_screen.dart

class _TitleScreenState extends State<TitleScreen>
   
with SingleTickerProviderStateMixin {
 
final _orbKey = GlobalKey<OrbShaderWidgetState>();

 
/// Editable Settings
 
/// 0-1, receive lighting strength
 
final _minReceiveLightAmt = .35;
 
final _maxReceiveLightAmt = .7;

 
/// 0-1, emit lighting strength
 
final _minEmitLightAmt = .5;
 
final _maxEmitLightAmt = 1;

 
/// Internal
 
var _mousePos = Offset.zero;

 
Color get _emitColor =>
     
AppColors.emitColors[_difficultyOverride ?? _difficulty];
 
Color get _orbColor =>
     
AppColors.orbColors[_difficultyOverride ?? _difficulty];

 
/// Currently selected difficulty
 
int _difficulty = 0;

 
/// Currently focused difficulty (if any)
 
int? _difficultyOverride;
 
double _orbEnergy = 0;
 
double _minOrbEnergy = 0;

 
double get _finalReceiveLightAmt {
   
final light =
        lerpDouble
(_minReceiveLightAmt, _maxReceiveLightAmt, _orbEnergy) ?? 0;
   
return light + _pulseEffect.value * .05 * _orbEnergy;
 
}

 
double get _finalEmitLightAmt {
   
return lerpDouble(_minEmitLightAmt, _maxEmitLightAmt, _orbEnergy) ?? 0;
 
}

  late
final _pulseEffect = AnimationController(
    vsync
: this,
    duration
: _getRndPulseDuration(),
    lowerBound
: -1,
    upperBound
: 1,
 
);

 
Duration _getRndPulseDuration() => 100.ms + 200.ms * Random().nextDouble();

 
double _getMinEnergyForDifficulty(int difficulty) => switch (difficulty) {
       
1 => 0.3,
       
2 => 0.6,
        _
=> 0,
     
};


 
@override
 
void initState() {
   
super.initState();
    _pulseEffect
.forward();
    _pulseEffect
.addListener(_handlePulseEffectUpdate);
 
}

 
void _handlePulseEffectUpdate() {
   
if (_pulseEffect.status == AnimationStatus.completed) {
      _pulseEffect
.reverse();
      _pulseEffect
.duration = _getRndPulseDuration();
   
} else if (_pulseEffect.status == AnimationStatus.dismissed) {
      _pulseEffect
.duration = _getRndPulseDuration();
      _pulseEffect
.forward();
   
}
 
}

 
void _handleDifficultyPressed(int value) {
    setState
(() => _difficulty = value);
    _bumpMinEnergy
();
 
}

 
Future<void> _bumpMinEnergy([double amount = 0.1]) async {
    setState
(() {
      _minOrbEnergy
= _getMinEnergyForDifficulty(_difficulty) + amount;
   
});
    await
Future<void>.delayed(.2.seconds);
    setState
(() {
      _minOrbEnergy
= _getMinEnergyForDifficulty(_difficulty);
   
});
 
}

 
void _handleStartPressed() => _bumpMinEnergy(0.3);

 
void _handleDifficultyFocused(int? value) {
    setState
(() {
      _difficultyOverride
= value;
     
if (value == null) {
        _minOrbEnergy
= _getMinEnergyForDifficulty(_difficulty);
     
} else {
        _minOrbEnergy
= _getMinEnergyForDifficulty(value);
     
}
   
});
 
}

 
/// Update mouse position so the orbWidget can use it, doing it here prevents
 
/// btns from blocking the mouse-move events in the widget itself.
 
void _handleMouseMove(PointerHoverEvent e) {
    setState
(() {
      _mousePos
= e.localPosition;
   
});
 
}

 
@override
 
Widget build(BuildContext context) {
   
return Scaffold(
      backgroundColor
: Colors.black,
      body
: Center(
        child
: MouseRegion(
          onHover
: _handleMouseMove,
          child
: _AnimatedColors(
            orbColor
: _orbColor,
            emitColor
: _emitColor,
            builder
: (_, orbColor, emitColor) {
             
return Stack(
                children
: [
                 
/// Bg-Base
                 
Image.asset(AssetPaths.titleBgBase),

                 
/// Bg-Receive
                  _LitImage
(
                    color
: orbColor,
                    imgSrc
: AssetPaths.titleBgReceive,
                    pulseEffect
: _pulseEffect,
                    lightAmt
: _finalReceiveLightAmt,
                 
),

                 
/// Orb
                 
Positioned.fill(
                    child
: Stack(
                      children
: [
                       
// Orb
                       
OrbShaderWidget(
                          key
: _orbKey,
                          mousePos
: _mousePos,
                          minEnergy
: _minOrbEnergy,
                          config
: OrbShaderConfig(
                            ambientLightColor
: orbColor,
                            materialColor
: orbColor,
                            lightColor
: orbColor,
                         
),
                          onUpdate
: (energy) => setState(() {
                            _orbEnergy
= energy;
                         
}),
                       
),
                     
],
                   
),
                 
),

                 
/// Mg-Base
                  _LitImage
(
                    imgSrc
: AssetPaths.titleMgBase,
                    color
: orbColor,
                    pulseEffect
: _pulseEffect,
                    lightAmt
: _finalReceiveLightAmt,
                 
),

                 
/// Mg-Receive
                  _LitImage
(
                    imgSrc
: AssetPaths.titleMgReceive,
                    color
: orbColor,
                    pulseEffect
: _pulseEffect,
                    lightAmt
: _finalReceiveLightAmt,
                 
),

                 
/// Mg-Emit
                  _LitImage
(
                    imgSrc
: AssetPaths.titleMgEmit,
                    color
: emitColor,
                    pulseEffect
: _pulseEffect,
                    lightAmt
: _finalEmitLightAmt,
                 
),

                 
/// Fg-Rocks
                 
Image.asset(AssetPaths.titleFgBase),

                 
/// Fg-Receive
                  _LitImage
(
                    imgSrc
: AssetPaths.titleFgReceive,
                    color
: orbColor,
                    pulseEffect
: _pulseEffect,
                    lightAmt
: _finalReceiveLightAmt,
                 
),

                 
/// Fg-Emit
                  _LitImage
(
                    imgSrc
: AssetPaths.titleFgEmit,
                    color
: emitColor,
                    pulseEffect
: _pulseEffect,
                    lightAmt
: _finalEmitLightAmt,
                 
),

                 
/// UI
                 
Positioned.fill(
                    child
: TitleScreenUi(
                      difficulty
: _difficulty,
                      onDifficultyFocused
: _handleDifficultyFocused,
                      onDifficultyPressed
: _handleDifficultyPressed,
                      onStartPressed
: _handleStartPressed,
                   
),
                 
),
               
],
             
).animate().fadeIn(duration: 1.seconds, delay: .3.seconds);
           
},
         
),
       
),
     
),
   
);
 
}
}
  1. Измените _LitImage следующим образом:

lib/title_screen/title_screen.dart

class _LitImage extends StatelessWidget {
 
const _LitImage({
    required
this.color,
    required
this.imgSrc,
    required
this.pulseEffect,                            // Add this parameter
    required
this.lightAmt,
 
});
 
final Color color;
 
final String imgSrc;
 
final AnimationController pulseEffect;                  // Add this attribute
 
final double lightAmt;

 
@override
 
Widget build(BuildContext context) {
   
final hsl = HSLColor.fromColor(color);
   
return ListenableBuilder(                             // Edit from here...
      listenable
: pulseEffect,
      builder
: (context, child) {
       
return Image.asset(
          imgSrc
,
          color
: hsl.withLightness(hsl.lightness * lightAmt).toColor(),
          colorBlendMode
: BlendMode.modulate,
       
);
     
},
   
);                                                    // to here.
 
}
}

Это результат этого дополнения.

7. Добавьте анимацию частиц

На этом этапе вы добавляете в приложение анимацию частиц, чтобы создать в приложении едва заметное пульсирующее движение.

Добавляйте частицы повсюду

  1. Создайте новый файл lib/title_screen/particle_overlay.dart и добавьте следующий код:

lib/title_screen/particle_overlay.dart

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:particle_field/particle_field.dart';
import 'package:rnd/rnd.dart';

class ParticleOverlay extends StatelessWidget {
 
const ParticleOverlay({super.key, required this.color, required this.energy});

 
final Color color;
 
final double energy;

 
@override
 
Widget build(BuildContext context) {
   
return ParticleField(
      spriteSheet
: SpriteSheet(
        image
: const AssetImage('assets/images/particle-wave.png'),
     
),
     
// blend the image's alpha with the specified color:
      blendMode
: BlendMode.dstIn,

     
// this runs every tick:
      onTick
: (controller, _, size) {
       
List<Particle> particles = controller.particles;

       
// add a new particle with random angle, distance & velocity:
       
double a = rnd(pi * 2);
       
double dist = rnd(1, 4) * 35 + 150 * energy;
       
double vel = rnd(1, 2) * (1 + energy * 1.8);
        particles
.add(Particle(
         
// how many ticks this particle will live:
          lifespan
: rnd(1, 2) * 20 + energy * 15,
         
// starting distance from center:
          x
: cos(a) * dist,
          y
: sin(a) * dist,
         
// starting velocity:
          vx
: cos(a) * vel,
          vy
: sin(a) * vel,
         
// other starting values:
          rotation
: a,
          scale
: rnd(1, 2) * 0.6 + energy * 0.5,
       
));

       
// update all of the particles:
       
for (int i = particles.length - 1; i >= 0; i--) {
         
Particle p = particles[i];
         
if (p.lifespan <= 0) {
           
// particle is expired, remove it:
            particles
.removeAt(i);
           
continue;
         
}
          p
.update(
            scale
: p.scale * 1.025,
            vx
: p.vx * 1.025,
            vy
: p.vy * 1.025,
            color
: color.withOpacity(p.lifespan * 0.001 + 0.01),
            lifespan
: p.lifespan - 1,
         
);
       
}
     
},
   
);
 
}
}
  1. Измените импорт для lib/title_screen/title_screen.dart следующим образом:

lib/title_screen/title_screen.dart

import 'dart:math';
import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';

import '../assets.dart';
import '../orb_shader/orb_shader_config.dart';
import '../orb_shader/orb_shader_widget.dart';
import '../styles.dart';
import 'particle_overlay.dart';                          // Add this import
import 'title_screen_ui.dart';

class TitleScreen extends StatefulWidget {
  1. Добавьте ParticleOverlay в пользовательский интерфейс, изменив метод build _TitleScreenState следующим образом:

lib/title_screen/title_screen.dart

@override
Widget build(BuildContext context) {
 
return Scaffold(
    backgroundColor
: Colors.black,
    body
: Center(
      child
: MouseRegion(
        onHover
: _handleMouseMove,
        child
: _AnimatedColors(
          orbColor
: _orbColor,
          emitColor
: _emitColor,
          builder
: (_, orbColor, emitColor) {
           
return Stack(
              children
: [
               
/// Bg-Base
               
Image.asset(AssetPaths.titleBgBase),

               
/// Bg-Receive
                _LitImage
(
                  color
: orbColor,
                  imgSrc
: AssetPaths.titleBgReceive,
                  pulseEffect
: _pulseEffect,
                  lightAmt
: _finalReceiveLightAmt,
               
),

               
/// Orb
               
Positioned.fill(
                  child
: Stack(
                    children
: [
                     
// Orb
                     
OrbShaderWidget(
                        key
: _orbKey,
                        mousePos
: _mousePos,
                        minEnergy
: _minOrbEnergy,
                        config
: OrbShaderConfig(
                          ambientLightColor
: orbColor,
                          materialColor
: orbColor,
                          lightColor
: orbColor,
                       
),
                        onUpdate
: (energy) => setState(() {
                          _orbEnergy
= energy;
                       
}),
                     
),
                   
],
                 
),
               
),

               
/// Mg-Base
                _LitImage
(
                  imgSrc
: AssetPaths.titleMgBase,
                  color
: orbColor,
                  pulseEffect
: _pulseEffect,
                  lightAmt
: _finalReceiveLightAmt,
               
),

               
/// Mg-Receive
                _LitImage
(
                  imgSrc
: AssetPaths.titleMgReceive,
                  color
: orbColor,
                  pulseEffect
: _pulseEffect,
                  lightAmt
: _finalReceiveLightAmt,
               
),

               
/// Mg-Emit
                _LitImage
(
                  imgSrc
: AssetPaths.titleMgEmit,
                  color
: emitColor,
                  pulseEffect
: _pulseEffect,
                  lightAmt
: _finalEmitLightAmt,
               
),

               
/// Particle Field
               
Positioned.fill(                          // Add from here...
                  child
: IgnorePointer(
                    child
: ParticleOverlay(
                      color
: orbColor,
                      energy
: _orbEnergy,
                   
),
                 
),
               
),                                        // to here.

               
/// Fg-Rocks
               
Image.asset(AssetPaths.titleFgBase),

               
/// Fg-Receive
                _LitImage
(
                  imgSrc
: AssetPaths.titleFgReceive,
                  color
: orbColor,
                  pulseEffect
: _pulseEffect,
                  lightAmt
: _finalReceiveLightAmt,
               
),

               
/// Fg-Emit
                _LitImage
(
                  imgSrc
: AssetPaths.titleFgEmit,
                  color
: emitColor,
                  pulseEffect
: _pulseEffect,
                  lightAmt
: _finalEmitLightAmt,
               
),

               
/// UI
               
Positioned.fill(
                  child
: TitleScreenUi(
                    difficulty
: _difficulty,
                    onDifficultyFocused
: _handleDifficultyFocused,
                    onDifficultyPressed
: _handleDifficultyPressed,
                    onStartPressed
: _handleStartPressed,
                 
),
               
),
             
],
           
).animate().fadeIn(duration: 1.seconds, delay: .3.seconds);
         
},
       
),
     
),
   
),
 
);
}

Конечный результат включает анимацию, фрагментные шейдеры и эффекты частиц — на нескольких платформах!

Добавляйте частицы повсюду — даже в Интернете

Есть одна небольшая проблема с кодом в его нынешнем виде. Когда Flutter работает в Интернете, можно использовать два альтернативных механизма рендеринга: механизм CanvasKit, который по умолчанию используется в браузерах классов настольных компьютеров, и механизм рендеринга HTML DOM, который по умолчанию используется для мобильных устройств. Проблема в том, что средство рендеринга HTML DOM не поддерживает фрагментные шейдеры.

Исправление заключается в сборке для Интернета с использованием только средства визуализации CanvasKit. Для этого добавьте флаг в команду сборки следующим образом:

$ flutter build web --web-renderer canvaskit
Font asset "MaterialIcons-Regular.otf" was tree-shaken, reducing it from 1645184 to 7692 bytes (99.5% reduction). Tree-shaking can be disabled by providing the --no-tree-shake-icons flag
when building your app.
Font asset "CupertinoIcons.ttf" was tree-shaken, reducing it from 257628 to 1172 bytes (99.5% reduction). Tree-shaking can be disabled by providing the --no-tree-shake-icons flag when
building your app.
Compiling lib/main.dart for the Web...                             15.6s
✓ Built build/web

Вот вся ваша тяжелая работа, показанная на этот раз в браузере Chrome.

8. Поздравления

Вы создали полнофункциональный вступительный экран игры с анимацией, фрагментными шейдерами и анимацией частиц! Теперь вы можете использовать эти методы на всех платформах, которые поддерживает Flutter.

Узнать больше