← README.md | 🌱
The Observer Plugin provides declarative field observation for SceneGraph nodes. It enables monitoring node field changes through an observer configuration, supporting single observers, multiple observers per widget, callback routing, and automatic lifecycle management.
| Type | Description | Example |
|---|---|---|
| Single Object | One observer per widget | observer: { fieldId: "text", callback: ... } |
| Array | Multiple observers per widget | observer: [{ fieldId: "state" }, { fieldId: "position" }] |
| Property | Type | Required | Description |
|---|---|---|---|
fieldId |
string | Yes | Name of the SceneGraph field to observe |
callback |
function | * | Function called with parsed payload when field changes |
handler |
function | * | Function called without arguments when field changes |
id |
string | No | Custom identifier for the observer |
value |
dynamic | No | Initial value to set on the field |
alwaysNotify |
boolean | No | Trigger callback even if value unchanged (default: true) |
once |
boolean | No | Remove observer after first callback (default: false) |
until |
function | No | Function returning true when observer should be removed |
parsePayload |
function | No | Transform payload before passing to callback |
infoFields |
array | No | Additional fields to include in callback payload |
* One of
callbackorhandleris required. They are mutually exclusive — configure one or the other, not both. If neither is provided, the framework throws an error.
{
nodeType: "Video",
' Single observer - one field
observer: {
fieldId: "state",
callback: sub(payload)
print "State: " + payload.state
end sub
},
' Multiple observers - array of observers
observer: [
{
fieldId: "state",
callback: sub(payload)
print "State changed: " + payload.state
end sub
},
{
fieldId: "position",
callback: sub(payload)
print "Position: " + payload.position.toStr()
end sub
}
]
}Use callback when you need to read the changed field value:
observer: [{
fieldId: "state",
callback: sub(payload)
if payload.state = "finished"
m.getViewModel().onVideoFinished()
end if
end sub
}]- Signature:
sub(payload as object)orsub(payload) typecast m as Rotor.Widget payloadis an AA containing the changed field value (keyed byfieldId) plus anyextraInfo/infoFields
Use handler when you only need the notification trigger, not the value:
' renderTracking: reposition after layout completes
observer: [{
fieldId: "renderTracking",
handler: sub()
m.node.translation = [100, 200]
end sub
}]
' Timer: react to timer fire event
observer: [{
fieldId: "fire",
handler: sub()
m.getViewModel().onTimerFired()
end sub
}]- Signature:
sub()orsub() typecast m as Rotor.Widget - Ideal for
renderTrackingobservers, timer callbacks, and simple refresh triggers
Usage in Observer Callbacks:
observer: {
fieldId: "state",
callback: sub(payload) typecast m as Rotor.Widget
typecast m as Rotor.Widget
' Now IDE provides autocomplete for:
m.getWidget("childId")
m.render({ ... })
m.viewModelState.customProperty
end sub
}The typecast statement is a BrighterScript V1 feature that provides type information to the language server and IDE. It is entirely optional and has no runtime effect - it only improves development experience.
Type examples: Rotor.Widget, Rotor.ViewModel, or any class that extends them.
Benefits:
- IDE Autocomplete: Enables IntelliSense/autocomplete for widget methods and properties
- Type Safety: Catches type errors during development before runtime
- Documentation: Makes code intent clearer for other developers
Note: Without typecast, the code works identically at runtime. Use it only when you need IDE support for widget-specific methods or when working in teams where type safety improves code quality.
The Observer Plugin operates automatically through widget lifecycle:
| Lifecycle Hook | Purpose |
|---|---|
beforeMount |
Register observers when widget mounts |
beforeUpdate |
Detach old observers, register new observers |
beforeDestroy |
Unobserve fields and cleanup observer instances |
- Configuration Parse: Plugin processes observer config (single object or array)
- Attachment ID Generation: Unique ID created per node attachment
- Helper Interface Setup: Internal field added to node for observer tracking
- Observer Registration: Creates Observer instance, stores in ObserverStack
- Native Observation: Calls
node.observeFieldScoped()with routing info - Callback Routing: Field changes route to correct Observer via callback router
- Payload Processing: Optional
parsePayloadtransformation before callback - Callback/Handler Execution:
callbackexecuted in widget scope with payload, orhandlerexecuted with no arguments - Cleanup: Observers automatically detached on widget destroy or update
{
nodeType: "Video",
observer: [
{
fieldId: "state",
callback: sub(payload)
if payload.state = "playing"
m.node.visible = true
else if payload.state = "finished"
m.handleVideoFinished()
end if
end sub
},
{
fieldId: "position",
callback: sub(payload)
m.updateProgressBar(payload.position)
end sub
}
]
}{
nodeType: "Label",
observer: {
fieldId: "loadComplete",
once: true,
callback: sub(payload)
' This callback only fires once, then observer is removed
print "Load complete!"
end sub
}
}{
nodeType: "Animation",
observer: {
fieldId: "state",
until: function(payload)
' Remove observer when animation finishes
return payload.state = "finished"
end function,
callback: sub(payload)
m.updateAnimationState(payload.state)
end sub
}
}{
nodeType: "Label",
observer: {
fieldId: "text",
parsePayload: function(payload) as object
' Transform payload before callback
return {
text: payload.text,
length: Len(payload.text),
timestamp: CreateObject("roDateTime").AsSeconds()
}
end function,
callback: sub(payload)
print "Text: " + payload.text
print "Length: " + payload.length.toStr()
end sub
}
}The renderTracking field is one of the most common observer targets. It fires when a SceneGraph node finishes rendering, enabling dynamic layout calculations based on actual rendered dimensions.
To use it, set enableRenderTracking: true in the widget's fields, then observe renderTracking:
{
id: "messageLabel",
nodeType: "Label",
fields: {
text: m.props.message,
width: 600,
wrap: true,
enableRenderTracking: true
},
observer: {
fieldId: "renderTracking",
handler: sub()
textHeight = m.node.localBoundingRect().height
bgHeight = textHeight + 40
m.getSiblingWidget("background").node.height = bgHeight
end sub
}
}When you need to check whether the node is fully rendered (not just partially), use callback and inspect the payload value:
observer: {
fieldId: "renderTracking",
callback: sub(payload)
if payload?.renderTracking = "full"
m.startAnimation()
end if
end sub
}Multiple field changes can trigger the same handler — useful when several fields affect layout:
autoSizeHandler = sub()
rect = m.node.localBoundingRect()
m.getSiblingWidget("container").node.width = rect.width + 24
m.getSiblingWidget("container").node.height = rect.height + 16
end sub
{
id: "buttonLabel",
nodeType: "Label",
fields: {
text: m.props.text,
enableRenderTracking: true
},
observer: [
{
fieldId: "renderTracking",
handler: autoSizeHandler
},
{
fieldId: "text",
handler: autoSizeHandler
}
]
}Timer nodes expose a fire field that triggers when the timer completes. Use handler since the fire value itself is irrelevant:
' One-shot delay timer
{
id: "delayTimer",
nodeType: "Timer",
fields: {
repeat: false,
duration: 2.0
},
observer: {
fieldId: "fire",
handler: sub()
m.getViewModel().onDelayComplete()
end sub
}
}
' Repeating timer (e.g., clock update every second)
{
id: "refreshTimer",
nodeType: "Timer",
fields: {
repeat: true,
duration: 1.0
},
observer: {
fieldId: "fire",
handler: sub()
m.getViewModel().updateDisplayedTime()
end sub
}
}Combine once: true with a timer to guarantee a single callback execution:
{
id: "initTimer",
nodeType: "Timer",
fields: {
duration: 0.5,
repeat: false
},
observer: {
fieldId: "fire",
callback: m.onInitializationComplete,
once: true
}
}{
nodeType: "Group",
observer: {
fieldId: "customState",
value: "initial", ' Sets field initial value
alwaysNotify: true, ' Triggers callback even if value unchanged
callback: sub(payload)
print "State: " + payload.customState
end sub
}
}' Good: Remove observer after single event
observer: {
fieldId: "loadComplete",
once: true,
callback: sub(payload)
m.handleLoadComplete()
end sub
}
' Avoid: Manual cleanup in callback
observer: {
fieldId: "loadComplete",
callback: sub(payload)
m.handleLoadComplete()
' Forgot to remove observer - memory leak
end sub
}' Good: Clean separation of concerns
observer: {
fieldId: "text",
parsePayload: function(payload) as object
return {
text: payload.text,
length: Len(payload.text)
}
end function,
callback: sub(payload)
m.updateTextStats(payload.text, payload.length)
end sub
}
' Less clean: All logic in callback
observer: {
fieldId: "text",
callback: sub(payload)
text = payload.text
length = Len(payload.text)
m.updateTextStats(text, length)
end sub
}' Good: Related observers together
observer: [
{ fieldId: "state", callback: m.onStateChange },
{ fieldId: "position", callback: m.onPositionChange }
]
' Avoid: Scattered observer definitions
observer: { fieldId: "state", callback: m.onStateChange }
' ... other config ...
' (position observer defined elsewhere or forgotten)' Good: handler for renderTracking, timers, and triggers where the value is irrelevant
observer: {
fieldId: "renderTracking",
handler: sub()
m.node.translation = [100, 200]
end sub
}
' Avoid: callback with an unused parameter
observer: {
fieldId: "renderTracking",
callback: sub(payload)
' payload is never used — use handler instead
m.node.translation = [100, 200]
end sub
}
parsePayloadstill executes whenhandleris used, but its result is discarded. OmitparsePayloadwhen usinghandlerfor clarity.
' Good: Automatic cleanup when condition met
observer: {
fieldId: "progress",
until: function(payload)
return payload.progress >= 100
end function,
callback: sub(payload)
m.updateProgress(payload.progress)
end sub
}
' Less clean: Manual condition checking
observer: {
fieldId: "progress",
callback: sub(payload)
if payload.progress >= 100
' Need to manually unobserve - error prone
else
m.updateProgress(payload.progress)
end if
end sub
}-
Observing Non-Existent Fields: Field doesn't exist on node type
- Solution: Verify field exists for specific SceneGraph node type
-
Memory Leaks: Forgetting to clean up observers
- Solution: Plugin handles cleanup automatically in lifecycle
-
Callback Scope Issues:
mcontext in callbacks- Solution: Use
typecast m as Rotor.Widgetif needed for type safety
- Solution: Use
-
Missing Callback/Handler: Observer configured without
callbackorhandler- Solution: Always provide one of
callbackorhandler(mutually exclusive, one is required)
- Solution: Always provide one of
-
Crash on
callbackwith No Parameters: If your function is defined assub()but configured withcallback:instead ofhandler:, it will crash because the framework passes a payload argument- Solution: Switch to
handler:for zero-argument callbacks
- Solution: Switch to
-
Payload Structure Assumptions: Assuming payload format
- Solution: Use
parsePayloadto normalize payload structure
- Solution: Use
-
Heavy Callback Operations: Expensive operations in callbacks
- Solution: Keep callbacks lightweight, defer heavy work
' Debug observer setup
sub debugObserver(widget as object)
print "Widget ID: " + widget.id
print "Node Type: " + widget.nodeType
print "Observer Config: " + FormatJson(widget.observer)
' Check if field exists on node
if widget.node.hasField("yourFieldId")
print "Field exists on node"
print "Current value: " + widget.node.getField("yourFieldId").toStr()
else
print "ERROR: Field does not exist on node type " + widget.node.subtype()
end if
end sub- Check field name: Ensure
fieldIdmatches exact SceneGraph field name - Verify node type: Confirm field exists for specific node type
- Test field changes: Manually change field to verify observation works
- Check callback function: Ensure callback is valid function reference
- Inspect payload: Print payload in callback to see what's received
- Profile callbacks: Measure callback execution time
- Reduce observer count: Combine related observations when possible
- Optimize parsePayload: Keep payload processing minimal and fast
- Use
onceanduntil: Remove observers when no longer needed
NEXT STEP: Focus Plugin
Reference Documentation:
- Framework Initialization - Configuration, task synchronization, and lifecycle
- ViewBuilder Overview - High-level architecture and concepts
- Widget Reference - Complete Widget properties, methods, and usage patterns
- ViewModel Reference - Complete ViewModel structure, lifecycle, and state management
Plugin Documentation:
- Fields Plugin - Field management with expressions and interpolation
- FontStyle Plugin - Typography and font styling
- Focus Plugin - Focus management and navigation
Additional Documentation:
- Cross-Thread MVI design pattern - State management across threads
- Internationalization support - Locale-aware interface implementation