cs193p – Project #4 Assignment #4 Extra Task #6

Pavel Ševela [CC BY-SA 3.0 (http://creativecommons.org/licenses/by-sa/3.0)], via Wikimedia Commons

Please note, this blog entry is from a previous course. You might want to check out the current one.

Add some UI which displays a new view controller showing a UICollectionView of the first image (or all the images if you want) in all the Tweets that match the search. When a user clicks on an image in this UICollectionView, segue to showing them the Tweet.


Let’s start with the storyboard:

  • Add a collection view controller.
  • Add a segue from the tweet table view controller to the new controller (note: from the controller itself not from one of its elements or table cells) and name the segue.
  • Add a button to the right side of the navigation bar and link it to the unwind method from extra task #3
  • Add a reuse identifier for the collection view cell.
  • Add an image view to the cell (including autolayout constraints).
  • Add an activity indicator on top (including constraints)
  • Finally add a segue from the cell back to the tweets table view controller and name it.


cs193p - Project #4 Assignment #4 Extra Task #6 - collection view controller
cs193p – Project #4 Assignment #4 Extra Task #6 – collection view controller

Add two new class files – for the collection view controller and for the collection view cell – and use them in the storyboard. Create outlets for the image view and the activity indicator to the new cell class:

    @IBOutlet weak var spinner: UIActivityIndicatorView!    
    @IBOutlet weak var imageView: UIImageView!

In storyboard it is only possible to set a single button on the left and right of the navigation bar. Additional ones can be set in code.

    override func viewDidLoad() {
        ...
        let imageButton = UIBarButtonItem(barButtonSystemItem: .Camera, target: self, action: "showImages:")
        if let existingButton = navigationItem.rightBarButtonItem {
            navigationItem.rightBarButtonItems = [existingButton, imageButton]
        } else {
            navigationItem.rightBarButtonItem = imageButton
        }        
    }

When the new button gets pressed, fire the segue to show the collection view controller:

    private struct Storyboard {
        ...
        static let ImagesIdentifier = "Show Images"
    }

    func showImages(sender: UIBarButtonItem) {
        performSegueWithIdentifier(Storyboard.ImagesIdentifier, sender: sender)
    }

The collection view has a single section:

    override func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {
        return 1
    }

The number of cells equals the number of images:

    override func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return images.count
    }

When preparing the segue, pass the tweets to the collection view controller:

    override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
        ...
            } else if identifier == Storyboard.ImagesIdentifier {
                if let icvc = segue.destinationViewController as? ImageCollectionViewController {
                    icvc.tweets = tweets
                    icvc.title = "Images: \(searchText!)"
                }
            }
        ...
    }

However, the tweets property is not very usable for our purposes. Instead store the images as an array of a new data structure holding the tweet itself and the image data:

    var images = [TweetMedia]()
    
    struct TweetMedia: Printable
    {
        var tweet: Tweet
        var media: MediaItem
        
        var description: String { return "\(tweet): \(media)" }
    }

The tweets array is two dimensional holding the tweets from different downloads in separate sub arrays. First flatten it to a one-dimensional array. A tweet could have multiple images. For each tweet create an array of our new data structure holding the individual images – or none. Map them into a new two-dimensional array holding those sub arrays and finally flatten them (which also takes care of tweets without images):

    var tweets: [[Tweet]] = [] {
        didSet {
            images = tweets.reduce([], +)
                .map { tweet in
                    tweet.media.map { TweetMedia(tweet: tweet, media: $0) }
                }.reduce([], +)
        }
    }

For now just set the background color of the cell – to check if the code is working up to now.

    private struct Storyboard {
        static let CellReuseIdentifier = "Image Cell"
    }

    override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCellWithReuseIdentifier(Storyboard.CellReuseIdentifier, forIndexPath: indexPath) as ImageCollectionViewCell    
        cell.backgroundColor = UIColor.darkGrayColor()
        return cell
    }

Now change the size of the individual cells using a constant cell area and the images aspect ratios:

    private struct Storyboard {
        ...
        static let CellArea: CGFloat = 4000
    }

    func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize {
        let ratio = CGFloat(images[indexPath.row].media.aspectRatio)
        let width = min(sqrt(ratio * Storyboard.CellArea), collectionView.bounds.size.width)
        let height = width / ratio
        return CGSize(width: width, height: height)
    }

Add a pinch gesture recognizer to change the scale of the images

    var scale: CGFloat = 1
    
    override func viewDidLoad() {
        super.viewDidLoad()
        collectionView?.addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: "zoom:"))
    }
    
    func zoom(gesture: UIPinchGestureRecognizer) {
        if gesture.state == .Changed {
            scale *= gesture.scale
            gesture.scale = 1.0
        }
    }

Add the scale to the calculation of the cell sizes:

    func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize {
        ...
        let width = min(sqrt(ratio * Storyboard.CellArea) * scale, collectionView.bounds.size.width)
        ...
    }

For the size change to happen, the layout of the collection view needs to be updated:

    var scale: CGFloat = 1 { didSet { collectionView?.collectionViewLayout.invalidateLayout() } }

The calculation of the cell sizes takes already care, that the width of the cells is not bigger than the collection view itself (which the collection view really does not like and complains about using warnings). However, rotating the device from landscape to portrait mode might produce too large cells. Thus we need to force to relayout the cells before the rotation:

    override func willRotateToInterfaceOrientation(toInterfaceOrientation: UIInterfaceOrientation, duration: NSTimeInterval) {
        super.willRotateToInterfaceOrientation(toInterfaceOrientation, duration: duration)
        collectionView?.collectionViewLayout.invalidateLayout()
    }

Let’s add the images and provide the image URL to the cell:

    override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
        ...
        cell.imageURL = images[indexPath.row].media.url    
        ...
    }

When the URL is set, set the background color, remove any existing image and fetch the image:

    var imageURL: NSURL? {
        didSet {
            backgroundColor = UIColor.darkGrayColor()
            image = nil
            fetchImage()
        }
    }

The image itself is a computed property accessing the image of the image view. When setting a new image, make sure to stop the activity indicator:

    
    private var image: UIImage? {
        get { return imageView.image }
        set {
            imageView.image = newValue
            spinner?.stopAnimating()
        }
    }

Start the activity indicator starting the asynchronous download,

    private func fetchImage() {
        if let url = imageURL {
            spinner?.startAnimating()            
            let qos = Int(QOS_CLASS_USER_INITIATED.value)
            dispatch_async(dispatch_get_global_queue(qos, 0)) {
                imageData = NSData(contentsOfURL: url)
                dispatch_async(dispatch_get_main_queue()) {
                    if url == self.imageURL {
                        if imageData != nil {
                            self.image = UIImage(data: imageData!)
                        } else {
                            self.image = nil
                        }
                    }
                }
            }
        }
    }

The cache for the images should be common to all cells and should be emptied when the collection view controller is not used any more. Thus create the cache in the controller and pass it to the individual cells:

    var cache = NSCache()

    override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
        ...
        cell.cache = cache
        ...
    }

Before the download starts, check if the image is available in the cache. Otherwise start the download and save the image to the cache afterwards:

    var cache: NSCache?

    private func fetchImage() {
        ...
            var imageData = cache?.objectForKey(imageURL!) as? NSData
            if imageData != nil {
                self.image = UIImage(data: imageData!)
                return
            }
            ...
                imageData = NSData(contentsOfURL: url)
                ...
                            self.image = UIImage(data: imageData!)
                            self.cache?.setObject(imageData!, forKey: self.imageURL!, cost: imageData!.length / 1024)
                        ...
    }

Clicking on a cell segue to a new tweet table view controller. Use the tweet from the current cell as sole data source:

    private struct Storyboard {
        ...
        static let SegueIdentifier = "Show Tweet"
    }

    override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
        if segue.identifier == Storyboard.SegueIdentifier {
            if let ttvc = segue.destinationViewController as? TweetTableViewController {
                if let cell = sender as? ImageCollectionViewCell {
                    ttvc.tweets = [[images[collectionView!.indexPathForCell(cell)!.row].tweet]]
                }
            }
        }
    }

The tweet table view controller needs to be prevented to start a new download, when it has already tweets – in our case a tweet:

    override func viewDidLoad() {
        ...
        if tweets.count == 0 {
            refresh()
        } else {
            searchTextField.text = "@" + tweets.first!.first!.user.screenName
            tableView.reloadSections(NSIndexSet(indexesInRange: NSMakeRange(0, tableView.numberOfSections())), withRowAnimation: .None)
        }
    }

Update: Thanks to Hugo for pointing it out! I inherited a bug from lecture #10 which the instructor corrected in his posted code … and I missed. I never set the last-successful-request property. The corrected code set it after returning tweets … which worked fine in the code from the lecture … However, when setting the search text from another controller refresh gets called twice (when setting the text and in viewDidLoad). Thus I added the code a little bit above – before the download:

    @IBAction func refresh(sender: UIRefreshControl?) {
        ...
            if let request = nextRequestToAttempt {
                self.lastSuccessfulRequest = request // oops, forgot this line in lecture
                ...
    }

The complete code for extra task #6 is available on GitHub.

FacebooktwitterredditpinterestlinkedintumblrmailFacebooktwitterredditpinterestlinkedintumblrmail

4 thoughts on “cs193p – Project #4 Assignment #4 Extra Task #6”

  1. Correct me if I’m mistaken, but the cache seems get deleted the ImageCollectionViewController goes away, somewhat defeating the purpose.

    It would seem better to keep the cache variable further up the MVC stack and reference it into the collection view controller?

  2. Two issues here:

    If the cache is cleared when you leave the collection view controller, then the cache is of limited use. A fix is perhaps to put var cache as a property of the TweetTableViewController. The cache will then persist as you shift back to the TweetTableViewController, with the addition of code that voids the cache when the TweetTable is changed.

    However in doing so, the “NSCache does not copy keys as does NSMutableDictionary” feature comes up. The NSCache reference associated with a key appears to be destroyed when the object where the key (not the NSCache property) is destroyed. This implies the need to keep a key table array in the same object as the NSCache property.

    Feel free to tell me this comment should be elsewhere like stackoverflow.

Leave a Reply

Your email address will not be published.