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:
- Model - An object defining how the mutator should modify its base
Block
. - Layout - An object defining how much space the mutator will use inside a
BlockLayout
, for layout purposes. - 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);