Sitemap
Itsuki

Itsuki

Life is for enjoying!

SwiftUI: Table + TabularData = Handling Unknown Dynamic

A simple CSV Editor in PURE SwiftUI! Dynamic Tables. Edit, Sort, Append, Delete.

22 min read3 days ago

--

Press enter or click to view image in full size

I don’t know about you but I never got much chance to work with the TabularData framework until recently.

Press enter or click to view image in full size

What was keeping me away from it? That “to train a machine learning model”.

However, it definitely has a lot more use case than just that! I mean, we don’t just use pandas to prepare data for PyTorch!

In this article, let’s make a simple CSV editor together with this TabularData framework and SwiftUI Table! To check out how we can

  • read, write, display CSV files.
  • Insert / delete / edit columns / rows (so many slashes…hope you get what I meant…)
  • sort columns / rows

PS: A lot of the functions like transformColumn, or functions with the same name but different parameter types are NOT available on the developer’s doc! You just have to command click and check out the source code!

Full sample on my GitHub!

Feel free to grab it and let’s start!

TabularData

Let’s start off our day with a short introduction to the TabularData framework and how we can use it to perform some basic (but crucial) manipulations on our data.

DataFrame

DataFrame is like the data table, arranges data in rows and columns. If you have ever used pandas, this is pretty obvious, because pandas also names their data holder pandas.DataFrame.

We can load one from CSV using either init(contentsOfCSVFile:columns:rows:types:options:) or init(csvData:columns:rows:types:options:)

let dataFrame = try DataFrame(contentsOfCSVFile: url)

We can load it from JSON, file or data.

let dataFrame = try DataFrame(contentsOfJSONFile: url)

Or create it programmatically from scratch.

An empty DataFrame.

let dataFrame = DataFrame()

From another DataFrame rows.

let base: DataFrame = ...
let dataFrame = DataFrame(base.prefix(base.rows.count))

From another DataFrame columns.

let base: DataFrame = ...
let dataFrame = base.selecting(columnNames: ["..."])

With some prefilled contents by either using dictionary literals

let dataFrame = DataFrame(
dictionaryLiteral: ("string", ["string"]),
("Int", [1]),
("Double", [1.1]),
("Date", [Date()])
)

// result
┏━━━┳━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
┃ ┃ string ┃ Int ┃ Double ┃ Date ┃
┃ ┃ <String> ┃ <Int> ┃ <Double> ┃ <Date> ┃
┡━━━╇━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
│ 0 │ string │ 1 │ 1.1 │ 2026/10/31, 8:50 │
└───┴──────────┴───────┴──────────┴──────────────────┘
1 row, 4 columns

or by creating some columns first with contents

let stringColumn = Column(name: "string", contents: ["string"])
.eraseToAnyColumn()
let intColumn = Column(name: "int", contents: [1]).eraseToAnyColumn()
let doubleColumn = Column(name: "double", contents: [1.1])
.eraseToAnyColumn()
let dataFrame = DataFrame(
columns: [stringColumn, intColumn, doubleColumn]
)

// result:
┏━━━┳━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━┓
┃ ┃ string ┃ int ┃ double ┃
┃ ┃ <String> ┃ <Int> ┃ <Double> ┃
┡━━━╇━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━┩
│ 0 │ string │ 1 │ 1.1 │
└───┴──────────┴───────┴──────────┘
1 row, 3 columns

Or columns with empty (nil) rows of a specific count.

let stringColumn = Column<String>(name: "string", capacity: 1)
.eraseToAnyColumn()
let intColumn = Column<Int>(name: "int", capacity: 1).eraseToAnyColumn()
let doubleColumn = Column<Double>(name: "double", capacity: 1)
.eraseTsoAnyColumn()
let dataFrame = DataFrame(
columns: [stringColumn, intColumn, doubleColumn]
)

// result
┏━━┳━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ ┃ string ┃ int ┃ double ┃
┃ ┃ <String> ┃ <Int> ┃ <Double> ┃
┡━━╇━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━┩
└──┴──────────┴───────┴───────────────┘

The type we specified are not optional, but they will basically be optional internally anyway.

Also, when we create it programmatically, we can have types like Date or Array, but when we load it from file, I never get those to parse as types other than String, Int, and Double (optional of course).

Update A Cell

Next thing next, we might want to update some contents of a specific cell and there are a couple of ways of doing it.

Given a row and column index,

let colIndex = 1
let rowIndex = 0
let newValue = 2

we can assign through the column or the row subscript.

dataFrame[column: colIndex][rowIndex] = newValue
dataFrame[row: rowIndex][colIndex] = newValue

Note! The newValue has to be of the same type as the column type. Otherwise the app will crash (I know, so Objective-C behavior!).

We can replace the entire column.

var column = dataFrame.columns[colIndex]
column[rowIndex] = newValue
dataFrame.replaceColumn(column.name, with: column)

However, do note dataFrame.columns[colIndex][rowIndex] = newValue will NOT work (neither the rows[rowIndex][colIndex] version).

We can transform the column, for example, to add 1 to all values.

let column = dataFrame.columns[colIndex]
dataFrame.transformColumn(column.name) {
return $0 + 1
}

And of course, this only works if the column is Int, otherwise the app will crash. But you can always check the column’s type by, for example, if column.wrappedElementType is Int.Type { }. Or a little safer version of the transform.

dataFrame[column.name, Int.self].transform {
return $0 + 1
}

Update Column Name

One line!

dataFrame.renameColumn(column.name, to: "new name")

However, do note that all column names has to be unique. If the new name here is already used by some other columns, crash! (I know, everything crashes with TabularData!))

Insert / Append / Delete Column

To insert or append, we will first need an AnyColumn, either with nil filled, or with actual contents.

let intColumn = Column<Int>(name: "int2", capacity: dataFrame.rows.count).eraseToAnyColumn()
dataFrame.insert(column: intColumn, at: 0)

PS: we could also leave it as a typed Column instead of eraseToAnyColumn.

Note that the capacity here actually does NOT matter. Whether it is less than, equal to, or greater than the dataFrame.rows.count, the new column will be added successfully, with all rows filled with nil.

With actual contents though, the contents length does matter. For example, let’s say our current dataFrame.rows.count is 1, and we are inserting the following column with a row count of 3.

let intColumn = Column<Int>(name: "int2", contents: [1, 23, 3]).eraseToAnyColumn()
dataFrame.insert(column: intColumn, at: 0)

The resulting dataFrame will then have a row count of 3 where other columns’ rows will be filled with nil. Something like following.

┏━━━┳━━━━━━━┳━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━┓
┃ ┃ int2 ┃ string ┃ Int ┃ Double ┃
┃ ┃ <Int> ┃ <String> ┃ <Int> ┃ <Double> ┃
┡━━━╇━━━━━━━╇━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━┩
│ 0 │ 1 │ string │ 1 │ 1.1 │
│ 1 │ 23 │ nil │ nil │ nil │
│ 2 │ 3 │ nil │ nil │ nil │
└───┴───────┴──────────┴───────┴──────────┘

To append, it is just swapping the insert with append.

dataFrame.append(column: intColumn)

To delete, we use the column name.

dataFrame.removeColumn(intColumn.name)

Insert / Append / Delete Rows

Things get a little creepy (in terms of design) /annoying here…So let’s start with the non-creepy part, the delete. We use row index. Make sense. Rows don’t have names.

dataFrame.removeRow(at: index)

Append, perfectly fine.

We append another DataFrame.Row, for example, a duplicate of the existing.

dataFrame.append(row: dataFrame.rows[0])

Append all rows of another DataFrame

dataFrame.append(rowsOf: dataFrame)

Append one directly from variadic.

dataFrame.append(row: "string2", 1, 2.2)

NOTE! The app will crash if the count here is not the same as the column count.

Or value by column (because variadic sucks in terms of dynamic)

dataFrame.append(valuesByColumn: ["string": "1", "Int": 2, "Double": 3.3])

If you want the column name and type dynamically, all you have to do is to loop through the dataFrame.columns.

var dict: [String:Any?] = [:]
for column in dataFrame.columns {
let colName = column.name
if column.wrappedElementType is Int.Type {
dict[colName] = 1
}
if column.wrappedElementType is String.Type {
dict[colName] = "1"
}
// ... check other types
}

Or just an empty row. Without having to worry about the column count!

dataFrame.appendEmptyRow()

Everything is so great!

What about insert?

All of a sudden, all we get is this ONE function signature to insert a DataFrame.Row. NOTHING ELSE.

dataFrame.insert(row: dataFrame.rows[0], at: 0)

Why is this so problematic? Because there is a public constructor on the DataFrame.Row!

So! This is a little work around to create a DataFrame.Row with the values you want without messing with the existing dataFrame.

  1. Create a new DataFrame with the same columns as the existing
  2. Append the row we want to insert to this new DataFrame
  3. Get the DataFrame.Row from the new DataFrame and insert it back to the actual one!

For example, if we want to insert an Empty Row at index 0.

var rowFrame = DataFrame(dataFrame.prefix(0))
rowFrame.appendEmptyRow()
dataFrame.insert(row: rowFrame.rows[0], at: 0)

Why would anyone think that this is a good design and give a go sign?

When TabularData Meets Table

I think we get enough of what we need from TabularData so let’s add in our Tables and see how we can combine the two together!

First of all, I assume you are used to SwiftUI Table enough. If not, feel free to check out some of my previous articles

Rows

First of all, when creating a table, we first need some kind of RandomAccessCollection where the elements are identifiable to act as our source.

Unfortunately, that DataFrame.Row is not.

Fortunately, all we need is a wrapper or a @retroactive identifiable conformation.


nonisolated struct IdentifiableRow: Identifiable, Equatable {
var id: String { "\(index)_\(row.description)" }
var index: Int
var row: DataFrame.Row

init(_ row: DataFrame.Row) {
self.index = row.index
self.row = row
}
}

I bet there are couple of things you are wondering or want to scream at me. Why is the index not a calculated variable, why cannot the id be just the index. There is a reason. I am not AI nor vibe coder who literally throws s*** without being able to justify those!

We can then create our Table.

Table(Array(dataFrame.rows).map(IdentifiableRow.init), columns: {
TableColumn("index", value: \.index)
})

Columns

What about other columns?

index is something we created ourselves. We know it exists. And obviously this cannot be true on other columns! Those are dynamic! We have no idea what those are at the time we write out code!

I bet we already knew this but, obviously, we cannot just ForEach(columns)!

// WON'T WORK! WON'T COMPILE!
ForEach(columns) { column in
TableColumn(...) {...}
}

TableColumnForEach is our friend today! And of course, same as the rows, we need a wrapper for the Column (or AnyColumn) as well.

nonisolated struct IdentifiableColumn: Identifiable {
var id: Int { index }
var index: Int
var col: AnyColumn

var name: String {
col.name
}

var type: any Any.Type {
col.wrappedElementType
}

init(_ col: AnyColumn, index: Int) {
self.index = index
self.col = col
}
}

TableColumnForEach(
Array(dataFrame.columns).enumerated().map({
IdentifiableColumn.init($0.element, index: $0.offset)
})
) {
column in
TableColumn(column.name) { row in
let row = row.row
let value = row[column.name]
if let value {
Text("\(String(describing: value))")
} else {
Text("nil")
}
}
}
Press enter or click to view image in full size

If all you need is to display some CSV in your app, you can call it today.

Get Itsuki’s stories in your inbox

Join Medium for free to get updates from this writer.

However, I want to be able to sort, to edit, to insert, to delete!

Sort Rows

First of all, I want to use Table’s built-in sortOrder (because I don’t like making UI…).

However, this will mean we will need to create a custom SortComparator. (You write UI code or you write Logic code. I prefer logic.)

Why?

Because to create a sortable column, we need the init(_:value:comparator:) initializer.

There are a bunch of subscripts already implemented for DataFrame.Row , for example, the following.

public subscript<T>(columnName: String, type: T.Type) -> T?
public subscript(columnName: String) -> Any?

As we can see, to get a Typed value, we need to pass in the type. And, as we all know by now, we cannot pass in un-hashable things (like Type) to keyPath which means the keyPath here will always be pointing to an Any?.

And obviously, no Swift API can sort Any? for us, so we need a little custom SortComparator here to compare Any?!

private struct AnyComparator: SortComparator {

var order: SortOrder = .forward

typealias Compared = Any?

static func isComparable(_ type: Any.Type = Any.self) -> Bool {
return type is Int.Type || type is String.Type || type is Double.Type
}

func compare(_ lhs: Any?, _ rhs: Any?)
-> ComparisonResult
{
/// Bool
if let lhs = lhs as? Bool?, let rhs = rhs as? Bool? {
return compareBool(lhs, rhs)
}

/// Int
if let lhs = lhs as? Int?, let rhs = rhs as? Int? {
return compareInt(lhs, rhs)
}

/// Double
if let lhs = lhs as? Double?, let rhs = rhs as? Double? {
return compareDouble(lhs, rhs)
}

/// String
if let lhs = lhs as? String?, let rhs = rhs as? String? {
return compareString(lhs, rhs)
}

/// UUID
if let lhs = lhs as? UUID?, let rhs = rhs as? UUID? {
return compareString(lhs?.uuidString, rhs?.uuidString)
}

/// Date
if let lhs = lhs as? Date?, let rhs = rhs as? Date? {
return compareDate(lhs, rhs)
}

/// Array
if let lhs = lhs as? [Any]?, let rhs = rhs as? [Any]? {
return compareArray(lhs, rhs)
}

guard let lhs, let rhs else {
return compareNil(lhs == nil, rhs == nil)
}

/// Fall back to string
return compareString(String(describing: lhs), String(describing: rhs))
}

private func compareNil(_ lhs: Bool, _ rhs: Bool) -> ComparisonResult {
return compareBool(lhs, rhs)
}

private func compareBool(_ lhs: Bool?, _ rhs: Bool?) -> ComparisonResult {
if lhs == rhs {
return .orderedSame
}
if order == .forward {
return lhs == true ? .orderedDescending : .orderedAscending
} else {
return lhs == true ? .orderedAscending : .orderedDescending
}
}

private func compareInt(_ lhs: Int?, _ rhs: Int?)
-> ComparisonResult
{
guard let lhs, let rhs else {
return compareNil(lhs == nil, rhs == nil)
}
if order == .forward {
return lhs > rhs ? .orderedDescending : .orderedAscending
} else {
return lhs > rhs ? .orderedAscending : .orderedDescending
}
}

private func compareDouble(_ lhs: Double?, _ rhs: Double?)
-> ComparisonResult
{
guard let lhs, let rhs else {
return compareNil(lhs == nil, rhs == nil)
}

if order == .forward {
return lhs > rhs ? .orderedDescending : .orderedAscending
} else {
return lhs > rhs ? .orderedAscending : .orderedDescending
}
}

private func compareString(_ lhs: String?, _ rhs: String?)
-> ComparisonResult
{
guard let lhs, let rhs else {
return compareNil(lhs == nil, rhs == nil)
}

/// Enable numeric here to handle date string as well.
return String.Comparator(
options: [.caseInsensitive, .numeric],
order: self.order
)
.compare(lhs, rhs)
}

private func compareDate(_ lhs: Date?, _ rhs: Date?)
-> ComparisonResult
{
guard let lhs, let rhs else {
return compareNil(lhs == nil, rhs == nil)
}

/// not using Date.compare as it does not take order into account nor have a parameter for the order
if order == .forward {
return lhs > rhs ? .orderedDescending : .orderedAscending
} else {
return lhs > rhs ? .orderedAscending : .orderedDescending
}
}

private func compareArray(_ lhs: [Any]?, _ rhs: [Any]?)
-> ComparisonResult
{
guard let lhs = lhs?.count, let rhs = rhs?.count else {
return compareNil(lhs == nil, rhs == nil)
}

/// not using Date.compare as it does not take order into account nor have a parameter for the order
if order == .forward {
return lhs > rhs ? .orderedDescending : .orderedAscending
} else {
return lhs > rhs ? .orderedAscending : .orderedDescending
}
}

}

Of course, there is NO way we can cover ALL Any. But there are a specific set of types that user will actually be interested in sort on.

Also, you might want to say what about Float, CGFloat, UInt, Int8, or whatever. Those are common types.

Yes, they are common, but also note that the types here are either (1) from a loaded CSV, or (2) columns we insert programatically.

If you are having the user choosing a column type, you can simply use Double instead of using Float.

When the data frame is from a loaded CSV, I cannot get any column types out other than String, Int, and Double. Even though the nominal CSVType contains data, date, float, and etc. (I think it is just casting internally to that 3 types, unless we specify it explicitly while reading using types: [String : CSVType] = [:])

Now that we get our SortComparator, heading back to creating a TableColumn.

I have mentioned that there are already a number of subscripts implemented for DataFrame.Row, so which one should we use? What should the keyPath be?

We COULD subscript with either position (column index) or column name like following.

TableColumn(
columnName,
value: \.row[colIndex],
comparator: AnyComparator(
order: Self.order
)
)

However, both approaches will crash our App SPECIFIC point. Because Apple does NOT check whether the index is out of range or whether the column name exists before trying to access the value! (Why would anyone write that kind of code and release it?!)

What is the crashing point?

If we are subscripting using column index, crash when inserting/deleting columns.

If we are using column name, crash when rename a column.

So what’s the answer? A custom subscript! A safe one!

extension DataFrame.Row {
/// A custom subscript for keyPath usage.
/// The built in one does not check whether if the index is out of bound
/// which causes crash when inserting / deleting columns
public subscript(position: Int, safe: Bool = true) -> Any? {
if position < self.count {
return self[position]
} else {
return nil
}
}
}

It is always safe, regardless of what the caller says. That parameter is just there for the overloading-purpose.

And use it instead.

TableColumn(
columnName,
value: \.row[colIndex, true],
comparator: AnyComparator(
order: Self.order
)
)

Here is how our Table become.

@State private var sortOrder: [KeyPathComparator<IdentifiableRow>] = [
KeyPathComparator(\IdentifiableRow.id, order: Self.order)
]

private var rows: [IdentifiableRow] {
Array(dataFrame.rows).map(IdentifiableRow.init)
}

private var sortedRows: [IdentifiableRow] {
return rows.sorted(using: sortOrder)
}

Table(
sortedRows,
sortOrder: $sortOrder,
columns: {
self.columnContent
}
)

@TableColumnBuilder<IdentifiableRow, KeyPathComparator<IdentifiableRow>>
private var columnContent:
some TableColumnContent<
IdentifiableRow, KeyPathComparator<IdentifiableRow>
>
{
TableColumn(Text(" ").font(.headline), value: \.index) { row in
if row.index != IdentifiableRow.headerRowIndex {
Text("\(row.index)")
}
}
.width(36)

TableColumnForEach(columns) { column in
let columnName = column.name
let colId = column.id
let colIndex = column.index
let column = column.col

TableColumn(
Text(columnName).font(.headline),
/// Use the custom subscript for keyPath.
/// The built in one using the position (column index) does not check whether if the index is out of bound
/// which causes crash when inserting / deleting columns.
/// Also, we cannot use `columnName` here for subscript neither as that will cause crash when renaming a column.
value: \.row[colIndex, true],
comparator: AnyComparator(
order: Self.order
)
) { row in
let row = row.row
let value = row[column.name]
if let value {
Text("\(String(describing: value))")
} else {
Text("nil")
}
}
}
}

Now I don’t know about you, but what if that DataFrame just loaded some CSV of types other than String, Int or Double. I cannot reproduce it right now but there is NOTHING documented on it cannot.

I mean code-wise, nothing bad will happen.

However, UI-wise, we will end up having some columns that look like they are sortable, but are actually not, because we always return .orderedSame for unhandled types.

So, can we create the TableColumn based on whether the type is sortable or not?

// WON'T WORK!
func isComparable(_ type: Any.Type = Any.self) -> Bool {
return type is Int.Type || type is String.Type || type is Double.Type
}

if isComparable(column.type) {
TableColumn(
columnName,
value: \.row[columnName],
comparator: AnyComparator()
) { row in
let row = row.row
let value = row[column.name]
if let value {
Text("\(String(describing: value))")
} else {
Text("nil")
}
}
} else {
TableColumn(
columnName,
) { row in
let row = row.row
let value = row[column.name]
if let value {
Text("Int: \(String(describing: value))")
} else {
Text("nil")
}
}
}

I have already added in the comment in my code but this won’t work. Because the TableColumn is not the same as TableColumn!

Press enter or click to view image in full size

What if we chunk our columns to be an array of array of IdentifiableColumn , grouped by whether they are comparable or not, and then create a TableColumnForEach for each chunk?

Nope, this is basically the same as that ForEach(columns) above that we have already rejected (or actually rejected by SwiftUI).

So! I guess the best we can do is to, instead of returning orderedSame, maybe falling back to string compare…

guard let lhs, let rhs else {
return compareNil(lhs == nil, rhs == nil)
}

/// Fall back to string
return compareString(String(describing: lhs), String(describing: rhs))

Reorder Rows

There is no built-in Table API for it so we will just be adding some draggable and DropDestination ourselves + a custom Transferable.

struct TransferableRow: Transferable {
let rowIndex: String
static var transferRepresentation: some TransferRepresentation {
ProxyRepresentation(exporting: \.rowIndex)
}
}

@TableRowBuilder<IdentifiableRow>
private var rowContent: some TableRowContent<IdentifiableRow> {
ForEach(rows) { row in
TableRow(row)
.draggable(TransferableRow(rowIndex: String(row.index)))
/// since we are using proxy representable, the type here has to be string instead of TransferableRow
.dropDestination(for: String.self) {
movedRows in
let movedIndices: [Int] = movedRows.compactMap {
Int($0)
}.sorted().reversed()

let destinationIndex = row.index

let toMove = self.rows.filter({
movedIndices.contains($0.index)
})

movedIndices.forEach {
dataFrame.removeRow(at: $0)
}

toMove.forEach {
dataFrame.insert(
row: $0.row,
at: destinationIndex
)
}
}
}
}

If you need a little more detail, feel free to refer to my previous article Drag and Drop List Across Pages With Custom Transferable.

Now, remember that above, I have the id of my IdentifiableRow being a combination of index and row description?

If we are ONLY using index, when dragging and dropping, even though the position change is successful, it WON’T be reflected UI-wise! Unless you force a full reload of the table.

Why? Honestly speaking, I am not sure.

You might want to say this is because I was using index above instead of row.index, but I actually tried both, and either way, it results in the same thing, not updated.

Maybe it is because we are basically inserting the same row as the removed one, so it must have the same index?

No. The index changed. I logged it.

Reorder Columns

This part COULD be simple. If all you need is to reorder for display purposes.

  1. Define a @State or @SceneStorage property for TableColumnCustomization
  2. Pass the binding to the columnCustomization parameter of the Table.
  3. Optionally define explicit customizationId if you need to persist the state, for example, across app version updates.
@State private var columnCustomization = TableColumnCustomization<
IdentifiableRow
>()

Table(
of: IdentifiableRow.self,
selection: $rowSelection,
sortOrder: $sortOrder,
columnCustomization: $columnCustomization,
columns: {
self.columnContent
},
rows: {
self.rowContent
}
)

This will not only allow users to drag column headers to reorder the columns, but also right-click to customize column order and visibility.

Now, recall what I have said 1 second ago. This part COULD be simple. If you only want to reorder for display purpose, skip the rest and go to the next point. However, I want to be able to save the data frame with the reordered columns.

APPLE! I cannot remember how many times I have been screaming at you today! But why do you think it is a good idea to ONLY give us access to the visibility of each column but NOT their order!

Yes. That TableColumnCustomization allows us to get AND set visibility,

columnCustomization[visibility: "<customization_id>"] = .hidden

and even bind on those!

$columnCustomization[visibility: "<customization_id>"]

But for order, all we get is a resetOrder() to reset the column order back to the default, preserving the customized visibility and size.

We have NO idea (public API wise) on what columns are reordered and what their current orders are.

Yes, public API wise.

If we print out the columnCustomization, it will look something like following.

TableColumnCustomization<IdentifiableRow>(
perColumnState: [:],
columnOrder: Optional([[explicit: index], [explicit: date_false], [explicit: hlpi_name_false], [explicit: hlpi_false], [explicit: tot_hhs_false], [explicit: own_false], [explicit: own_wm_false], [explicit: own_prop_false], [explicit: own_wm_prop_false], [explicit: prop_hhs_false], [explicit: age_false], [explicit: size_false], [explicit: income_false], [explicit: expenditure_false], [explicit: eqv_income_false], [explicit: eqv_exp_false]])
)

If you haven’t assigned an explicit customizationID to your column, it will show up as something like [transient: TransientHint(name: "...", id: "DA84CDAB-60B5–44D2–9580–8CEB4E487E11")] under columnOrder instead.

We can see(print) the information, which means we can extract those, using Mirror!

extension TableColumnCustomization {
/// Returning: A list of `customizationId` if `explicit` ,`name` if `transient` (columns that does not have `customizationID(_:) `attached)
var columnOrder: [String] {
let mirror = Mirror(reflecting: self)
var currentOrder: [String] = []

guard
let columnOrder = mirror.children.first(where: {
$0.label == "columnOrder"
})
else {
return []
}

guard let columnOrder = columnOrder.value as? [Any] else {
return []
}

for col in columnOrder {
// TableColumnCustomizationID -> `base`
guard
let base = Mirror(reflecting: col).children.first(where: {
$0.label == "base"
})?.value,
// enum `base` -> single child labeled with the case name
let enumCase = Mirror(reflecting: base).children.first
else { continue }

switch enumCase.label {
/// columns with `customizationID(_:)`
/// ex: [explicit: date_1_false]
case "explicit":
if let id = enumCase.value as? String {
currentOrder.append(id)
}
/// columns without `customizationID(_:)`
/// ex: [transient: TransientHint(name: " ", id: "DA84CDAB-60B5-44D2-9580-8CEB4E487E11")]
case "transient":
// TransientHint struct -> mirror again for `name` / `id`
let hint = Mirror(reflecting: enumCase.value)
let name =
hint.children.first(where: { $0.label == "name" })?.value
as? String
if let name {
currentOrder.append(name)
}
default:
continue
}
}

return currentOrder
}
}

Yes, this is not the most efficient way, so instead of actually updating the underlying dataFrame on every column order change. I just create a new DataFrame with that order when trying to save it.

func columnNameFromCustomizationID(_ customizationId: String) -> String? {
var splits = customizationId.split(separator: "_")
splits.removeLast()
return splits.joined(separator: "_")
}

func reorderedColumns() -> DataFrame {
let currentFrame = DataFrame(columns: self.dataFrame.columns)
let columnCustomizationIDs = self.columnCustomization.columnOrder
guard !columnCustomizationIDs.isEmpty else {
return currentFrame
}
let reorderedColumns: [IdentifiableColumn] =
columnCustomizationIDs.map(
columnNameFromCustomizationID
).compactMap({ $0 }).map({ name in
self.columns.first(where: { $0.name == name })
}).compactMap { $0 }

guard reorderedColumns.count == self.columns.count else {
return currentFrame
}

return DataFrame(columns: reorderedColumns.map(\.col))
}

If you are wondering why am I returning a new frame instead of updating the current one, because setting self.dataFrame (the one being displayed by the table) with column customization will result in unwanted UI (changed columns will change to the new position, swap back to the original position, then change to the new position). Nothing is deadly wrong (in terms of app crashing or wrong logic). It is just I don’t like it. I don’t want it.

Add / Delete Rows

Nothing special that needs to be noted here. A context menu + the logic above.

ForEach(rows) { row in
TableRow(row)
.contextMenu {
rowEditMenu(row.index)
}
}


@ContentBuilder
private func rowEditMenu(_ index: Int) -> some View {
Button("Add Row Above") {
self.insertRow(at: index)
}
Button("Add Row Below") {
self.insertRow(at: index + 1)
}
Divider()
Button("Duplicate Row") {
duplicateRow(at: index)
}
Divider()
Button("Delete Row") {
deleteRow(at: index)
}
}


private func deleteRow(at index: Int) {
guard index < self.dataFrame.rows.count else {
return
}
self.dataFrame.removeRow(at: index)
}

private func insertRow(at index: Int) {
var rowFrame = DataFrame(self.dataFrame.prefix(0))
rowFrame.appendEmptyRow()
self.dataFrame.insert(row: rowFrame.rows[0], at: index)
}

private func duplicateRow(at index: Int) {
guard index < self.dataFrame.rows.count else {
return
}
let row = self.dataFrame.rows[index]
self.dataFrame.insert(row: row, at: index + 1)
}

Add / Delete Columns

The logic is exactly above. However, we will have a little UI twist.

We need a custom header so that we can add some action.

PS: I still want the built-in reorder above so I will have a separate edit-column mode…

PPS: If you asked stupid AIs, they will say you VStack an HStack on top of the table to create custom headers and blah! Seriously, who the f*** in this world thinks it should work? With variable column width, bro!

Anyway!

Recall that my IdentifiableRow has a stored index instead of a calculated one? Because I want to assign a special value for headers.

nonisolated struct IdentifiableRow: Identifiable, Equatable {
static let headerRowIndex = -1

/// create a row for column names (As header row)
init(colNames: [String]) {
var cols: [Column<String>] = []
colNames.forEach { col in
cols.append(Column(name: col, contents: [col]))
}
let name = DataFrame(columns: cols.map({ $0.eraseToAnyColumn() }))
let headerRow = name.rows[0]
self.index = Self.headerRowIndex
self.row = headerRow
}
}

We can then add it in as a row.

if editColumn {
TableRow(headerRow)
ForEach(rows) { row in
TableRow(row)
}

} else {
ForEach(rows) { row in
TableRow(row)
}
}

Make sure the built-in header is not shown.

.tableColumnHeaders(editColumn ? .hidden : .visible)

And add custom actions / context menus we like!

let isHeader =
rowIndex == IdentifiableRow.headerRowIndex

if isHeader {
Text(String(describing: value ?? ""))
.contextMenu {
colEditMenu(colIndex, name: columnName)
}
} else {
Text(String(describing: value ?? ""))
}

@ContentBuilder
private func colEditMenu(_ index: Int, name: String) -> some View {
Button("Add Column Before") {
self.showColumnTypeSelection = .init(targetIndex: index)
}
Button("Add Column After") {
self.showColumnTypeSelection = .init(targetIndex: index + 1)
}
Divider()
Button("Duplicate Column") {
self.duplicateCol(at: index)
}
Divider()
Button("Delete Column") {
deleteCol(name)
}
}

enum ValueType: String, Identifiable, Hashable, Equatable, CaseIterable {
case string
case int
case double
var id: String { rawValue }
}

private func duplicateCol(at index: Int) {
guard index < self.dataFrame.columns.count else {
return
}
var column = self.dataFrame.columns[index]
column.name = resolveColumnName(column.name)
self.dataFrame.insert(column: column, at: index + 1)
}

private func deleteCol(_ name: String) {
guard self.dataFrame.containsColumn(name) else { return }
self.dataFrame.removeColumn(name)
}

private func insertCol(at index: Int, of type: ValueType) {
let columnName = resolveColumnName("new")

/// we will use the rowCount for capacity but it actually doesn't matter.
/// Even if we just set capacity to 0 or something larger than rowCount,
/// no additional rows will be created, and the new column will still be all filled with nil.
let rowCount = dataFrame.rows.count
let column: AnyColumn
switch type {
case .string:
column = Column<String>(name: columnName, capacity: rowCount)
.eraseToAnyColumn()
case .int:
column = Column<Int>(name: columnName, capacity: rowCount)
.eraseToAnyColumn()
case .double:
column = Column<Double>(name: columnName, capacity: rowCount)
.eraseToAnyColumn()
}

self.dataFrame.insert(column: column, at: index)
}

Edit Cell

Last bit of the day, inline editing.

I have actually written a full article for this like forever ago. Inline Editable Table. The logic/idea is exactly the same. The only extra bit here is to handle different value types!

struct EditableContent: View {

init(
text: String?,
type: any Any.Type,
update: @escaping (Any?) -> Void,
tableSelection: Binding<IdentifiableRow.ID?>,
rowId: IdentifiableRow.ID,
) {
self.valueType = ValueType(type)
self._tableSelection = tableSelection
self.previousValue = text ?? ""
self.update = update
self.rowId = rowId
if let text {
self._text = State(initialValue: text)
self._int = State(initialValue: Int(text) ?? 0)
self._double = State(initialValue: Double(text) ?? 0.0)
} else {
self._text = State(initialValue: "")
self._int = State(initialValue: nil)
self._double = State(initialValue: nil)
}
}

var update: (_ newValue: Any?) -> Void
@Binding var tableSelection: IdentifiableRow.ID?
var rowId: IdentifiableRow.ID

private var previousValue: String?

private var valueType: ValueType?
@State private var text: String
@State private var int: Int?
@State private var double: Double?

@State private var textSelection: TextSelection?
@FocusState private var focused

var body: some View {
Text(text)
.frame(maxWidth: .infinity)
.foregroundStyle(.clear)
.overlay(content: {
if let valueType {
self.textField(valueType)
}
})
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
/// required instead of using `onChange(of: self.tableSelection) { self.focused = false }` to reset
/// so that when focused, and user tapped another row, the row can indeed be highlighted (otherwise, it will be selected, but grayed, instead of taking the highlighted link color.)
.simultaneousGesture(
TapGesture()
.onEnded({
focused = false
self.tableSelection = rowId
}),
isEnabled: !focused
)
.onChange(of: focused) {
guard !focused else { return }
self.runUpdate()
}

}

@ContentBuilder
private func textField(_ type: ValueType) -> some View {
Group {
switch type {
case .double:
TextField("", value: $double, format: .number)
case .int:
TextField("", value: $int, format: .number)
case .string:
TextField("", text: $text, selection: $textSelection)
}
}
.focused($focused)
.onExitCommand {
self.undoChanges()
focused = false
}
.onSubmit {
focused = false
}
.frame(maxWidth: .infinity)
.overlay {
Color.clear
.frame(
minWidth: 60,
maxWidth: .infinity,
maxHeight: .infinity
)
.contentShape(Rectangle())
.allowsHitTesting(!focused)
.gesture(
TapGesture()
.onEnded({
guard tableSelection == rowId else {
return
}
self.focused = true
}),
isEnabled: tableSelection == rowId
&& !self.focused
)

}
}

private func runUpdate() {
guard let valueType else { return }
switch valueType {
case .string:
self.update(text)
case .int:
self.update(int)
case .double:
self.update(double)
}
}

private func undoChanges() {
guard let valueType else { return }
switch valueType {
case .string:
self.text = previousValue ?? ""
case .int:
if let previousValue {
self.int = Int(previousValue)
} else {
self.int = nil
}
case .double:
if let previousValue {
self.double = Double(previousValue)
} else {
self.double = nil
}
}
}
}

We can then use it within our table column!

EditableContent(
text: String(describing: value ?? ""),
type: column.wrappedElementType,
update: {
self.updateCell(
$0,
columnIndex: colIndex,
columnType: column.wrappedElementType,
rowIndex: rowIndex
)
},
tableSelection: $rowSelection,
rowId: rowId
)

Import and Export

For import, we just use a file importer.

.fileImporter(
isPresented: $showImporter,
allowedContentTypes: [.commaSeparatedText]
) { result in
switch result {
case .success(let url):
self.loadCSV(url)
case .failure(let failure):
self.error = failure
}
}

You can also allow JSON as DataFrame can be loaded from those as well.

For export, there is a bit more.

First of all, we need a custom Transferable that can

  1. Save CSV on request
  2. Return a FileRepresentation
struct CSVTransferable: Transferable {
var writeCSV: () throws -> URL

static var transferRepresentation: some TransferRepresentation {
FileRepresentation(
exportedContentType: .commaSeparatedText,
exporting: { csv in
let url = try csv.writeCSV()
let file = SentTransferredFile(
url,
allowAccessingOriginalFile: false
)
return file
}
)
.suggestedFileName("exported.csv")
}
}

NOTE!

The suggested File name HAS TO be set! (The actual value doesn’t matter much if we are setting the defaultFilename on our exporter later.)

Otherwise, the app will crash when the user clicks on the save button

/// *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[NSFileWrapper setPreferredFilename:] *** preferredFilename cannot be empty.'
/// terminating due to uncaught exception of type NSException

We can then use it with the fileExporter.

.fileExporter(
isPresented: $showExporter,
item: CSVTransferable(writeCSV: {
return try self.saveCSV()
}),
defaultFilename: "exported.csv",
onCompletion: { result in print(result) }
)

private func saveCSV() throws -> URL {
let dataFrame = self.reorderedColumns()
let url = URL.temporaryDirectory.appending(
path: "temp.csv"
)
try dataFrame.writeCSV(to: url)
return url
}

Thank you for reading!

That’s it for this article! It is getting a lot longer than what I originally planned…Maybe because I screamed too much! But I feel great now!

Anyway, feel free to grab the full code from my GitHub!

Happy CSV-ing!