zemlan.in

Widgetarian

Если бы март в этом году не затянулся, то я скорее всего скатался бы на BeerJSSummit 2020 с «сиквелом» выступления про всякую автоматизацию, но на iOS/iPadOS вместо macOS. Но произошло то, что произошло, рассказать про JS на iOS/iPadOS всё ещё хочется, так что here we go…

В iOS, в отличии от macOS, нет JXA. Но у нативных приложений уже довольно давно есть доступ к JavaScriptCore для запуска JS-кода. Окружение этого кода отличается от браузерного или nodejs-ового, но приложение может определить свои собственные глобальные функции и объекты, при взаимодействии с которыми будут дёргаться нативные API. Зачастую, про JavaScriptCore вспоминают в контексте React Native, но нам это сегодня не интересно

Кроме приложений, которые используют JS чтобы рисовать кривые интерфейсы, есть приложения вроде Scriptable, в которых можно запускать произвольный JS-код с доступом к GPS, к HTTP, к локальным фото/календарю/контактам, файлам в iCloud…

Пфф, Shortcuts умеет всё это и даже больше! Зачем приплетать сюда JS?

А затем, что в iOS 14 завезли виджеты…

Самый простой из виджетов выше — чуточку более красивая реализация wakemeupwhenmarchends.com1 — работает только с датами и (примитивнейшим) UI, так что он идеально подойдёт как «мой первый виджет»2

Основы интерфейса

Редактор скриптов в Scriptable довольно базовый, но самодостаточный — есть подсветка и автодополнение, есть консоль, есть документация, есть запуск скрипта прямо в редакторе (тапом по ▶️ или нажатием Cmd-R, если подключена внешняя клавиатура)

Мой первый виджет

Для того, чтобы вывести самый базовый виджет, достаточно трёх строк кода (даже двух, если забить на дебаг):

const widget = new ListWidget();

widget.presentSmall();

Script.setWidget(widget);

Вбиваем три эти строки в редактор, нажимаем ▶️, и видим наш виджет — белый (или чёрный, если включен Dark Mode) квадрат:

This House is not a DOM

API для работы с виджетами в Scriptable похож на браузерный DOM, но не DOM. Например:

Учитывая всё это, можно приступить к UI виджета. Наш виджет будет выглядеть как три строки отцентрованного текста на белом фоне:

Чтобы не повторяться, определим функцию addTextRow, которая добавит в виджет WidgetStack с заданными текстом и фоном и вернёт WidgetText чтобы можно было добавить тексту дополнительных стилей:

const widget = new ListWidget();

widget.backgroundColor = Color.white();

function addTextRow(text, backgroundColor = Color.white()) {  
  const textStack = widget.addStack();
  textStack.backgroundColor = backgroundColor;
  textStack.setPadding(4, 0, 4, 0);

  textStack.layoutHorizontally();

  textStack.addSpacer(null);

  const widgetText = textStack.addText(text);
  widgetText.textColor = Color.black();
  
  textStack.addSpacer(null);
  
  return widgetText;
}

const titleText = addTextRow("Март", Color.red());

const dayText = addTextRow(`999+`);

const dayOfWeekText = addTextRow("воскресенье");

widget.presentSmall();

Script.setWidget(widget);

Нажимаем ▶️…

Ай, забыл про стандартные стили виджета… Чтобы красная полоса марта была на всю ширину виджета, надо сбросить стандартный padding. Добавим widget.setPadding(0, 0, 0, 0); сразу после первой строки скрипта:

Добавим spacer’ов и стилей тексту…

const widget = new ListWidget();

widget.setPadding(0, 0, 0, 0);
widget.backgroundColor = Color.white();

function addTextRow(text, backgroundColor = Color.white()) {  
  const textStack = widget.addStack();
  textStack.backgroundColor = backgroundColor;
  textStack.setPadding(4, 0, 4, 0);

  textStack.layoutHorizontally();

  textStack.addSpacer(null);

  const widgetText = textStack.addText(text);
  widgetText.textColor = Color.black();
  
  textStack.addSpacer(null);
  
  return widgetText;
}

const titleText = addTextRow("Март", Color.red());
titleText.textColor = Color.white();
titleText.font = Font.title2();

widget.addSpacer(null);

const dayText = addTextRow(`999+`);
dayText.font = Font.systemFont(60);
dayText.minimumScaleFactor = 0.5;

widget.addSpacer(null);

const dayOfWeekText = addTextRow("воскресенье");

widget.presentSmall();

Script.setWidget(widget);

И видим красивый, хоть и с неправильными данными, виджет:

Time is haaaaaard

Пора разбираться с данными в виджете… Месяц в нём никогда не меняется, так что оставляем его как константу

День месяца немного сложнее. Нам нужно посчитать количество суток с полуночи первого марта 2020 года и округлить его в большую сторону (т.е., в шесть утра второго марта прошло 1.25 суток, округляем в большую сторону до 2 и получаем правильный день месяца)

const MARCH_FIRST = new Date("2020-03-01T00:00:00");

// должно вывести полночь первого марта в текущем часовом поясе
console.log(MARCH_FIRST);

const HOUR = 1000 * 60 * 60;
const day = Math.ceil((new Date() - MARCH_FIRST) / (24 * HOUR));

console.log(day);

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

DateFormatter конвертирует дату в строчку и обратно, причём строчка может быть на любом языке и/или в произвольном формате. Открываем NSDateFormatter.com, находим подходящий нам “EEEE — Tuesday — The wide name of the day of the week” и форматируем сегодняшнюю дату в день недели:

const dayOfWeekFormatter = new DateFormatter();
dayOfWeekFormatter.locale = "ru"
dayOfWeekFormatter.dateFormat = "EEEE";

const dayOfWeek = dayOfWeekFormatter.string(new Date());
console.log(dayOfWeek);

Используем day и dayOfWeek в вызовах addTextRow и проверяем результат:

const MARCH_FIRST = new Date("2020-03-01T00:00:00");

// должно вывести полночь первого марта в текущем часовом поясе
console.log(MARCH_FIRST);

const HOUR = 1000 * 60 * 60;
const day = Math.ceil((new Date() - MARCH_FIRST) / (24 * HOUR));

console.log(day);

const dayOfWeekFormatter = new DateFormatter();
dayOfWeekFormatter.locale = "ru"
dayOfWeekFormatter.dateFormat = "EEEE";

const dayOfWeek = dayOfWeekFormatter.string(new Date());
console.log(dayOfWeek);

const widget = new ListWidget();

widget.setPadding(0, 0, 0, 0);
widget.backgroundColor = Color.white();

function addTextRow(text, backgroundColor = Color.white()) {  
  const textStack = widget.addStack();
  textStack.backgroundColor = backgroundColor;
  textStack.setPadding(4, 0, 4, 0);

  textStack.layoutHorizontally();

  textStack.addSpacer(null);

  const widgetText = textStack.addText(text);
  widgetText.textColor = Color.black();
  
  textStack.addSpacer(null);
  
  return widgetText;
}

const titleText = addTextRow("Март", Color.red());
titleText.textColor = Color.white();
titleText.font = Font.title2();

widget.addSpacer(null);

const dayText = addTextRow(`${day}`);
dayText.font = Font.systemFont(60);
dayText.minimumScaleFactor = 0.5;

widget.addSpacer(null);

const dayOfWeekText = addTextRow(dayOfWeek);

widget.presentSmall();

Script.setWidget(widget);

🎉

Осталось только добавить наш виджет на домашний экран4 и готово

…and the Clever Depart

Scriptable умеет общаться с файлами и HTTP-эндпоинтами, так что эта запись (и твиттер-тред в начале) лишь поверхностно описывает возможности виджетов. Да и писать свои виджеты совсем необязательно — в Scriptable недавно появилась какая-никакая галерея виджетов

Спасибо за внимание


  1. Исходники сайта ↩︎

  2. Вторым виджетом стал бы виджет со статистикой COVID, с его HTTP-запросами, графиком и поддержкой Dark Mode… Пишите, если интересно ↩︎

  3. Для письменностей слева-на-право, вроде кириллицы и латиницы, leading = left, trailing = right, так что порядок аргументов «против часовой» в отличии от «по часовой» в CSS ↩︎

  4. После добавления виджета Scriptable, его нужно ещё раз нажать, чтобы выбрать скрипт, который будет рендерить его содержание ↩︎