iOS Programming · · 10 min read

Building a Custom Content View with UITableView and MapKit

Building a Custom Content View with UITableView and MapKit

This is a guest post by Sean Choo, the developer of Cureto. In this tutorial, he will show us how he implements the app’s article view using custom table view.

Hi everyone, I am Sean Choo, a Malaysian who is currently studying in Hong Kong. What bugs me all the time is: I always have difficulty finding good food to eat in Hong Kong. Back in Malaysia, I don’t have this worry because 1. I grew up there, 2. There are really lots of nice food in Malaysia.

All the apps currently available just couldn’t solve my problem. So I think about it and realize that most of the good food I’ve eaten in Hong Kong are either discovered by myself, or introduced by my friends. And hence I set out to make an app so that my friends and I could introduce and convince each other on good food in Hong Kong. I have just published this iOS app on App Store recently, it’s called Cureto. The website for this app is here. You can also read the design philosophy here.

Cureto App

I see this app as an artwork. I want it to be pretty, the experience to be relax and enjoyable. If you have downloaded the app, you can see each food introduction is in fact an article. And to make reading pleasant, I have put in a lot of time designing the article. In this tutorial, I am going to share how I use UITableView to render an article shown below.

First, download the starter project here and open Delicious.xcodeproj. You will need Xcode 7.3 and Swift 2.2 or above.

Dissecting an Article

Dissecting an Article in uitableview

Take a look at the above photo. An article view consists of a Cover Photo, a Title, a Main Content, and a list of Sub Contents. To design a view like this, I use a custom table view, and create three prototype cells for the cover photo, main content, and the sub content.

To model this structure, let’s create two files in the Models folder: Article.swift and SubContent.swift. You can right click the Models folder, choose “New File…”, and then create a Swift file.

Create two files in xcode

In Article.swift, insert the following code:

import UIKit

class Article {
    
    var title: String?
    var mainContent: String?
    
    var coverPhoto: String?
    var coverPhotoWidth: CGFloat?
    var coverPhotoHeight: CGFloat?
    
    var mealType: String?
    var mealPrice: Int?
    
    var authorDisplayName: String?
    var authorUsername: String?
    
    var restaurantName: String?
    var restaurantAddress: String?
    var restaurantLatitude: Double?
    var restaurantLongitude: Double?
    
    var subContents = [SubContent]()
    
    init(title: String, mainContent: String, coverPhoto: String, coverPhotoWidth: CGFloat, coverPhotoHeight: CGFloat, mealType: String, mealPrice: Int) {
        self.title = title
        self.mainContent = mainContent
        self.coverPhoto = coverPhoto
        self.coverPhotoWidth = coverPhotoWidth
        self.coverPhotoHeight = coverPhotoHeight
        self.mealType = mealType
        self.mealPrice = mealPrice
    }
    
}

The Article class has several properties including title, cover photo, meal type, restaurant location, and sub contents.

In SubContent.swift, insert the following code:

import UIKit

class SubContent {
    
    var photo: String?
    var photoWidth: CGFloat?
    var photoHeight: CGFloat?
    var text: String?

    init(photo: String, photoWidth: CGFloat, photoHeight: CGFloat, text: String) {
        self.photo = photo
        self.photoWidth = photoWidth
        self.photoHeight = photoHeight
        self.text = text
    }
    
}

Each subcontent contains a photo and paragraphs of text. So we define the corresponding properties.

Next, open ArticleController.swift under the Controllers folder. The ArticleController class, which is used to render an article, is associated with the table view controller in Interface Builder.

In the class, add a currentArticle variable, then create a initializeArticle() method and call it in viewDidLoad().

import UIKit

class ArticleController: UITableViewController {
    var currentArticle: Article?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        initializeArticle()
    }
  
    func initializeArticle() {
        let mainContent = "Start your day with this amazing breakfast, and you will be happy throughout the day"
        let article = Article(title: "Lovely Breakfast", mainContent: mainContent, coverPhoto: "Toast", coverPhotoWidth: 1080, coverPhotoHeight: 810, mealType: "Breakfast", mealPrice: 34)
        article.restaurantName = "Toast Box"
        article.restaurantAddress = "G/F, JD Mall, 233-239 Nathan Rd, Jordan"
        article.restaurantLatitude = 22.304864882982680
        article.restaurantLongitude = 114.171386361122100
        article.authorDisplayName = "The Dreamer"
        article.authorUsername = "dreamer"
        
        let subContentOne = SubContent(photo: "Egg", photoWidth: 1080, photoHeight: 810, text: "Half-boiled eggs is a must")
        let subContentTwo = SubContent(photo: "Tea", photoWidth: 1080, photoHeight: 810, text: "Singapore/Malaysia-styled milk tea. Milder than Hong Kong style but still great")
        
        article.subContents = [subContentOne, subContentTwo]
        
        currentArticle = article
    }
}

The currentArticle variable is used to hold the article being displayed. The initializeArticle() method helps setup the article details, and it initializes a sample article after the view has finished loading.

Now that you’ve finished the implementation of the Article class, we are going to setup the UITableView for the article. First of all, add these 4 lines in ArticleController.swift.

import UIKit

class ArticleController: UITableViewController {
    let screenWidth: CGFloat = UIScreen.mainScreen().bounds.width
    let screenHeight: CGFloat = UIScreen.mainScreen().bounds.height
    
    var currentArticle: Article?
    
    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.rowHeight = UITableViewAutomaticDimension
        tableView.estimatedRowHeight = 500.0

        initializeArticle()
    }
}

First, we declare two constants for holding the screen width and height of the device. And we make use of the self sizing cell feature by setting the row height to UITableViewAutomaticDimension and giving the table view an estimated row height.

Next, let’s setup the methods for rendering the table view in ArticleController.swift. In the ArticleController class, insert the following method:

override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    if let article = currentArticle {
        return 2 + article.subContents.count
    } else {
        return 0
    }
}

Each article contains the following sections:

  1. The first section is for the cover photo.
  2. The second section is for the article title, plus the main content.
  3. The rest of the section is for the subcontents.

Therefore, the total number of rows is 2 + article.subContents.count.

override func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
    if indexPath.row == 0 {
        if let width = currentArticle?.coverPhotoWidth, height = currentArticle?.coverPhotoHeight {
            let heightRatio = height / width
            return screenWidth * heightRatio
        }
    }
    
    return UITableViewAutomaticDimension
}

For the cover photo row, we have to calculate the row height. For example, if a cover photo is 4w : 3h in ratio, height ratio will be 3 / 4 = 0.75 and row height would be screen width (e.g. 375 for iPhone 6S) * 0.75 = 281.25. For the rest of the rows, it is good enough to use automatic row height.

func attributedContentFromText(text: String) -> NSMutableAttributedString {
    let paraStyle = NSMutableParagraphStyle()
    paraStyle.lineSpacing = 7
    let attrs = [NSFontAttributeName: UIFont.systemFontOfSize(15),
                 NSParagraphStyleAttributeName: paraStyle]
    let attrContent = NSMutableAttributedString(string: text, attributes: attrs)
    return attrContent
}

override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {

    var cellForRow: UITableViewCell!

    if indexPath.row == 0 {
        let cell = tableView.dequeueReusableCellWithIdentifier("CoverPhotoCell", forIndexPath: indexPath) as! CoverPhotoTableViewCell

        if let imageName = currentArticle?.coverPhoto {
            cell.coverImageView.image = UIImage(named: imageName)
        }

        cellForRow = cell

    } else if indexPath.row == 1 {
        let cell = tableView.dequeueReusableCellWithIdentifier("MainContentCell", forIndexPath: indexPath) as! MainContentTableViewCell
        cell.titleLabel.text = currentArticle?.title

        cell.contentLabel.textAlignment = .Left
        if let text = currentArticle?.mainContent {
            cell.contentLabel.attributedText = attributedContentFromText(text)
        }

        cellForRow = cell

    } else {
        let cell = tableView.dequeueReusableCellWithIdentifier("SubContentCell", forIndexPath: indexPath) as! SubContentTableViewCell

        if let article = currentArticle {
            let subContent = article.subContents[indexPath.row - 2]

            if let width = subContent.photoWidth, height = subContent.photoHeight {
                let heightRatio = height / width
                cell.subImageViewHeight.constant = screenWidth * heightRatio
            }

            if let imageName = subContent.photo {
                cell.subImageView.image = UIImage(named: imageName)
            }

            cell.subContentLabel.textAlignment = .Left
            if let text = subContent.text {
                cell.subContentLabel.attributedText = attributedContentFromText(text)
            }

        }

        cellForRow = cell
    }

    return cellForRow
}

First, instead of using a normal string for text in the article, we use NSMutableAttributedString, that the article will be more pleasant to read. The attributedContentFromText() method is a helper method for converting a string to a NSMutableAttributedString. The rest of the code is pretty straightforward. We simply configure the cells for cover photo row, title/main content row, and the sub content rows.

Now it’s ready to have a quick test. Run the app, and you should now have something like this:

cureto article view

Yay! You have just created a very basic layout of the article. The font size and line spacing is just right to read. Then, you will have the urge to eat at this restaurant. But wait, where is this restaurant? Yes, we need a map, and perhaps also the restaurant name and address. Moreover, we need to acknowledge the author of this article too. To do this, we are going to use a footer for this UITableView.

Adding a Map to the Table View Footer

Now, we are going to add a footer for the UITableView. The starter project already came with the UI design of the table footer. You can refer to ArticleFooterView.xib for details. To implement the footer view, add the following lines of code right below the initializeArticle() method of ArticleController.swift:

func addFooterView() {
    let footerView = NSBundle.mainBundle().loadNibNamed("ArticleFooterView", owner: self, options: nil)[0] as! ArticleFooterView
    footerView.frame = CGRectMake(0, 0, screenWidth, 486)

    footerView.separatorHeight.constant = 0.6

    if let type = currentArticle?.mealType, price = currentArticle?.mealPrice {
        footerView.mealTypeLabel.text = type
        footerView.mealPriceLabel.text = "HK$ \(price)"
    }

    if let name = currentArticle?.restaurantName, address = currentArticle?.restaurantAddress {
        footerView.restaurantNameLabel.text = name
        footerView.restaurantAddressLabel.text = address
    }

    if let name = currentArticle?.authorDisplayName, username = currentArticle?.authorUsername {
        footerView.displayNameLabel.text = name
        footerView.usernameLabel.text = "@\(username)"
    }

    tableView.tableFooterView = footerView
}

Then, in viewDidLoad(), add the line of code highlighted in yellow to call up the addFooterView() method:

override func viewDidLoad() {
    super.viewDidLoad()

    tableView.rowHeight = UITableViewAutomaticDimension
    tableView.estimatedRowHeight = 500.0

    initializeArticle()
    addFooterView()
}

Run the app again. Scroll to the bottom and you will have something like this:

article view cell with maps

Seems like we’re done! But no, why the map not showing the expected location of the restaurant? Of course, it’s because we haven’t implemented it. You now have an empty MKMapView in the footer. We will now move on to add a pin to the MKMapView. But first you will have to enable Maps in Capabilities.

xcode add maps capability

add maps to xcode project

Back in ArticleController.swift, right below import UIKit add a line of code to import MapKit:

import UIKit
import MapKit

class ArticleController: UITableViewController {
  ...
}

Then, for the method addFooterView(), please update it to the following code so as to add the logic to render the correct location:

func addFooterView() {
    let footerView = NSBundle.mainBundle().loadNibNamed("ArticleFooterView", owner: self, options: nil)[0] as! ArticleFooterView
    footerView.frame = CGRectMake(0, 0, screenWidth, 486)

    footerView.separatorHeight.constant = 0.6

    if let type = currentArticle?.mealType, price = currentArticle?.mealPrice {
        footerView.mealTypeLabel.text = type
        footerView.mealPriceLabel.text = "HK$ \(price)"
    }

    if let name = currentArticle?.restaurantName, address = currentArticle?.restaurantAddress {
        footerView.restaurantNameLabel.text = name
        footerView.restaurantAddressLabel.text = address
    }

    if let name = currentArticle?.authorDisplayName, username = currentArticle?.authorUsername {
        footerView.displayNameLabel.text = name
        footerView.usernameLabel.text = "@\(username)"
    }

    if let lat = currentArticle?.restaurantLatitude, lng = currentArticle?.restaurantLongitude {
        let location = CLLocation(latitude: lat, longitude: lng)
        let coordinateRegion = MKCoordinateRegionMakeWithDistance(location.coordinate, 250.0, 250.0)
        footerView.mapView.setRegion(coordinateRegion, animated: false)

        let pin = MKPointAnnotation()
        pin.coordinate = location.coordinate
        footerView.mapView.addAnnotation(pin)
    }

    tableView.tableFooterView = footerView
}

Assuming you have some knowledge of MapKit, here we simply set the region of the map to the location of the restaurant. Then, we put a pin on the exact position of the restaurant.

Editor’s note: You can refer to our Swift programming book if you want to learn more about MapKit.

Great! Let’s run the app again. You should have something like this:

uitableviewcell with maps

Adding an Action Menu

Now the article looks complete! But I feel one thing is missing: we need an action bar for people to take actions, such as giving a like to this wonderful article. I would also like to share how I use extension in Swift to customize existing class such as UIView to carry out the desired animation, so that you can also write your own extension in the future!

For the starter project, I have created the menu bar in ArticleMenuView.xib. But I want the article menu to swipe up from the bottom of the screen instead of just popping up from no where. Let’s get started to implement it.

First, add these 3 lines (highlighted in yellow) in ArticleController.swift:

import UIKit
import MapKit

class ArticleController: UITableViewController {
    
    let screenWidth: CGFloat = UIScreen.mainScreen().bounds.width
    let screenHeight: CGFloat = UIScreen.mainScreen().bounds.height
    
    var currentArticle: Article?
    var articleMenu: ArticleMenuView?
    var articleMenuHidden = false
    var lastContentOffset: CGFloat = 0.0
    
    override func viewDidLoad() {
        ...
    }
}

Here we just add:

  1. A variable to hold the Article Menu
  2. A boolean to check whether the Article Menu is hidden
  3. A variable to be used for hiding the menu on scroll later

The Article Menu slides up from the bottom of the screen when the article appears. To do that, we add a custom extension for UIView. Create a new file named UIViewExtension.swift and put it below AppDelegate.swift in the directory.

Next, insert the following code in UIViewExtension.swift:

import UIKit

extension UIView {
    
    func slideInFromBottom(duration: NSTimeInterval = 0.1, completionDelegate: AnyObject? = nil) {
        let slideInFromBottomTransition = CATransition()

        if let delegate: AnyObject = completionDelegate {
            slideInFromBottomTransition.delegate = delegate
        }

        slideInFromBottomTransition.type = kCATransitionPush
        slideInFromBottomTransition.subtype = kCATransitionFromTop
        slideInFromBottomTransition.duration = duration
        slideInFromBottomTransition.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
        slideInFromBottomTransition.fillMode = kCAFillModeRemoved

        self.layer.addAnimation(slideInFromBottomTransition, forKey: "slideInFromBottomTransition")
    }
    
}

Then, in ArticleController.swift, add these 4 methods right below addFooterView():

func addArticleMenu() {
    if articleMenu == nil {
        let appDelegate = UIApplication.sharedApplication().delegate as! AppDelegate
        let menuView = NSBundle.mainBundle().loadNibNamed("ArticleMenuView", owner: self, options: nil)[0] as! ArticleMenuView
        menuView.frame = CGRectMake(0, screenHeight - 70, screenWidth, 70)
        menuView.blurView.layer.cornerRadius = 3
        menuView.blurView.layer.masksToBounds = true

        appDelegate.window?.addSubview(menuView)
        menuView.slideInFromBottom()

        articleMenu = menuView
    }
}
    
func hideArticleMenu(menu: UIView) {
    if !articleMenuHidden {
        UIView.animateWithDuration(0.3, delay: 0.0, options: UIViewAnimationOptions.CurveEaseInOut, animations: {
            menu.frame = CGRectMake(0, self.screenHeight, self.screenWidth, 70)
            }, completion: { finished in
                self.articleMenuHidden = true
        })
    }
}
    
func unhideArticleMenu(menu: UIView) {
    if articleMenuHidden {
        UIView.animateWithDuration(0.3, delay: 0.0, options: UIViewAnimationOptions.CurveEaseInOut, animations: {
            menu.frame = CGRectMake(0, self.screenHeight - 70, self.screenWidth, 70)
            }, completion: { finished in
                self.articleMenuHidden = false
        })
    }
}
    
func removeArticleMenu() {
    if let menu = articleMenu {
        menu.removeFromSuperview()
        self.articleMenu = nil
    }
}

After that, right under viewDidLoad(), add the following methods:

override func viewDidAppear(animated: Bool) {
    addArticleMenu()
}

override func viewWillDisappear(animated: Bool) {
    removeArticleMenu()
}

override func scrollViewDidScroll(scrollView: UIScrollView) {
    if let menu = articleMenu {
        if lastContentOffset < 0.0 {
            // do nothing
        } else if lastContentOffset > scrollView.contentOffset.y {
            unhideArticleMenu(menu)
        } else if lastContentOffset < scrollView.contentOffset.y {
            hideArticleMenu(menu)
        }
        lastContentOffset = scrollView.contentOffset.y
    }
}

In the above code, we invoke the addArticleMenu() method in ViewDidAppear(), so that we bring up the article menu when the article appears. Conversely, we call the removeArticleMenu() method after the article disappears. We also implement the scrollViewDidScroll method to hide the article menu when the user scrolls down the article view. And we will unhide the article menu when the user scrolls up again.

Summary

Cool! You have created a food article using UITableView. I hope you enjoy reading this tutorial, and understand how to implement a simple article view using custom table view. For complete project, you can download it from GitHub.

Read next