TabularData Framework: DataFrame as a List in SwiftUI

The new TabularData framework in iOS 15, MacOS 12 and watchOS 8 opens up opportunities for easier, more efficient ingestion of data (and for ML possibilities). However, it does not appear to be possible to directly use a DataFrame's rows for a List or ForEach in SwiftUI: the compiler gives an error that the rows do not conform to RandomAccessCollection Protocol. The documentation for Rows does not state compliance with such, even through there are methods which inherit from the protocol.

Using a separate iteration to extract each Row (as a DataFrame.Row) from the DataFrame into a new array works. I make this array identifiable by using the row.index as the id. However, if the DataFrame is large this adds considerably to storage and processing overheads.

Any thoughts on directly using the DataFrame's Rows in SwiftUI?

Replies

Ah, fixed it :). All I needed to do was add protocol compliance in an extension for DataFrame.Rows

extension DataFrame.Rows : RandomAccessCollection { }.

Then use the index property of rows as the id in the SwiftUI ForEach (or List) with yourDataFrame.rows as the source, handling the optionals as required.

Cheers, Michaela

  • Would you be able to give us a few lines of code to illustrate this? I am building an app that needs a lot of input from .csv files and I can get the DataFrame to load but am having trouble accessing the data, and you seem to be one of the few people who have actually used the TabularData framework... I would be very grateful as I have been wrestling with this for weeks.

  • OK, will do - sometime later today (I'm in Australia so just getting started on the day)

  • That's a great method..... I don't have to use .columns.enumerated()!

Add a Comment

Here's a working example for Mac OS (but should be the same for iOS except for the URL setup for the incoming csv file).

The csv test data are in 3 columns, with headers of "Name", "Position" and "Score" - so as to test data types of String, Integer and Double.

The Data Model (ViewModel)

import Foundation
import TabularData

class DataModel {
    static let shared = DataModel()

    @Published var dataTable: DataFrame?

    init() {
        getData()
    }

     func getData() {
        var url: URL?
        do {
             url = try FileManager.default.url(for: FileManager.SearchPathDirectory.downloadsDirectory, in:     FileManager.SearchPathDomainMask.userDomainMask, appropriateFor: nil, create: true)
        } catch{
              print("Failed to get Downsloads URL \(error)")
            return
        }

        let csvOptions = CSVReadingOptions(hasHeaderRow: true, ignoresEmptyLines: true, delimiter: ",")
        let fileURL = url?.appendingPathComponent("TestCSV.csv")
        
        do {
            dataTable = try DataFrame(contentsOfCSVFile: fileURL!,columns: nil, rows: nil, types: ["Name":CSVType.string,"Position":CSVType.integer,"Score":CSVType.double], options: csvOptions)
            } catch {
                print("Failed to get load datatable from CSV \(error)")
                return
        }
    }
}

extension DataFrame.Rows : RandomAccessCollection {
}

SwiftUI ContentView

import SwiftUI
import TabularData

struct ContentView: View {
    var model = DataModel.shared
    var body: some View {
        List(model.dataTable!.rows,id:\.index) { row in
            HStack{
                Text(row["Name"] as! String)
                Text(String(row["Position"] as! Int))
                Text(String(row["Score"] as! Double))
            }
        }
    }
}

With forced unwrap (as!) the app will crash if the processed column does not contain the correct type, so I tend to use a function that checks and returns a string - which then gets used in the View:


// in Data Model
func columnToString(_ column: Any) -> String {
        if let str = column as? String {
            return str
        }
        if let int = column as? Int {
            return String(int)
        }
        if let double = column as? Double {
            return String(double)
        }
        return ""
    }

// a row in ContentView
Text(model.columnToString(row["Position"] as Any))

I hope this helps. Regards, Michaela

  • I was in a hurry to get this sample done before visitors arrived, so it's not as robust as it could be. It would be better to initiate the dataTable in DataModel as @Published var dataTable = DataFrame(). With this initialisation, the reference to model.dataTable! in ContentView doesn't need to be (shouldn't be) force unwrapped i.e. just use model.dataFrame. Also, with a large input csv file it would be better to load as a background task (via DispatchQueue) and rely on the @Published var dataTable to populate the SwiftUI List.

  • Lovely, thanks a lot! I was having trouble with the unwrapping which threw a lot of errors in the view, will try using your suggestions.

Add a Comment