Как стать автором
Обновить

Почему анимированная погода – это код из конфигуратора или История одного грустного пакета

Уровень сложностиСредний
Время на прочтение18 мин
Количество просмотров1.5K

«Чёрт, где искать эти ваши бесплатные и безвозмездные ресурсы для коммерческого и личного пользования в своих богоподобных разработках?» — именно так, неудачно и нетерпеливо воспользовавшись поиском, я решил создать анимацию с помощью кода, дабы украсить личный проект — приложение погоды «Weather Today».

Задача: есть приложение погоды. И при первом запуске хочется показать красивое интро. И чтобы анимация была, а не просто картинки. И всё это приятно листалось. Срок реализации — 2 недели.

Но нельзя ведь так просто взять, и не нагородить свой велосипед на такой простой задаче? Хех. Конечно, нам нужен отдельный пакет. Так и появился на свет «weather_animation».

На тот момент была ещё одна трудность — я не понимал, как работает анимация в flutter. Поэтому решил сделать так: взять какой‑нибудь имеющийся пакет, разобраться, как в нём устроена анимация, и использовать его в своей работе.

Но почему появился новый пакет тогда? Обо всём по порядку.

Содержание

Глава 1. Непринятие чужого

На pub.dev я нашел подходящий проект — weather_widget и начал разбираться. Действительно, анимированная погода, в меру интересная и приятная на взгляд, а под капотом — код на flutter. То, что нужно, подумал я.

Однако, кодовая база не обновлялась больше двух лет и проект не полагался на null safety. Есть документация какая‑никакая, да ещё и с gif (!) анимацией, и даже продублирована на китайском! Уже неплохо, но под капотом...

Читатель правильно заметит, что обучаться на непонятно каких материалах — дело сомнительное. Но таков путь.

Почти сразу я понял, что так дело не пойдет и код тут, скажем так, не соответствует не только моим принципам кодонаписания, но и официальным рекомендациям dart/flutter. Чтобы не быть голословным, вот некоторые примеры:

На основе v 1.0.6, последний commit on Oct 26, 2021 в master. Здесь и далее все манипуляции будут проводиться именно с этой версией.

  1. В папке lib содержится example, чего не нужно делать. Тот же example продублирован в корне проекта (только там он и должен быть).

  2. Весь код содержится в одном единственном файле WeatherWidget.dart, который ещё и не обозначен как library. В добавок ко всему, есть рекомендация именовать проект и главный файл одинаковым способом. Например, если пакет называется weather_widget, то главный файл пакета должен быть lib/weather_widget.dart.

    Инструмент анализа пакетов в pub.dev, переваривая два этих пункта, показывает вот что:

  3. В файле pubspec.yaml подтягиваются лишние зависимости. Зависимость cupertino_icons: ^1.0.3 в проекте не используется и явно там не нужна. Такого рода issue:

Because every version of weather_widget depends on cupertino_icons ^0.1.2 and flutter_sun_set_rise_api depends on cupertino_icons ^1.0.0, weather_widget is forbidden.

решается тем, что автор обновляет версию пакета cupertino_icons в pubspec.yaml, вместо того, чтобы его вообще убрать оттуда.

То же самое касается и import 'package:flutter/cupertino.dart';, который не используется в проекте (Кстати да, android studio очень вредный, и даже если ты провел с ним много времени и всегда использовал import 'package:flutter/material.dart';, то он всё равно будет предлагать первой рекомендацией импортировать нужный виджет именно из cupertino).

Я уже не буду говорить о semantic versions, о расставленных по всему коду TODO‑шках, которые уже реализованы, и о структуре самого кода (линтер никто не использовал и файла analysis_options.yaml там нет). В общем, я сначала подумывал, может законтрибьютить, но понял, что хочу изменить вообще всё. Благо, имеющаяся Apache-2.0 license позволяет это сделать и, конечно, я указал автора в NOTICE.

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

Было решено переписывать всё и полностью.

Глава 2. Депрессивное переписывание

Собственно, процесс преобразования выглядел примерно так:

  1. Изымаем виджет и кладем в отдельный файл

  2. Запускаем на отдельном экране. Если что‑то не работает, изменяем код так, чтобы заработало.

  3. Глубоко перерабатываем (refactoring) код, периодически запуская hot reload.

  4. Добавляем интересные поля для легкой конфигурации

Необходимо обратить внимание на то, что код не «null safety». Мы могли бы использовать команду dart migrate, но поступим иначе.

Чтобы узнать, что такое «null safety», ознакомьтесь с данной статьёй — Null safety в Dart. Чтобы проект соответствовал данному критерию, необходимо использовать версию dart не ниже 2.12

В нашем случае это:

environment:
  sdk: ">=2.1.0 <3.0.0"

Ещё есть некоторая полезная информация из файла .metadata: автор использовал flutter версии v1.12.13+hotfix.8, что соответствует тому периоду времени.

в пакете weather_widget нет плашки "Null safety". Также, критических изменений кода с версии 1.0.0+1 до 1.0.6 не произошло (разве что добавлен dispose в паре виджетов и произведено автоформатирование кода).
в пакете weather_widget нет плашки "Null safety". Также, критических изменений кода с версии 1.0.0+1 до 1.0.6 не произошло (разве что добавлен dispose в паре виджетов и произведено автоформатирование кода).

Стало очень интересно, а что же скажет линтер. Подключим таковой, но не последней версии, а наиболее близкий к периоду публикации пакета (lint: ^1.1.1), и добавим в файл analysis_options.yaml одну строчку кода:

analyzer:
  strong-mode:
    implicit-casts: true

После завершения dart analysis видим:

Если мы отключим неявное приведение типов:

analyzer:
  strong-mode:
    implicit-casts: true

то все ошибки (именно erros) исчезнут. Уже неплохо.

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

В dart версиях старше 2.16 это можно активировать так:

analyzer:
  language:
    strict-casts: false

Подробнее о настройке статического анализа здесь: Customizing static analysis.

Итог: ребята, используйте линтер. Ваш код будет качественней и, если повезет, понятней коллегам и вам через полгода:)

Меня это всё не волновало, ибо переписать можно что угодно, если понять логику работы кода. Я попытался показать это на примере виджета дождика:

Очень длинный участок кода

было два облака с двумя дождями и фоном, а остался один дождь.
было два облака с двумя дождями и фоном, а остался один дождь.

Да, картинка вылезает из штанов необъятна и спрятана за спойлер. Но как иначе показать до/после? Некоторые рассуждения:

  1. Если виджет назван RainWidget должен ли он включать в себя облака? А фон? Дыма без огня Дождя без облака не бывает? Как по мне, таки бывает, и в данном случае дождь должен быть только виджетом дождя (это ведь про принцип единственной ответственности (Single Responsibility Principle)). По моей логике, если мы хотим сделать облако с дождем, то виджет назовём RainOfCloudsWidget, при этом всю логику облака (анимацию и т. д.) определим в классе CloudWidget. То же самое касается и определения фона всей картины BackgroundWidget — ни к чему это в виджете дождя.

  2. Реализован метод didUpdateWidget(), т.к. конфигурация дождя rainConfig может измениться и в некоторых случаях наша анимация должна быть переопределена.

  3. Переработана модель конфигурации. Признаюсь, мне визуально нравится определение переменных в одной строке (и я использовал это для полей виджетов). Однако, линтер dart рекомендует этого избегать (мы можем отключить данное поведение avoid_multiple_declarations_per_line, добавив правило для линтера в файл analysis_options.yaml). В нашем случае, используя фабричный конструктор для создания модели, это невозможно. После я объясню, зачем здесь freezed и json.

  4. По всему коду раскиданы значения по умолчанию. Теперь такие значения в модели.

И так, виджет за виджетом, был произведен полный рефакторинг кода.

Глава 3. Наторговывание конфигуратора

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

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

Был составлен список с функциями, реализуемыми в зависимости от оставшегося времени и важности.

внешний вид конфигуратора погодных сцен
внешний вид конфигуратора погодных сцен

Проведу краткий экскурс по реализованным функциям конфигуратора. Вся рабочая область делится на три секции. Слева направо:

  1. Погодная сцена — это место, где вы увидите готовый результат вашей погодной картины (в realtime). Ниже имеется форма для настройки размеров холста; слева от неё значение соотношения сторон (aspect ratio), справа — возможность сброса позиций.

  2. Стек погодных виджетов, где мы можем добавлять, удалять, дублировать и перемещать наши погодные виджеты (каждый такой я называю «Weathunit»), которые будут накладываться друг на друга в сцене. Слева рядом с кнопкой «добавление нового виджета» есть так называемые presets — набор разных сгенерированных сцен. Ниже имеется toolbar — кнопки «копирование всего кода», «показать на весь экран погодную сцену», «сбросить неправильное состояние» (позже расскажу), и светлая/системная/темная темы.

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

Слайдер настройки "Weathunit" представляет из себя следующее:

извиняюсь за "рваный красный край". Тестировал инструмент.
извиняюсь за "рваный красный край". Тестировал инструмент.
  1. Тип значения. В web числа представлены иначе, поэтому могут быть нюансы.

  2. Параметр и его значение. Представлен ровно в том виде, в котором будет скопирован.

  3. Кнопка копирования кода. Бывает удобно настроить некоторые отдельные параметры и скопировать именно их. После можно вставлять в код.

  4. Кнопка разрядности. Возможные величины 0.01, 0.1, 1, 10, 100.

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

  6. Ползунок (Slider) для изменения значения. Он дискретный и зависит от разрядности.

  7. Кнопка расширения диапазона. По клику наш диапазон увеличивается на магическую величину. Подробности в следующей главе.


<Рекламная пауза>: здесь пишу о чём‑то ещё — TODO: change after </Рекламная пауза>


Главная задача конфигуратора не только «нарисовать шедевр» и полюбоваться им, но и получить готовый код для быстрого использования в проекте. Функция копирования занимает ~60 строк кода; freezed переопределяет метод toString() в соответствии с реальными полями, что играет здесь ключевую роль.

Весь код, полученный через кнопку "Скопировать код", находится в методе build().
Весь код, полученный через кнопку "Скопировать код", находится в методе build().

Осталось применить dart format, нажав Ctrl+Alt+L (в Android Studio), чтобы лицезреть красиво отформатированный код.

WrapperScene — это виджет‑обертка, которая имеет некоторые полезные параметры для создания полноценной погодной сцены. Под капотом есть автоматическое масштабирование погодных виджетов в зависимости от размеров экрана/доступного места.

Масштабирование имеет крайне простой код:

 насколько прост код, настолько незатейливо это и работает
насколько прост код, настолько незатейливо это и работает

Протестировать сиё чудо можно онлайн — Weather configurator.

Слава небесам пользователям Stackoverflow: после открытого bounty я получил некоторую дополнительную информацию о том, как преодолеть трудности и разместить приложение на Github Pages.

Ваша помощь: помните, была информация о волшебной кнопочке, которая сбрасывает некорректное состояние? Так вот, есть открытое issue, согласно которому кнопка не всегда может спасти. Когда происходит сбой? При некорректных/несовместимых значениях некоторых полей конфигурации снежинок, мы теряем к ним доступ, и нет возможности сбросить это состояние. Если кто знает, каким образом 100% можно очистить состояние виджетов, не сбив при этом текущие настройки сцены, просьба откликнуться в комментариях.

Глава 4. Гнев от рефлексии или необдуманные решения

Основная идея: у нас есть очень много полей конфигурационных файлов (сейчас их больше 60). Эти поля нужно связать с виджетами (Slider, ColorPicker, Switch и т. д.), которые могли бы их изменять. И если виджетов‑погод сейчас 7 и их легко описать одним Enum'ом, то вот с «бесконечными» полями этим заниматься очень не хотелось! Так я чуть не угодил в ловушку под названием dart:mirrors library.

Необходим был механизм под названием рефлексия (reflection) и одна из основных функций — самоанализ (introspection), который может быть доступен в dart. Так мы могли бы получить наши поля файлов конфигураций в режиме выполнения. Но когда я это трогал, мне было так больно, что хотелось порезаться flutter‑бабочкой, благо понял, что этим не получится воспользоваться в flutter. Конечно, есть ещё reflectable пакет, который использует генерацию кода, и есть базовое руководство по внедрению в flutter.

Но я решил пойти другим путём — использовать json_serializable. Через генерацию мы можем как получить все поля toJson() и отобразить их в интерфейсе, так и создать новую конфигурационную модель в одну строку fromJson(). Это показалось очень интересной мыслью, настолько, что я так и реализовал.

Естественно, возник целый ряд проблем.

Проблема № 1:

Для начала хочу показать, как это можно было сделать адекватно через «перечисление» (или через простые «классы»):

enum SunConfigHelper<T> {
	width<double>(0.0, 360.0, 2000.0),
	blurSigma<double>(0.0, 13.0, 36),
	isLeftLocation<bool>(true, false, false),
	coreColor<Color>(
	    Color(0xFFFFFFFF), Color.fromARGB(255, 255, 152, 0), Color(0xFFFFFFFF)),
	// midColor(...),
	// outColor(...),
	animMidMill<int>(0, 1500, 10000);
	// animOutMill(...);
	
	const SunConfigHelper(this.minValue, this.defValue, this.maxValue)
	
	final T minValue;
	final T defValue;
	final T maxValue
	
	// final double scale;
	// final String name;
	// final String description;
	// final double/int/...(enum with types) typeWidget/typeValue;
}

Заметка: это не сильно обдуманный код и, возможно, не решит нашу задачу.

Минусы:

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

  • преобразование этого в виджеты всё ещё может быть проблематичным.

Плюсы:

  • код больше типизирован;

  • У нас есть границы допустимых значений! Это означает, что Slider использовать очень просто — указывайте min и max и всё. Это также предотвратит опасные ситуации с отрицательными числами и слишком большими числами. А ещё использование scale для каждого поля позволяет определить количество дискретных делений (и, по сути, десятичную точность) и порекомендовать разрядность;

  • Не будет приведения типов (value is int и value as int), и мы проще и надежнее преобразуем данные в необходимые виджеты (после будет понятно, почему);

  • Избежим некоторых нюансов в web (я напомню, там dart превращается в js и, например, число 5.6 типа double может превратиться в int, если станет 5.0).

Как вы понимаете, было выбрано другое решение — посредством ModelConfig.toJson() получаем все поля и значения в виде Map<String, dynamic>. Далее, dynamic преобразуем в конкретный тип и, исходя из этого, показываем нужный виджет‑крутилку. Как только мы меняем значение, то обновляем конфигурацию посредством .fromJson(newMap).

"офигенный" код, который без пузыря не разберёшь. Не делайте так.
"офигенный" код, который без пузыря не разберёшь. Не делайте так.

Проблема № 2:

Предыдущее решение основано на типе поля в runtime. Самый простой для преобразования тип — bool, есть два значения — true и false. Виджет может быть обычным переключателем. А вот int и double уже так просто не определишь. Нужны диапазоны, точность и цена деления шкалы.

Хех, и вот она, ловушка работы по типам. Ведь в строке могут лежать любые данные. И я принял решение, что если поле конфигурации является строкой, то это определенно ColorPickerWidget, т.к. на данный момент нет никаких других полей с типом String, которые не хранили бы Color. Да, Color преобразуется именно в String, хотя можно было бы использовать int. Знайте наверняка, это отвратительное решение. Но другого, основанного на типах я не придумал.

Проблема № 3:

У всех int и double полей должны быть разные границы, точность и цена деления. Решение банальное:

Magic-click представляет из себя:

final oldMin = _min;
_min -= (_max * 2).abs();
_max += (oldMin * 2).abs();  

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

Проблема № 4:

Некоторым величинам нужна разная разрядность. Во‑первых, чтобы с помощью кнопок слайдера (<) ‑- (>) величина изменялась на определенный порядок. Щелчок, и вот уже разрядность изменена (доступно 0.01, 0.1, 1, 10, 100), а наш слайдер перестроен.

Тут тоже нюанс:

Чтобы определить количество знаков после запятой, необходимо преобразовать число в String и просто посчитать их количество после точки. Но вот беда, в web всё ломается. Поэтому если вдруг double превратился в int, мы вернём 1. Есть очень хорошее официальное руководство, объясняющее, почему так происходит.

Бонус: в слайдер встроена поддержка удерживания для непрерывного линейного изменения значения (необязательно щелкать).

После всех страданий я понял, что dto‑решение было бы очень кстати, и в дальнейшем так и нужно делать. Но, господа, «быстро, качественно, недорого» — на выбор только два.

Глава 5. Отрицательный рост облачка или как работает анимация

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

Данная анимация была создана с помощью конфигуратора. Результирующий код после копирования из конфигуратора выглядит так:

внимания удостоены только CloudWidget'ы, другие элементы упрощены. Хочу также заметить, что весь виджет WrapperScene является const.
внимания удостоены только CloudWidget'ы, другие элементы упрощены. Хочу также заметить, что весь виджет WrapperScene является const.

Начнём с модели конфигурации CloudConfig. Будем использовать пакет freezed для сгенерированных переопределений toString, operator ==, hashCode + метод copyWith() для легкого получения нового объекта на основе старого и json_serializable, чтобы сгенерировать методы fromJson() и toJson(). По большей части, всё это нужно для неизменяемости (@immutable) объекта и легкой обработки его полей (в том числе, мы можем удобно «передавать на сервер/сохранять в локальной бд» эти модели). Выглядит немного монструозно, но фактически освобождает нас от шаблонного кода (boilerplate code).

part 'cloud_config.freezed.dart';
part 'cloud_config.g.dart';

/// Configuration of the Cloud.
@freezed
class CloudConfig with _$CloudConfig {
  @JsonSerializable(converters: [ColorSerializer()])
  const factory CloudConfig({
    /// Cloud size.
    @Default(250.0) double size,

    /// The color of the cloud.
    @Default(Color.fromARGB(170, 255, 255, 255)) Color color,

    /// Cloud icon. You can use a custom widget [widgetCloud].
    @Default(Icons.cloud_rounded) @JsonKey(ignore: true) IconData icon,

    /// Specify the cloud widget. In this case, the fields [icon] and
    /// [color] be ignored.
    @JsonKey(ignore: true) Widget? widgetCloud,

    /// The coordinate of cloud displacement along the x-axis (in pixels).
    @Default(70.0) double x,

    /// The coordinate of cloud displacement along the x-axis (in pixels).
    @Default(5.0) double y,

    /// The scale factor of the widget at the beginning of the animation.
    @Default(1.0) double scaleBegin,

    /// The scale factor of the widget at the end of the animation.
    @Default(1.1) double scaleEnd,

    /// Animation curve for [ScaleTransition].
    @Default(Curves.fastOutSlowIn) @JsonKey(ignore: true) Curve scaleCurve,

    /// Offset of the widget along the X-axis during the slide animation (in pixels).
    @Default(11.0) double slideX,

    /// Offset of the widget along the Y-axis during the slide animation (in pixels).
    @Default(5.0) double slideY,

    /// Shift duration (in milliseconds).
    @Default(2000) int slideDurMill,

    /// Animation curve for [SlideTransition].
    @Default(Curves.fastOutSlowIn) @JsonKey(ignore: true) Curve slideCurve,
  }) = _CloudConfig;

  factory CloudConfig.fromJson(Map<String, dynamic> json) =>
      _$CloudConfigFromJson(json);
}

После запускаем команду

flutter pub run build_runner build

и получаем всю мощь миксина _$CloudConfig.

Несколько интересных деталей.

  1. Строчка @JsonSerializable(converters: [ColorSerializer()]) говорит, что там, где есть поля типа Color, мы будем применять конвертер и преобразовывать Color в String и наоборот (не в int, что было бы лучше):

/// Convert [Color] to/from json.
class ColorSerializer implements JsonConverter<Color, String> {
  const ColorSerializer();

  @override
  Color fromJson(String json) => Color(int.parse(json));

  @override
  String toJson(Color color) => color.value.toString();
}
  1. @Default(<значение>) это значение по умолчанию для данного поля. Если значение не может быть null, то данная аннотация обязательна.

  2. @JsonKey(ignore: true) эта аннотация указывает, что мы не используем при преобразованиях fromJson()/toJson() данное поле.

Хочу заметить, что это очень некорректный подход. Есть некоторые основания полагать, что происходит «подгонка» моего пакета под конфигуратор. Но ведь основной продукт моей работы будет использоваться без конфигуратора. Не делайте так.

Конфигурационная модель готова, и теперь самое время создать виджет облака:

class CloudWidget extends StatefulWidget {
  const CloudWidget({Key? key, this.cloudConfig = const CloudConfig()})
      : super(key: key);

  final CloudConfig cloudConfig;

  @override
  State<CloudWidget> createState() => _CloudWidgetState();
}

class _CloudWidgetState extends State<CloudWidget>
    with TickerProviderStateMixin {
  late final AnimationController controller;
  late Animation<double> scaleAnimation;
  late Animation<Offset> slideAnimation;

  late CloudConfig _config;

  @override
  void initState() {...}

  void _initAnimation() {...}

  @override
  Widget build(BuildContext context) {...}

  @override
  void didUpdateWidget(covariant CloudWidget oldWidget) {...}

  @override
  void dispose() {...}
}

Что здесь за поля и методы?:

  1. controller — контроллер для анимации. Именно это поле отвечает за длительность и осуществление анимации.

  2. scaleAnimation — анимация увеличения/уменьшения облака.

  3. slideAnimation — анимация движения облака.

  4. _config наша модель‑конфигурация. Изменяемое поле.

  5. initState() запускается один раз при создании виджета‑состояния.

  6. _initAnimation() метод запускается всякий раз, когда изменяются поля конфигурации CloudConfig _config, влияющие на анимацию.

  7. build() здесь происходит создание и отрисовка нашего виджета (грубо).

  8. didUpdateWidget() — вызывается всякий раз, когда изменяется конфигурация виджета. В данном случае, когда мы предоставляем новый CloudConfig cloudConfig, родительский виджет перестраивается и запрашивает обновление этого места в дереве для отображения нового виджета с тем же runtimeType и Widget.key. Фреймворк обновит свойство widget объекта _CloudWidgetState для ссылки на новый виджет, а затем вызовет этот метод с предыдущим виджетом в качестве аргумента.

  9. dispose() метод вызывается, когда объект (CloudWidget) удаляется из дерева навсегда.

Поэтапно раскрываем реализацию:

@override
void initState() {
  super.initState();
  controller = AnimationController(vsync: this);
  _initAnimation();
}

void _initAnimation() {
  _config = widget.cloudConfig;
  controller.duration = Duration(milliseconds: _config.slideDurMill);
  
  scaleAnimation =
      Tween(begin: _config.scaleBegin, end: _config.scaleEnd).animate(
    CurvedAnimation(parent: controller, curve: _config.scaleCurve),
  );
  slideAnimation = Tween(
    begin: Offset.zero,
    end: Offset(_config.slideX / _config.size, _config.slideY / _config.size),
  ).animate(CurvedAnimation(parent: controller, curve: _config.slideCurve));
  
  controller.repeat(reverse: true);
}

По большому счёту, в методе _initAnimation() мы запускаем анимацию и определяем поля исходя из конфигурации. Tween — это линейная интерполяция между начальным и конечным значением, а CurvedAnimation позволяет применить кривую к анимации (по умолчанию, Curves.fastOutSlowIn).

Один контроллер отвечает за две анимации. В конце мы зацикливаем анимацию.

@override
void didUpdateWidget(covariant CloudWidget oldWidget) {
  super.didUpdateWidget(oldWidget);
  final oldConfig = oldWidget.cloudConfig;
  final nowConfig = widget.cloudConfig;
  // rebuild only what is used in the `build` method
  if (oldConfig.x != nowConfig.x ||
      oldConfig.y != nowConfig.y ||
      oldConfig.icon != nowConfig.icon ||
      oldConfig.color != nowConfig.color) {
    _config = nowConfig;
    return;
  }
  // rebuild all
  if (oldConfig != nowConfig) {
    _initAnimation();
  }
}

Так‑так, наша конфигурация была изменена → запускается данный метод. Суть в том, что мы хотим перезапускать (и переопределять) анимацию только тогда, когда изменились значения от которых она зависит. Как видим, она не зависит от координат, виджета‑иконки (по умолчанию это Icons.cloud_rounded) и её цвета.

@override
Widget build(BuildContext context) {
  return Stack(
    fit: StackFit.expand,
    clipBehavior: Clip.none,
    children: [
      Positioned(
        left: _config.x,
        top: _config.y,
        child: SlideTransition(
          position: slideAnimation,
          child: ScaleTransition(
            scale: scaleAnimation,
            child: _config.widgetCloud ??
                Icon(
                  _config.icon,
                  color: _config.color,
                  size: _config.size,
                ),
          ),
        ),
      ),
    ],
  );
}

Всё достаточно просто. Stack необходим, чтобы расшириться в доступных пределах родителей и спозициционировать виджет‑облачко в координатной сетке. В данном случае никакого CustomPainter нет, т.к. используется либо пользовательский виджет, либо иконка.

Ну и не забываем утилизировать контроллер, когда виджет больше не используется (иначе, добро пожаловать, утечка памяти):

@override
void dispose() {
  controller.dispose();
  super.dispose();
}

Вот и всё, анимированный виджет создан. Хочу заметить, что если применить useEffect из пакета flutter_hooks, то код сократится на порядок. Хук useEffect эквивалентен initState + didUpdateWidget + dispose.

Заключение

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

git switch --create other_solution last_stable_commit

Всего хорошего! ( Пожалуйста, поделитесь в комментариях, насколько плох был код конфигуратора )

Список ссылок:

  1. «weather_animation» на github — Link

  2. «weather_animation» на pub.dev — Link

  3. Покрутить конфигуратор онлайн — Link

  4. Посмотреть стартовый экран в приложении — Weather Today. Собираюсь опубликовать ряд статей о создании данного приложения и проблемах, с которыми столкнулся.

Теги:
Хабы:
+2
Комментарии3

Публикации

Истории

Работа

Swift разработчик
30 вакансий
iOS разработчик
22 вакансии

Ближайшие события