One of the most important tasks that a developer has to deal with when creating applications is data handing and manipulation. Data can be expressed in many different formats, and mastering at least the most common of them is a key ability for every single programmer. Speaking of mobile applications specifically now, it's quite common nowadays for them to exchange data with web applications. In such cases, the way that data is expressed may vary, but usually uses either the JSON or the XML format.
The iOS SDK provides classes for handling both of them. For managing JSON data, there is the JSONSerialization
class. This one allows developers to easily convert JSON data into a Foundation object, and the other way round. I have covered JSON parsing in chapter 4. In this chapter, we will look into the APIs for parsing XML data.
iOS offers the XMLParser
class, which takes charge of doing all the hard work and, through some useful delegate methods gives us the tools we need for handling each step of the parsing. I have to say that XMLParser
is a very convenient class and makes the parsing of XML data a piece of cake.
Being more specific, let me introduce you the XMLParserDelegate
protocol we'll use, and what each of the methods is for. The protocol defines the optional methods that should be implemented for XML parsing. For clarification purpose, every XML data is considered as an XML document in iOS. Here are the core methods that you will usually deal with:
parserDidStartDocument
- This one is called when the parsing actually starts. Obviously, it is called just once per XML document.parserDidEndDocument
- This one is the complement of the first one, and is called when the parser reaches the end of the XML document.parser(_:parseErrorOccurred:)
- This delegate method is called when an error occurs during the parsing. The method contains an error object, which you can use to define the actual error.parser(_:didStartElement:namespaceURI:qualifiedName:attributes:)
- This one is called when the opening tag of an element (e.g. parser(_:didEndElement:namespaceURI:qualifiedName:)
- Contrary to the above method, this is called when the closing tag of an element (e.g. ) is found.parser(_:foundCharacters:)
- This method is called during the parsing of the contents of an element. Its second argument is a string value containing the character that was just parsed.To help you understand the usage of the methods, we will build a simple RSS reader app together. The app will consume an RSS feed (in XML format), parse its content and display the data in a table view.
I can show you how to build a plain XML parser that reads an XML file but that would be boring. Wouldn't it be better to create a simple RSS reader?
The RSS Reader app reads an RSS feed of Apple, which is essentially XML formatted plain text. It then parses the content, extracts the news articles and shows them in a table view.
To help you get started, I have created the project template that comes with a prebuilt storyboard and view controller classes. You can download the template from http://www.appcoda.com/resources/swift55/SimpleRSSReaderStarter.zip.
The NewsTableViewController
class is associated with the table view controller in the storyboard, while the NewsTableViewCell
class is connected with the custom cell. The custom cell is designed to display the title, date and description of a news article. I have also configured the auto layout constraints of the cell so that it can be self-sized.
We will use a free RSS feed from NASA (https://www.nasa.gov/rss/dyn/breaking_news.rss) as the source of XML data. If you load the feed into any browser (e.g. Chrome), you will get a sample of the XML data, as shown below:
<?xml version="1.0" encoding="utf-8" ?> <rss version="2.0" xml:base="http://www.nasa.gov/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:media="http://search.yahoo.com/mrss/"> <channel> <title>NASA Breaking News</title>
<description>A RSS news feed containing the latest NASA news articles and press releases.</description>
<link>http://www.nasa.gov/</link>
<atom:link rel="self" href="http://www.nasa.gov/rss/dyn/breaking_news.rss" />
<language>en-us</language>
<managingEditor>jim.wilson@nasa.gov</managingEditor>
<webMaster>brian.dunbar@nasa.gov</webMaster>
<docs>http://blogs.harvard.edu/tech/rss</docs>
<item> <title>Colorado Students to Hear from NASA Astronauts Aboard Space Station</title>
<link>http://www.nasa.gov/press-release/colorado-students-to-hear-from-nasa-astronauts-aboard-space-station</link>
<description>Students from Colorado will have an opportunity this week to hear from a NASA astronaut aboard the International Space Station.</description>
<enclosure url="http://www.nasa.gov/sites/default/files/styles/1x1_cardfeed/public/thumbnails/image/iss066e092007_0.jpg?itok=8axVkcXC" length="2441934" type="image/jpeg" />
<guid isPermaLink="false">http://www.nasa.gov/press-release/colorado-students-to-hear-from-nasa-astronauts-aboard-space-station</guid>
<pubDate>Wed, 16 Feb 2022 10:47 EST</pubDate>
<source url="http://www.nasa.gov/rss/dyn/breaking_news.rss">NASA Breaking News</source>
<dc:identifier>477356</dc:identifier>
</item>
.
.
.
</channel>
</rss>
As I said before, an RSS feed is essentially XML formatted plain text. It's human readable. Every RSS feed should conform to a certain format. I will not go into the details of RSS format. If you want to learn more about RSS, you can refer to http://en.wikipedia.org/wiki/RSS. The part that we are particularly interested in are those elements within the item tag. The section represents a single article. Each article basically includes the title, description, published date and link. For our RSS Reader app, the nodes that we are interested in are:
Our job is to parse the XML data and get all the items so as to display them in the table view. When we talk about XML parsing, there are two general approaches: Tree-based and Event-driven. The XMLParser
class adopts the event-driven approach. It generates a message for each type of parsing event to its delegate, that adopts the XMLParserDelegate
protocol. To better elaborate the concept, let's consider the following simplified XML content:
<item>
<title>NASA Discoveries, R&D, Moon to Mars Exploration Plans Persevere in 2020</title>
<pubDate>Mon, 21 Dec 2020 10:21 EST</pubDate>
</item>
When parsing the above XML, the NSXMLParser
object would inform its delegate of the following events:
Event No. | Event Description | Invoked method of the delegate |
---|---|---|
1 | Started parsing the XML document | parserDidStartDocument(_:) |
2 | Found the start tag for element item | parser(_:didStartElement:namespaceURI:qualifiedName:attributes:) |
3 | Found the start tag for element title | parser(_:didStartElement:namespaceURI:qualifiedName:attributes:) |
4 | Found the characters Websites on iPhone X | parser(_:foundCharacters:) |
5 | Found the end tag for element title | parser(_:didEndElement:namespaceURI:qualifiedName:) |
6 | Found the start tag for element pubDate | parser(_:didStartElement:namespaceURI:qualifiedName:attributes:) |
7 | Found the characters Mon, 13 Nov 2017 15:50:00 PST | parser(_:foundCharacters:) |
8 | Found the end tag for element pubDate | parser(_:didEndElement:namespaceURI:qualifiedName:) |
9 | Found the end tag for element item | parser(_:didEndElement:namespaceURI:qualifiedName:) |
10 | Ended parsing the XML document | parserDidEndDocument(_:) |
By implementing the methods of the XMLParserDelegate
protocol, you can retrieve the data you need (e.g. title) and save them accordingly.
Having an idea of XML parsing in iOS, we can now proceed to create the FeedParser
class. In the project navigator, right click the SimpleRSSReader folder and select New File...
to create a new Swift file. Name the file FeedParser
.
Here are a few things we will implement in the class:
XMLParser
class provides a convenient method to specify a URL of the XML content. If you use the method, the class automatically downloads the content for further parsing. However, it only works synchronously. That means that the main thread (or any UI update) is blocked while retrieving the feed. We don't want to block the UI, so we will use URLSession
to download the content asynchronously.XMLParser
object to start the parsing.XMLParser
delegate methods to handle the parsed data. During the parsing, we look for the value of title, description and pubDate tags, and then we group them into a tuple and save the tuple in the rssItems array.parserCompletionHandler
closure when the parsing completes.Let's see everything step by step. Initially, open the FeedParser.swift
file, and adopt the XMLParserDelegate
protocol. It's necessary to do that in order to handle the data later.
typealias ArticleItem = (title: String, description: String, pubDate: String)
class FeedParser: NSObject, XMLParserDelegate {
}
Here we also use an alias to represent the tuple, which has the essential fields of an article.
After a type alias is declared, the aliased name can be used instead of the existing type everywhere in your program.
Now declare an array of tuples to store the items:
private var rssItems: [ArticleItem] = []
We use tuples to temporarily store the parsed items. If you haven't heard of Tuple, this is one of the nifty features of Swift. It groups multiple values into a single compound value. Here we group title, description and pubDate into a single item.
Quick Tip: To learn more about Tuples, check out Apple's official documentation at https://docs.swift.org/swift-book/LanguageGuide/TheBasics.html.
Let's also declare an enumeration for the XML tag that we are interested:
enum RssTag: String {
case item = "item"
case title = "title"
case description = "description"
case pubDate = "pubDate"
}
Next, declare the following variables in the FeedParser
class:
private var currentElement = ""
private var currentTitle:String = "" {
didSet {
currentTitle = currentTitle.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
}
}
private var currentDescription:String = "" {
didSet {
currentDescription = currentDescription.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
}
}
private var currentPubDate:String = "" {
didSet {
currentPubDate = currentPubDate.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
}
}
private var parserCompletionHandler:(([(title: String, description: String, pubDate: String)]) -> Void)?
The currentElement
variable is used as a temporary variable for storing the currently parsed element (e.g.
The currentTitle
, currentDescription
and currentPubDate
variables are used to store the value of an element (e.g. the value within the <title>
tags) when the parser(_:foundCharacters:)
method is called. Because the value may contain white space and new line characters, we add a property observer to trim these characters.
Note: Property observer is a handy feature in Swift. You can specify adidSet
(orwillSet
) observer for a property. ThewillSet
observer is called before the value is store, while thedidSet
observer is called right after the new value is stored. Take thecurrentTitle
property as an example. When a new title is set, the code block ofdidSet
will be executed and perform change on the new value.
The parserCompletionHandler
variable is a closure to be specified by the caller class. You can think of it as a callback function. When the parsing finishes, there are certain actions we should take, such as displaying the items in a table view. This completion handler will be called to perform the actions at the end of the parsing. We will talk more about it in a later section.
Next, let's add a new method called parseFeed
:
func parseFeed(feedUrl: String, completionHandler: (([(title: String, description: String, pubDate: String)]) -> Void)?) -> Void {
self.parserCompletionHandler = completionHandler
let request = URLRequest(url: URL(string: feedUrl)!)
let urlSession = URLSession.shared
let task = urlSession.dataTask(with: request, completionHandler: { (data, response, error) -> Void in
guard let data = data else {
if let error = error {
print(error)
}
return
}
// Parse XML data
let parser = XMLParser(data: data)
parser.delegate = self
parser.parse()
})
task.resume()
}
This method takes in two variables: feedUrl
and completionHandler
. The feed URL is a String
object containing the link of the RSS feed. The completion handler is the one we just discussed, and will be called when the parsing finishes. In this method, we create an URLSession
object and a download task to retrieve the XML content asynchronously. When the download completes, we initialize the parser object with the XML data, set the delegate to itself, and start the parsing.
Now let's implement the delegate methods one by one. Referring to the event table I mentioned before, the first delegate method to be invoked is the parserDidStartDocument
method. Implement the method like this:
func parserDidStartDocument(_ parser: XMLParser) {
rssItems = []
}
To begin, here we just initialize an empty rssItems
array. When a new element (e.g. <item>
) is found, the parser(_:didStartElement:namespaceURI:qualifiedName:attributes:)
method is called. Insert this method in the class:
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
currentElement = elementName
if currentElement == RssTag.item.rawValue {
currentTitle = ""
currentDescription = ""
currentPubDate = ""
}
}
We simply assign the name of the element to the currentElement
variable. If the <item>
tag is found, we reset the temporary variables of title, description, pubDate to blank for later use.
When the value of an element is parsed, the parser(_:foundCharacters:)
method is called with a string representing all or part of the characters of the current element. Implement the method like this:
func parser(_ parser: XMLParser, foundCharacters string: String) {
switch currentElement {
case RssTag.title.rawValue: currentTitle += string
case RssTag.description.rawValue: currentDescription += string
case RssTag.pubDate.rawValue: currentPubDate += string
default: break
}
}
Note that the string
object may only contain part of the characters of the element. Instead of assigning the string object to the temporary variable, we append it to the end.
When the closing tag (e.g. </item>
) is found, the parser(_:didEndElement:namespaceURI:qualifiedName:)
method is called. Here we only perform actions when the tag is item.
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
if elementName == RssTag.item.rawValue {
let rssItem = (title: currentTitle, description: currentDescription, pubDate: currentPubDate)
rssItems += [rssItem]
}
}
We create a tuple using the title, description and pubDate tags just parsed, and then we add the tuple to the rssItems
array.
Lastly, we come to the parserDidEndDocument
method. When the parsing is completed successfully, the method is invoked. In this method, we will call up parseCompletionHandler
as specified by the caller to perform any follow-up actions:
func parserDidEndDocument(_ parser: XMLParser) {
parserCompletionHandler?(rssItems)
}
Optionally, we also implement the following in the FeedParser
class:
func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) {
print(parseError.localizedDescription)
}
This method is called when the parser encounters a fatal error. Now that we have completed the implementation of FeedParser
. Let's go to the NewsTableViewController.swift
file, which is the caller of the FeedParser
class. Declare a variable to store the article items:
private var rssItems: [ArticleItem]?
In the viewDidLoad
method, insert the following lines of code:
let feedParser = FeedParser()
feedParser.parseFeed(feedUrl: "https://www.nasa.gov/rss/dyn/breaking_news.rss", completionHandler: {
(rssItems: [ArticleItem]) -> Void in
self.rssItems = rssItems
OperationQueue.main.addOperation({ () -> Void in
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
})
})
Here we create a FeedParser
object and call up the parseFeed
method to parse the specified RSS feed. As said before, the completionHandler
, which is a closure, will be called when the parsing completes. So we save rssItems
and ask the table view to display them by reloading the table data. Note that the UI update should be performed in the main thread.
Lastly, update the following methods to load the items in the table view:
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
// Return the number of rows in the section.
guard let rssItems = rssItems else {
return 0
}
return rssItems.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! NewsTableViewCell
// Configure the cell...
if let item = rssItems?[indexPath.row] {
cell.titleLabel.text = item.title
cell.descriptionLabel.text = item.description
cell.dateLabel.text = item.pubDate
}
return cell
}
Great! You can now run the project. If you're testing the app using the simulator, make sure your computer is connected to the Internet. The RSS Reader app should be able to retrieve the news feed of Apple.
That's it. For reference, you can download the final Xcode project from http://www.appcoda.com/resources/swift55/SimpleRSSReader.zip.
Currently, the app displays the full content of each news article. It may be a bit lengthy to some users. Wouldn't it be great if we just show the first few lines of the news content? And, when the user taps the cell, it will expand to show the full content. Conversely, if the user taps the cell with full content, the cell collapses to display the excerpt of the article.
With self sizing cells that we have already implemented, it is not very difficult to add this feature.
First, let's limit the description to display the first four lines of the content. There are multiple ways to do that. You can go to the storyboard, and set the lines option of the description label to 4
. This time, I want to show you how to do it in code.
Open NewsTableViewCell.swift
and update the code in the didSet
observer of the descriptionLabel
like this:
@IBOutlet weak var descriptionLabel:UILabel! {
didSet {
descriptionLabel.numberOfLines = 4
}
}
That's it. If you run the app now, it will display an excerpt of the news articles.
Probably you already know how to expand (and collapse) the cell. Here are a couple of things we have to do:
tableView(_:didSelectRowAt:)
method - when a cell is tapped (or selected), this method will be called. numberOfLines
property of the description label from 4
to 0
. Conversely, when the user taps the cell again, we will change the line number to 4
again.When you translate the above into code, it will be like this:
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let cell = tableView.cellForRow(at: indexPath) as! NewsTableViewCell
tableView.beginUpdates()
cell.descriptionLabel.numberOfLines = (cell.descriptionLabel.numberOfLines == 0) ? 4 : 0
tableView.endUpdates()
}
At the beginning, we deselect the cell and retrieve the selected cell. Then we set the numberOfLines
property of the description label to either 4
(collapse) or 0
(expand).
You probably notice that we call beginUpdates()
and endUpdates()
of tableView
. This is to notify the table view that we got some changes to the cell. When endUpdates()
is invoked, the table view animates the cell changes. If you forget to call these two methods, the cell will not update itself even if the value of numberOfLines
is changed.
Now run the app to have a quick test. It works! Tapping the cell will expand the cell content. If you tap the same cell again, it collapses.
But there is a bug in the existing app.
Say, if you expand a cell, you will find another expanded cell as you scroll through the table. The problem is due to cell reuse as we have explained in the beginner book. To avoid the issue, we will have to keep track of the state (expanded/collapsed) for each cell.
First, declare a new enum called CellState
in NewsTableViewController
to indicate the two possible cell states:
enum CellState {
case expanded
case collapsed
}
Next, declare an array variable to store the state for each cell:
private var cellStates: [CellState]?
The cellStates
is not initialized by default because we have no idea about the total number of RSS feed items. Instead, we will initialize the array after we retrieve the RSS items in the viewDidLoad
method. Insert the following line of code after self.rssItems = rssItems
:
self.cellStates = [CellState](repeating: .collapsed, count: rssItems.count)
We initialize the array items with the default state .collapsed
.
Next, modify the tableView(_:didSelectRowAt:)
method to update the state of the selected cell:
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let cell = tableView.cellForRow(at: indexPath) as! NewsTableViewCell
tableView.beginUpdates()
cell.descriptionLabel.numberOfLines = (cell.descriptionLabel.numberOfLines == 0) ? 4 : 0
cellStates?[indexPath.row] = (cell.descriptionLabel.numberOfLines == 0) ? .expanded : .collapsed
tableView.endUpdates()
}
In the above code, we just update the cell state in reference to the number of lines set in the description label.
Lastly, update the tableView(_:cellForRowAt:)
method such that we set the numberOfLines
property of the description label according to the cell state:
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! NewsTableViewCell
// Configure the cell...
if let item = rssItems?[indexPath.row] {
cell.titleLabel.text = item.title
cell.dateLabel.text = item.pubDate
cell.descriptionLabel.text = item.description
if let cellStates = cellStates {
cell.descriptionLabel.numberOfLines = (cellStates[indexPath.row] == .expanded) ? 0 : 4
}
}
return cell
}
Great! The bug should now be fixed. Run the app again and play around with it. All the cells can expand / collapse properly.
For reference, you can download the full Xcode project from http://www.appcoda.com/resources/swift55/SimpleRSSReaderCellExpand.zip