Stax is a (very thin) abstraction over Scriptable's built-in widget API. It aims to provide a more declarative API, and allows you to create reusable components.
(assume font
, mainImage
, etc, are defined above)
const widget = new ListWidget();
const mainStack = widget.addStack();
mainStack.layoutVertically();
mainStack.spacing = 2;
const titleStack = mainStack.addStack();
titleStack.layoutHorizontally();
titleStack.addSpacer();
const text = titleStack.addText("A Really Cool Widget!");
titleStack.addSpacer();
line.font = font;
line.textColor = fontColor;
line.centerAlignText();
const imageStack = mainStack.addStack();
imageStack.layoutVertically();
const image = imageStack.addImage(mainImage);
image.centerAlignImage();
Script.setWidget(widget);
Script.complete();
const { Widget, HorizontalStack, VerticalStack, Spacer, Text, Picture } = importModule("Stax");
const title = HorizontalStack({}, [
//
Spacer(),
Text("A Really Cool Widget!", { font: font, color: fontColor, align: "center" }),
Spacer(),
]);
const content = VerticalStack({}, [
//
Picture(mainImage, { align: "center" }),
]);
const widget = Widget({ spacing: 2 }, [
//
VerticalStack({}, [title, content]),
]);
widget.render();
Script.complete();
Add Stax.js
to your Scriptable library, and then just import what you need:
const { Widget, HorizontalStack, Picture } = importModule("Stax");
If you store your Scriptable scripts in iCloud, you can clone this repo and run ./install.sh
, which will try to copy Stax.js
to the Scriptable folder in your iCloud drive. You might need to edit install.sh
if your Scriptable directory is different from mine.
Stax exposes a number of constructors for various types of Widget elements.
In general, the arguments for containers (Stacks, Widgets) are (params, children)
, while for content elements (Text, Picture, etc) it's (content, params)
. Generally, all keys in params
are optional, unless otherwise specified.
Widget({ bgType: 'gradient', bgGradient: new LinearGradient(), spacing: 2 }, [...children])
- Currently only gradient backgrounds are supported. If
bgType
is"gradient"
,bgGradient
must be present and must be a ScriptableLinearGradient
object.Widget.render()
will render the entire content tree, and callScript.setWidget
with itself as an argument.
- Currently only gradient backgrounds are supported. If
Stack({ layout: "horizontal" | "vertical", align: "top" | "center" | "bottom", spacing: 2 }, [...children])
- Creates a Scriptable
Stack
object.
- Creates a Scriptable
HorizontalStack({ align: "top" | "center" | "bottom", spacing: 2 }, [...children])
- This is just shorthand for
Stack({ layout: "horizontal", ...params }, [...children]
, to make layouts a little easier to read.
- This is just shorthand for
VerticalStack({ align: "top" | "center" | "bottom", spacing: 2 }, [...children])
- This is just shorthand for
Stack({ layout: "vertical", ...params }, [...children]
, to make layouts a little easier to read.
- This is just shorthand for
Spacer({ size: 2 | null })
- Creates a
Spacer
object.{ size: null }
will let the Spacer flex to fill the available space; you can also pass a number to set an absolute value.Spacer()
is shorthand forSpacer({size: null})
.
- Creates a
Text(content, { font: new Font(), color: new Color(), align: "left" | "center" | "right" })
- Creates a line of text with
content
. If present,font
must be a ScriptableFont
object. Likewise, if present,color
must be a ScriptableColor
object.
- Creates a line of text with
Picture(content, { align: "left" | "center" | "right", mode: "fit" | "fill" })
- Creates a picture with
content
.
- Creates a picture with
Stax exposes a single class, Component
that can be used to create your own reusable components, like so:
class Title extends Component {
constructor(content, params) {
super(content, params);
}
build() {
const { font } = this.params;
return HorizontalStack({}, [
//
Spacer({ size: 5 }),
Text(this.content, { font }),
]);
}
}
const title = new Title("This is a title", { font: new Font() });
Another way to build reusable components is just by creating simple functions:
const Title = (content, params) => HorizontalStack({}, [
Spacer({ size: 5 }),
Text(content, { params.font })
]);
const title = new Title("This is a title", { font: new Font() });
Both methods are more or less equal to each other; it mostly comes down to a stylistic choice.
Stax also exposes a single utility function, wrapComponent
which can be used to allow you to omit the new
keyword on custom components:
const Title = wrapComponent(
class TitleComponent extends Component {
constructor(content, params) {
super(params);
}
render() {
const { font } = this.params;
return HorizontalStack({}, [
//
Spacer({ size: 5 }),
Text(this.content, { font }),
]);
}
}
);
const title = Title("This is a title", { font: new Font() });
The code is pretty simple, 90% of the magic happens at the top of the file in the UIElement
class, which everything else extends.
UIElement
has the following properties and methods:
content
- Whatever the component will be rendering - text, a picture - nothing in the case ofStack
components.children
- Any other components this component contains. Only used byStack
components andWidget
s.parent
- The parent Component - every component except the top-levelWidget
needs this.config
- An object storing whatever is passed in theparams
argument.element
- A reference to the underlying Scriptable UI object.null
untilcreateElement()
is called.createElement()
- This is the most important piece. This method calls the Scriptable API method onthis.parent
and returns the result. For example,return this.parent.addStack();
.render()
- CallscreateElement()
, setsthis.element
to the result, callsrender()
on each of the components children, and then callsthis.configure()
.configure()
- Sets up any configuration on the underlying UI object. For example,this.element.font = this.config.font;
addContent(...children)
- Given a list of other components, this adds those components tothis.children
and (importantly) setsthis.parent
on each of them.
Everything else in Stax is built on top of this, in some cases extremely simply:
class Spacer extends UIElement {
createElement() {
return this.parent.element.addSpacer(this.config.size);
}
}
This means that the whole UI is represented by a tree of UIElement
objects related via their parent
and children
properties. The actual underlying Scriptable objects are purely theoretical, until Widget.render()
is called - which then creates the Scriptable ListWidget
object, and continues down the tree calling render()
on each child, adding the Scriptable objects to their parents.
For example:
const widget = Widget({}, [
HorizontalStack({}, [
//
Text("Widget!", {}),
]),
]);
// Nothing exists at this point except three UIElements - a Widget, a HorizontalStack and a Text.
// The Text's `parent` is the HorizontalStack, and the HorizontalStack's `parent` is the Widget.
widget.render();
// Widget.render() runs `this.element = new ListWidget()`, and then calls HorizontalStack.render()
// HorizontalStack.render() runs `this.element = this.parent.addStack()`, and then calls Text.render()
// Text.render() runs `this.element = this.parent.addText(this.content), and then calls this.configure() (which does nothing here, since we haven't passed any configuration properties to the Text component).
// HorizontalStack.configure() is called, which runs `this.element.layoutHorizontally()`
// Widget.configure() is called, which runs `Script.setWidget(this.element)`
The Component
class adds another method, build()
. This should be overridden by custom components that extend Component
. It should return a single UIElement
class (eg. Stack
, Text
, etc). The return value of build()
is passed to the custom component's createElement()
method, which handles creating the underlying UI elements and attaching them to their parents, etc.
Supporting new components is very easy - just create a new class that extends UIElement
and give it the corrent createElement
method.
Supporting new configurations is also pretty straightforward - just add the relevant Scriptable code to the configure
method:
class Text extends UIElement {
configure() {
const { opacity } = this.config;
this.element.textOpacity = opacity;
}
createElement() {
return this.parent.element.addText(this.content);
}
}
One nice thing about the thinness of Stax is that it's not hard to reach in to the Scriptable API if you need to for some reason. All Stax components have an element
property that references the underlying Scriptable object:
const title = Text("This is a title", {});
title.createElement(); // Note that you must call this method first; usually the underlying objects are not created until the final call to `render()` on the Widget.
title.element.textOpacity = 0.5;
This is mostly useful for access properties that haven't been implemented by Stax yet, like above.
That's it! The library is under "active" development, in that I add features as I encounter a need for them 😅 - so as you'll notice, there is plenty of the built in widget API that is not implemented yet. (This project was originally started at around 2am while I was working on a custom widget and was wishing there was a cleaner way to lay them out).
Feel free to add stuff and submit PRs!
I use Prettier to format my code. I think Prettier is great, but it won't let me keep my arrays on multiple lines if they'll fit on one. I think these layouts are a lot easier to read if they're split into multiple lines though. The empty comment at the top of the array tricks Prettier into keeping them on multiple lines. Yeah, I could use a different formatter, or no formatter at all, or just cope with the arrays being inline...but this is just the way that works best for me.