Widget Factory for more readable widget creation

I created a wrapper for widgets that (for me) makes them a bit easier to create and read. Interested to hear thoughts and feedback. Example of before/after code using Simon’s news example (full code at bottom)

//
// Using Scriptable API
//

let w = new ListWidget();
if (imgURL != null) {
  let imgReq = new Request(imgURL);
  let img = await imgReq.loadImage();
  w.backgroundImage = img;
}
w.backgroundColor = new Color("#b00a0f");
w.backgroundGradient = gradient;
// Add spacer above content to center it vertically.
w.addSpacer();
// Show article headline.
let titleTxt = w.addText(item.title);
titleTxt.font = Font.boldSystemFont(16);
titleTxt.textColor = Color.white();
// Add spacing below headline.
w.addSpacer(8);
// Show authors.
let authorsTxt = w.addText("by " + authors);
authorsTxt.font = Font.mediumSystemFont(12);
authorsTxt.textColor = Color.white();
authorsTxt.textOpacity = 0.9;
// Add spacing below authors.
w.addSpacer(2);
// Show date.
let dateTxt = w.addText(strDate);
dateTxt.font = Font.mediumSystemFont(12);
dateTxt.textColor = Color.white();
dateTxt.textOpacity = 0.9;
// Add spacing below content to center it vertically.
w.addSpacer();
//
// Using widgetFactory
//

const { widgetFactory, widgetSpacer, widgetText } = importModule(
  "./widgetFactory"
);

/** @param {string | void} imgUrl */
const getBgImage = async imgUrl => {
  if (!imgUrl) return null;
  const imgReq = new Request(imgUrl);
  return await imgReq.loadImage();
};

const bgImage = await getBgImage(imgURL);
const bgGradient = new LinearGradient();
bgGradient.locations = [0, 1];
bgGradient.colors = [new Color("#b00a0fe6"), new Color("#b00a0fb3")];

const widget = widgetFactory({
  ...(bgImage ? { bgImage } : {}),
  bgColor: new Color("#b00a0f"),
  bgGradient,
  content: [
    widgetSpacer(),
    widgetText({
      text: item.title,
      font: Font.boldSystemFont(16),
      color: Color.white(),
    }),
    widgetSpacer({ length: 8 }),
    widgetText({
      text: `by ${authors}`,
      font: Font.mediumSystemFont(12),
      color: Color.white(),
      opacity: 0.9,
    }),
    widgetSpacer({ length: 2 }),
    widgetText({
      text: strDate,
      font: Font.mediumSystemFont(12),
      color: Color.white(),
      opacity: 0.9,
    }),
    widgetSpacer(),
  ],
});

Here’s the factory code:

//
// JSDoc Types
//

/**
 * @typedef {'left' | 'center' | 'right'} Align
 * @typedef {{
 * text: string;
 * color?: Color;
 * font?: Font;
 * opacity?: number;
 * lineLimit?: number;
 * align?: Align;
 * }} TextOpts
 * @typedef {{
 *  image: Image;
 *  opacity?: number;
 *  size?: Size;
 *  cornerRadius?: number;
 *  borderWidget?: number;
 *  containerRelativeShape?: boolean;
 *  align?: Align;
 * }} ImageOpts
 * @typedef {{ length?: number }} SpacerOpts
 * @typedef {{
 *  bgColor?: Color;
 *  bgImage?: Image;
 *  bgGradient?: LinearGradient;
 *  spaceBetweenEls?: number;
 *  onTapUrl?: string;
 *  padding?: string; // CSS syntax (without `px` suffix)
 *  content: (TextOpts | ImageOpts | SpacerOpts)[];
 * }} WidgetOpts
 */

//
// Utils
//

/**
 * @param {string} paddingStr
 * Returns array of padding values in order of `ListWidget.setPadding()`.
 * Example: `1 2 3 4` will set padding for top, right, bottom, and left, respectively.
 * See for usage: https://developer.mozilla.org/en-US/docs/Web/CSS/padding
 */
const cssPaddingStrToArr = paddingStr => {
  const values = paddingStr
    .trim()
    .split(' ')
    .map(numStr => parseInt(numStr, 10));
  const numVals = values.length;
  if (!numVals) return null;
  const top = values[0];
  const right = numVals > 1 ? values[1] : values[0];
  const bottom = numVals > 2 ? values[2] : values[0];
  const left = numVals > 3 ? values[3] : numVals > 1 ? values[1] : values[0];
  return [top, left, bottom, right];
};

/** @type {(content: any) => content is TextOpts} */
const isText = content => Boolean(content.text);
/** @type {(content: any) => content is ImageOpts} */
const isImage = content => Boolean(content.image);
/** @type {(content: any) => content is SpacerOpts} */
const isSpacer = content => Boolean(content.isSpacer);

//
//
//

/** @param {WidgetOpts} opts */
module.exports.widgetFactory = ({
  bgColor,
  bgImage,
  bgGradient,
  spaceBetweenEls,
  onTapUrl,
  padding,
  content,
}) => {
  const widget = new ListWidget();
  if (bgColor) widget.backgroundColor = bgColor;
  if (bgImage) widget.backgroundImage = bgImage;
  if (bgGradient) widget.backgroundGradient = bgGradient;
  if (spaceBetweenEls) widget.spacing = spaceBetweenEls;
  if (onTapUrl) widget.url = onTapUrl;
  if (padding) {
    const parsedPadding = cssPaddingStrToArr(padding);
    if (parsedPadding) widget.setPadding(...parsedPadding);
  }
  content.forEach(pieceOfContent => {
    if (isText(pieceOfContent)) {
      const { text, color, font, opacity, lineLimit, align } = pieceOfContent;
      const addedText = widget.addText(text);
      if (color) addedText.textColor = color;
      if (font) addedText.font = font;
      if (opacity) addedText.textOpacity = opacity;
      if (lineLimit) addedText.lineLimit = lineLimit;
      if (align === 'left') addedText.leftAlignText();
      if (align === 'center') addedText.centerAlignText();
      if (align === 'right') addedText.rightAlignText();
    }
    if (isImage(pieceOfContent)) {
      const {
        image,
        opacity,
        size,
        cornerRadius,
        borderWidth,
        containerRelativeShape,
        align,
      } = pieceOfContent;
      const addedImage = widget.addImage(image);
      if (opacity) addedImage.imageOpacity = opacity;
      if (size) addedImage.imageSize = size;
      if (cornerRadius) addedImage.cornerRadius = cornerRadius;
      if (borderWidth) addedImage.borderWidth = borderWidth;
      if (containerRelativeShape)
        addedImage.containerRelativeShape = containerRelativeShape;
      if (align === 'left') addedImage.leftAlignImage();
      if (align === 'center') addedImage.centerAlignImage();
      if (align === 'right') addedImage.rightAlignImage();
    }
    if (isSpacer(pieceOfContent)) widget.addSpacer(pieceOfContent.length);
  });
  return widget;
};

/** @param {TextOpts} opts */
module.exports.widgetText = ({
  text,
  color,
  font,
  opacity,
  lineLimit,
  align,
}) => ({ text, color, font, opacity, lineLimit, align });

/** @param {ImageOpts} opts */
module.exports.widgetImage = ({
  image,
  opacity,
  size,
  cornerRadius,
  borderWidth,
  containerRelativeShape,
  align,
}) => ({
  image,
  opacity,
  size,
  cornerRadius,
  borderWidth,
  containerRelativeShape,
  align,
});

/** @param {SpacerOpts} opts */
module.exports.widgetSpacer = ({ length } = {}) => ({ length, isSpacer: true });

I have similar wrappers for other Scriptable APIs like alert & UITable that I can share if there’s interest.

2 Likes

Hi do you have a more complete worked example, I get undefined errors on what it here?
UITable Wapper might be good also

Hey, there may have been some changes since I posted this, I’ll take a look later

Edit: and if you can post the exact error message that’d be helpful.

2021-06-06 22:24:54: Error on line 16:40: ReferenceError: Can’t find variable: imgURL

Yeah sorry, I’m not sure why I didn’t include working examples in this post, but neither of those examples will work, only the widget factory code at the bottom.

Here’s a very brief (working) example of how to use the factory code:

const widget = widgetFactory({  
  bgColor: Color.red(),
  content: [
    widgetSpacer(),
    widgetText({ text: 'Hello world', align: 'center', color: Color.white() }),
    widgetSpacer()
  ]
});
await widget.presentLarge();

When I try to run the example code, along with the factory code, I get an error saying “Can’t find variable: widgetFactory”.

Any ideas why that might be happening. Did I need more that the code from the Factory and the example at the bottom?

Hey Chris, if you have everything in one file, just replace all instances of module.exports. with const (note the space after const).

The module.exports bit just means you can import the widget functions into other Scriptable files.

You could also declare the functions and export them as well, if you want them available both in this file and in others. Eg:

const widgetFactory = …

module.exports.widgetFactory = widgetFactory;

I am fairly new to Javascript, but I might understand how to use multiple files for a script one day. I get the concept, just don’t understand the practice. Thanks!

1 Like