Composing View Nodes
A ScreenView is a Node like any other, which makes it tempting to build a screen by adding dozens of Circle/Text/HBox children directly to it in one long constructor. Instead, treat the ScreenView as an assembly point: each visually or behaviorally distinct piece of the screen — a control panel, a graph, a single draggable body — is its own Node subclass, constructed once and added as a child. The ScreenView wires them to the model and to each other; it doesn't contain their internals.
The anti-pattern: one giant ScreenView
// Don't do this - every control lives inline, the constructor keeps growing,
// and nothing here is reusable or independently testable.
export default class ProjectileScreenView extends ScreenView {
public constructor( model: ProjectileModel, providedOptions: ScreenViewOptions ) {
super( providedOptions );
const angleSlider = new HSlider( model.angleProperty, new Range( 0, 90 ) );
const angleLabel = new Text( 'Angle' );
const speedSlider = new HSlider( model.speedProperty, new Range( 0, 20 ) );
const speedLabel = new Text( 'Speed' );
const controlPanel = new Panel( new VBox( {
children: [ new HBox( { children: [ angleLabel, angleSlider ] } ),
new HBox( { children: [ speedLabel, speedSlider ] } ) ]
} ) );
// ...forty more lines of graph axes, a launch button, a trajectory path...
this.children = [ controlPanel, /* ... */ ];
}
}Every new control adds to a single constructor that mixes layout, styling, and model-wiring concerns for the whole screen, and none of it can be unit-tested or reused without instantiating the entire ScreenView.
The pattern: one Node subclass per concern
// view/ProjectileControlPanel.ts
import { Panel, type PanelOptions } from 'scenerystack/sun';
import { VBox } from 'scenerystack/scenery';
import { HSlider } from 'scenerystack/sun';
import type ProjectileModel from '../model/ProjectileModel.js';
export default class ProjectileControlPanel extends Panel {
public constructor( model: ProjectileModel, providedOptions?: PanelOptions ) {
const content = new VBox( {
spacing: 8,
children: [
new HSlider( model.angleProperty, model.angleProperty.range ),
new HSlider( model.speedProperty, model.speedProperty.range )
]
} );
super( content, providedOptions );
}
}// view/ProjectileScreenView.ts
import { ScreenView, type ScreenViewOptions } from 'scenerystack/sim';
import ProjectileControlPanel from './ProjectileControlPanel.js';
import ProjectileBodyNode from './ProjectileBodyNode.js';
import type ProjectileModel from '../model/ProjectileModel.js';
export default class ProjectileScreenView extends ScreenView {
public constructor( model: ProjectileModel, providedOptions: ScreenViewOptions ) {
super( providedOptions );
const controlPanel = new ProjectileControlPanel( model, {
right: this.layoutBounds.maxX - 20,
top: this.layoutBounds.minY + 20
} );
const bodyNode = new ProjectileBodyNode( model );
this.children = [ bodyNode, controlPanel ];
}
}The ScreenView now reads as a table of contents for the screen: what exists, and roughly where it sits. Each piece — ProjectileControlPanel, ProjectileBodyNode — can be constructed and inspected in isolation, and a change to how the control panel lays itself out never touches the ScreenView at all.
How to decide where the seam goes
- A visually distinct region (a panel, a graph, a toolbar) is its own
Nodesubclass, even if it's only used once — the goal isn't reuse, it's keeping each constructor's responsibility narrow. - A repeated element (one item per array entry, one body per particle) is unconditionally its own class; see also Composing View Nodes applied per-item and Avoiding Property Leaks for cleanup when those items are created and destroyed dynamically.
- Pure layout glue with no independent identity (a one-off
HBoxgrouping two Nodes that are otherwise unrelated) can stay inline in the parent — composing aNodesubclass for every last spacer is its own anti-pattern. - Each subclass still follows Model-View Separation: it takes the model (or the one Property it needs) as a constructor argument and never reaches into sibling view classes directly.
A Node subclass's constructor argument list is its interface
Prefer passing exactly the model (or Properties) a view Node needs, not the whole screen's model plus a grab-bag of siblings. A ProjectileBodyNode that only takes model.positionProperty documents, by its signature, that it doesn't care about the control panel's state — and it's trivial to construct in a test or a Storybook-style harness later.