Is it possible to mix code statements with @_functionBuilder elements?

Viewed 300

Edited to have my current solution

I'm writing a declarative animation framework, my current example looks like this:

class ViewController: UIViewController {

  override func viewDidLoad() {
    Animation(duration: 3, curve: .easeOut) {
      Code {
        self.label.alpha = 1
      }
      OnEnd {
        Animation {
          Code {
            self.label.center = CGPoint(x: self.label.center.x + 100,
                                        y: self.label.center.y)
          }
        }
      }
    }.startAnimation()
  }
}

Ideally I'd like to drop Code {} so that code reads like this:

class ViewController: UIViewController {

  override func viewDidLoad() {
    Animation(duration: 3, curve: .easeOut) {
      self.label.alpha = 1
      OnEnd {
        Animation {
          self.label.center = CGPoint(x: self.label.center.x + 100,
                                      y: self.label.center.y)
        }
      }
    }.startAnimation()
  }
}

My current Animation constructor looks like:

class Animation {
  init(duration: TimeInterval? = nil,
       curve: UIView.AnimationCurve? = nil,
       @AnimationBuilder build: @escaping AnimationBuilderClosure) {
  }
}

I also tried to add

class Animation {
  convenience init(duration: TimeInterval? = nil,
       curve: UIView.AnimationCurve? = nil,
       code: @escaping AnimationCodeBlock) {
    self.init(duration: duration, curve: curve, build: {
      return AnimationElements(elements: [Code(block: code)])
    })
  }

  // ...
}

But that leads to all the Animation { } code assuming it's the convenience initializer (understandably)

2 Answers

Depending on how you did set up your function builder you can add an overload of buildExpression that takes Void as an argument and returns no result, e.g.:

static func buildExpression( _ statement: Void) -> [Expression] {
    return []
}

This allows statements like this in function builder blocks:

self.label.alpha = 1

Or empty statements (e.g. in switch cases that should not do anything):

()

Edit: Fully working Solution

This is a slightly simplified version of what was requested (with label initialization filled in to make it compile):

class ViewController: UIViewController {

  let label = UILabel(frame: .init(x: 30, y: 50, width: 200, height: 30))

  override func viewDidLoad() {
    super.viewDidLoad()
    label.text = "Hello World"
    label.alpha = 0
    view.addSubview( label)

    Animation {
      self.label.alpha = 1
      OnEnd {
        Animation {
          self.label.frame.origin = .init(x: 120, y: 300)
        }
      }
    }.startAnimation()
  }
}

The Animation type would look like this:

struct Animation {
  
  let block: () -> [OnEnd]

  init(@AnimationBuilder block: @escaping () -> [OnEnd]) {
    // save block for later execution
    self.block = block
  }

  func startAnimation() {
    var endBlocks: [OnEnd] = []
    UIView.animate(withDuration: 3) {
      // save OnEnd blocks for later execution and execute free statements
      endBlocks = self.block()
    } completion: { _ in
      endBlocks.forEach { $0.run() }
    }
  }
}

OnEnd looks like this then:

struct OnEnd {

  let block: () -> [Animation]

  init(@OnEndBuilder block: @escaping () -> [Animation]) {
    // save block for later execution
    self.block = block
  }

  func run() {
    block().forEach { $0.startAnimation() }
  }
}

Now we need two function builders to make the above code work. One for the Animation:

@_functionBuilder
enum AnimationBuilder {
  static func buildExpression(_ value: Void) -> [OnEnd] {
    []
  }
  static func buildExpression(_ value: OnEnd) -> [OnEnd] {
    [value]
  }
  static func buildBlock(_ values: [OnEnd]...) -> [OnEnd] {
    values.flatMap { $0 }
  }
}

And one for completion:

@_functionBuilder
enum OnEndBuilder {
  static func buildExpression(_ value: Void) -> [Animation] {
    []
  }
  static func buildExpression(_ value: Animation) -> [Animation] {
    [value]
  }
  static func buildBlock(_ values: [Animation]...) -> [Animation] {
    values.flatMap { $0 }
  }
}

The end result looks like this:

Animation

Edit 2: Result of the transformation

For reference, this is the result of the compiler transformations that are triggered by the function builder attributes:

Animation {
  let e0 = AnimationBuilder.buildExpression( self.label.alpha = 1)
  let e1 = AnimationBuilder.buildExpression( OnEnd {
    let e0 = OnEndBuilder.buildExpression( Animation {
      let e0 = AnimationBuilder.buildExpression( self.label.frame.origin = .init(x: 120, y: 300))
      return AnimationBuilder.buildBlock( e0)
    })
    return OnEndBuilder.buildBlock( e0)
  })
  return AnimationBuilder.buildBlock( e0, e1)
}.startAnimation()

Edit 3: Control Flow

It's probably a good idea to implement the rest of the builder methods to allow control flow statements (to the extent possible with function builders):

extension AnimationBuilder {
  // Allow if statements that don't have an else clause
  public static func buildOptional(_ elements: [OnEnd]?) -> [OnEnd] {
    elements ?? []
  }

  // Allow if/else and switch statements
  public static func buildEither(first elements: [OnEnd]) -> [OnEnd] {
    elements
  }

  // Allow if/else and switch statements
  public static func buildEither(second elements: [OnEnd]) -> [OnEnd] {
    elements
  }

  // Allow for..in loops (in Swift 5.4)
  public static func buildArray(_ elements: [[OnEnd]]) -> [OnEnd] {
    elements.flatMap { $0 }
  }
}

Same for OnEnd blocks:

extension OnEndBuilder {
  public static func buildOptional(_ elements: [Animation]?) -> [Animation] {
    elements ?? []
  }
  public static func buildEither(first elements: [Animation]) -> [Animation] {
    elements
  }
  public static func buildEither(second elements: [Animation]) -> [Animation] {
    elements
  }
  public static func buildArray(_ elements: [[Animation]]) -> [Animation] {
    elements.flatMap { $0 }
  }
}

Edited to reflect on possible workarounds.

You initialize an Animation object, which probably contains an array (or other collection) of closures. At a certain point you will call its startAnimation() method, which will probably execute the closures sequentially. So any "code statement" such as self.label.alpha = 1 you want to use in the build block would need to be added to the list of closures, rather than be executed immediately at declaration time.

However, when you pass a closure literal to a parameter with a result builder (nee function builder) attribute (as you do in init), the following happens:

  1. The closure gets executed immediately when accessed. This is the moment when self.label.alpha = 1 will run, as will every statement in the closure.
  2. The return values of these statements will be collected and recursively transformed by the various builder functions (buildBlock, buildExpression, etc.) into Expressions, Components, and a FinalResult in the end (with Component being mandatory and the other two defaulting to the same type as Component). This is an iterative process where expressions are combined into components, and finally components into a final result.
    • Your builder functions must explicitly support all return types. For instance, if you want your build closure containing self.label.alpha = 1 to compile, you need to make sure that a builder function (e.g. buildExpression) handles the return type ().
    • But even in that case, note that all the builder function will see from self.label.alpha = 1 will be its return type of ()! It won't know that self.label.alpha = 1 was executed, only that something was executed and it returned ().

So you can put non-building code in a build closure (if you support all possible return types of each statement you want to use), but that code will be executed when the closure gets transformed.

EDIT: As seen in the other answer, you can control, via the @escaping attribute, the order in which these nested closures are executed. The statements inside them will be executed in sequence, and their return values (if explicitly supported by your builder functions) will be collected for later. This may actually be an acceptable solution in your case (with some weirdness, e.g. you can put the OnEnd block before, after, or in the middle of your animation statements, and it will still be executed at the end).

In general, mixing building and non-building statements in a declarative DSL may be tricky since the latter will be executed outside the structure you're constructing by the build system, and the order of execution may be difficult to control.

Just imagine a case where you want to build animation sequences:

Sequence {
    Animation {
        self.label.center.x = 100.0
    }
    self.label.center.x == 0.0
    Animation {
        self.label.center.x = 200.0
    }
}

When transforming the closure passed to Sequence, the statement self.label.center.x == 0.0 will be executed immediately, whereas the two wrapped in Animation structs or objects will be transformed and added to the build result. Sure, you can also have your system specifically execute them immediately, and maybe even discard the build result, but I have to wonder if it's worthwhile to support two execution sequences for each closure parameter: one at the time of transforming the closure, and the other at the time of executing the sequence resulting from all the result builder transformations… all in order to save a little bit of typing.

I'd still go for a cleaner solution where you construct the entire animation hierarchy and execute it in one go. In that situation, what you would need in your builder closure is not self.label.alpha = 1 but rather an expression that returns { self.label.alpha = 1} .

I think the closest you can probably get to your idea is to support (via buildExpression(_:) and probably the introduction of a protocol that covers both building closures and void closures to be used as your type) something like this:

    Animation(duration: 3, curve: .easeOut) {
      { self.label.alpha = 1 }
      OnEnd {
        Animation {
          {
              self.label.center = CGPoint(x: self.label.center.x + 100,
                                          y: self.label.center.y)
          }
        }
      }
    }.startAnimation()

You may also want to check out the new multiple trailing closure syntax in Swift 5.3, maybe it can help in some special cases.

Also, the callAsFunction method may have some benefits with closure parameters… Again, for some special cases that might apply to your needs.

Related