In the MasterViewController
(created from the Master Detail template; or a new subclass of a UITableViewController
), define an instance variable AppDelegate
that is assigned in the viewDidLoad
method:
class MasterViewController:UITableViewController { var app:AppDelegate! override func viewDidLoad() { app = UIApplication.sharedApplication().delegate as? AppDelegate … } }
The table view controller provides data in a number of sections and rows. The numberOfSections
method will return the number of users, with the section title being the username (indexed by the users list):
override func numberOfSectionsInTableView(tableView: UITableView) -> Int { return app.users.count } override func tableView(tableView: UITableView, titleForHeaderInSection section: Int) -> String? { return app.users[section] }
The numberOfRowsInSection
function is called to determine how many rows are present in each section. If the number is not known, 0
can be returned while running a background query to find the right answer:
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int { let user = app.users[section] if let repos = app.repos[user] { return repos.count } else { app.loadRepoNamesFor(user) { Threads.runOnUIThread { tableView.reloadSections( NSIndexSet(index: section), withRowAnimation: .Automatic) } } return 0 } }
Finally, the repository name needs to be shown in the value of the cell. If a default UITableViewCell
function is used, then the value can be set on the textLabel
; if it is loaded from a storyboard prototype cell, then the content can be accessed appropriately using tags:
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCellWithIdentifier( "Cell", forIndexPath: indexPath) as UITableViewCell let user = app.users[indexPath.section] let repo = app.repos[user]![indexPath.row] cell.textLabel.text = repo return cell }
When the application is run, the list of repositories will be shown, grouped by the user.
At the moment, the list of users is hard-coded into the application. It would be preferable to remove this hard-coded list and allow users to be added on demand. Create an addUser
function in the AppDelegate
class:
func addUser(user:String) { users += [user] users.sort({ $0 < $1 }) }
This allows the detail controller to call the addUser
function and ensure that the list of users is ordered alphabetically.
The add button can be created in the MasterDetailView
in the viewDidLoad
method, such that the insertNewObject
method is called when tapped:
override func viewDidLoad() { super.viewDidLoad() let addButton = UIBarButtonItem(barButtonSystemItem: .Add, target: self, action: "insertNewObject:") self.navigationItem.rightBarButtonItem = addButton … }
When the add button is selected, a UIAlertView
dialog can be shown with a delegate
that will be called back to add the user. As before, the delegate must maintain a reference to itself until it completes, as otherwise, the object will be deallocated immediately.
Add (or replace) the insertNewObject
function in the MasterViewController
as follows:
func insertNewObject(sender: AnyObject) { let alert = UIAlertView(title: "Add user", message: "Please select a user to add", delegate: AddAlertDelegate(app,tableView), cancelButtonTitle: "Cancel", otherButtonTitles: "Add") alert.alertViewStyle = .PlainTextInput alert.textFieldAtIndex(0)?.placeholder = "Username" alert.show() }
The AddAlertDelegate
performs two functions; when completed, it calls the addUser
on the AppDelegate
; and secondly, it invokes the reloadData
on the table, which ensures that the data is correctly shown. To do this, the delegate needs to have references to both the app
delegate and the tableView
, which are passed in the initializer:
class AddAlertDelegate: NSObject, UIAlertViewDelegate { var capture:AddAlertDelegate? var tableView:UITableView var app:AppDelegate init(_ app:AppDelegate,_ tableView:UITableView) { self.app = app self.tableView = tableView super.init() capture = self // prevent immediate deallocation } func alertView(alertView: UIAlertView, clickedButtonAtIndex buttonIndex: Int) { if buttonIndex == 1 { if let user = alertView.textFieldAtIndex(0)?.text { app.addUser(user) Threads.runOnUIThread { self.tableView.reloadData() } } } capture = nil } }
Now the users can be added in the UI by clicking the add (+) button on the top-right of the application. Each time the application is launched, the users array will be empty, and users can be re-added.
The final step is to implement the detail view, so that when a repository is selected per-repository information is shown. At the time the repository is selected from the master screen, the username and repository name are known. These can be used to pull more information from the repository and add items into the detail view.
Update the view in the storyboard to add four labels and four label titles for username, repository name, number of watchers, and number of open issues. Wire these into outlets in the DetailViewController
:
@IBOutlet weak var userLabel: UILabel? @IBOutlet weak var repoLabel: UILabel? @IBOutlet weak var issuesLabel: UILabel? @IBOutlet weak var watchersLabel: UILabel?
To set content on the details view, user
and repo
will be stored as (optional) strings, and the additional data
will be stored in string key/value pairs. When they are changed, the setupUI
method should be called to redisplay content:
var user: String? { didSet { setupUI() } } var repo: String? { didSet { setupUI() } } var data:[String:String]? { didSet { setupUI() } }
The setupUI
call will also need to be called after the viewDidLoad
method is called, to ensure that the UI is set up as expected:
override func viewDidLoad() { setupUI() }
In the setupUI
method, the labels might not have been set, so they need to be tested with an if let
statement before setting the content:
func setupUI() { if let label = userLabel { label.text = user } if let label = repoLabel { label.text = repo } if let label = issuesLabel { label.text = self.data?["open_issues_count"] } if let label = watchersLabel { label.text = self.data?["watchers_count"] } }
If using the standard template, the splitViewController
of the AppDelegate
needs to be changed to use return true
after the detail view is amended:
func splitViewController( splitViewController: UISplitViewController, collapseSecondaryViewController secondaryViewController:UIViewController!, ontoPrimaryViewController primaryViewController:UIViewController!) -> Bool { return true }
The connection between the master view and the detail view is triggered by the showDetail
segue. This can be used to extract the selected row from the table, which can then be used to extract the selected row and section:
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) { if segue.identifier == "showDetail" { if let indexPath = self.tableView.indexPathForSelectedRow() { // get the details controller // set the details } } }
The details controller can be accessed from the segue's destination controller—except that the destination is the navigation controller, so it needs to be unpacked one step further:
// get the details controller let controller = (segue.destinationViewController as UINavigationController).topViewController as DetailViewController // set the details
Next, the details need to be passed in, which can be extracted from the indexPath
as in the prior parts of the application:
let user = app.users[indexPath.section] let repo = app.repos[user]![indexPath.row] controller.repo = repo controller.user = user
The data needs to be acquired asynchronously using the withUserRepos
method created previously. As this returns an array of repositories, it is necessary to filter out the one with the desired name:
app.api.withUserRepos(user) { repos -> () in controller.data = repos.filter({$0["name"] == repo}).first }
Finally, to ensure that the application works in split mode with a SplitViewController
, the back button needs to be displayed if in split mode:
controller.navigationItem.leftBarButtonItem = self.splitViewController?.displayModeButtonItem() controller.navigationItem.leftItemsSupplementBackButton = true
Running the application now will show a set of repositories and when one is selected, the details will be shown:
If a crash is seen when displaying the detail view, check in the Main.storyboard
that the connector for a non-existent field is not defined. Otherwise, an error similar to this class is not key value coding-compliant for the key detailDescriptionLabel might be seen, which is caused by the Storyboard attempting to assign a missing outlet in the code.
The user might have an avatar or icon that they have uploaded to GitHub. This information is stored in the user info, which is accessible from a separate lookup in the GitHub API. Each user's avatar will be stored as a reference with avatar_url
in the user info document such as https://api.github.com/users/alblue, as follows:
{ … "avatar_url": "https://avatars.githubusercontent.com/u/76791?v=2", … }
This URL represents an image that can be used in the header for the user's repository.
To add support for this, the user info needs to be added to the GitHubAPI
class:
func getURLForUserInfo(user:String) -> NSURL { let key = "ui:(user)" if let url = cache.objectForKey(key) as? NSURL { return url } else { let userURL = services["user_url"]! let userSpecificURL = URITemplate.replace(userURL, values:["user":user]) let url = NSURL(string:userSpecificURL, relativeToURL:base)! cache.setObject(url,forKey:key) return url } }
This looks up the user_url
service from the GitHub API, which returns the following URI template:
"user_url": "https://api.github.com/users/{user}",
This can be instantiated with the user, and then the image can be loaded asynchronously:
import UIKit ... func withUserImage(user:String, fn:(UIImage -> ())) { let key = "image:(user)" if let image = cache.objectForKey(key) as? UIImage { fn(image) } else { let url = getURLForUserInfo(user) url.withJSONDictionary { userInfo in if let avatar_url = userInfo["avatar_url"] { if let avatarURL = NSURL(string:avatar_url, relativeToURL:url) { if let data = NSData(contentsOfURL:avatarURL) { if let image = UIImage(data: data) { self.cache.setObject(image,forKey:key) fn(image) } } } } } } }
Once the support to load the user's avatar has been implemented, it can be added to the view's header to display in the user interface.
The table view that presents the repository information by user can be amended so that along with the user's name, it also shows their avatar at the same time. Currently this is done in the tableView:titleForHeaderInSection
method, but an equivalent tableView:viewForHeaderInSection
method is available that provides more customization options.
Although the method signature indicates that the return type is UIView
, in fact it must be a subtype of UITableViewHeaderFooterView
. Unfortunately there is no support for editing or customizing these in Storyboard, so they must be implemented programmatically.
To implement the viewForHeaderInSection
method, obtain the username as before, and set it to the textLabel
of a newly created UITableViewHeaderFooterView
. Then, in the asynchronous image loader, create a frame that has the same origin but a square size for the image, and then create and add the image as a subview of the header view. The method will look like:
override func tableView(tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { let cell = UITableViewHeaderFooterView() let user = app.users[section] cell.textLabel.text = user app.api.withUserImage(user) { image in let minSize = min(cell.frame.height, cell.frame.width) let squareSize = CGSize(width:minSize, height:minSize) let imageFrame = CGRect(origin:cell.frame.origin, size:squareSize) Threads.runOnUIThread { let imageView = UIImageView(image:image) imageView.frame = imageFrame cell.addSubview(imageView) cell.setNeedsLayout() cell.setNeedsDisplay() } } return cell }
Now when the application is run, the avatar will be shown overlaying the user's repositories.