A fast table for business data on the Mac, iPad and iPhone: frozen columns, column
groups, a summary row with formulas, row grouping with subtotals, search and
filters, live updates, and a selection that stays on the same records across
reloads. The GPU draws it, and its API mirrors NSTableView, so apps migrate
without reworking their patterns. One NGTableView class works in AppKit and
in UIKit, with the same data source, delegate and features; on the iPad and
iPhone it takes touches, the pointer, a hardware keyboard and VoiceOver as the iPad's
own controls do.
Note
Free for noncommercial use (personal projects, study, research, schools, universities, charities). Using it in a business needs a commercial license, including internal tools: write to d.polikhranidi+ngtable@gmail.com. See Licensing.
comparison.mp4
20,000 live trades sorted by amount, 45,000 changes a second, in each table in
turn (a Release build on Apple silicon, 120 Hz screen). NGTable sorts the rows
itself and moves only the changed ones; NSTableView, NSCollectionView and
SwiftUI's Table leave sorting to the app, which sorts again after each change. The app is the
NGTable Comparison scheme of NGTable.xcworkspace (see Demos and benches).
More scenarios, measured the same way. Main thread: the share of time it was busy (less is more room for your app). Frames: screen updates a second.
| Scenario | NGTable | NSTableView | SwiftUI Table |
|---|---|---|---|
| Live trades sorted by amount: 20,000 rows, 45,000 changes a second | 23–26% · 120 frames | 86–87% · 30–31 frames | 99% · 3 frames |
| Order book: 200,000 rows, 244,000 changes a second | 51% · 120 frames | 97% · 28 frames | doesn't load the rows |
| Trade feed: 1,000,000 rows, 300,000 changes a second | 61% · 120 frames | 97% · 15 frames | doesn't load the rows |
| Binance live tickers: 3,100 pairs | 58% · 120 frames | 100% · 4 frames | 99% · 18 frames |
| Binance order books: 58,000 levels | 17% · 118 frames | 99% · 7 frames | 100% · 0 frames |
| Sorting 1,000,000 rows by a text column | 0.15–0.26 s, the screen keeps drawing | 1.9 s, the app stops for it | — |
NGTable also sorts, filters, sums and highlights changes while doing it. NSCollectionView, measured the same way, gets 34–57 frames with the main thread 72–75% busy on the live trades.
With tens of millions of rows the app gives the table a column source
(NGColumnSource). On a MacBook Pro M1 Max, the huge-CSV viewer Rowbolt's
1 GB file of 11.9 million rows sorts by text in 0.52–0.54 s and by a number
in 0.27 s, is searched in 0.09 s on the GPU (0.15–0.17 s on the CPU's cores)
and scrolls at 120 frames a second. An iPhone 15 Pro Max sorts a 5 GB file
of 59 million rows by name in 5.9–6.3 s and by amount in 4.8–5.6 s, and
searches it in 2.9 s.
On the iPad, the bench in Demos/iPadBench shows the
same live rows in NGTable, UITableView, UICollectionView and SwiftUI's Table
and List, and scrolls each down and up as fast as a flick for eight seconds.
In the simulator (a Release build, 100,000 rows, 1,000 changes a second):
| Scrolling, values changing | NGTable | SwiftUI Table | SwiftUI List | UICollectionView | UITableView |
|---|---|---|---|---|---|
| Frames a second (the worst second) | 60 (60) | 53 (37) | 52 (40) | 38 (14) | 25 (3) |
| Longest frame | 18 ms | 83 ms | 83 ms | 217 ms | 496 ms |
With a million rows and 10,000 changes a second NGTable still gets 60 frames (59 in the worst second). The simulator has the Mac's keyboard attached, and with a keyboard iPadOS's focus system slows UIKit's tables while they scroll; measure on your device too (the bench's launch arguments run the test by themselves).
- Frozen columns at the leading and trailing edges, and column groups under a common header.
- Summary row: sums, averages, counts, minimums and maximums, value filters and formulas over other columns (e.g. ROI as a ratio of sums), for all rows, the selected rows or all except the selected ones. Large tables are summarized in the background.
- Row grouping by column values, several levels deep, under collapsible header rows with row counts and subtotals.
- Search and filters that hide rows without touching the data source.
- Row identity: the selection follows the same records when data is sorted or refreshed.
- Live data: targeted updates (
reloadData(forRowIndexes:columnIndexes:), inserts, removes, moves, batched withbeginUpdates()/endUpdates()) cost what changed, also with 200,000 rows;reorderRows(from:to:)re-sorts without reading the rows again. - NSTableView's patterns: the same data source, delegate, sort
descriptor and notification names, a diffable data source like
NSTableViewDiffableDataSource, and an adapter for an existingNSTableViewDataSource. - Context menus built by the app, with Finder's rules for which rows a click is for.
- Column customization: resize, move, hide and show columns, saved and restored.
- Values as text, numbers, currencies, percentages, dates, check marks, colored tags, icon marks, sparklines (lines, areas, bars, wins and losses), links, buttons, progress bars, ratings, and pictures and videos by URL (loaded, cached and played under the pointer by the table); text that wraps, rows as high as it.
- Native feel: overlay scrollers, elastic scrolling, Liquid Glass summary bar on macOS 26, dark mode, accent colors, keyboard navigation, type select, copying to spreadsheets, VoiceOver.
- On the iPad and iPhone: momentum scrolling, taps that select and act like clicks, editing in place, context menus on a long press, swipe actions, columns resized and moved by a finger, filters and choices in popovers, drag and drop, export to the share sheet, the pointer's hover, a hardware keyboard, VoiceOver's data table, and the summary row above the home indicator.
- Fast with a million rows and hundreds of thousands of changes a second: the GPU (Metal) draws the rows from a glyph atlas, with Core Text's typesetting; changing a value costs no rasterization, and frames are drawn only when something visible changed.
- Tens of millions of rows through a column source (
NGColumnSource): the table's sort, search and totals read your data on all cores, off the main thread, and keep no values; a search of text the source gives in place runs on the GPU.
Your app owns the data. The table sorts the rows itself (sortsRows, in the
background for large tables) or leaves sorting to your app like
NSTableView, and your app performs the actions of its buttons and menus.
Note
Text sorts like Finder, without ICU. NGTable makes its own collation
keys (NGTextKeys, the default) for every European language, Cyrillic,
Greek, Hebrew, Arabic, Devanagari, kana, Han and Hangul, learned from
Foundation's order for the user's language: a million texts sort in about
0.2 seconds on all cores, in the background, in exactly the order of
localizedStandardCompare (the pairs whose keys might not tell are
compared). The optional NGTableICU library adds ICU's keys
(about 9 MB per architecture) for Thai (NGTable compares its texts), or
texts where its keys leave many pairs to compare (Chinese readings):
import NGTableICU
NGTableView.defaultCollationKeyProvider = NGBundledICUCollation()See "Sorting a million rows" in the documentation.
In SwiftUI on the Mac, NGDataTable works like Table (on the iPad it
comes next; until then wrap NGTableView in a UIViewRepresentable):
NGDataTable(campaigns, selection: $selection) {
NGDataColumn("Name", value: \.name)
NGDataColumn("Spend", value: \.spend, format: .currency(code: "USD"), summary: .aggregate(.sum))
}- macOS 14 or later (Liquid Glass on macOS 26 and later)
- iOS and iPadOS 17 or later (the table view in UIKit, on the iPad and the iPhone)
- Swift 6.2 or later (Xcode 26 or later)
In Xcode, choose File > Add Package Dependencies, enter the repository's
URL, and add the NGTable library to your app target. In a Package.swift:
dependencies: [
.package(url: "https://github.com/ngtable/NGTable", from: "1.7.0"),
],
targets: [
.target(name: "MyApp", dependencies: ["NGTable"]),
]For ICU's collation keys (Thai, Chinese text with many words read otherwise,
or ICU's own order), also add the optional NGTableICU library, .product(name: "NGTableICU", package: "NGTable") (about 9 MB per
architecture, see the note above). Apps without it don't build its ICU.
NGTable follows semantic versioning, with one rule for enums: a minor
version may add cases to the public enums that describe values, formats,
filters and summaries (NGValueFormat, NGColumnFilter, NGCellValue,
NGAggregate and the like), as 1.1 added the .dates, .top and
.bottom filters. Keep a default in a switch over
them, and updates won't stop your code from compiling. Only NGColumnItem,
NGDataColumnItem, NGColumnPin and NGColumnVisibility.State keep their
cases until the next major version.
Tip
In the binary NGTable.xcframework (XCFramework/build-xcframework.sh,
built with library evolution) enums may get new cases: a switch over one
needs @unknown default. Only NGColumnItem, NGDataColumnItem,
NGColumnPin and NGColumnVisibility.State are @frozen and switch
without it.
import AppKit
import NGTable
struct Campaign {
var id: String
var name: String
var spend: Double
}
@MainActor
final class CampaignTableData: NGTableViewDataSource {
var campaigns: [Campaign] = []
func numberOfRows(in tableView: NGTableView) -> Int {
campaigns.count
}
func tableView(_ tableView: NGTableView, valueFor column: NGTableColumn, row: Int) -> NGCellValue {
let campaign = campaigns[row]
switch column.id {
case "id": return .text(campaign.id)
case "name": return .text(campaign.name)
case "spend": return .number(campaign.spend)
default: return .empty
}
}
}
final class CampaignsViewController: NSViewController {
private let data = CampaignTableData()
private let table = NGTableView(frame: .zero)
override func loadView() {
table.columns = [
.column(NGTableColumn("name", title: "Campaign", width: 240, pin: .leading)),
.column(NGTableColumn("spend", title: "Spend", width: 120, alignment: .automatic,
format: .currency(code: "EUR"), summary: .aggregate(.sum))),
]
table.rowIdentifierColumnID = "id" // The selection follows the same campaigns.
table.autosaveName = "Campaigns" // The user's columns are remembered.
table.dataSource = data
view = table
}
func show(_ campaigns: [Campaign]) {
data.campaigns = campaigns
table.reloadData() // Always right after changing the data.
}
}On the iPad the same data source makes the table; put it in a
UIViewController (view = table, or as a subview pinned to the edges:
it keeps its summary row above the home indicator by itself). What the Mac
does with the mouse the iPad does by touch:
| On the Mac | On the iPad |
|---|---|
| Click, Command- and Shift-click | Tap (Command and Shift with a hardware keyboard) |
| Double click | Double tap (edits an editable cell) |
Right-click menu: tableView(_:menuForRows:clickedRow:column:) returns an NSMenu |
Long press: the same method returns a UIMenu |
| Trackpad swipe across a row | Finger swipe (where the table can't scroll further sideways) |
| Dragging a column's edge or title | The same, by a finger |
| Filter button, Option-Down Arrow | The header menu's Filter item, the filter button of a filtered column |
pasteboardWriterForRow, validateDrop, acceptDrop |
itemsForBeginning, dropSessionDidUpdate, performDrop (like UITableView's) |
exportDocument(_:): a save panel |
exportDocument(_:): the share sheet |
- Call
reloadData()immediately after changing the data, before selecting, scrolling or returning to the run loop. Until then the table still asks about the old rows. - Row indexes in the API (selection,
clickedRow, data source and delegate calls) are data source indexes, also while a filter hides rows. They are only valid until the next reload: keep record ids, not indexes, in menus and asynchronous work. - Give rows an identity (
rowIdentifierColumnIDortableView(_:identifierForRow:)), unique per record. - The table holds its data source and delegate weakly; capture
selfweakly inNGRowFilter.isIncluded. - Keep
tableView(_:valueFor:row:)a cheap lookup, and return static color constants for row tints and markers. - Don't put the table in an
NSScrollViewor aUIScrollView: it scrolls by itself.
The package has full DocC documentation: API reference for every public type and member, and articles (written for the Mac; on the iPad the API is the same, and the table above maps the interactions):
| Article | Covers |
|---|---|
| Getting Started | Adding the package, columns, data source, showing and reloading |
| Integrating into an App | A complete CRM screen: model, data source, sorting, search, filters, menus, refresh, live updates, column sources for millions of rows, checklist |
| Migrating from NSTableView | The NSTableView → NGTableView mapping, the data source adapter, diffable data sources, what works differently |
| Defining Columns | Options, frozen columns, groups, icon columns, user customization, saving layouts |
| Editing Cells | Cell navigation, editing in place, check boxes and choices, Undo |
| Displaying Values | Value kinds, formats, tags, marks, sparklines, links, buttons, progress bars, ratings, pictures and videos, the pointer over cells |
| Summaries | Aggregates, value filters, formulas, scopes, group subtotals, large tables |
| Filtering and Search | Row filters, search matching, row indexes while filtering, empty states |
| Grouping Rows | Groups by column values, header rows with subtotals, collapsing, order of groups |
| Selection and Sorting | Selection API, row identity, sorting, re-sorting without reloading |
| Menus and Actions | Row context menus, actions, copying |
| Appearance | Sizes, row tints and markers, selection style, scrolling |
| Keyboard and Accessibility | Keyboard commands, type select, VoiceOver |
| Performance and Threading | GPU rendering, what the table reads and when, large data, tens of millions of rows, live updates, memory |
| Using NGTable in SwiftUI | NGDataTable: columns, selection, sorting, editing, menus (the Mac's) |
| Release Notes | What each version brought |
To read it in Xcode, open the package (or your app that uses it) and choose
Product > Build Documentation; it opens in the documentation window.
Symbol documentation also shows in Quick Help (Option-click a symbol). The
sources are in Sources/NGTable/NGTable.docc.
Open NGTable.xcworkspace: the package (the libraries, their tests and
NGTableDemo) and every app below, in one window. Choose a scheme and run
it (Command-R); the benches' schemes run Release builds, as measuring needs.
| Scheme | What it is |
|---|---|
NGTableDemo |
A small Mac app with ad campaign data: frozen and grouped columns, marks, a ROI formula, search, a filter, summary scopes, row tints, a context menu and 0 to 100,000 rows. Its source, Sources/NGTableDemo/NGTableDemo.swift, is a working example of most of the API. |
NGTable Comparison |
The same live trades in NGTable, NSTableView, NSCollectionView and SwiftUI's Table in turn (the animation above shows an earlier version, without NSCollectionView). |
NGTable Mac Bench |
The Mac's benches, a tab each: live data that changes by itself (updates, sorting, grouping), a real app's campaigns table with synthetic campaigns, a live feed over the network from an ngfeed server, and Binance's public market data; the feed and Binance can show NSTableView and SwiftUI's Table instead. Launch arguments open a tab and start it: -BenchAutostart YES, -FeedAutostart trading, -BinanceAutostart tickers. |
NGTable Log Viewer |
The Mac's own logs as they come, or the last hour of them (about a million entries): wrapped messages, search, column filters, grouping, export, and the frames a second it gets meanwhile. |
NGTable iPad |
The table on the iPad with live campaigns: editing, menus, swipe actions, filters, drag and drop, export to the share sheet, the frames a second. |
NGTable iPad Bench |
NGTable, UITableView, UICollectionView and SwiftUI's Table and List on the same live rows (up to a million, up to 10,000 changes a second), with a scroll test; measure on a device too. Launch arguments run it by themselves: -engine 0…4 -rows 0…2 -rate 0…3 -autoTest YES. |
The apps are in Demos/NGTableDemos.xcodeproj (made with XcodeGen from
Demos/project.yml). The comparison and the log viewer also run from the
command line:
swift run -c release --package-path Comparison NGTableComparison
swift run -c release --package-path Demos/LogViewer NGTableLogViewer --last 60Run the tests with the NGTable-Package scheme in Xcode (Command-U), or
swift test in the package folder. The same scheme runs them on an iPad
simulator (the view tests that need AppKit are the Mac's;
NGTouchTests drives the iPad's taps, menus, keyboard, VoiceOver, swipes
and drag and drop).
| Path | Contents |
|---|---|
Sources/NGTable/NGTable.swift |
Data source and delegate protocols |
Sources/NGTable/Model |
Columns, values, formats, summaries, filters, layouts (no AppKit or UIKit) |
Sources/NGTable/Table |
NGTableView for both platforms (the class in NGTableView.swift, its parts in NGTable*.swift extensions): layout, scrolling, selection, sorting, row grouping, updates, filters, export, editing, and what the interactions do (menus' items, keys, column moves, swipes, drops) |
Sources/NGTable/AppKit |
The Mac's views and its mouse, keyboard and menu events, scrollers, accessibility elements, the filter popover, printing |
Sources/NGTable/UIKit |
The iPad's views and its touches, pointer, keys, context menus, popovers, VoiceOver and drag and drop |
Sources/NGTable/Metal |
The GPU renderer (shaders, the glyph atlas, Core Text shaping) and the GPU search |
Sources/NGTable/SwiftUI |
NGDataTable and its columns (the Mac's) |
Sources/NGTable/NGTable.docc |
Documentation articles |
NGTable.xcworkspace |
The package and the demos project, in one window |
Sources/NGTableDemo |
The demo app |
Sources/NGTableICU |
The optional NGTableICU library (NGTableICU.md) |
Vendor/CollationICU |
The ICU it is built with, made by Scripts/vendor-icu.py |
Tests/NGTableTests |
Unit and view tests |
Tests/NGTableICUTests |
NGTableICU's tests |
XCFramework |
The binary frameworks' builds (ICU: NGTableICU's) |
Comparison |
The engine comparison (Docs/comparison.png and the README's video), a package of its own too |
Demos |
The apps' project (NGTableDemos.xcodeproj, XcodeGen's project.yml) and their sources: iPadTable, iPadBench, MacBench, LogViewer |
NGTable is source-available, under two licenses: free for noncommercial use
(personal projects, study, research, schools, universities, charities and
other noncommercial organizations) under the
PolyForm Noncommercial License 1.0.0, and a
commercial license for use in a business, internal tools included. A
commercial license covers a major version (all of 1.x), and apps shipped with
it stay licensed. To buy one, write to d.polikhranidi+ngtable@gmail.com.
See LICENSING.md; NGTableView.version tells which version
you have.
Bug reports, questions and ideas are welcome as issues; pull requests aren't taken for now. See CONTRIBUTING.md.
