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.

1 Like

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();