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

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

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

Полиция прибыла на место происшествия в мотель «Burnout Meteo Inn» за час до меня. В 911 сообщили о подозрительном шуме в номере одного из постояльцев по имени Билл. Белый мужчина, средних лет, был обнаружен без сознания, привязанным к стулу в своей перевёрнутой вверх дном комнате. Его увезли на скорой, в себя он так и не пришёл.

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

Задание

Создать виджет, который занимает всю доступную ширину экрана и принимает в параметрах конструктора текст произвольной длины вместе с boolean‑значением состояния чекбокса.

Внешний вид виджета:
Текст | -------------------- | Чекбокс
Разделители | показаны условно — визуальных границ между ячейками нет

Виджет состоит из трёх ячеек:
1. Переданный текст
2. Пунктирная линия от начала до конца ячейки
3. Чекбокс с переданным состоянием

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

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

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

Вздохнув в предвкушении облегчения, я достал записку из мотеля и папку для нового дела. Задание из записки могло пролить свет на произошедшее в мотеле, и его надо было решить. Метка flutter create tricky_widget заняла своё место на титульном листе папки, куда я уже был готов добавить первый лист, как вспомнил, что у меня есть помощник! Самое время занять его: пусть приготовит начальный код приложения и нормальный горячий кофе.

Помощника зовут Джуно. Джордано Джуно. В отделе «Flutter Bureau of Investigation» он всего пару дней. По молодости этот паренёк чуть было не вляпался в секту свидетелей Electron — её участники считают, что современный мир движется слишком быстро, поэтому пытаются замедлить его с помощью тормознутого софта. Однако, вовремя осознав ошибку, Джордано решил начать бороться со злом и поступил к нам в отдел.

Джуно принёс два листа кода, а кофе — нет. Как выяснилось, отдел R&D нашего департамента третий раз за год начал переписывать фронтенд офисной системы на новом JS‑фреймворке и за неделю уничтожил месячный запас кофе всего полицейского участка.

Ладно, посмотрим, что принёс Джуно:

// main.dart

import 'package:flutter/material.dart';

import 'step1.dart';

void main() => runApp(const App());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(colorSchemeSeed: Colors.blue),
      home: const Home(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Tricky Widget')),
      body: const Padding(
        padding: EdgeInsets.all(16),
        child: Center(
          child: TrickyWidget(
            text: 'My Beretta stirred nervously under my coat',
            isChecked: true,
          ),
        ),
      ),
    );
  }
}
// step1.dart

import 'package:flutter/material.dart';

class TrickyWidget extends StatefulWidget {
  final String text;
  final bool isChecked;

  const TrickyWidget({
    super.key,
    required this.text,
    required this.isChecked,
  });

  @override
  State<TrickyWidget> createState() => _TrickyWidgetState();
}

class _TrickyWidgetState extends State<TrickyWidget> {
  late bool _isChecked;

  @override
  void initState() {
    super.initState();
    _isChecked = widget.isChecked;
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        _text(),
        _dashes(),
        _checkbox(),
      ],
    );
  }

  Widget _text() {
    return Text(widget.text);
  }

  Widget _dashes() {
    return const Text('--DASH--LINE--PLACEHOLDER--');
  }

  Widget _checkbox() {
    return Checkbox(
      value: _isChecked,
      onChanged: _setCheckbox,
    );
  }

  void _setCheckbox(bool? value) {
    if (value != null) {
      setState(() => _isChecked = value);
    }
  }
}

Стандартное начало, ничего необычного.

Джуно говорит, что озадачен реализацией пунктирной линии, ведь готового виджета для этого во Flutter нет. На самом деле это не сложно. Для построения пунктира нам потребуется «растянуть» его элемент в свободном пространстве между крайними ячейками ряда и узнать размер этого пространства. Первое достигается применением виджета Expanded, второе — с помощью LayoutBuilder.

Виджет Row (как и Column) не накладывает никаких ограничений (constraints) на своих наследников. Поэтому изначально все элементы ряда занимают ровно столько места, сколько им нужно и не больше. Но для того, чтобы средняя ячейка с пунктирной линией захватила всё доступное ей пространство, её ограничения нужно изменить. Именно это и делает виджет Expanded: он накладывает на своего ребёнка строгое ограничение (tight constraint) иметь длину, в нашем случае равную ширине Row за вычетом ячеек по краям. В реализации конструктора Expanded видно, что он наследует виджет Flexible с предопределённым значением fit, равным FlexFit.tight — это и есть строгое ограничение.

А LayoutBuilder представляет собой виджет, который даёт доступ к текущим ограничениям. Он пересчитывается при каждом изменении родительских размеров, например, когда окно приложения растягивается или сужается. За текущее значение ширины виджета отвечает свойство constraints.maxWidth, значение которого пригодится нам не только для построения пунктирной линии, но и для проверки условия её скрытия при размере 10 пикселей.

Теперь можно заменить заглушку метода _dashes() на рабочий код:

// step2.dart

const dashesMinSize = 10.0;
const dashWidth = 5.0;
const dashHeight = 1.0;
const dashColor = Colors.black;

…

Widget _dashes() {
  return Expanded(
    child: LayoutBuilder(builder: (_, constraints) {
      final width = constraints.maxWidth;
      return _dottedLine(width);
    }),
  );
}

Widget _dottedLine(double width) {
  final dashesCount = (width / (dashWidth * 2)).floor();
  return Row(
    mainAxisAlignment: MainAxisAlignment.spaceBetween,
    children: List.generate(
      dashesCount,
      (_) {
        return const SizedBox(
          width: dashWidth,
          height: dashHeight,
          child: ColoredBox(color: dashColor),
        );
      },
    ),
  );
}

Здесь мы вручную считаем нужное количество пунктиров линии и строим внутри Row последовательность чёрных ColoredBox, равномерно распределённых по доступной ширине способом spaceBetween. Значения констант для свойств пунктиров подбираются по вкусу.

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

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

Сделав заказ, мы сели за стол. В баре царили шум и веселье — казалось, что в преддверии конца света люди спешили успеть потратить на развлечения все свои деньги.

Визитной карточкой заведения было соревнование «Shitware Jenga»: на виртуальной машине участники по очереди устанавливают и запускают по одной из дерьмовых программ вроде Skype, Teams, Slack, браузеров сорта Амиго, мусорных антивирусов и прочего хлама. Тот, кто своим действием приводит систему к зависанию, выбывает и оплачивает выпивку игрокам. В следующем раунде виртуальная машина обнуляется, и игра продолжается среди оставшихся.

Нам принесли классический набор для копов на дежурстве: кофе и пончики. Питание, конечно, дрянь, как и вся наша жизнь, но голод утолён, сердцебиение разогнано, а головная боль укрощена — можно продолжать жить у здоровья в долг.

Что касается расследования, то с текущим кодом есть проблема — уменьшение виджета приводит к ошибке RenderFlex overflowing, намекающей, что тексту из первой ячейки для полноценного размещения становится мало места:

Ошибка во Flutter категории хрестоматийных, избавиться от которой можно при помощи…

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

Джуно говорит, что обрезку текста с добавлением многоточия легко можно получить штатным значением TextOverflow.ellipsis параметра overflow у виджета Text. Чтобы этот параметр заработал, виджету Row надо дать понять, что его ячейка с текстом может занимать размер меньше оригинальной длины. Сделать это можно с помощью того же Expanded.

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

// step3.dart

Widget _text() {
  return Expanded(
    child: Text(
      widget.text,
      overflow: TextOverflow.ellipsis,
    ),
  );
}

Так, получается ещё один Expanded в одном и том же Row… Кажется, я начинал понимать, в чём заключается сложность этой головоломки:

Наличие Expanded у двух ячеек ряда заставляет их делить доступное пространство не как нам надо, а кратно друг другу, в данном случае — поровну. И никакая настройка параметра flex у Expanded тут не поможет.

Хитрость заключается в том, что нам действительно нужно использовать два Expanded, но не сразу, а поочерёдно. То есть понадобится некоторая переменная _isCompact, которая будет отражать состояние виджета — пора ли ему начинать прятать пунктирную линию и обрезать текст, «разрешая» использование Expanded одной из ячеек. Условие включения этого компактного режима дано в задании: когда средней ячейке достанется меньше 10 пикселей в ширину.

Временно убрав Expanded у текста для облегчения достижения компактного режима, Джуно с энтузиазмом пишет код для изменения состояния новой переменной при помощи стандартной функции setState():

// step4.dart

class _TrickyWidgetState extends State<TrickyWidget> {
  …
  var _isCompact = false;

…

Widget _text() {
  return Text(
    widget.text,
    overflow: TextOverflow.ellipsis,
  );
}

Widget _dashes() {
  return Expanded(
    child: LayoutBuilder(builder: (_, constraints) {
      final width = constraints.maxWidth;
      if (width <= dashesMinSize) {
        setState(() => _isCompact = true);
      }
      return _dottedLine(width);
    }),
  );
}

…и вместо компактного режима получает ошибку setState() or markNeedsBuild() called during build.

Азарт на лице Джуно сменился адресованным в мою сторону недоумением. Сделать так действительно не получится. Готового обходного рецепта у меня нет, но мне известен человек, который может подсказать решение. Речь про одного из наших информаторов. По правде говоря, редкое дело обходится без его участия.

Я вышел на улицу, чтобы по телефону договориться с ним о встрече. Вернувшись с холода, я не без удивления обнаружил Джуно в компании Евы — именно так она представилась.

— Вы меня извините, но я как фармацевт не могу пройти мимо этого препарата — обратилась она ко мне, указывая на стеклянную баночку, — вы знаете, что его запретили?Из‑за списка побочных эффектов длиннее побережья Лонг‑Айленда.

— Правда?.. Я не знал. А почему вас это так тревожит? — моё удивление требовало ответа.
— Я считаю, что назначать такое пациентам непрофессионально. И если вам небезразлично своё здоровье, то настоятельно рекомендую использовать что‑то посовременнее.

Ева взяла пустую баночку и брезгливо отставила её с центра на край стола.

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

— Хм, спасибо за заботу, мисс. Я подумаю над вашей рекомендацией. Однако сейчас мне пора идти, — я встал, завернул баночку со стола в салфетку и протянул Джуно, — отнеси, пожалуйста, потом это ко мне на стол.

Помощник недоумевающе посмотрел на меня.

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

Радиоприёмник даже не пытался звучать оптимистично:

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

Неоновая реклама IT‑курсов на улицах города отражениями проплывала в окнах машины — ещё одно явление, стремительно охватившее мегаполис. Несколько встреченных по дороге билбордов повалил протестующий против них ветер.

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

Из‑за перекрытых дорог проще было воспользоваться метро, поэтому я оставил машину и спустился в подземку, наполненную холодом с привкусом дурного предчувствия. Что‑то тут было не так, но двери вагона уже захлопнулись за мной, и поезд тронулся. Следующая остановка — станция «Stack Overflow Street».

При подъезде к станции я увидел практически безлюдную платформу с тремя мужчинами в дальнем углу. Поезд остановился, я вышел из центрального вагона. Троица активизировалась и двинулась в моём направлении. Я медленно пошёл к ним навстречу вдоль состава. Поезд стоял на месте — он не мог закрыть двери, потому что одна из них была заблокирована моей папкой. Сокращение дистанции до группы мужчин позволило разглядеть буквы AIDA на логотипах их одинаковых чёрных курток.

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

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

Это были наёмники из IT‑стартапа AIDA, который предлагает решение проблем методами, отличными от цивилизованных. Айтишность проекта заключалась в том, что заказать услугу можно прямо на сайте, ну и плюс заветные буквы AI в названии компании для хайпа.

Немного придя в себя, я достал телефон. Информатор был недоступен. Собрав страницы обратно в папку, я доковылял до торгового аппарата по пути к выходу, купил бутылку воды и залпом выпил. Немного полегчало. Я пошарил рукой по стенкам отсека для выдачи покупок в аппарате и вместе с обрывком скотча получил награду — плотно свёрнутую записку.

Информатор тоже обратил внимание на непрошеных гостей и не стал выходить на платформу, оставив аварийное сообщение в предусмотренном для нештатных ситуаций месте. Приятно иметь дело с профессионалами.

В записке спешным почерком было написано:

addPostFrameCallback()

или

Future.delayed() + Duration.zero

Именно так внутри метода build() можно запланировать смену состояния, минуя порождение рекурсии. Заворачивая вызов функции setState() в метод addPostFrameCallback() объекта WidgetsBinding.instance, мы говорим фреймворку выполнить этот код не сразу же, а после завершения построения кадра, то есть после того, как build() закончит свою работу.

Этой же цели можно добиться асинхронными средствами Dart, а именно Future.delayed() с нулевой задержкой. Так мы отправим код смены состояния в очередь событий (Event Queue), откуда эта задача при помощи цикла событий (Event Loop) попадёт на выполнение в стек вызовов (Call Stack) после завершения всех находящихся в нём синхронных вызовов, коим и является метод build().

Первый подход мне нравился больше, так как в описании второго слишком много терминов в скобках, да и в целом он больше выглядит как трюк, проигрывая в явности прямолинейному addPostFrameCallback(). Также второй вариант заставит нас плодить лишние в данном случае артефакты в виде ключевых слов async и await.

// step5.dart

Widget _dashes() {
  return Expanded(
    child: LayoutBuilder(builder: (_, constraints) {
      final width = constraints.maxWidth;
      if (width <= dashesMinSize) {
        WidgetsBinding.instance.addPostFrameCallback(
          (_) => setState(() => _isCompact = true),
        );
      }
      return _dottedLine(width);
    }),
  );
}

Ошибки setState() called during build больше нет, но в остальном ничего не изменилось, и RenderFlex overflowing тоже никуда не делся. Это потому, что переменную _isCompact мы меняем, но значение её нигде не используем. Исправляемся:

// step6.dart

Widget _text() {
  if (!_isCompact) return Text(widget.text);
  return Expanded(
    child: Text(
      widget.text,
      overflow: TextOverflow.ellipsis,
    ),
  );
}

Widget _dashes() {
  if (_isCompact) return const SizedBox(width: dashesMinSize);
  return Expanded(
    child: LayoutBuilder(builder: (_, constraints) {
      final width = constraints.maxWidth;
      if (width <= dashesMinSize) {
        WidgetsBinding.instance.addPostFrameCallback(
          (_) => setState(() => _isCompact = true),
        );
      }
      return _dottedLine(width);
    }),
  );
}

Теперь в ячейке с текстом при компактном режиме мы используем Expanded и TextOverflow.ellipsis, а в противном случае отдаём просто Text. Во второй же ячейке в компактном режиме вместо пунктирной линии показываем пустую заглушку из 10 пикселей.

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

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

Сидеть на холодной лавке в проходе подземки было неудобно. Накрывала усталость, внимание плыло, мысли путались. Надеюсь, свежий воздух поможет взбодриться. Поднимаясь из метро наверх, я попытался позвонить Джуно, но связи не было. Видимо, погодный апокалипсис и здесь внёс поправки в привычный ритм города.

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

Я чувствовал, как в работе над задачей начинал виднеться финал. Мы заставили виджет корректно работать при сужении, осталось научить его восстанавливаться. Условие включения компактного режима проверялось в коде средней ячейки ряда, и было логично условие его выключения проверять симметрично — внутри первой ячейки. Надо как‑то понять, начинает ли текст полностью помещаться в отведённой ему ширине ячейки. Этот момент и будет сигналом к отключению компактного режима и возвращению пунктирной линии.

Штатных средств для прямого решения такой задачи во Flutter нет. Поэтому такси доставило меня в подходящее место — на городскую фабрику велосипедов. Центральный вход предприятия украшала большая вывеска: «Завод имени Not Invented Here». Репутация у этого места была противоречивая, но бывают случаи, когда по‑другому никак.

На проходной сидел охранник. Его большой хромированный значок на груди ослеплял своим блеском даже ночью. Вахтёр ведёт себя странно — на мой вопрос, как пообщаться с кем‑нибудь из местных специалистов, никак не реагирует. Просто не отвечает. Благо я знаю, как сделать его более отзывчивым — дополнительная пара гигабайт RAM быстро развязала ему язык.

Рекомендованный мастер коротал ночную смену в небольшом кабинете. Я понял, что он русский, ещё до того, как тот произнёс хоть слово — от него резко пахло то ли перегаром, то ли 1С. Это был взъерошенный паренёк с красными глазами, которого от забвения отделяла только энергия кофеина. Как и меня.

Выслушав проблему, он походил по комнате, закурил, сел в кресло и завис. Спустя некоторое время, когда связь с космосом, видимо, была успешно установлена, он низким голосом грудного регистра выдал последовательность звучащих как призыв дьявола слов «TextPainter, layout, didExceedMaxLines» и отключился. Пришлось открыть окно, чтобы немного снизить градус креатива в атмосфере помещения и помочь коллеге прийти в себя.

Проверить, помещается ли текст без обрезки в определённом пространстве, можно, подготовив этот текст к рендерингу в объекте TextPainter и посмотрев его свойство didExceedMaxLines. Оно отвечает на вопрос, умещается ли получившийся текст на требуемом количестве линий — в нашем случае, на одной.

Вообще, объект TextPainter предназначен для работы внутри Canvas — специального пространства для рисования во Flutter. Однако никто не мешает нам такой объект создать, вычислить его расположение методом layout и посмотреть на получившиеся свойства без дальнейшей отрисовки. Звучит как какое‑то непотребство. Нам подходит:

// step7.dart

bool _isTextOverflowed(double width) {
  final textPainter = TextPainter(
    maxLines: 1,
    text: TextSpan(
      text: widget.text,
      style: DefaultTextStyle.of(context).style,
    ),
    textDirection: TextDirection.ltr,
    textScaler: MediaQuery.of(context).textScaler,
  );
  textPainter.layout(maxWidth: width);
  return textPainter.didExceedMaxLines;
}

При создании TextPainter требуется явно указать стиль текста и его направление, так как этот объект существует отдельно от корневых элементов приложения и не наследует задающиеся по умолчанию атрибуты. Также важно не забыть про параметр textScaler (textScaleFactor для Flutter < 3.16) — его начальные значения для виджетов Text и TextPainter отличаются. Всё это нужно для точного совпадения размеров виртуального текста внутри TextPainter с обычным текстом из первой ячейки.

Теперь у нас есть все необходимые инструменты для проверки условия отключения компактного режима:

// тот же step7.dart

Widget _text() {
  if (!_isCompact) return Text(widget.text);
  return Expanded(
    child: LayoutBuilder(
      builder: (_, constraints) {
        final width = constraints.maxWidth;
        if (!_isTextOverflowed(width)) {
          WidgetsBinding.instance.addPostFrameCallback(
            (_) => setState(() => _isCompact = false),
          );
        }
        return Text(widget.text, overflow: TextOverflow.ellipsis);
      },
    ),
  );
}

Только что мы получили изящное симметричное решение не только локальный проблемы, но и всего задания! Замечательно!

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

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

На улице мне позвонил Джуно с новостями по делу:

— Пришла информация, что наш пострадавший Билл сбежал из больницы, как только пришёл в себя, и неизвестно где теперь находится. Кстати, не поверишь, какая у него настоящая фамилия…

— Как? Виджет? Билл Виджет? — от смеха у меня закололо в боку, — с такой фамилией только в IT работать. Как, кстати, вечер с Евой?

— Никак, она ушла почти сразу после тебя. Ей позвонил, как она сказала, её бойфренд. Краем глаза я видел экран телефона — не понял прикола записывать своего парня как AIDA. Юмор походу у неё своеобразный…

— Твою ж мать! — ударила меня своим светом истина, — Джуно, сейчас же бери ту банку из‑под таблеток с моего стола и бегом в лабораторию снимать отпечатки пальцев! Как пробьёшь эту «Еву», сразу же звони.

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

Ну хоть с чем‑то мне сегодня везёт — такси стояло через дорогу. Похоже, даже та самая машина, на которой я сюда приехал. Сделав пару шагов в сторону спасительного транспорта, я услышал тихий свист. Громкость звука быстро усилилась, в глазах потемнело. Меня приподняло над землёй и через мгновение с силой швырнуло обратно в снег. Огни вокруг поплыли ядовитыми цветами — сознание начало погружаться в бездну.

Из темноты вспыхнула жёлтая искра. Перед глазами дрожащими пикселями побежали строчки: '1' + 1 == '11', Uncaught TypeError, realShit is undefined. Тени, мечущиеся в темноте, смеялись мерзким хохотом: {} + [] == 0, du -sh node_modules → 19.8G. Тьма острыми когтями сдавила дыхание и обволокла едким ужасом…

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

Как долго я пролежал здесь?.. Судя по тому, что ещё жив, вряд ли больше двадцати минут при такой погоде. Машины такси уже не было. Как и моей папки с кодом.

Дубовыми руками я нащупал телефон, на экране которого светилось семь пропущенных звонков от Джуно.

Её действительно зовут Ева. Никакой она не фармацевт. И теперь я знаю, где она живёт.

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

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

Комнаты были заставлены канализационными люками, мячиками для гольфа и канистрами ёмкостью три и пять галлонов. Шучу, ничего этого не было. Просто оказалось, что Ева принадлежит к трепетно хранимой в специальном отделе моего сердца профессии, бо́льшая часть представителей которой не в состоянии даже грамотно поставить ударение в слове «рекру́тер», не говоря уже о качестве выполнения своих прямых должностных обязанностей.

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

В бардаке рабочего стола виднелись вскрытые упаковки из‑под лекарств. Ноутбук тоже был открыт. Ломать голову над паролем не пришлось — рядом с экраном на стикере старательным почерком было выведено: Qwerty123. В браузере посреди тридцати вкладок был открыт сайт AIDA. Самая же интересная находка ждала меня в мессенджере, который Ева оставила открытым на диалоге со знакомым мне не только именем, но и лицом на аватарке. С Биллом Виджетом.

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

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

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

Откуда я это узнал? Задание Биллу выдала Ева, когда тот откликнулся на вакансию в компании, где она работает. Спустя некоторое время Ева совершила непростительную с точки зрения кодекса HR оплошность — прочитала резюме кандидата — и поняла, что это не кто иной, как её бывший, с которым она плохо разошлась ещё до своего приезда в город.

Билл понимает, что наконец‑то ухватил шанс пробиться на заветную работу за счёт Евы. Она явно не горела желанием помогать ему, но возможность воспользоваться основным оружием HR — отказать кандидату без объяснения причины — была предусмотрительно пресечена шантажом публикации интимных фото и видео Евы, бережно сохранившихся у Билла после расставания.

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

А Билл, кстати, и был тем таксистом, не упустившим удачно подвернувшийся случай моего недоотравления для похищения папки. До встречи в такси я ни разу не видел его вживую — с места происшествия в мотеле Билла увезли до моего прибытия.

На этом я закончил своё повествование, доедая в потёмках роллы из холодильника. Надеюсь, они не были отравлены. В прихожей снова раздался шум — домой явилась хозяйка.

Осознав патовость положения, Ева приняла на удивление благоразумное решение. Она раскаялась в своих действиях и даже слёзно попросила помочь ей выбраться из леса наломанных дров. Кроме этого, она знала ответ на последний неизвестный вопрос этой истории — где сейчас находится Билл?

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

Фонари во дворе метеостанции нервно перемигивались. Попадавшие в ловушку света хлопья снега на мгновение вспыхивали и снова уносились во тьму. Метель любила это место.

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

Мы застали Билла сгорбившимся за пультом управления главного компьютера в ворохе листов из знакомой папки. Он в самом деле собирался испытать тут наш код.

— Билл, стой! — крикнул я, пытаясь заглушить гул машинного зала, — не вздумай запускать это здесь! Решение в папке не оптимально, а избыточная вычислительная нагрузка на таких старых компьютерах может привести к непредсказуемым последствиям!

В самом деле, зачем пересчитывать TextPainter при каждом изменении размеров виджета, если сам текст при этом не меняется? Разумно создать этот объект всего один раз.

Обычно такие задачи решаются с помощью метода initState() — он однократно выполняется во время инициализации состояния Stateful виджета. Однако в данной ситуации такой подход не поможет из‑за необходимости коду иметь доступ к контексту для получения стиля и коэффициента масштабирования текста. Несмотря на то, что сам объект context уже доступен внутри initState(), использовать здесь методы вроде .of(context) не получится.

Тут пригодится другой метод жизненного цикла Stateful виджета — didChangeDependencies(), который вызывается фреймворком после initState(), и где уже можно достучаться до нужных нам InheritedWidget объектов: DefaultTextStyle и MediaQuery.

// tricky_widget.dart

@override
void didChangeDependencies() {
  _textPainter = TextPainter(
    maxLines: 1,
    text: TextSpan(
      text: widget.text,
      style: DefaultTextStyle.of(context).style,
    ),
    textDirection: TextDirection.ltr,
    textScaleFactor: MediaQuery.of(context).textScaleFactor,
  );
  super.didChangeDependencies();
}

bool _isTextOverflowed(double width) {
  _textPainter.layout(maxWidth: width);
  return _textPainter.didExceedMaxLines;
}

Но Билла это не убедило:

— Ха‑ха, глупцы! Неужели вы думаете, что я поведусь на это? Я в одном шаге от зарплаты, обещанной мне на курсах! Я столько времени шёл к этому! Посмотрим, как ты, Ева, заговоришь со мной теперь!

Было слишком поздно. Гул от стоек с оборудованием перешёл в свист. В воздухе запахло гарью. Свет погас, вспыхнули аварийные лампы. Раздался стук, за которым лязгнул оглушительный взрыв, отбросивший Билла от пульта управления. На этот раз я был готов поверить, что потерю сознания он не имитирует.

С улицы донёсся долгожданный вой сирен. В помещение прибыла группа полицейских, вручившая Биллу заслуженную награду — наручники. Выходя из здания, Джуно вдруг неудержимо заискрил каламбурами:

— Слушай, шеф, твой код — просто огонь! Бомбическое решение! А из Билла мог бы получиться настоящий боевой программист, работать он предпочитает только на бою!

Джуно с Евой смеялись. Кажется, это был смех облегчения. Я их не слушал. Всё было кончено.

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

PS

Воу, дружище, это была весьма долгая история. Спасибо, что прошёл весь путь целиком!

Напоследок надо отметить, что история основана на реальных событиях это настоящее тестовое задание, которое мне предложили в одной из компаний при отклике на вакансию Flutter Junior Developer. И я точно могу утверждать, что приведённое решение работает и было принято, но не могу ручаться за его идеальность. Не стесняйтесь писать в комментариях, как можно сделать лучше и проще.

Репозиторий с кодом

То самое видео с Евой, которым Билл её шантажировал.

Комментарии (8)


  1. ookami_kb
    30.11.2023 13:42
    +1

    Не стесняйтесь писать в комментариях, как можно сделать лучше и проще.

    Через кастомный RenderObject.


    1. eshfield Автор
      30.11.2023 13:42

      Это точно будет проще?


      1. ookami_kb
        30.11.2023 13:42
        +3

        Это точно правильнее, потому что не будет всяких лишних addPostFrameCallback, setState и Row. Да и сложного там, в общем-то, ничего нет: https://dartpad.dev/?id=6ac06fb364d4b3a11e1de920a5a38a87


        1. eshfield Автор
          30.11.2023 13:42

          Это без преувеличения очень крутое решение! Отдельное спасибо за рабочий пример.

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


  1. ChessMax
    30.11.2023 13:42
    +1

    Как-то вы довольно сложно навертели. Разве нельзя сделать как-то так:

    Row(
      children: [
        Expanded(
          child: LayoutBuilder(
            builder: (context, constraints) {
              return Row(
                children: [
                  ConstrainedBox(
                    constraints: BoxConstraints(maxWidth: constraints.maxWidth),
                    child: _text(),
                  ),
                  Flexible(child: _dots()),
                ],
              );
            },
          ),
        ),
        _checkbox(),
      ],
    );

    Не проверял, но по идее должно работать.

    Вызывать WidgetsBinding.instance.addPostFrameCallback из build метода это костыль. По-хорошему build метод должен быть чистой функцией. Отрисовывать dash линию стоило через CustomPaint, либо воспользоваться готовым пакетом. Так же могут быть проблемы с отрисовкой текста. Такие, как потенциальный рассинхрон параметров стилей. А так же двойной пересчет размеров текста, что как-бы не быстрая операция.

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


    1. eshfield Автор
      30.11.2023 13:42

      Всё верно, ваше решение действительно работает. Получилось поразительно просто!

      Правильно ли будет сказать следующее:
      − чтобы текст начал обрезаться с многоточием, элементу текста надо дать возможность занимать меньше отведённого под полный текст места без ошибки RenderFlex overflowing , то есть применить констрейнт
      − в статье я добивался этого оборачиванием текста в Expanded и решением кучи попутно возникающих проблем, а в вашем случае наложение нужных констрейнтов достигается прямым использованием ConstrainedBox без побочных эффектов

      Или какой последовательностью соображений вы руководствовались при решении этой задачи?


    1. eshfield Автор
      30.11.2023 13:42

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

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

      Это всё со слов интервьюера, конечно же.


  1. kharitonovAL
    30.11.2023 13:42
    +1

    Даже не смотря на код, очень крутая подача материала!

    Я даже не знаю как решил бы подобное. Скорее всего это были бы костыли, какая то работа с Row, может быть попытка замечать конец текста.

    В общем решения предложенные тут явно круто выглядят. Комментарии тоже интересно почитать. Спасибо!