Intermediate iOS 15 Programming with Swift

Chapter 26
XML Parsing, RSS and Expandable Table View Cells

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. ) is found.
  • 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.

Demo App

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.

Figure 26.1. The starter project of the Simple RSS Reader app
Figure 26.1. The starter project of the Simple RSS Reader app

A Sample RSS Feed

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:

  • title
  • description
  • pubDate

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&amp;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.

Building the Feed Parser

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:

  1. Download the content of RSS Feed asynchronously. The 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.
  2. Once the XML content is downloaded, we will initialize an XMLParser object to start the parsing.
  3. Use the 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.
  4. Lastly, we call a 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. tag). We'll need it to determine if every found element is of interest or not.

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 a didSet (or willSet) observer for a property. The willSet observer is called before the value is store, while the didSet observer is called right after the new value is stored. Take the currentTitle property as an example. When a new title is set, the code block of didSet 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.

Figure 26.2. The Simple RSS Reader app now reads and parses the Apple's news feed
Figure 26.2. The Simple RSS Reader app now reads and parses the Apple's news feed

That's it. For reference, you can download the final Xcode project from http://www.appcoda.com/resources/swift55/SimpleRSSReader.zip.

Expanding and Collapsing Table View Cells

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.

Figure 26.3. The table cells now display the first few lines of the article
Figure 26.3. The table cells now display the first few lines of the article

Probably you already know how to expand (and collapse) the cell. Here are a couple of things we have to do:

  • Implement the tableView(_:didSelectRowAt:) method - when a cell is tapped (or selected), this method will be called.
  • To expand the cell, we will implement the method to change the 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