Mutators

A mutator is like a special extension; in addition to changing the block, it defines how those changes will be saved to XML and loaded from XML. Extensions, by comparison, may not save changes to or load changes from XML. The most visible example of a mutator is the pop-up dialog which allows if statements to acquire extra else-if and else clauses.

A mutator consists of the following parts:

  1. Model - An object defining how the mutator should modify its base Block.
  2. Layout - An object defining how much space the mutator will use inside a BlockLayout, for layout purposes.
  3. UI - (Optional) If provided, this is the UIView a user will use to interact with and configure the mutator.

Defining Mutator Model

The model object for a mutator must extend the Mutator protocol. The main piece of a mutator is the mutateBlock() function. This function is responsible for mutating the block the mutator is attached to. It is important for this function to be able to mutate the underlying block from any valid state. For example, take an if-else block:

The initial block looks like this:

If the user adds an else-if clause it modifies the block to look like this:

mutateBlock() must add an else-if input to the block:

let ifBuilder = InputBuilder(type: .value, name: "IF\(i)")
let elseIfText = message(forKey: "BKY_CONTROLS_IF_MSG_ELSEIF")
ifBuilder.appendField(FieldLabel(name: "ELSEIF", text: elseIfText))

Importantly, the mutator must be able to mutate in either direction, so additional logic is likely required to check the original state and update to the desired state.

Serializing and Deserializing

In addition to mutating a block, mutators can serialize and deserialize their mutator, so they can be saved into and loaded from XML. Mutators define two functions to achieve this: toXMLElement() and update(fromXML:).

Using the same example as above, let's implement these two methods to save and load the number of else-if inputs on the block:

var elseIfCount: Int

public func toXMLElement() -> AEXMLElement {
  return AEXMLElement(name: "mutation", value: nil, attributes: [
    "elseif": String(elseIfCount)
  ])
}

public func update(fromXML xml: AEXMLElement) {
  let mutationXML = xml["mutation"]
  elseIfCount = Int(mutationXML.attributes["elseif"] ?? "") ?? 0
}

Finally, mutators must implement the copyMutator() function:

public func copyMutator() -> Mutator {
  let mutator = MutatorIfElse()
  mutator.elseIfCount = elseIfCount
  return mutator
}

Defining Mutator Layout

The layout object for a mutator must implement the MutatorLayout class. Like other Blockly components, mutators require a layout class to define how much space it needs to display itself inside a block. Using the if-else block as an example again:

Note the gear icon on the block. The MutatorLayout is responsible for calculating the space required for that.

public override func performLayout(includeChildren: Bool) {
  // Inside a block, this mutator is only the size of a "settings" button
  self.contentSize = WorkspaceSize(width: 44, height: 44)
}

Like all Layout objects, the MutatorLayout is responsible for notifying the Mutator that a mutation happened. The performMutation() function needs to update the layout-appropriate elements to the mutation and call mutateBlock():

/** This would be called by MutatorElseIfView to perform the mutation. */
public override func performMutation() throws {
  // Since mutateBlock() may change the inputs on the blocks, track the
  // current connections to reconnect them if needed.
  var connections = ...

  try captureChangeEvent {
    // Update the definition of the block
    mutatorIfElse.mutateBlock()

    // Update UI
    layoutCoordinator.layoutBuilder.buildLayoutTree(
      forBlock: block, engine: self.engine)
  }

  // Reconnect connections from mutatorIfElse
  MutatorHelper.reconnectSavedTargetConnections(...)
}

Defining Mutator UI

Mutators don't require special UI. Some may mutate a block depending on other state in the program, like flags or API calls. However, many mutators will. The UI for mutators behaves the same way it does for any custom view in Blockly, where it will need to subclass the LayoutView class. To finish our if-else example, we need to set up a button to open the popover controlling the mutation:

open fileprivate(set) lazy var popoverButton: UIButton = {
  let button = UIButton(type: .system)
  button.setImage(UIImage(named: "settings"), for: .normal)
  button.addTarget(self, action: #selector(openPopover(_:)), for: .touchUpInside)
  return button
}()

The openPopover(_:) function opens a custom view controller where the user can configure the number of "else-if" clauses on the block. After the user has finished making changes, the code to mutate the block looks something like this:

// Update else-if counts from popover values
mutatorIfElseLayout.elseIfCount = ...

// Perform the mutation to change the shape of the block.
// All events that occur during this mutation are grouped
// so the event stack can undo/redo all of them together
// in the future.
try EventManager.shared.groupAndFireEvents {
  try mutatorIfElseLayout.performMutation()
}

Registering a Mutator

Once the model, layout, and UI classes have created for your mutator, they must be registered with their respective factory objects inside WorkbenchViewController.

Consider the JSON definition of a block defined in a file named custom_mutator_blocks.json:

{
  "type": "custom_block",
  "mutator": "custom_mutator",
  ...
}

Mutator model objects are registered through updateMutators(_) via blockFactory:

// First, associate custom mutators with the block factory
let blockFactory = _workbenchViewController.blockFactory
let custom = ["custom_mutator": CustomMutator()]
blockFactory.updateMutators(custom)

// Now, we can load JSON definitions into the block factory
try blockFactory.load(fromJSONPaths: ["custom_mutator_blocks.json"])

Similarly, mutator layout objects are registered through registerMutatorLayoutCreator(forType:) via layoutBuilder.layoutFactory:

// Associate custom mutator layouts with the layout factory
let layoutFactory = _workbenchViewController.layoutBuilder.layoutFactory
layoutFactory.registerMutatorLayoutCreator(forType: CustomMutator.self) {
  mutator, engine in
    return CustomMutatorLayout(mutator: mutator as! CustomMutator, engine: engine)
}

If defined, mutator view objects are registered through registerLayoutType(withViewType:) via viewFactory:

// Associate custom mutator views with the view factory
let viewFactory = _workbenchViewController.viewFactory
viewFactory.registerLayoutType(CustomMutatorLayout.self,
  withViewType: CustomMutatorView.self)

Code Generation

Once all of the iOS components are registered correctly, the only thing that's left is to generate code. In the generator code, we must add and register a function to perform the mutation before code is generated. The domToMutation takes the XML we serialized above, and performs the mutation on the underlying block:

CodeGeneratorBridge.Mutators.CONTROLS_IF_MUTATOR_MIXIN = {
  domToMutation: function(xmlElement) {
    var elseifCount = parseInt(xmlElement.getAttribute('elseif'), 10) || 0;

    // Remove existing inputs
    var i = 1;
    while (this.getInput('IF' + i)) {
      this.removeInput('IF' + i);
      i++;
    }

    // Rebuild block.
    for (var i = 1; i <= elseifCount; i++) {
      this.appendValueInput('IF' + i)
        .setCheck('Boolean')
        .appendField(Blockly.Msg.CONTROLS_IF_MSG_ELSEIF);
    }
  },
  mutationToDom: function() {
    // No-op.
  }
};

Blockly.Extensions.registerMutator(
  'controls_if_mutator',
  CodeGeneratorBridge.Mutators.CONTROLS_IF_MUTATOR_MIXIN);