Создание виджетов на iOS с использованием JSX

Олег Поздняков, OpenWeather

Создание виджетов на iOS
с использованием JSX

Обо мне

avatar
JS everywhere

Небольшая предыстория

Обо мне (в прошлом)

avatar

iOS 14 (2020 год)

iOS 14

Виджеты бывают разные
Чёрные, белые, красные

Часы

Small

Заряд баттарей

Medium

Погода

Large

Подкасты

Extra-Large

Scriptable Scripts Screen

Scriptable

Automate iOS using JavaScript

Scriptable App site

❗ Дисклеймер ❗

Hello World.js в Scriptable

const widget = new ListWidget();
const text = widget.addText('Hello World!');
text.textColor = Color.red();

if (config.runsInWidget) {
    Script.setWidget(widget);
} else {
    await widget.presentMedium();
}
Скриншот виджета “Hello world” из примера выше
...else {
  // Создаём новый алерт и наполняем его контентом
  let alert = new Alert();
  alert.title = "Enter Code";
  alert.addTextField("0000#0000", "");
  alert.addAction("OK");
  alert.addAction("Cancel");
  // "Презентуем" алерт и проверяем, что пользователь нажал на кнопку OK
  if (await alert.present() == 0) {
    // Получаем текст из поля ввода и выводим его в консоль
    let text = alert.textFieldValue(0);
    console.log("Entered Text: " + text);
  }
}

👍 , но...
А как писать?

А какие у нас вообще есть варианты?

Писать прямо на телефоне

❌ Больно

Ускорено в 5 раз. В реальности видео длиной 2:37

Купить iPad и делать на нём

❌ Дорого

Пишем код как нормальный человек

✅ ДААА!!!

Ведь так мем
JSX

Ужасный Workflow

Mac Required!

Путь к папке Scriptable в iCloud: ~/Library/MobileDocuments/iCloud~dk~simonbs~Scriptable/Documents

  1. При помощи скрипта (Gulp) или ручками переносим .js файл в папку

2. Ждём синхронизацию в iCloud с Mac

3. Ждём синхронизацию
из iCloud на iPhone

4. Пытаемся заставить iOS
перезагрузить виджеты

А ещё оно ломается...

Scriptable Scriptable
на MacOS Но есть маленькое "но"...

Писать всё равно не удобно

Random Scriptable API Widget

Scriptable на MacOS Scriptable Gallery

Random Scriptable API Widget

let widget = new ListWidget();
// Show app icon and title
let titleStack = widget.addStack();
let appIconElement = titleStack.addImage(appIcon);
appIconElement.imageSize = new Size(15, 15);
appIconElement.cornerRadius = 4;
titleStack.addSpacer(4);
let titleElement = titleStack.addText(title);
titleElement.textColor = Color.white();
titleElement.textOpacity = 0.7;
titleElement.font = Font.mediumSystemFont(13);
widget.addSpacer(12);

Не пытайтесь понять что тут происходит. Честно.

Полный код сприпта в gist

Идея 💡 Реализовать JSX

Сформулируем хотелки

Hello World.js

const widget = new ListWidget();
const text = widget.addText('Hello World!');
text.textColor = Color.red();

if (config.runsInWidget) {
    Script.setWidget(widget);
} else {
    await widget.presentMedium();
}

Hello World.tsx

import {ScriptableJSX} from "@jag-k/scriptable-tsx";

const widget = (
  <widget>
    <text color={Color.red()}>Hello World!</text>
  </widget>
);

if (config.runsInWidget) {
    Script.setWidget(widget);
} else {
    await widget.presentMedium();
}

Hello World.tsx (compiled)

const widget = (
  ScriptableJSX.createElement("widget", null,
    ScriptableJSX.createElement("text", { color: Color.red() },
    "Hello World!"
    )
  )
);

if (config.runsInWidget) {
    Script.setWidget(widget);
}
else {
    await widget.presentMedium();
}

Как это всё работает?

JSX itself
@jag-k/scriptable-jsx (Делал сам)
Rollup plugin
@jag-k/rollup-plugin-scriptable (Тоже делал сам)
Scriptable API types
@types/scriptable-ios (А за это спасибо комьюнити)

ScriptableJSX

const createElements = [widgetCreateElement, alertCreateElement];

class ScriptableJSX {
  static createElement(element, props, ...children) {
    for (const creator of createElements) {
      const res = creator(element, props, ...children);
      if (res) {
        return res;
      }
    }
  }
}
Полный код
function widgetCreateElement(element, props, ...children) {
  switch (element) {
    case "widget":
      const widget = new ListWidget();
      processContainerChildren(widget, children);
      processWidgetProps(widget, props || {});
      return widget;
    case "stack":
      return { type: element, props, children };
    case "spacer":
    case "image":
    case "date":
      return { type: element, props };
  }
}
Полный код
function processContainerChildren(widget, children) {
  for (const child of children) {
    if (typeof child === "string" || ...) { widget.addText(String(child)); }
    else if (typeof child === "object") {
      if (child instanceof Array) { processContainerChildren(widget, child); }
      else if (child instanceof Date) { widget.addDate(child); }
      else if (child instanceof Image) { widget.addImage(child); }
      else if ("type" in child) {
        switch (child.type) {
          case "text": {
            const text = widget.addText(child.text);
            processTextProps(text, child.props || {});
            break;
          }
          case "date": ...
          case "stack": ...
          case "spacer": ...
          case "image": ...
          ...
Полный код

tsconfig.json

{
  "compilerOptions": {
    "jsx": "react",
    "jsxFactory": "ScriptableJSX.createElement",
    "typeRoots": [
      "./node_modules/@jag-k/scriptable-jsx",
    ],
    "types": [
      "node",
      "@jag-k/scriptable-jsx/types",
      "@types/scriptable-ios",
    ]
  }
}

rollup.config.js

import typescript from '@rollup/plugin-typescript';
import scriptableBundle from "@jag-k/rollup-plugin-scriptable";
import * as config from "./config.json";
import {nodeResolve} from '@rollup/plugin-node-resolve';

export default {
  input: 'src/main.ts',
  output: [
    {
      file: `dist/${config.name}.js`, format: 'es',
      plugins: [scriptableBundle(config)]
    },
  ],
  plugins: [ nodeResolve(), typescript() ],
};

config.json

{
  "always_run_in_app": false,
  "icon": {
    "color": "yellow",
    "glyph":"magic"  // SF Symbols
  },
  "name":"Hello JSX",
  "share_sheet_inputs": [],  // Это настраивается в самом Scriptable
}

Hello JSX.scriptable

{
  "always_run_in_app": false,
  "icon": {
    "color": "yellow",
    "glyph":"magic"  // SF Symbols
  },
  "name":"Hello JSX",
  "share_sheet_inputs": [],  // Это настраивается в самом Scriptable
  "script":"function processJSXColor(color) {..."
}

Примеры: Виджет для SberFood

СберФуд — Система лояльности от Сбербанка. Теперь называется Plazius

SberFood Widget SberFood Widget

Примеры: Яндекс.Бейдж

Виджет для отображения баланса бейджика сотрудника Яндекса

Яндекс.Бейдж YandexBalance Widget

Примеры: HomeAssistant Persons Widget

Просто узнать кто сейчас находится дома. Берёт данные из HomeAssistant

HomeAssistant Persons Widget

Try it now!

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

avatar