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);
const widget = new ListWidget();
создаёт объект виджета, который мы будем изменять для отрисовки календаряwidget.presentSmall();
показывает окно предпросмотра с маленьким форматом виджетаScript.setWidget(widget);
обновляет виджет на домашнем экране
Вбиваем три эти строки в редактор, нажимаем ▶️, и видим наш виджет — белый (или чёрный, если включен Dark Mode) квадрат:
This House is not a DOM
API для работы с виджетами в Scriptable похож на браузерный DOM, но не DOM. Например:
- все элементы виджета создаются как дочерние элементы самого виджета или его элементов. Другими словами, вместо браузерного «создать элемент и присоединить его к родителю» (
const child = document.createElement("span"); parent.appendChildren(child)
), в Scriptable используется «создать дочерний элемент у родителя» (const child = parent.addText()
) - стили элементов (цвета, шрифты, отступы) задаются отдельными методами, а не свойством
style
- для определения порядка элементов используются
WidgetStack
с заданным горизонтальным или вертикальным layout’ом - отступы между элементами задаются с помощью
WidgetSpacer
’ов, а неmargin
’ов (которых нет ни в Scriptable, ни в SwiftUI, который используется для рендера виджетов) - отступы внутри
WidgetStack
задаются методомsetPadding(top, leading, bottom, trailing)
3 - вместо строчных шрифтов, цветов и размеров, Scriptable использует классы
Font
,Color
иSize
. У первых двух классов есть множество статических методов для стандартных значений (вродеFont.title()
иColor.red()
), которые выглядят консистентно с остальными виджетами и приложениями
Учитывая всё это, можно приступить к 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 недавно появилась какая-никакая галерея виджетов
Спасибо за внимание
Вторым виджетом стал бы виджет со статистикой COVID, с его HTTP-запросами, графиком и поддержкой Dark Mode… Пишите, если интересно ↩︎
Для письменностей слева-на-право, вроде кириллицы и латиницы,
leading
=left
,trailing
=right
, так что порядок аргументов «против часовой» в отличии от «по часовой» в CSS ↩︎После добавления виджета Scriptable, его нужно ещё раз нажать, чтобы выбрать скрипт, который будет рендерить его содержание ↩︎