exupero's blog
RSSApps

ClojureDart macro for nested components

I discovered ClojureDart recently and used it to make a simple mobile app for tracking time outdoors. The ClojureDart community is somewhat fledgling, so I was pleasantly surprised to see "Why Flutter needs Clojure" and learn of the nest macro. In this post, I'd like to develop the nest macro to a couple more levels of sophistication.

While web UIs configure padding, margin, and background color with CSS, Flutter uses container components. Adding a background color to a component requires wrapping the component in a Container and setting the original component as the value of the container's child property. To add padding, wrap the component in Padding. This isn't impractical, though it can be slightly annoying to indent code further and further as more ancestors are added, but it wasn't a big enough problem for me to think of writing a macro to solve it. That said, a macro to nest components into a .child hierarchy is delightfully simple:

(defmacro nest [form & forms]
  (let [[form & forms] (reverse (cons form forms))]
    `(->> ~form
          ~@(for [form forms]
              (-> form
                  (cond-> (symbol? form) list)
                  (concat ['.child])
                  (with-meta (meta form)))))))
(macroexpand '(nest a b c d))
(a .child (b .child (c .child d)))

Actual code looks more like this:

(nest
  (m/Container .color m/Colors.red)
  (m/Padding .padding (m/EdgeInsets.fromLTRB 0 0 10 0))
  (m/Row .mainAxisAlignment m/MainAxisAlignment.end
         .children [(m/Icon m/Icons.delete .color m/Colors.white)]))

A minor added benefit is that inserting a new ancestor component changes fewer lines, as it doesn't indent all its descendant components.

Notice that nest doesn't nest .children, only .child. The difference is mere plurality, so let's create a new macro that nests the last value under .children when the last value is a vector. I'll call my revised macro >>, since I think of it similarly to the threading macro ->>:

(defmacro >> [& forms]
  (let [form (last forms)
        forms (butlast forms)
        forms (if (vector? form)
                (concat (interpose '.child forms) ['.children])
                (interleave forms (repeat '.child)))
        value-props (reverse (partition 2 forms))]
    `(->> ~form
          ~@(for [[form prop] value-props]
              (-> form
                  (cond-> (symbol? form) list)
                  (concat [prop])
                  (with-meta (meta form)))))))
(macroexpand '(>> a b c [d]))
(a .child (b .child (c .children [d])))

Note that this doesn't work if the last value is only a vector at runtime; it has to be a vector literal at compile time:

(macroexpand '(>> a b c (into [] [d])))
(a .child (b .child (c .child (into [] [d]))))

Because of this, we may sometimes want to force the nesting property to be .children, and it may occasionally make sense to nest components under other properties (e.g., under RichText's .text property). Thus, let's allow property names to be specified before a form, and only when a property name is missing use .child:

(>> (a) (b) .text (c) .children (d))

To create such a macro, we need a couple helper functions. Here's the first, which determines whether a symbol denotes a property name (in ClojureDart, Clojure functions needed by macros must be given the metadata :macro-support):

(defn ^:macro-support prop? [s]
  (and (symbol? s) (= \. (first (name s)))))

The second helper function parses a sequence of values by walking it backwards and looking for prop? symbols to associate with the previous form:

(defn ^:macro-support value-prop-pairs [forms]
  (loop [[form & forms] (reverse forms)
         prop '.child
         pairs []]
    (cond
      (nil? form)
      , pairs
      (prop? form)
      , (recur forms form pairs)
      :else
      , (recur forms '.child (conj pairs [form prop])))))

Our earlier >> macro now only needs minor adjustment:

(defmacro >> [& forms]
  (let [form (last forms)
        forms (butlast forms)
        forms (if (vector? form)
                (concat forms ['.children])
                forms)
        value-props (value-prop-pairs forms)]
    `(->> ~form
          ~@(for [[form prop] value-props]
              (-> form
                  (cond-> (symbol? form) list)
                  (concat [prop])
                  (with-meta (meta form)))))))

Let's test it. Are vectors in the final position still placed under .children?

(macroexpand '(>> (a) (b) .text (c) [d]))
(a .child (b .text (c .children [d])))

Can non-vectors be specified as .children?

(macroexpand '(>> (a) (b) .text (c) .children (d)))
(a .child (b .text (c .children (d))))

Can vectors be specified as .children?

(macroexpand '(>> (a) (b) .text (c) .children [d]))
(a .child (b .text (c .children [d])))

And finally, can a vector in the final position be placed under a different property that .children?

(macroexpand '(>> (a) (b) .text (c) .friends [d]))
(a .child (b .text (c .friends [d])))

The behavior in that last case is handled by value-prop-pairs walking forms in reverse order. Even though when a vector is in the final position we append a '.children to the list of forms, it's dropped in favor of any property symbols before it. This makes >> very forgiving, perhaps undesirably so:

(macroexpand '(>> (a) (b) .text (c) .friends .romans .countrymen [d]))
(a .child (b .text (c .friends [d])))

If you have suggestions for improvements, or leveraging ClojureDart further, I'm happy to hear them. Feel free to email me.