The power behind all computing is data. We collect data, process data, present data and most importantly store data. Without storage, the others are meaningless. In a live app, we can store our data in collection types such as dictionaries and arrays. But that only works when the app is working. When the app closes, the data disappears. We have several ways of storing that data for the time the app stops. For small amounts of data we have NSUserdefaults
. For larger sets of data this doesn’t work.
One of the oldest, and simplest ways of storing persistent data is a text file. This is a data file stored on disk or persistent memory of characters. Sometimes it is a single string, sometimes a series of strings. In this lesson, we’ll look at how to store data in a text file, and how to use a text file to store with the most oldest and a still often used methods: the comma separated value, or CSV file
Set Up The Project
Create a new project named TextFileCSVDemo Using Swift as a the language and a Universal device.
We’ll do this project in the simulator using a text view. This will allow for scrolling through largest data sets. To make it easy to use the simulator and not get messy with keyboards, we’ll tell the simulator to use the external keyboard and not display the internal keyboard.
Go to the storyboard and drag four buttons and a the text view on the storyboard. Roughly place them like this:
Title the buttons. Reset, Load,Save, and Report. Select all four buttons and click the stack view button on the lower right of the storyboard. The buttons will group into a stack view. Set the attribute for the stack view like this:
Click the auto layout pin button . Click off Constrain to Margins. Set the margins for the top to 0 point, the left to 0 points and the right to 0 points. Click on the height. Set to 50 points. Be sure to tab out of the text fields. Set the Update frames to Items of new constraints like this:
Add the four constraints. Select the text view. Set the attributes like this:
Click the auto layout pin button . Click off Constrain to margins. Set the margins for the top to 10 points, the left to 0 points, the right to 0 points and the bottom for 20 points. Set the Update frames to Items of new constraints like this:
Add the constraints. This should give you a layout like this:
Open the assistant editor. Close whatever panes you need to give you some space. Control drag the text view to the code and make an outlet named textView
. Control drag from the Report button to make an action called reportData
. Control drag from the Reset button to make an action called resetData
. Control drag from the Read button to make an action called readData
. Control drag from the Write button to make an action called writeData
. You should now have the following outlets and actions in your code.
@IBOutlet weak var textView: UITextView! @IBAction func reportData(sender: UIButton) { } @IBAction func resetData(sender: UIButton) { } @IBAction func readData(sender: UIButton) { } @IBAction func writeData(sender: UIButton) { }
Reading a Data File
Let’s learn how to read data first. We’ll need a file to read.
Close the assistant editor. Make a new file by pressing Command-N. Select an Other file that is blank. Call this file data.txt, saving the file in the TextFileCSVDemo group.
Add the following to the file:
Title,data1,data2 Hello Pizza!,24,42 Ice Cream,16,32
This data has no meaning whatsoever, but we’ll use it as our first set of test data. Go to the viewController
class.
The First Rule of Data Files
Add the following function
func readDataFromFile(file:String) -> String!{ }
Our function will return an optional value of String. The first rule of reading external files is to never trust them. They may be wrong, they may not exist. If anything goes wrong, we’ll return nil
so the code calling this function knows to act accordingly.
if…let and guard…let
The never trust a data file rule means we need to make a short detour into error handling. Our first stop is detecting a nil
value. The basic way is this
var a:String? = nil if a != nil { let b = a! print b } else { print ("That was nil") }
However this has a few very subtle problems. Without going into too much detail, the variable a
may be too weak to withstand ARC in certain situations, (see here for the classic example) and gets cleaned up before it can pass its value to b
.
A better approach is to use optional chaining which assigns the value of b
. This tends to make a stronger value, one that keeps around in those same situations. There are two version of this assignment. One uses the if..let
clause
if let b = a { print(b) } else { print ("That was nil") }
if A is nil
the if
clause is false and we do the else clause. If true, we execute the statements with b
The Master-Detail template uses this method to avoid nil
popping up unexpectedly.
There is a slight problem with this. The identifier b only exists within the scope of the if
clause. When you have several values to unwrap this can become a tangle of code. The latest way to unwrap a value is guard
. You use it like this:
guard let b = a else{ print ("That was nil") break } print(b)
With guard
, we declare the constant like any constant, open to the scope it is declared in. However our else
clause will break execution so will need a break
, return
or continue
For the Big Errors do-catch
We will encounter big errors, ones that if left alone we can’t recover from. Not finding a file would be an example. For those types of errors, we have do...try...catch
. Some functions have built-in error systems, using the keyword throws
and throw
to indicate errors. When we encounter a function that uses throws
, we need to use the do-catch
clause. Suppose we have a function readMe
which reads a file
func readme() throws -> String{ // stuff in here including a error type }
When we use this function, it needs a syntax like this
do { let myData = try readme() } catch { //handle my error } //now use data
Since throws
associates itself with an error type, you can catch specific errors and handle them differently. A file not found could be handled differently than a loss of connection for example. If you don’t care what the error is, you can also use try?
as a quicker method. The try?
casts the result to an optional where nil
means there was an error, and you get a value otherwise.
let myData = try? readme() if goodData == nil { //error handling here }
Getting a File Path from the Bundle
With that background, we are ready to look at our demo code again.
func readDataFromFile(file:String)-> String!{ guard let filepath = NSBundle.mainBundle().pathForResource(file, ofType: "txt") else { return nil } }
We use guard
on the method pathForResource(File:type:)
This method finds a file with a specific extension and returns its full path. It returns nil
if the file is not found.
We need to explain bundles a bit here. Bundles are groupings of files the system keeps track of. There is always one bundle mainBundle
where your app resides, and many of its resources such as the images in Assets.xcassets. You can make more bundles if you wish, but for our purposes it’s best to keep everything in the main bundle. The code NSBundle.mainBundle()
gets the main bundle, and then we use the pathForResource
method to search for our files.
With a file name, we can read the data in that file.
func readDataFromFile(file:String)-> String!{ guard let filepath = NSBundle.mainBundle().pathForResource(file, ofType: "txt") else { return nil } do { let contents = try String(contentsOfFile: filepath, usedEncoding: nil) return contents } catch { print("File Read Error for file \(filePath)") return nil } }
The String
initializer can read a file into a text string. However it does throw errors, so we use do-catch
to handle the errors. When I get an error, I print to the console an error message, and return nil
. Otherwise, we return the contents of the file as a string.
Add this function to the readData
action.
@IBAction func readData(sender: UIButton) { textView.text = readDataFromFile("data") }
Since the text
property is optional, we don’t need to do a lot here.
Running and Configuring the Simulator
Build and run. You get a screen like this:
Click on the text and the keyboard will most likely show up.
Rotate the device with Command-Left Arrow. You’ll find the keyboard obscures way too much of our text.
In the menu for the Simulator , click Hardware>Keyboard. Make sure Uses the Same Layout as OS X and Connect Hardware Keyboard are checked.
You’ll notice the third choice here is Toggle Software Keyboard. You’ll also notice the shortcut is Command-K. Click it and the keyboard disappears. (note: I changed the background to #EEEEEE for visibility in the post, yours should be white)
For these lessons formatting for the keyboard is a pain, but we will be needing to type into the text box. We’ve set up the simulator to use our hardware keyboard and not the software keyboard. You can always get the keyboard back with a Command-K. You can also hide it with the keyboard button, if it is visible.
Now that we’ve set up our simulator, tap the Read button. We read the text file and get our data file.
Writing Data
Writing data is slightly more complicated than reading data. The complexity is in the path name. Instead of just checking it exists and returning nil
if it doesn’t, we need to generate the path if it does not exist.
We have the same problem with our data. if textView.text
is nil
, we cant save anything.
Add this code to ViewController
func writeDataToFile(file:String)-> Bool{ // check our data exists guard let data = textView.text else {return false} //get the file path for the file in the bundle // if it doesn't exist, make it in the bundle var fileName = file + ".txt" if let filePath = NSBundle.mainBundle().pathForResource(file, ofType: "txt"){ fileName = filePath } else { fileName = NSBundle.mainBundle().bundlePath + fileName } }
Just for a variation, I returned a Bool
from this function. I could have used an optional or a string or thrown an error here. This is one of those choices you as a programmer need to make, based on what you know about the rest of the code using this function. If this is going in code already existing, it needs to match the error handling of that code. While you’ll hear “there’s this right way” or “that right way” to do something, that’s pretty much bunk. Here’s rule two of Data work: Whatever you do make sure it fits the context of the other APIs and code you are using. This is also why I’m not a big fan of 3rd party libraries for data work. You’ll invariably need so much customization you might as well write it yourself for your specific case. Since this is a tutorial I’m arbitrary in my choices, but do some homework before you start coding a real app.
Line 3 checks my data exists. If I don’t have any, I return false
and leave the function. Line 6 makes a filename by appending an extension .txt
to my file name. I then get the filePath
for that file like I did in the readDataFromFile
method. If there I assign it to fileName
. If not I get the path of the bundle and append the filename to it.
We now have a file path for our file and we know we have data. Next we write the file with a string function writeTofile
. Change the method to this:
func writeDataToFile(file:String)-> Bool{ // check our data exists guard let data = textView.text else {return false} print(data) //get the file path for the file in the bundle // if it doesnt exisit, make it in the bundle var fileName = file + ".txt" if let filePath = NSBundle.mainBundle().pathForResource(file, ofType: "txt"){ fileName = filePath } else { fileName = NSBundle.mainBundle().bundlePath + fileName } //write the file, return true if it works, false otherwise. do{ try data.writeToFile(fileName, atomically: true, encoding: NSUTF8StringEncoding ) return true } catch{ return false } }
writeToFile
throws errors, so we must use do-catch
. We return false
if a problem occurs and true
if everything is successful.
This method has three parameters we should talk about. The fileName
we’ve already covered, and we know how to get the path. atomically
writes to a temporary copy of the data then replaces the original with the new copy. This is a safety measure. Writing data to storage can take time, and if something were to happen to interrupt that process, you don’t corrupt your data that way. In a big file it can mean a huge use of resources, and might be false
if storage is an issue. I tend to go for the safe instead of sorry school here, and keep it true
unless something tells me I’m going to have trouble. In files that big, I might look at other alternatives for saving files.
The next parameter, encoding
is the code set for the string characters. The simple answer is that different data uses different ways to code strings. For the longer answer I suggest this explanation from Joel On Software. The point here is another application of rule two: make sure you know the context this code needs to be. I used a pretty standard one here in UTF8, but that might not always be the case.
Change the action writeData
to this:
@IBAction func writeData(sender: UIButton) { if writeDataToFile("data") { print("data written") } else { print("data not written") } }
Data writes are invisible to the user, and often the developer. It’s important to give feedback. I used print
to send the message to the console. You might use an alert or a simple icon for success and an alert for failure, but some feedback is necessary.
I’ll need a quick way of clearing the text on textView
to prove I actually saved and loaded something. Change the resetData
action to this:
@IBAction func resetData(sender: UIButton) { textView.text = "Nope, no Pizza here" }
Build and run. We get our Lorem Ipsum text.
Tap the write button, you’ll see the console will tell you
Data Written
Tap the Reset button.
Tap the Read button. Our text loads
Introducing CSV
While that’s the basics of text files, we don’t often have just text in a text file. Instead, we use a variety of arrangements to store other types of data than text. One of the oldest of these methods is the CSV, or comma separated value file. We’ve already made one In Xcode. Look at the data.txt file.
Title,data1,data2 Hello Pizza!,24,42 Ice Cream,16,32
This is a CSV file in all but extension, .CSV
. As the name implies, we separated out three values per line by a comma. The CSV file represents this table:
Title | data1 | data2 |
---|---|---|
Hello Pizza! | 24 | 42 |
Ice Cream | 16 | 32 |
There is no real standard for CSV that everyone follows. You’ll find several variations. There are a few rules that tend to be true. A row of data usually ends with a newline character \n
. The elements, which I’ll often call fields of the row have some character to delimit them. The most popular is the comma, but tab characters and the pipe character | can get used. Once again, it’s important to know what you are dealing with. Notice the first line of our CSV file has a description of the column of data. That is helpful, and you’ll find it often in data sets, but it isn’t always there. When writing my own, I tend to include it. Sometimes its the only documentation about this file.
A string file is a beautiful thing, but a workable data structure is better. There’s many different data structures we can use. Usually the data will be some sort of array. You can have individual rows associated with structs, classes, or dictionaries. In some cases it makes sense to use an array for each field. I’ll use dictionaries in this example.
In the declaration part of the code add this:
var data:[[String:String]] = [] var columnTitles:[String] = []
For our first iteration, I’ll leave everything a string. I made a second array columnTitles
to keep track of the keys we’ll assign in the dictionary.
Cleaning the Rows
Before I do anything else, there some preventative cleaning I have to do. Suppose our data looks like this:
Title,data1,data2 Hello Pizza!,24,42 Ice Cream,16,32
To the system it appears like this:
Title,data1,data2\n Hello Pizza!,24,42\n \n Ice Cream,16,32\n
The \n is the newline character. There’s also a return character \r
that sometimes shows up. We’d like to get rid of the blank rows and change all the \r
to \n
. This will make reading and converting our data file a lot easier.
Add the following function to ViewController
:
func cleanRows(file:String)->String{ var cleanFile = file cleanFile = cleanFile.stringByReplacingOccurrencesOfString("\r", withString: "\n") cleanFile = cleanFile.stringByReplacingOccurrencesOfString("\n\n", withString: "\n") return cleanFile }
There is wonderfully handy string method stringByReplacingOccurrencesOfString:withString:
that lets us replace the \r
in line 3. Line 4 remove any double returns \n\n
in our file with \n
. This is one way to get rid of blank lines. We’ll come to the other one shortly.
We’ll start our CSV conversion to a data structure by doing this cleaning
func convertCSV(file:String){ let rows = cleanRows(file) }
Why do we want only /n
? So we can use it as a separation character with another handy string method componentsSeparatedByString
. Change the code to this:
func convertCSV(file:String){ let rows = cleanRows(file).componentsSeparatedByString("\n") }
The function componentsSeparatedByString
returns an array of strings broken up for a single string. It uses the parameter as the string that breaks up the other strings, in this case a newline \n
. We assign that to the constant rows. We’ll check that the file has real data, and if not print an error message. If we do have real data, we’ll take the first row of titles and make an array to use for keys in our dictionary. In the next lesson, we’ll be building on this for reading the fields in a row. For now add the following function to break the row strings into string arrays.
func getStringFieldsForRow(row:String, delimiter:String)-> [String]{ return row.componentsSeparatedByString(delimiter) }
We’ll use two nested loops to populate the data we’ve now broken apart. Change the method to this:
func convertCSV(file:String){ let rows = cleanRows(file).componentsSeparatedByString("\n") if rows.count > 0 { data = [] columnTitles = getStringFieldsForRow(rows.first!,delimiter:",") for row in rows{ let fields = getStringFieldsForRow(row,delimiter: ",") if fields.count != columnTitles.count {continue} var dataRow = [String:String]() for (index,field) in fields.enumerate(){ let fieldName = columnTitles[index] dataRow[fieldName] = field } data += [dataRow] } } else { print("No data in file") } }
Line 3 checks if the input string has any rows. If we don’t, we send a message that there is no rows and exit. If we do, we clear the property data
in line 4. Line 5 sets the property columnTitles
with the contents of the first row. We’ll use the field names in this row as the keys of our dictionary entries. Line 6 sets is a loop through rows
. In that loop, we break a row into an array of fields. We check if we have the right number of fields. If not, we skip this row. You could do more than that, and in our next lesson we’ll look at a variation of CSV that will handle this in a more robust manner. We create a dictionary to place the keys and values in, then run another loop over the fields. You may not be familiar with this loop structure using enumerate()
. Instead of a single value, we have an index of the value and is value stored in a tuple This loop uses the index to find a key for our dictionary, and add the value to the dictionary in line 14 . When we finish the second loop, we add the dictionary to the array data,finishing a row, then looping back for the next row.
We’ll need a way to output our data, to prove we actually got it. Add the following method.
func printData(){ convertCSV(textView.text) var tableString = "" var rowString = "" print("data: \(data)") for row in data{ rowString = "" for fieldName in columnTitles{ guard let field = row[fieldName] else{ print("field not found: \(fieldName)") continue } rowString += String(format:"%@ ",field) } tableString += rowString + "\n" } textView.text = tableString }
This does pretty much the same as the conversion method, except in reverse. It takes the data and makes it a text file again, using two loops. It takes whatever is in the text view and makes it a CSV file, then reports the results.
Change reportData
to this:
@IBAction func reportData(sender: UIButton) { printData() }
Build and run. Tap Read to read in our data.
Tap Report and our badly formatted report shows.
There’s a few more tricks we can do with CSV to get data in. In our next lesson we’ll learn some more about CSV. We’ll look at dealing with strings that have commas, and using data types in our data, and formatting that chart a lot better.
The Whole Code
// // ViewController.swift // TextFileCSVDemo // // Created by Steven Lipton on 5/17/16. // Copyright © 2016 MakeAppPie.Com. All rights reserved. // import UIKit class ViewController: UIViewController { //MARK: Outlets and properties var data:[[String:String]] = [] var columnTitles:[String] = [] @IBOutlet weak var textView: UITextView! @IBAction func reportData(sender: UIButton) { printData() } @IBAction func resetData(sender: UIButton) { textView.text = "Nope, no Pizza here" } @IBAction func readData(sender: UIButton) { textView.text = readDataFromFile("data") } @IBAction func writeData(sender: UIButton) { if writeDataToFile("data") { print("data written") } else { print("data not written") } } //MARK: - Instance methods //MARK: CSV Functions func cleanRows(file:String)->String{ //use a uniform \n for end of lines. var cleanFile = file cleanFile = cleanFile.stringByReplacingOccurrencesOfString("\r", withString: "\n") cleanFile = cleanFile.stringByReplacingOccurrencesOfString("\n\n", withString: "\n") return cleanFile } func getStringFieldsForRow(row:String, delimiter:String)-> [String]{ return row.componentsSeparatedByString(delimiter) } func convertCSV(file:String){ let rows = cleanRows(file).componentsSeparatedByString("\n") if rows.count > 0 { data = [] columnTitles = getStringFieldsForRow(rows.first!,delimiter:",") for row in rows{ let fields = getStringFieldsForRow(row,delimiter: ",") if fields.count != columnTitles.count {continue} var dataRow = [String:String]() for (index,field) in fields.enumerate(){ dataRow[columnTitles[index]] = field } data += [dataRow] } } else { print("No data in file") } } func printData(){ convertCSV(textView.text) var tableString = "" var rowString = "" print("data: \(data)") for row in data{ rowString = "" for fieldName in columnTitles{ guard let field = row[fieldName] else{ print("field not found: \(fieldName)") continue } rowString += field + "\t" } tableString += rowString + "\n" } textView.text = tableString } //MARK: Data reading and writing functions func writeDataToFile(file:String)-> Bool{ // check our data exists guard let data = textView.text else {return false} print(data) //get the file path for the file in the bundle // if it doesnt exist, make it in the bundle var fileName = file + ".txt" if let filePath = NSBundle.mainBundle().pathForResource(file, ofType: "txt"){ fileName = filePath } else { fileName = NSBundle.mainBundle().bundlePath + fileName } //write the file, return true if it works, false otherwise. do{ try data.writeToFile(fileName, atomically: true, encoding: NSUTF8StringEncoding ) return true } catch{ return false } } func readDataFromFile(file:String)-> String!{ guard let filepath = NSBundle.mainBundle().pathForResource(file, ofType: "txt") else { return nil } do { let contents = try String(contentsOfFile: filepath, usedEncoding: nil) return contents } catch { print ("File Read Error") return nil } } override func viewDidLoad() { super.viewDidLoad() //textView.text = readDataFromFile("data") } }
Leave a Reply