In this video, I explore how to work with a “matrix page,” a page type we briefly had back in the 90ties but lost when Navision moved to Windows. I go through the entire process of building a modern replacement of the matrix page. Check out the video:

In this video, Erik tells the story of the “fabled” Matrix page — a page type that existed in the character-based Navision era but was never carried forward into modern Business Central. He walks through the history, shows the original implementation in Navision 3.56a, and then builds a working matrix page from scratch in AL, complete with horizontal scrolling and editable cells backed by a two-dimensional dictionary.
What Is a Matrix Page?
A matrix page is essentially a spreadsheet-like view where you have one table defining the rows (vertical axis) and another table defining the columns (horizontal axis). Each cell sits at the intersection of a row and column, and its value can be calculated or edited based on both dimensions.
Think of it like Excel: rows scroll downward, columns scroll horizontally, and each cell “knows” about both its row and column context. If the source for both axes is something like the Integer table, you could theoretically have infinite scrolling in both directions.
A Brief History Lesson
Erik shares a fun way to “carbon date” old-timers in the Navision/Business Central community: ask them their opinion on losing the Matrix page. If they have strong feelings about it, they’ve been around since the mid-1990s.
He demonstrates the budget view in the character-based Navision version 3.56a (in Danish, as that’s the version he has available). In this classic interface, you can see G/L accounts running down the rows and periods running across the columns. You could switch between days, weeks, and months, and the values would aggregate accordingly using flowfields with filters derived from both axes.
The page designer in that version clearly shows a “Matrix” page type alongside Journal, List, Card, and others. Page 113 — the budget page — still uses the same number in modern Business Central, a testament to 30 years of continuity.
What We Lost
When Navision transitioned to “Financials” (the Windows client era), the Matrix page type was never recreated as a first-class citizen. Looking at modern AL page types — List, Card, Document, Worksheet, API, and many newer additions — there is no Matrix option. Some might argue that the Analysis Views feature partially compensates, but it’s not quite the same thing.
Building a Matrix Page in Modern AL
Since there’s no built-in matrix page type, Erik builds one using a standard List page with some clever techniques. The approach uses:
- The Integer table as the source for both rows and columns
- A set of fixed fields on the page that represent visible columns
- A
LeftMostColumnvariable to track horizontal scroll position CaptionClassto dynamically set column headers- Actions for horizontal scrolling (page left/page right)
- A two-dimensional dictionary for storing edited values
The Page Structure
The page starts as a simple list bound to the Integer table, filtered to only show positive numbers. Insert, modify, and delete operations are disabled since the Integer table is virtual:
page 50800 "Matrix Page"
{
PageType = List;
Caption = 'Matrix View';
SourceTable = Integer;
SourceTableView = where(Number = filter(> 0));
InsertAllowed = false;
ModifyAllowed = false;
DeleteAllowed = false;
UsageCategory = Administration;
ApplicationArea = all;
Defining the Visible Columns
Since we can’t dynamically add columns, we define a fixed number of fields (five in this case), each bound to a variable rather than a table field. The key trick is CaptionClass — by using the format '3,' + expression, we can dynamically control the column caption at runtime:
repeater(VerticalRep)
{
field(Number; Rec.Number)
{
Caption = ' ';
Editable = false;
}
field(c1; V1)
{
CaptionClass = '3,' + GiveMeTheColumnCaption(LeftMostColumn + 0);
Width = 10;
// OnValidate trigger handles edits...
}
field(c2; V2)
{
CaptionClass = '3,' + GiveMeTheColumnCaption(LeftMostColumn + 1);
Width = 10;
// ...
}
// c3, c4, c5 follow the same pattern with offsets +2, +3, +4
}
The CaptionClass property uses a special convention: when the first value is 3, it treats the second part as a literal caption expression. This lets us update column headers dynamically as the user scrolls horizontally.
Populating Cell Values
A secondary Integer record variable called Columns represents the horizontal axis. On page open, it’s filtered and positioned:
trigger OnOpenPage()
begin
Columns.SetFilter(Number, '>0');
Columns.FindFirst();
LeftMostColumn := Columns.Number;
end;
Each time a row is retrieved, the UpdateRow procedure populates all five column variables based on the current LeftMostColumn offset:
local procedure UpdateRow()
begin
V1 := GiveMeTheCellValue(Rec.Number, LeftMostColumn + 0);
V2 := GiveMeTheCellValue(Rec.Number, LeftMostColumn + 1);
V3 := GiveMeTheCellValue(Rec.Number, LeftMostColumn + 2);
V4 := GiveMeTheCellValue(Rec.Number, LeftMostColumn + 3);
V5 := GiveMeTheCellValue(Rec.Number, LeftMostColumn + 4);
end;
This is called from both OnAfterGetRecord and OnAfterGetCurrRecord to ensure the values are always current.
The Cell Value Function
The GiveMeTheCellValue function returns the value for a given row-column intersection. For this demo, the default formula is simply Row * Column — essentially a multiplication table. But it first checks if there’s an override value stored in the dictionary:
local procedure GiveMeTheCellValue(Row: Integer; Column: Integer): Integer
var
ColumnsDict: Dictionary of [Integer, Integer];
begin
if OverrideValues.ContainsKey(Row) then begin
ColumnsDict := OverrideValues.Get(Row);
if ColumnsDict.ContainsKey(Column) then
exit(ColumnsDict.Get(Column));
end;
exit(Row * Column);
end;
Horizontal Scrolling
Two actions provide page-left and page-right functionality. Clicking the left arrow decreases LeftMostColumn by 5 (the number of visible columns), and the right arrow increases it. The page is then updated to refresh all values and captions:
action(Left)
{
Caption = '<--';
Image = DecreaseIndent;
Promoted = true;
PromotedCategory = Process;
PromotedOnly = true;
trigger OnAction()
begin
if LeftMostColumn > 5 then
LeftMostColumn -= 5;
CurrPage.Update();
end;
}
action(Right)
{
Caption = '-->';
Image = Indent;
Promoted = true;
PromotedCategory = Process;
PromotedOnly = true;
trigger OnAction()
begin
LeftMostColumn += 5;
CurrPage.Update();
end;
}
Erik notes that ideally you’d bind these to arrow keys for a seamless experience like the original Navision matrix page, but keyboard shortcut binding for arrow keys doesn’t work as hoped in the current platform.
Making Cells Editable with a Two-Dimensional Dictionary
The real challenge is making cells editable. Since the fields are bound to variables (not table fields), we need a way to store user-entered values. Erik’s solution is a dictionary of dictionaries:
OverrideValues: Dictionary of [Integer, Dictionary of [Integer, Integer]];
The outer dictionary is keyed by row number, and each value is another dictionary keyed by column number, holding the cell’s integer value. This creates a sparse two-dimensional storage structure.
When a user edits a cell, the OnValidate trigger stores the value:
trigger OnValidate()
var
ColumnsDict: Dictionary of [Integer, Integer];
begin
if not OverrideValues.ContainsKey(Rec.Number) then
OverrideValues.Add(Rec.Number, ColumnsDict);
ColumnsDict := OverrideValues.Get(Rec.Number);
if not ColumnsDict.ContainsKey(LeftMostColumn + 0) then
ColumnsDict.Add(LeftMostColumn + 0, V1)
else
ColumnsDict.Set(LeftMostColumn + 0, V1);
end;
Each of the five visible column fields has its own OnValidate trigger with the appropriate offset. When reading values back, GiveMeTheCellValue checks the override dictionary first, falling back to the calculated value if no override exists.
The Complete Source Code
Here is the full AL page object bringing all these concepts together:
page 50800 "Matrix Page"
{
PageType = List;
Caption = 'Matrix View';
SourceTable = Integer;
SourceTableView = where(Number = filter(> 0));
InsertAllowed = false;
ModifyAllowed = false;
DeleteAllowed = false;
UsageCategory = Administration;
ApplicationArea = all;
layout
{
area(Content)
{
repeater(VerticalRep)
{
field(Number; Rec.Number)
{
Caption = ' ';
Editable = false;
}
field(c1; V1)
{
CaptionClass = '3,' + GiveMeTheColumnCaption(LeftMostColumn + 0);
Width = 10;
trigger OnValidate()
var
ColumnsDict: Dictionary of [Integer, Integer];
begin
if not OverrideValues.ContainsKey(Rec.Number) then
OverrideValues.Add(Rec.Number, ColumnsDict);
ColumnsDict := OverrideValues.Get(Rec.Number);
if not ColumnsDict.ContainsKey(LeftMostColumn + 0) then
ColumnsDict.Add(LeftMostColumn + 0, V1)
else
ColumnsDict.Set(LeftMostColumn + 0, V1);
end;
}
field(c2; V2)
{
CaptionClass = '3,' + GiveMeTheColumnCaption(LeftMostColumn + 1);
Width = 10;
trigger OnValidate()
var
ColumnsDict: Dictionary of [Integer, Integer];
begin
if not OverrideValues.ContainsKey(Rec.Number) then
OverrideValues.Add(Rec.Number, ColumnsDict);
ColumnsDict := OverrideValues.Get(Rec.Number);
if not ColumnsDict.ContainsKey(LeftMostColumn + 1) then
ColumnsDict.Add(LeftMostColumn + 1, V1)
else
ColumnsDict.Set(LeftMostColumn + 1, V1);
end;
}
field(c3; V3)
{
CaptionClass = '3,' + GiveMeTheColumnCaption(LeftMostColumn + 2);
Width = 10;
trigger OnValidate()
var
ColumnsDict: Dictionary of [Integer, Integer];
begin
if not OverrideValues.ContainsKey(Rec.Number) then
OverrideValues.Add(Rec.Number, ColumnsDict);
ColumnsDict := OverrideValues.Get(Rec.Number);
if not ColumnsDict.ContainsKey(LeftMostColumn + 2) then
ColumnsDict.Add(LeftMostColumn + 2, V1)
else
ColumnsDict.Set(LeftMostColumn + 2, V1);
end;
}
field(c4; V4)
{
CaptionClass = '3,' + GiveMeTheColumnCaption(LeftMostColumn + 3);
Width = 10;
trigger OnValidate()
var
ColumnsDict: Dictionary of [Integer, Integer];
begin
if not OverrideValues.ContainsKey(Rec.Number) then
OverrideValues.Add(Rec.Number, ColumnsDict);
ColumnsDict := OverrideValues.Get(Rec.Number);
if not ColumnsDict.ContainsKey(LeftMostColumn + 3) then
ColumnsDict.Add(LeftMostColumn + 3, V1)
else
ColumnsDict.Set(LeftMostColumn + 3, V1);
end;
}
field(c5; V5)
{
CaptionClass = '3,' + GiveMeTheColumnCaption(LeftMostColumn + 4);
Width = 10;
trigger OnValidate()
var
ColumnsDict: Dictionary of [Integer, Integer];
begin
if not OverrideValues.ContainsKey(Rec.Number) then
OverrideValues.Add(Rec.Number, ColumnsDict);
ColumnsDict := OverrideValues.Get(Rec.Number);
if not ColumnsDict.ContainsKey(LeftMostColumn + 4) then
ColumnsDict.Add(LeftMostColumn + 4, V1)
else
ColumnsDict.Set(LeftMostColumn + 4, V1);
end;
}
}
}
}
actions
{
area(Processing)
{
action(Left)
{
Caption = '<--';
Image = DecreaseIndent;
Promoted = true;
PromotedCategory = Process;
PromotedOnly = true;
trigger OnAction()
begin
if LeftMostColumn > 5 then
LeftMostColumn -= 5;
CurrPage.Update();
end;
}
action(Right)
{
Caption = '-->';
Image = Indent;
Promoted = true;
PromotedCategory = Process;
PromotedOnly = true;
ShortcutKey = RightArrow;
trigger OnAction()
begin
LeftMostColumn += 5;
CurrPage.Update();
end;
}
}
}
trigger OnAfterGetCurrRecord()
begin
UpdateRow();
end;
trigger OnAfterGetRecord()
begin
UpdateRow();
end;
local procedure UpdateRow()
begin
V1 := GiveMeTheCellValue(Rec.Number, LeftMostColumn + 0);
V2 := GiveMeTheCellValue(Rec.Number, LeftMostColumn + 1);
V3 := GiveMeTheCellValue(Rec.Number, LeftMostColumn + 2);
V4 := GiveMeTheCellValue(Rec.Number, LeftMostColumn + 3);
V5 := GiveMeTheCellValue(Rec.Number, LeftMostColumn + 4);
end;
trigger OnOpenPage()
begin
Columns.SetFilter(Number, '>0');
Columns.FindFirst();
LeftMostColumn := Columns.Number;
end;
local procedure GiveMeTheCellValue(Row: Integer; Column: Integer): Integer
var
ColumnsDict: Dictionary of [Integer, Integer];
begin
if OverrideValues.ContainsKey(Row) then begin
ColumnsDict := OverrideValues.Get(Row);
if ColumnsDict.ContainsKey(Column) then
exit(ColumnsDict.Get(Column));
end;
exit(Row * Column);
end;
local procedure GiveMeTheColumnCaption(Column: Integer): Text
begin
exit(format(Column));
end;
var
Columns: Record Integer;
LeftMostColumn: Integer;
V1: Integer;
V2: Integer;
V3: Integer;
V4: Integer;
V5: Integer;
OverrideValues: Dictionary of [Integer, Dictionary of [Integer, Integer]];
}
Key Techniques Recap
- CaptionClass with format ‘3,expression’ — Dynamically sets column headers at runtime, essential for columns that shift as the user scrolls horizontally.
- Variable-bound fields — Instead of binding to table fields, each column is bound to a page variable (
V1–V5) that gets populated inOnAfterGetRecord. - LeftMostColumn tracking — A single integer variable tracks which column is at the left edge, with each visible field offset from it.
- Dictionary of Dictionaries — A
Dictionary of [Integer, Dictionary of [Integer, Integer]]provides sparse two-dimensional storage for edited cell values without requiring a backing database table. - Action-based horizontal scrolling — Promoted actions with
CurrPage.Update()shift the viewport by the number of visible columns.
Limitations and Future Ideas
- No keyboard-driven horizontal scrolling — Ideally the left/right arrow keys would scroll columns, but shortcut key bindings for arrow keys don’t work as expected in the current BC platform.
- Fixed number of visible columns — You must define each column field individually in AL. There’s no way to dynamically add columns at runtime.
- In-memory only — The dictionary-based override storage lives only for the duration of the page session. In a real implementation, you’d want to persist changes to an actual table.
- Column sources — In this demo, both axes use the Integer table. In a real-world scenario, columns could represent dates, dimensions, locations, or any other entity — you’d just swap out the
GiveMeTheColumnCaptionandGiveMeTheCellValuefunctions accordingly.
Conclusion
The Matrix page was a beloved feature of the character-based Navision era that never made the transition to the modern Business Central platform. While there’s no native PageType = Matrix in AL, Erik’s demonstration shows that you can recreate a surprisingly functional matrix view using a list page, variable-bound fields, dynamic captions via CaptionClass, and a two-dimensional dictionary for editable cell storage. It’s not as elegant as the original — you lose seamless keyboard-driven horizontal scrolling and truly dynamic column counts — but for scenarios where you need a spreadsheet-like grid view in Business Central, this pattern gets the job done.