Введение

В эти последние дни у меня наконец-то появилось время, чтобы провести время с Flutter Web, поэтому я не мог упустить возможность проверить его возможности. Я начал играть с некоторыми экспериментальными анимациями, которые я сделал с помощью флаттера несколько месяцев назад, но я хотел сделать что-то другое. Тогда мне пришла в голову идея сделать что-то вроде книжного трейлера к моему роману «Последнее воскресенье» («L’ultima domenica» по-итальянски), изданному в октябре.

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

Вы можете увидеть это вживую здесь: https://frideosapps.github.io/booktrailer

Изображения

Для трейлера я использовал десять изображений. Для их обработки я создал перечислениеAssetsImages, а mapimagesFilenames, использовался для назначения каждому значению AssetsImage пути к файлу.

enum AssetsImages {
  cover,
  black,
  rain,
  smoke,
  city,
  man,
  alone,
  tunnel,
  eyes,
  coverPhoto,
}
final imageFilenames = {
  AssetsImages.cover: 'assets/images/cover.png',
  AssetsImages.black: 'assets/images/black.png',
  AssetsImages.rain: 'assets/images/rain.png',
  AssetsImages.smoke: 'assets/images/smoke.png',
  AssetsImages.alone: 'assets/images/alone.png',
  AssetsImages.city: 'assets/images/city.png',
  AssetsImages.eyes: 'assets/images/eyes.png',
  AssetsImages.man: 'assets/images/man.png',
  AssetsImages.tunnel: 'assets/images/tunnel.png',
  AssetsImages.coverPhoto: 'assets/images/cover_photo.png',
};

С этого момента, чтобы показать изображение, мы будем использовать виджет Image.assets следующим образом:

// Show the image of the first scene
Image.asset(imageFilenames[AssetsImages.city]);

Предварительное кэширование, загрузка виджета

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

Давайте посмотрим на класс State виджета с отслеживанием состояния SetupAssets. Мы видим три свойства:

final List<Image> images = [];
Future<bool> precache;
int loadingProgress = 0;

В методе initState список изображений добавляется ко всем изображениям путем перебора значений AssetsImages:

@override
void initState() {
  super.initState();
for (var img in AssetsImages.values) {
    images.add(Image.asset(imageFilenames[img]));
  }
}

Затем я создал асинхронный метод preachingImages для предварительного кэширования каждого из изображений:

Future<bool> precachingImages(BuildContext context) async {
  for (var image in images) {
    await precacheImage(image.image, context);
setState(() {
       loadingProgress++;
    });
  }
return true;
}

На каждой итерации цикла for loadingProgress увеличивается на единицу, он будет использоваться для управления полосой загрузки в виджете LoadingWidget.

В didChangeDependencies предварительное кэширование future-объекта инициализируется результатом метода precachingImages.

@override
void didChangeDependencies() {
  super.didChangeDependencies();
  precache = precachingImages(context);
}

В методе build это будущее будет использоваться с FutureBuilder для отображения LoadingWidget, пока он не завершен. Как только будущее будет завершено, snapshot.hasData станет истинным, и будет показан виджет Menu / PlayScenes.

@override
Widget build(BuildContext context) {
  return FutureBuilder<bool>(
    future: precache,
    builder: (context, snapshot) {
      if (!snapshot.hasData) {
        return LoadingWidget(
          progress: loadingProgress / (images.length - 1),
        );
    } else {
      return ValueBuilder<bool>(
        streamed: appState.isPlaying,
        builder: (context, snapshot) {
          if (snapshot.data) {
            return PlayScenes(
              key: Key(appState.playScenesKey),
              language: appState.lang,
            );
          } else {
            return Menu();
          }
         },
       );
      }
    },
  );
}

LoadingWidget

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

Чтобы оживить непрозрачность текста, мы собираемся использовать виджет AnimationCreate из моего пакета frideos. Идея состоит в том, чтобы изменить его непрозрачность с 0,1 на 1,0 и обратно на 0,1 в режиме цикла. Для этого просто передайте true параметрам repeat и reverse виджета. Параметр duration сообщает виджету, за сколько миллисекунд значение должно измениться с 0,1 до 1,0 (то же время в обратном направлении).

AnimationCreate<double>(
  begin: 0.1,
  end: 1.0,
  curve: Curves.easeIn,
  duration: 1000,
  repeat: true,
  reverse: true,
  builder: (context, anim) {
    return Column(
     mainAxisAlignment: MainAxisAlignment.center,
     children: [
       Opacity(
         opacity: anim.value,
         child: Text(
           'Loading...',
           style: TextStyle(
             color: Colors.white,
             fontSize: 26.0,
           ),
         ),
       ),

AppState

Давайте рассмотрим вторую часть метода сборки класса _SetupAssetsState:

return ValueBuilder<bool>(
  streamed: appState.isPlaying,
  builder: (context, snapshot) {
    if (snapshot.data) {
      return PlayScenes(
        key: Key(appState.playScenesKey),
        language: appState.lang,
      );
    } else {
      return Menu();
    }
  },
);

ValueBuilder - это виджет, который принимает объект StreamedValue в качестве аргумента и перестраивает свой построитель каждый раз, когда в поток отправляется новое событие. В этом случае appState (глобальный синглтон) является экземпляром класса AppState, а isPlaying - StreamedValue типа bool, объявленный в этом классе. Объект isPlaying будет использоваться для перестроения ValueBuilder, показывая виджет PlayScenes или Меню, в зависимости от значение, отправленное в поток.

Давайте рассмотрим класс AppState:

class AppState extends AppStateModel {
  factory AppState() => _singletonAppState;
  AppState._internal();
  static final AppState _singletonAppState = AppState._internal();
  // STREAM
  final isPlaying = StreamedValue<bool>(initialData: false);
  // PlayingScenes widget key
  String playScenesKey = DateTime.now().toString();
  Language lang = Language.english;
  final particlesSystem = ParticlesSystem();
  bool isParticlesSystemInitialized = false;
  void goToMenu() {
    isPlaying.value = false;
  }
  void play() {
     playScenesKey = DateTime.now().toString(); 
     isPlaying.value = true;
  }
  @override
  void init() {}
  @override
  void dispose() {
    isPlaying.dispose();
  }
}
final appState = AppState();
  • isPlaying инициализируется значением false в качестве initialData, поэтому при запуске приложения будет отображаться Меню.
  • playScenesKey - это строка, используемая в качестве ключа для виджета PlayScenes. В методе play ему назначается новая дата и время при каждом вызове, чтобы изменить ключ виджета. Это необходимо для принудительного перестроения виджета при нажатии кнопки воспроизведения, в результате чего сцены воспроизводятся с самого начала.
  • lang: используется для хранения языка, выбранного пользователем.
  • goToMenu (): дойдя до конца трейлера, пользователь может вернуться в меню или снова воспроизвести трейлер. При нажатии кнопки меню вызывается метод goToMenu, isPlaying StreamedValue присваивается значение false, что вызывает перестройку ValueBuilder, который, будучи ложным snapshot.data, будет отображать виджет Меню.
  • Play (): в этом методе playSceneKey будет присвоено новое значение, и аналогично методу goToMenu элементу isPlaying назначается новое значение, на этот раз true, чтобы перезапустить трейлер.
  • Particlesytem и isParticlesSystemInitialized: см. следующий абзац.

Меню

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

Важно выделить метод initState:

@override
void initState() {
  super.initState();
  WidgetsBinding.instance.addPostFrameCallback((_) {         
    appState.particlesSystem.init(
      totalParticles: 150,
      width: context.size.width,
      height: context.size.height,
    );
    setState(() {
      appState.isParticlesSystemInitialized = true;
    });
  });
}

Для инициализации системы частиц используется обратный вызов метода addPostFrameCallback, здесь контекст готов к использованию для получения размера виджета, необходимого для метода init. класса ParticlesSystem. Затем для флага isParticlesSystemInitialized устанавливается значение true, чтобы виджет можно было отобразить в методе сборки.

GestureDetector(
  onTap: () {
    appState.lang = Language.english;
    appState.play();
  },
  child: LanguageWidget(
    text: 'English',
  ),
),

Когда пользователи нажимают LanguageWidget (простое поле с указанием языка), значение свойства lang присваивается с выбранным языком и вызывается методом play , чтобы начать играть в трейлер.

Система частиц

Чтобы показать своего рода эффект снега, я создал действительно простую систему частиц. Класс Particle обрабатывает информацию об отдельной частице.

С помощью класса ParticlesSystem мы можем определить систему частиц с переменным количеством элементов со случайным начальным положением и характеристиками (метод init) и перемещать их в каждом новом кадре ( update метод).

Виджет ParticlesSystemPlayer просто принимает в качестве параметра экземпляр класса ParticlesSystem, затем обновляет положение частиц при каждой перестройке и отображает их в виджете стека.

Страница PlayScenes: FadeInWidget, ScenesWidget, Scene

Когда меню отображается и пользователь нажимает на поле с языком, запускается трейлер. В виджете PlayScenes есть FadeInWidget с длительностью 2500 мс и дочерний элемент ScenesCreate. Этот виджет из моего пакета просто воспроизводит сцены, переданные в его параметр сцены.

Каждая сцена является экземпляром класса Scene.

class Scene {
  Scene({this.widget, this.time, this.onShow});
Widget widget;
  int time; // milliseconds
  Function onShow;
}

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

Как видно из сути, в ScenesCreate передается список сцен, где в параметрах их виджета передается виджет BuildScene с разными настройками для каждой сцены. Параметр onShow опущен, так как в этом случае он не нужен.

ScenesCreate(
  scenes: [
    Scene(
      widget: BuildScene(
        key: Key('1'),
        texts: textsScenes[TrailerScene.first][language],
        image: imageFilenames[AssetsImages.city],
        transitionType: TransitionType.circular,
        textAnimationType: TextAnimationType.scale,
        duration: 11500,
      ),
      time: 11500,
    ),
    Scene(
      widget: BuildScene(
        key: Key('2'),
        texts: textsScenes[TrailerScene.second][language],
        image: imageFilenames[AssetsImages.man],
        transitionType: TransitionType.vertical,
        textAnimationType: TextAnimationType.split,
        duration: 11500,
        blur: true,
        blink: true,
      ),
      time: 11500,
     ),

Параметру key виджета BuildScene дается другой ключ, чтобы сообщить Flutter, что это не один и тот же виджет, что приведет к перестройке виджета.

Виджет BuildScene

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

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

Давайте посмотрим на сигнатуру виджета BuildScene:

class BuildScene extends StatefulWidget {
  const BuildScene({
    Key key,
    @required this.texts,
    @required this.image,
    this.boxFit = BoxFit.cover,
    this.blink = false,
    this.textAnimationReverse = false,
    this.textAnimationType = TextAnimationType.split,
    this.transitionType = TransitionType.circular,
    this.duration = 10000,
    this.blur = false,
}) : super(key: key);
  • тексты: этот параметр сообщает виджету отображаемый текст. Это списки строк, где каждая строка будет дочерним элементом виджета столбца.
  • изображение: изображение, которое будет использоваться в качестве фона.
  • boxFit
  • мигание: включить мигание изображения во время текстовой анимации.
  • textAnimationReverse: если true, анимация текста будет воспроизводиться в обратном порядке.
  • textAnimationType: тип текстовой анимации.
  • duration: продолжительность сцены, она будет равна продолжительности сцены.
  • blur: если true, фон будет анимированным и размытым.

Давайте посмотрим на метод initState:

@override
void initState() {
  super.initState();
particlesAnim = AnimationCurved<double>(
    duration: Duration(milliseconds: 3500),
    setState: setState,
    tickerProvider: this,
    begin: -1.0,
    end: 1.0,
    onAnimating: _onAnimating,
    curve: Curves.easeIn,
  );
}

Экземпляр класса AnimationCurved инициализирован. AnimationCurved - это помощник, который я сделал для моего пакета frideos, чтобы упростить работу с анимацией. Эта анимация будет управлять системой частиц.

CompositeCreate, CompositeItem, AnimationType

Виджет CompositeCreate - еще один помощник моего пакета frideos, предназначенный для обработки сложных анимаций.

class CompositeCreate extends StatefulWidget {
  const CompositeCreate({
    Key key,
    this.duration = 1000,
    this.compositeMap,
    this.repeat = false,
    this.onAnimating,
    this.onStart,
    this.onCompleted,
    this.builder,
}) : super(key: key);

Смысл параметров прост, стоит потратить несколько слов только на параметр complexMap: передача карты, значение которой является экземпляром CompositeItem, тогда можно получить значение анимации, используя ее ключ. Таким образом, можно очень легко создавать поэтапную / составную анимацию.

Давайте рассмотрим метод сборки виджета _BuildSceneState:

@override
Widget build(BuildContext context) {
  return CompositeCreate(
    duration: widget.duration,
    compositeMap: {
      AnimationType.fadeIn: CompositeItem<double>(
        begin: 0.2,
        end: 1.0,
        curve: const Interval(
          0.6,
          0.8,
          curve: Curves.easeIn,
        ),
      ),
      AnimationType.transition: CompositeItem<double>(
        begin: 0.0,
        end: 100.0,
        curve: const Interval(
          0.75,
          1.0,
          curve: Curves.easeIn,
        ),
      ),
     },
     repeat: false,
     builder: (context, comp) {

В этом случае в параметр длительности передается свойство duration виджета BuildScene (значение которого составляет 11500 мс, как вы можете видеть в PlayScenes виджет). Это означает, что составная анимация длится 11,5 секунд. Помня об этом значении, мы можем составить карту анимаций, задав каждой анимации определенный интервал продолжительности.

В параметры композитной карты передается карта типа Map<AnimationType, CompositeItem>.

enum AnimationType {
  fadeOut,
  fadeIn,
  mov,
  scale,
  grow,
  color,
  transition,
}

Получить значение отдельной анимации очень просто, достаточно использовать ключ анимации (например, AnimationType.fadeIn для первой) с получателем значения класса CompositeAnimation, экземпляр которого передается в построитель виджета CompositeCreate.

comp.value(AnimationType.fadeIn)

Первая анимация воспроизводится в интервале 0,6–0,8 анимации, а вторая - от 0,75 до 1,0. Две анимации перекрываются в интервале 0,75–0,8.

Opacity(
  opacity: _calcOpacity(
    isOverlay,
    comp.value(AnimationType.fadeIn),
  ),
  child: ParticlesSystemPlayer(    
    particlesSystem: particlesSystem,
  ),
),

Здесь мы передаем значение постепенного появления анимации в метод _calcOpacity, чтобы вычислить непрозрачность для виджета ParticleSystemPlayer.

AnimatedText

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

AnimatedText(
  textAnimationType: widget.textAnimationType,
  width: width,
  texts: !isOverlay ? widget.texts : [''],
  reverse: widget.textAnimationReverse,
  onAnimating: (progress) {
    if (widget.blink) {
      if (progress >= 30 && progress <= 34 ||
          progress >= 46 && progress <= 50) {
        isOverlay = true;
      } else {
        isOverlay = false;
      }
    }
  },
),

Параметр textAnimationType используется для изменения типа анимации: масштабирование или разделение.

enum TextAnimationType { scale, split }

Параметр тексты принимает списки строк. Флаг isOverlay используется для скрытия текста при отображении наложенного изображения. Обратный изменяет поведение анимации, если наоборот - истинно, размер текста начинается с минимального значения и увеличивается с течением времени.

AnimatedTextScale

Глядя на CompositeCreate, мы видим, как анимация состоит из трех субанимаций.

CompositeCreate(
  duration: duration,
  repeat: false,
  compositeMap: {
    AnimationType.fadeIn: CompositeItem<double>(
      begin: 0.2,
      end: 1.0,
      curve: const Interval(
        0.0,
        0.2,
        curve: Curves.linear,
      ),
     ),
     AnimationType.scale: CompositeItem<double>(
       begin: reverse ? 0.8 : 1.0,
       end: reverse ? 1.0 : 0.8,
       curve: const Interval(
         0.2,
         0.6,
        curve: Curves.linear,
       ),
     ),
     AnimationType.fadeOut: CompositeItem<double>(
       begin: 1.0,
       end: 0.0,
       curve: Interval(
         isLastScene ? 0.8 : 0.7,
         isLastScene ? 1.0 : 0.8,
         curve: Curves.linear,
       ),
     ),
   },
   onCompleted: onCompleted,
   builder: (context, comp) {
  • В начале, с 0,0–0,2 текст плавно появляется
  • От 0,2 до 0,6 текст увеличивается или уменьшается (в зависимости от параметра reverse).
  • Затем гаснет. Интервал здесь меняется, если сцена является последней, 0,8–1,0, в то время как в других сценах текст исчезает с 0,7–0,8, потому что с 0,8 начинается анимация перехода.
return Transform.scale(
  scale: comp.value(AnimationType.scale) * _scale(width),
  child: Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      for (var i = 0; i <= texts.length - 1; i++)
      Opacity(
        opacity: opacity: comp.progress <= 0.2
            ? comp.value(AnimationType.fadeIn)
            : comp.value(AnimationType.fadeOut),
        child: Text(
          texts[i],            
        ),
      ),       
    ],
  ),
);

В параметре непрозрачности виджета Opacity вы можете увидеть, как используется свойство progress анимации, переданной в построитель виджета CompositeCreate, для выбора значения любого из плавное появление (от 0,0 до 0,2) или исчезновение анимации (в зависимости от флага isLastScene, его интервал составляет 0,8–1,0 или 0,7–0,8). Таким образом, когда comp.progress меньше 0,2, мы находимся в интервале затухания анимации, в противном случае - в затухании.

AnimatedTextSplit:

Анимация здесь немного сложнее:

return CompositeAnimationWidget(
  duration: widget.duration,
  repeat: false,
  compositeMap: {
    AnimationType.fadeIn: CompositeItem<double>(
      begin: 0.0,
      end: 1.0,
      curve: const Interval(
        0.0,
        0.4,
        curve: Curves.linear,
      ),
     ),
     AnimationType.mov: CompositeItem<double>(
       begin: 0.5,
       end: 0.0,
       curve: const Interval(
         0.0,
         0.5,
         curve: Curves.linearToEaseOut,
       ),
     ),
     AnimationType.grow: CompositeItem<double>(
       begin: 1.5,
       end: 1.0,
       curve: const Interval(
         0.2,
         0.6,
         curve: Curves.linearToEaseOut,
       ),
     ),
     AnimationType.color: CompositeItem<int>(
       begin: 255,
       end: 170,
       curve: const Interval(
         0.3,
         0.6,
         curve: Curves.linear,
       ),
      ),
      AnimationType.fadeOut: CompositeItem<double>(
        begin: 1.0,
        end: 0.0,
        curve: Interval(
          0.6,
          0.8,
          curve: Curves.linear,
        ),
      ),
   },
   onCompleted: widget.onCompleted,
   builder: (context, comp) {
  • От 0,0–0,4 слова исчезают
  • 0,0–0,5: сдвинутые слова перемещаются
  • 0,2–0,6: слова уменьшаются в размере
  • 0,3–0,6: цвет некоторых слов постепенно меняется с желтого на белый
  • 0,6–0,8: слова исчезают

Виджет перехода

В интервале от 0,75 до 1,0 сцены (или анимации, они имеют одинаковую продолжительность) виджет Переход показывает своего рода эффект перехода для перехода к другой сцене.

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

enum TransitionType { horizontal, vertical, circular }

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

@override
Widget build(BuildContext context) {
  Widget child;
  
  switch (transitionType) {
    case TransitionType.vertical:
      child = TransitionVertical(
        height: height,
        width: width,
        transition: transition,
        image: image,
        fit: fit,
      );
      break;
    case TransitionType.circular:
       child = TransitionCircular(
         height: height,
         width: width,
         transition: transition,
         image: image,
         fit: fit,
       );
       break;
    default:
       child = TransitionHorizontal(
         height: height,
         width: width,
         transition: transition,
         image: image,
         fit: fit,
       );
  }
if (!blur) return child;
return AnimatedBlurWeb(
    strength: 12.0,
    duration: 4000,
    child: child,
  );
}

TransitionVertical:

Переход по горизонтали:

TransitionCircular:

Последняя сцена

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

Нажатие на кнопку «Меню» вызовет вызов метода goToMenu объекта appState для потока объекта isPlaying StreamedValue, который будет отправил событие «false», и ValueBuilder метода сборки _SetupAssetsState будет перестроен, показывая меню:

GestureDetector(
  onTap: appState.goToMenu,
  child: Container(
  padding: EdgeInsets.all(10.0),
  child: Text('Menu',

Вместо этого кнопка воспроизведения, как и кнопка воспроизведения в меню, вызовет метод воспроизведения:

GestureDetector(
  onTap: appState.play,
  child: Container(
    padding: EdgeInsets.all(10.0),
    child: Text('Replay',

Заключение

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

Я обновил свой пакет frideos с помощью некоторых помощников, которые создал для этого трейлера. Сейчас это версия 0.10.0, но я думаю, что собираюсь добавить кое-какие эффекты в следующие недели / месяцы.

Вы можете найти исходный код трейлера в моем репозитории на GitHub.