A fast table for business data on macOS and iPad: 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 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.
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 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 | ~15% · 120 frames | 97% · 17 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.34 s with NGTableICU (2 s without), the screen keeps drawing | 1.9 s, the app stops for it | — |
NGTable also sorts, filters, sums and highlights changes while doing it.
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: 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.
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.
Important
Sorting large text columns? Add NGTableICU. NGTable works on its own
and sorts text like Finder on all cores: a million shuffled texts in about
3 seconds, in the background. With the optional
NGTableICU library the same sort takes about 0.6 seconds,
in the same order, but your app grows by about 9 MB per architecture
(ICU's code and its collation data: about 17 MB in a universal Intel +
Apple silicon app). It is safe for the Mac App Store. Add the library (a
second product of this package) and one line at launch:
import NGTableICU
NGTableView.defaultCollationKeyProvider = NGBundledICUCollation()Tables of a few thousand rows don't need it: they sort in milliseconds either way. 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)
- iPadOS 17 or later (the table view in UIKit; it builds for the iPhone too, but is made for the iPad's width)
- 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.3.0"),
],
targets: [
.target(name: "MyApp", dependencies: ["NGTable"]),
]For fast sorts of large text columns, 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, 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 |
| 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, live updates, memory |
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 and SwiftUI's Table in turn (the animation above). |
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 |
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.gif), 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.
