LineGridCanvasPanel 2: Creating Grids, Timelines, Piano Rolls, And Native Grid Modules
What this covers
1. What changed from `LineGridCanvasPanel` 1.0 to 2.0
2. What `LineGridCanvasPanel` is for
3. The new version 2.0 architecture
4. Logical units, scrollable geometry, and viewport-space
5. The core building blocks
6. How line grids are rendered
7. How child widgets are positioned on the grid
8. How default child movement and snapping work
9. How scrolling and zooming work in version 2.0
10. How flipped logical units affect scrolling and resizing
11. How the native module system works
12. How to extend movement, snapping, rendering, timelines, piano rolls, and custom labels
13. How to synchronize child movement with backend data
14. What to adjust first
This article is for LineGridCanvasPanel version 2.0. The older version 1.0 article explained the original panel model, visible-only rendering, child widget placement, flipped grids, and wrapper-based scrolling expectations. Version 2.0 keeps the same core purpose, but changes the internal architecture, naming model, navigation model, and C++ extension model.
What LineGridCanvasPanel Solves
UAxLineGridCanvasPanel is used when a UI needs to display widgets against a logical grid.
Examples include:
- Timelines
- Piano rolls
- Sequencers
- Track editors
- Bar / beat rulers
- Time-based editors
- Row-and-column editors
- Animation editors
- Any editor where raw pixels are not the real meaning of the UI
The important problem it solves is:
How do we draw a large logical grid, only render the visible part, and place widgets using logical units instead of raw pixels?
A normal canvas places widgets using pixel position and size.
A line grid canvas places widgets using logical meaning.
For example:
For a piano roll:
X0 = Beat 4
X1 = Beat 8
Y0 = MIDI Note 60
Y1 = MIDI Note 61
For a timeline:
X0 = 12.5 seconds
X1 = 18.0 seconds
Y0 = Track 2
Y1 = Track 3
The panel converts those logical values into the physical position and size needed by UMG.
This article gets a bit technical, Its recommended you can jump to the
How To Use Section
If you’re trying to recap or just get started quickly. Below we’ll cover the technicalities/low level functionalities of the system in case you need to debug or extend it.
What Changed In Version 2.0
Version 2.0 keeps the same core behavior, but the implementation is now more maintainable and easier to extend.
The original panel handled almost everything directly:
Grid creation
Grid lookup
Grid synchronization
Rendering
Scrolling
Zooming
Child layout
Mouse coordinate conversion
Editor validation
Version 2.0 splits those responsibilities into native C++ modules:
FAxLineGridCanvasGridRegistry
FAxLineGridCanvasNavigation
FAxLineGridCanvasRenderer
FAxLineGridCanvasChildLayout
FAxLineGridCanvasInput
FAxLineGridCanvasMovement
FAxLineGridCanvasEditorValidator
The panel is now mostly the public API façade.
The modules do the focused implementation work.
This means:
Navigation bug -> check FAxLineGridCanvasNavigation
Rendering bug -> check FAxLineGridCanvasRenderer
Child layout bug -> check FAxLineGridCanvasChildLayout
Mouse conversion bug -> check FAxLineGridCanvasInput
Child movement bug -> check FAxLineGridCanvasMovement
Grid setup bug -> check FAxLineGridCanvasGridRegistry
Editor validation -> check FAxLineGridCanvasEditorValidator
The Version 2.0 Mental Model
Version 2.0 uses three important coordinate concepts:
Logical units
Scrollable geometry
Viewport-space
Logical Units
Logical units are the meaning of the grid.
Examples:
50 bars
128 pitches
20 seconds
12 tracks
240 frames
This is why version 2.0 uses:
LogicalUnitsToRenderThis value is not pixels.
If you set:
LogicalUnitsToRender = 50.0f;that means the axis represents 50 logical units.
Those units could be bars, seconds, pitches, tracks, or anything else represented by the axis line grid.
Scrollable Geometry
Scrollable geometry is the larger physical pixel area generated from logical units and line spacing.
The simplified relationship is:
ScrollableGeometrySize = LogicalUnitsToRender * LineSpacing
Example:
LogicalUnitsToRender = 50
LineSpacing = 100 px
ScrollableGeometrySize = 5000 px
50 bars = logical size
5000 pixels = scrollable geometry size
This distinction matters.
Logical size describes the editor domain.
Scrollable geometry describes the physical space needed to draw it.
Viewport-Space
Viewport-space is the visible local space of the widget.
If the widget is 1200 pixels wide and 600 pixels tall, then the viewport is: 1200 x 600
The scrollable geometry may be much larger than this.
For example:
Viewport size: 1200 x 600
Scrollable geometry size: 5000 x 2000
The panel renders only the part of the scrollable geometry currently visible through the viewport.
Scroll Offset
ScrollOffset is the positive pixel offset into the scrollable geometry.
The panel uses it to convert scrollable geometry-space into viewport-space.
The simplified conversion is:
ViewportPosition = ScrollableGeometryPosition - ScrollOffset
So if a note is physically located at X = 2000 inside the scrollable geometry, and the current scroll offset is X = 1500, that note appears at X = 500 in the viewport.
Core Building Blocks
UAxLineGridCanvasPanel
UAxLineGridCanvasPanel is the main UMG widget.
It owns the public API and designer-facing properties.
Important properties include:
LineGrids
LineGridObjects
ChildWidgetsLayoutProperties
HorizontalNavigationProperties
VerticalNavigationProperties
BorderLines
It also owns the internal native modules.
The panel still handles the high-level lifecycle:
RebuildWidget()
SynchronizeProperties()
RenderLineGrids()
HandlePreRenderLineGrids()
HandlePostRenderLineGrids()
But the detailed implementation is delegated to modules.
Where to look in code
Module initialization:
EnsureModulesInitialized()Native module factories:
CreateGridRegistryModule()
CreateNavigationModule()
CreateRendererModule()
CreateChildLayoutModule()
CreateInputModule()
CreateEditorValidatorModule()
UAxLineGrid
A UAxLineGrid represents one repeated logical unit grid.
A line grid can represent:
- Bars
- Beats
- Seconds
- Frames
- Tracks
- Pitch rows
- Notes
- Measures
- Any custom logical unit you define
A line grid has an ID.
That ID matters because child widgets use it to say what logical unit their X and Y values are in.
Example:
LogicalXUnitsID = "Beats"
LogicalYUnitsID = "Pitch"
A line grid controls:
- Orientation
- Line spacing
- Logical offset
- Whether logical units are flipped
- Line drawing
- Tick lines
- Text labels
- Background drawing
- Parent / child grid relationships
Parent and child line grids
A line grid can be a child of another line grid.
For example:
Bars -> parent grid
Beats -> child grid
This is useful during zooming.
Usually, you zoom the parent grid.
Then child grids update their spacing from the parent.
Example:
Bar spacing = 400 px
Beats per bar(ratio) = 4
Beat spacing = 100 px
You do not need to manually zoom both bars and beats.
You zoom the parent.
The child and grandchildren follow.
UAxLineGridChildWidget
A UAxLineGridChildWidget represents a widget positioned by logical units.
For example:
X0 = Beat 4
X1 = Beat 8
Y0 = Track 2
Y1 = Track 3
The child does not calculate its own pixel position.
Instead, the panel reads the child’s logical values and converts them into a CanvasPanelSlot position and size.
The child provides:
LogicalXUnitsID
LogicalYUnitsID
LogicalPositionRect
The panel resolves those unit IDs against the matching UAxLineGrid objects.
Default Child Movement And Snapping
UAxLineGridCanvasPanel now has a default child movement and snapping system.
This is handled by:
FAxLineGridCanvasMovement
The movement module is responsible for moving and resizing UAxLineGridChildWidget instances in logical grid space.
This means child movement is not treated as raw pixel movement.
Instead, the panel follows this simplified flow:
Mouse movement
↓
Convert mouse position into scrollable geometry-space
↓
Convert scrollable geometry position into logical grid units
↓
Apply movement or resizing
↓
Optionally snap the result to a configured grid
↓
Apply the new logical rect to the child
↓
Update the child widget geometry
Native Module Architecture
Version 2.0 uses a façade plus native modules.
The panel remains the API surface.
The modules handle the implementation.
The structure is:
UAxLineGridCanvasPanel
owns FAxLineGridCanvasGridRegistry
owns FAxLineGridCanvasNavigation
owns FAxLineGridCanvasRenderer
owns FAxLineGridCanvasChildLayout
owns FAxLineGridCanvasInput
owns FAxLineGridCanvasEditorValidator
...etc
The modules are not USTRUCTs.
They are not UObjects.
They are plain C++ classes so they can be inherited normally.
This lets you customize one subsystem without rewriting the entire panel.
Example: Replacing Only The Renderer
For a piano roll, you may want logical pitch values to display as musical pitch names.
Instead of rendering:
60
61
62
You may want:
C4
C#4
D4
In version 1.0, you could override GetLineLabel() on the panel.
That still works in version 2.0.
But version 2.0 also lets you override only the renderer module.
Example:
class FAxPianoRollKeysRenderer : public FAxLineGridCanvasRenderer
{
public:
virtual FString GetLineLabel(
int32 LogicalIndex,
FAxIntersectingRectRenderInfo IntersectingRectRenderInfo) override
{
static const TCHAR* PitchNames[] =
{
TEXT("C"), TEXT("C#"), TEXT("D"), TEXT("D#"),
TEXT("E"), TEXT("F"), TEXT("F#"), TEXT("G"),
TEXT("G#"), TEXT("A"), TEXT("A#"), TEXT("B")
};
if (LogicalIndex < 0 || LogicalIndex > 127)
{
return FString::FromInt(LogicalIndex);
}
int32 PitchClass = LogicalIndex % 12;
int32 Octave = (LogicalIndex / 12) - 1;
return FString::Printf(TEXT("%s%d"), PitchNames[PitchClass], Octave);
}
};
Then your panel returns that renderer:
TUniquePtr UAxPianoRollCanvasPanel::CreateRendererModule() const
{
return MakeUnique();
}
Now only the label behavior is customized.
The rest of the grid rendering system remains unchanged.
What Each Module Does
FAxLineGridCanvasGridRegistry
This module owns runtime line grid object setup.
It is responsible for:
- Creating runtime
UAxLineGridobjects - Looking up line grids by ID
- Finding the main line grid for an orientation
- Setting line spacing on runtime line grids
- Synchronizing runtime line grids from designer-facing
LineGrids - Rebuilding parent / child grid links
FAxLineGridCanvasNavigation
This module owns scroll and zoom logic.
It is responsible for:
- Reading horizontal and vertical navigation settings
- Resolving the zoom line grid
- Calculating logical units to render
- Calculating scrollable geometry size
- Calculating scroll offset
- Mapping normalized scroll to physical scroll offset
- Mapping logical units to normalized scroll
- Applying zoom to the zoom line grid
- Matching scroll direction to flipped logical units when requested
FAxLineGridCanvasRenderer
This module owns grid drawing.
It is responsible for:
- Sorting line grids by draw priority
- Drawing visible line grids
- Drawing grid lines
- Drawing tick lines
- Drawing text labels
- Drawing alternating backgrounds
- Drawing last-section backgrounds
- Drawing border lines
- Converting scrollable geometry-space draw positions into viewport-space
FAxLineGridCanvasChildLayout
This module owns child widget layout.
It is responsible for:
- Resolving a child’s logical X line grid
- Resolving a child’s logical Y line grid
- Calculating visible logical ranges
- Calling
RemoveOutOfViewWidgets() - Calling
SpawnInViewWidgets() - Updating child widget geometry
- Validating and auto-correcting child anchors
- Aligning smart handles with the primary line grid
FAxLineGridCanvasInput
This module owns input coordinate conversion.
Mouse input arrives in viewport-space.
The grid system usually needs the mouse position in scrollable geometry-space.
This module handles that conversion.
It is responsible for:
- Converting mouse viewport position into scrollable geometry position
- Clamping positions to the visible scrollable geometry rect
- Clamping rectangles to the visible scrollable geometry rect
FAxLineGridCanvasEditorValidator
This module owns editor-time validation.
It is responsible for checking that important LineGrid IDs reference valid entries.
It validates:
- Default child X units
- Default child Y units
- In-view X units
- In-view Y units
- Horizontal zoom line grid
- Vertical zoom line grid
- Orientation matches for enabled navigation axes
FAxLineGridCanvasMovement
This module owns default child movement, resizing, snapping, and logical-range clamping.
It is responsible for:
- Detecting whether the captured smart child handle should move or resize the child
- Reading the child widget’s starting logical rect
- Converting mouse position into logical X and Y values
- Applying movement or resizing in logical space
- Snapping movement per axis
- Snapping resizing per axis
- Clamping moved children to logical ranges
- Enforcing minimum logical resize sizes
- Applying the resulting logical rect through the panel backend hooks
This module exists so basic grid interaction works out of the box.
Specialized widgets can still replace it by overriding:
CreateMovementModule()
Child Widget Movement Backend Synchronization
The movement module updates the child widget’s logical rect by default.
However, many grid-based editors are data-backed.
For example:
A piano roll note widget represents a MidiNote.
A timeline clip widget represents a clip object.
A sequencer item widget represents a section or key range.
In those cases, the widget should usually be treated as a view over backend data.
The panel provides movement hooks for that.
Use:
ApplyLineGridChildLogicalRect(…)
when the logical rect changes during movement or resizing.
Use:
FinishLineGridChildMovement(…)
when the mouse interaction ends.
The default implementation can update the child widget directly, but data-backed panels can override these functions to update their own backend objects instead.
Simplified example:
void UMyTimelinePanel::ApplyLineGridChildLogicalRect_Implementation(
UAxLineGridChildWidget* InLineGridChild,
FAx2DRect InPreviousLogicalRect,
FAx2DRect InNewLogicalRect,
EAxSmartWidgetHandleType InHandleType)
{
UMyClipWidget* ClipWidget = Cast(InLineGridChild);
if (!ClipWidget)
{
return;
}
UMyClipData* ClipData = ClipWidget->GetClipData();
if (!ClipData)
{
return;
}
ClipData->StartTime = InNewLogicalRect.X0;
ClipData->EndTime = InNewLogicalRect.X1;
ClipData->TrackIndex = InNewLogicalRect.Y0;
Super::ApplyLineGridChildLogicalRect_Implementation(
InLineGridChild,
InPreviousLogicalRect,
InNewLogicalRect,
InHandleType);
}
If the backend is authoritative, the child widget can also override its logical getters and return values directly from the backend object.
Spawning And Removing Widgets
Before rendering the grid, the panel calculates the current visible logical range.
Then it calls:
RemoveOutOfViewWidgets()
SpawnInViewWidgets()
The base implementations are empty.
Derived panels decide what to do.
Examples:
Piano roll:
Spawn only notes that should be visible at that time.
Timeline:
Spawn visible clips.
Extending The Panel
You usually extend UAxLineGridCanvasPanel when you need custom behavior around:
- What appears in the visible range
- How widgets are spawned
- How widgets are recycled
- How labels are shown
- How ticks are drawn
- How backgrounds are drawn
- How mouse positions are interpreted
- How scrolling or zooming behaves
Version 2.0 gives you two extension styles.
1. Override Panel Functions
This is still supported.
Examples:
RemoveOutOfViewWidgets()
SpawnInViewWidgets()
GetLineLabel()
RenderLineLabel()
RenderLineTick()
UpdateChildWidgetGeometry()
Use this when the custom behavior is small or directly tied to the panel.
2. Replace A Native Module
Use this when the custom behavior belongs to one subsystem.
Examples:
Custom renderer -> FAxLineGridCanvasRenderer
Custom navigation -> FAxLineGridCanvasNavigation
Custom child layout -> FAxLineGridCanvasChildLayout
Custom input mapping -> FAxLineGridCanvasInput
This is the cleaner option when you are building specialized and complex widgets where there is a lot to do like:
UAxPianoRollCanvasPanel
UAxTimelineCanvasPanel
UAxSequencerCanvasPanel
UAxTrackEditorCanvasPanel
Where Scrolling And Zooming Fit
Version 2.0 has built-in per-axis scroll and zoom support.
The panel no longer needs to be physically moved or resized by a wrapper to represent a larger canvas.
Instead, navigation works through:
LogicalUnitsToRender
LineSpacing
ScrollableGeometrySize
CurrentScrollValue
CurrentZoomValue
ScrollOffset
The usual flow is:
Set normalized zoom
↓
Zoom module updates LineSpacing on the zoom LineGrid
↓
ScrollableGeometrySize changes
↓
Set normalized scroll
↓
Navigation module calculates ScrollOffset
↓
Renderer draws visible scrollable geometry rect
↓
Draw calls subtract ScrollOffset
A higher-level scroll/zoom widget may still control the normalized values by simply calling zoom and scroll functions.
But the line grid canvas itself owns the internal viewport model.
How To Use The System
If you are setting up a new version 2.0 grid, start here.
1. Define the line grids
Create line grid entries for the logical units you need.
Examples:
Bars
Beats
Tracks
Pitch
Seconds
Frames
2. Set each grid orientation
Use horizontal orientation for units that grow across X.
Use vertical orientation for units that grow across Y.
Example piano roll:
Beats -> Horizontal
Pitch -> Vertical
Example timeline:
Seconds -> Horizontal
Tracks -> Vertical
3. Set line spacing
This controls how dense the grid appears. This is just for the starting or initial size during design. At runtime, this value is controlled by zooming unless the grid is not meant to zoom at all, then it remains static.
Example:
LineSpacing = 100
4. Configure default child units
Set:
DefaultLogicalXUnitsID
DefaultLogicalYUnitsID
Example piano roll:
DefaultLogicalXUnitsID = "Beats"
DefaultLogicalYUnitsID = "Pitch"
5. Configure in-view units
Set:
LogicalInViewXUnitsID
LogicalInViewYUnitsID
These control the logical range used for spawning and removing child widgets. These are passed in during the Spawn() and Remove() functions.
Example:
LogicalInViewXUnitsID = "Beats"
LogicalInViewYUnitsID = "Pitch"
6. Configure navigation
For each enabled axis, set:
bEnableNavigation
ZoomLineGridID
LogicalUnitsToRender
MinLineSpacingForZoom
MaxLineSpacingForZoom
Example:
HorizontalNavigationProperties.bEnableNavigation = true;
HorizontalNavigationProperties.ZoomLineGridID = "Bars";
HorizontalNavigationProperties.LogicalUnitsToRender = 64.0f;
7. Check flipped axes
Some grids need logical values to increase in the opposite physical direction.
The common example is pitch.
UMG Y values increase downward.
Pitch usually increases upward.
So a pitch grid often uses:
bFlipLogicalUnits =true;
on that line grid.
Then keep:
bMatchScrollDirectionToLogicalUnits = true
8. Set child anchors correctly
Line grid children should use:
Anchors.Minimum = (0, 0)
Anchors.Maximum = (0, 0)
Alignment = (0, 0)
Do this in the widget blueprint or in runtime spawning code.
The panel can auto-correct, but the child should still be authored correctly.
9. Configure child movement and snapping
Decide whether child widgets should move, resize, snap, and clamp.
For general movement:
ChildMovementProperties.bEnableChildMovement = true;
ChildMovementProperties.bEnableChildResizing = true;
For horizontal movement:
ChildMovementProperties.HorizontalMovementSettings.bEnableMovement = true;
ChildMovementProperties.HorizontalMovementSettings.bSnapMovementToGrid = true;
ChildMovementProperties.HorizontalMovementSettings.SnapLineGridID = "Beats";
ChildMovementProperties.HorizontalMovementSettings.SnapStep =1.0;
10. Decide your extension style
For simple behavior, override panel functions.
For complex subsystem-specific behavior, replace a native module.
Custom labels only?
Replace renderer module.
Custom scroll rules?
Replace navigation module.
Custom child placement?
Replace child layout module.
Do this in the widget blueprint or in runtime spawning code.
The panel can auto-correct, but the child should still be authored correctly.
Debugging Checklist
If the grid does not render correctly, check:
LineGrids has valid entries.
LineGrid IDs are unique.
ZoomLineGridID references a valid LineGrid.
ZoomLineGridID orientation matches the navigation axis.
LineSpacing is greater than 0.
LogicalUnitsToRender is greater than 0.
bEnableNavigation is set correctly for the axis.
If child widgets do not appear correctly, check:
Child is a UAxLineGridChildWidget.
LogicalXUnitsID resolves to a valid line grid.
LogicalYUnitsID resolves to a valid line grid.
DefaultLogicalXUnitsID is valid.
DefaultLogicalYUnitsID is valid.
Child anchors are top-left.
Child alignment is top-left.
If scrolling feels backwards, check:
bFlipLogicalUnits on the zoom LineGrid.
bMatchScrollDirectionToLogicalUnits on the navigation axis.
bFlipScrolling only if manual override is needed.
If labels are wrong, check:
GetLineLabel()
FAxLineGridCanvasRenderer::GetLineLabel()
CreateRendererModule()
If rendering order is wrong, check:
DrawPriority
RenderLineGrids()
RenderCurrentLine()
RenderLineLabel()
RenderLineTick()
RenderLineBackground()
Especially the increment or decrement of rendering layer.