Update note: This tutorial was updated for iOS 8 and Swift by Vincent Ngo. Original post by Tutorial team member Eli Ganem.
Welcome back to part two of this introductory tutorial on design patterns! In the first part, you learned about some fundamental patterns in Cocoa such as MVC, singletons, and decorator.
In this final part, you’ll learn about the other basic design patterns that come up a lot in iOS and OS X development: adapter, observer, and memento. Let’s get right into it!
Getting Started
You can download the project source from the end of part 1 to get started.
Here’s where you left off the sample music library app at the end of the first part:
The original plan for the app included a horizontal scroller at the top of the screen to switch between albums. Instead of coding a single-purpose horizontal scroller, why not make it reusable for any view?
To make this view reusable, all decisions about its content should be left to another object: a delegate. The horizontal scroller should declare methods that its delegate implements in order to work with the scroller, similar to how the UITableView
delegate methods work. We’ll implement this when we discuss the next design pattern.
The Adapter Pattern
An Adapter allows classes with incompatible interfaces to work together. It wraps itself around an object and exposes a standard interface to interact with that object.
If you’re familiar with the Adapter pattern then you’ll notice that Apple implements it in a slightly different manner – Apple uses protocols to do the job. You may be familiar with protocols like UITableViewDelegate
, UIScrollViewDelegate
, NSCoding
and NSCopying
. As an example, with the NSCopying
protocol, any class can provide a standard copy
method.
How to Use the Adapter Pattern
The horizontal scroller mentioned before will look like this:
To begin implementing it, right click on the View group in the Project Navigator, select New File… and select, iOS > Cocoa Touch class and then click Next. Set the class name to HorizontalScroller
and make it a subclass of UIView
.
Open HorizontalScroller.swift and insert the following code above the class HorizontalScroller
line:
@objc protocol HorizontalScrollerDelegate { } |
This defines a protocol named HorizontalScrollerDelegate
. You’re including @objc
before the protocol declaration so you can make use of @optional
delegate methods like in Objective-C.
You define the required and optional methods that the delegate will implement between the protocols curly braces. So add the following protocol methods:
// ask the delegate how many views he wants to present inside the horizontal scroller func numberOfViewsForHorizontalScroller(scroller: HorizontalScroller) -> Int // ask the delegate to return the view that should appear at <index> func horizontalScrollerViewAtIndex(scroller: HorizontalScroller, index:Int) -> UIView // inform the delegate what the view at <index> has been clicked func horizontalScrollerClickedViewAtIndex(scroller: HorizontalScroller, index:Int) // ask the delegate for the index of the initial view to display. this method is optional // and defaults to 0 if it's not implemented by the delegate optional func initialViewIndex(scroller: HorizontalScroller) -> Int |
Here you have both required and optional methods. Required methods must be implemented by the delegate and usually contain some data that is absolutely required by the class. In this case, the required details are the number of views, the view at a specific index, and the behavior when the view is tapped. The optional method here is the initial view; if it’s not implemented then the HorizontalScroller
will default to the first index.
In HorizontalScroller.swift, add the following code to the HorizontalScroller
class definition:
weak var delegate: HorizontalScrollerDelegate? |
The attribute of the property you created above is defined as weak
. This is necessary in order to prevent a retain cycle. If a class keeps a strong
reference to its delegate and the delegate keeps a strong reference back to the conforming class, your app will leak memory since neither class will release the memory allocated to the other. All properties in swift are strong by default!
The delegate is an optional, so it’s possible whoever is using this class doesn’t provide a delegate. But if they do, it will conform to HorizontalScrollerDelegate
and you can be sure the protocol methods will be implemented there.
Add a few more properties to the class:
// 1 private let VIEW_PADDING = 10 private let VIEW_DIMENSIONS = 100 private let VIEWS_OFFSET = 100 // 2 private var scroller : UIScrollView! // 3 var viewArray = [UIView]() |
Taking each comment block in turn:
- Define constants to make it easy to modify the layout at design time. The view’s dimensions inside the scroller will be 100 x 100 with a 10 point margin from its enclosing rectangle.
- Create the scroll view containing the views.
- Create an array that holds all the album covers.
Next you need to implement the initializers. Add the following methods:
override init(frame: CGRect) { super.init(frame: frame) initializeScrollView() } required init(coder aDecoder: NSCoder) { super.init(coder: aDecoder) initializeScrollView() } func initializeScrollView() { //1 scroller = UIScrollView() addSubview(scroller) //2 scroller.setTranslatesAutoresizingMaskIntoConstraints(false) //3 self.addConstraint(NSLayoutConstraint(item: scroller, attribute: .Leading, relatedBy: .Equal, toItem: self, attribute: .Leading, multiplier: 1.0, constant: 0.0)) self.addConstraint(NSLayoutConstraint(item: scroller, attribute: .Trailing, relatedBy: .Equal, toItem: self, attribute: .Trailing, multiplier: 1.0, constant: 0.0)) self.addConstraint(NSLayoutConstraint(item: scroller, attribute: .Top, relatedBy: .Equal, toItem: self, attribute: .Top, multiplier: 1.0, constant: 0.0)) self.addConstraint(NSLayoutConstraint(item: scroller, attribute: .Bottom, relatedBy: .Equal, toItem: self, attribute: .Bottom, multiplier: 1.0, constant: 0.0)) //4 let tapRecognizer = UITapGestureRecognizer(target: self, action:Selector("scrollerTapped:")) scroller.addGestureRecognizer(tapRecognizer) } |
The initializers delegate most of the work to initializeScrollView()
. Here’s what’s going on in that method:
- Create’s a new
UIScrollView
instance and add it to the parent view. - Turn off autoresizing masks. This is so you can apply your own constraints
- Apply constraints to the scrollview. You want the scroll view to completely fill the
HorizontalScroller
- Create a tap gesture recognizer. The tap gesture recognizer detects touches on the scroll view and checks if an album cover has been tapped. If so, it will notify the
HorizontalScroller
delegate.
Now add this method:
func scrollerTapped(gesture: UITapGestureRecognizer) { let location = gesture.locationInView(gesture.view) if let delegate = self.delegate { for index in 0..<delegate.numberOfViewsForHorizontalScroller(self) { let view = scroller.subviews[index] as UIView if CGRectContainsPoint(view.frame, location) { delegate.horizontalScrollerClickedViewAtIndex(self, index: index) scroller.setContentOffset(CGPointMake(view.frame.origin.x - self.frame.size.width/2 + view.frame.size.width/2, 0), animated:true) break } } } } |
The gesture passed in as a parameter lets you extract the location with locationInView()
.
Next, you invoke numberOfViewsForHorizontalScroller()
on the delegate. The HorizontalScroller
instance has no information about the delegate other than knowing it can safely send this message since the delegate must conform to the HorizontalScrollerDelegate
protocol.
For each view in the scroll view, perform a hit test using CGRectContainsPoint
to find the view that was tapped. When the view is found, call the delegate method horizontalScrollerClickedViewAtIndex
. Before you break out of the for loop, center the tapped view in the scroll view.
Next add the following to access an album cover from the scroller:
func viewAtIndex(index :Int) -> UIView { return viewArray[index] } |
viewAtIndex
simply returns the view at a particular index. You will be using this method later to highlight the album cover you have tapped on.
Now add the following code to reload the scroller:
func reload() { // 1 - Check if there is a delegate, if not there is nothing to load. if let delegate = self.delegate { //2 - Will keep adding new album views on reload, need to reset. viewArray = [] let views: NSArray = scroller.subviews // 3 - remove all subviews views.enumerateObjectsUsingBlock { (object: AnyObject!, idx: Int, stop: UnsafeMutablePointer<ObjCBool>) -> Void in object.removeFromSuperview() } // 4 - xValue is the starting point of the views inside the scroller var xValue = VIEWS_OFFSET for index in 0..<delegate.numberOfViewsForHorizontalScroller(self) { // 5 - add a view at the right position xValue += VIEW_PADDING let view = delegate.horizontalScrollerViewAtIndex(self, index: index) view.frame = CGRectMake(CGFloat(xValue), CGFloat(VIEW_PADDING), CGFloat(VIEW_DIMENSIONS), CGFloat(VIEW_DIMENSIONS)) scroller.addSubview(view) xValue += VIEW_DIMENSIONS + VIEW_PADDING // 6 - Store the view so we can reference it later viewArray.append(view) } // 7 scroller.contentSize = CGSizeMake(CGFloat(xValue + VIEWS_OFFSET), frame.size.height) // 8 - If an initial view is defined, center the scroller on it if let initialView = delegate.initialViewIndex?(self) { scroller.setContentOffset(CGPointMake(CGFloat(initialView)*CGFloat((VIEW_DIMENSIONS + (2 * VIEW_PADDING))), 0), animated: true) } } } |
The reload
method is modeled after reloadData
in UITableView
; it reloads all the data used to construct the horizontal scroller.
Stepping through the code comment-by-comment:
- Checks to see if there is a delegate before we perform any reload.
- Since you’re clearing the album covers, you also need to reset the
viewArray
. If not you will have a ton of views left over from the previous covers. - Remove all the subviews previously added to the scroll view.
- All the views are positioned starting from the given offset. Currently it’s 100, but it can be easily tweaked by changing the constant
VIEW_OFFSET
at the top of the file. - The
HorizontalScroller
asks its delegate for the views one at a time and it lays them next to each another horizontally with the previously defined padding. - Store the view in
viewArray
to keep track of all the views in the scroll view. - Once all the views are in place, set the content offset for the scroll view to allow the user to scroll through all the albums covers.
- The
HorizontalScroller
checks if its delegate implementsinitialViewIndex()
. This check is necessary because that particular protocol method is optional. If the delegate doesn’t implement this method, 0 is used as the default value. Finally, this piece of code sets the scroll view to center the initial view defined by the delegate.
You execute reload
when your data has changed. You also need to call this method when you add HorizontalScroller
to another view. Add the following code to HorizontalScroller.swift to cover the latter scenario:
override func didMoveToSuperview() { reload() } |
didMoveToSuperview
is called on a view when it’s added to another view as a subview. This is the right time to reload the contents of the scroller.
The last piece of the HorizontalScroller
puzzle is to make sure the album you’re viewing is always centered inside the scroll view. To do this, you’ll need to perform some calculations when the user drags the scroll view with their finger.
Add the following method:
func centerCurrentView() { var xFinal = scroller.contentOffset.x + CGFloat((VIEWS_OFFSET/2) + VIEW_PADDING) let viewIndex = xFinal / CGFloat((VIEW_DIMENSIONS + (2*VIEW_PADDING))) xFinal = viewIndex * CGFloat(VIEW_DIMENSIONS + (2*VIEW_PADDING)) scroller.setContentOffset(CGPointMake(xFinal, 0), animated: true) if let delegate = self.delegate { delegate.horizontalScrollerClickedViewAtIndex(self, index: Int(viewIndex)) } } |
The above code takes into account the current offset of the scroll view and the dimensions and the padding of the views in order to calculate the distance of the current view from the center. The last line is important: once the view is centered, you then inform the delegate that the selected view has changed.
To detect that the user finished dragging inside the scroll view, you’ll need to implement some UIScrollViewDelegate methods. Add the following class extension to the bottom of the file; remember, this must be added after the curly braces of the main class declaration!
extension HorizontalScroller: UIScrollViewDelegate { func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { centerCurrentView() } } func scrollViewDidEndDecelerating(scrollView: UIScrollView) { centerCurrentView() } } |
scrollViewDidEndDragging(_:willDecelerate:)
informs the delegate when the user finishes dragging. The decelerate
parameter is true if the scroll view hasn’t come to a complete stop yet. When the scroll action ends, the the system calls scrollViewDidEndDecelerating
. In both cases you should call the new method to center the current view since the current view probably has changed after the user dragged the scroll view.
Your HorizontalScroller
is ready for use! Browse through the code you’ve just written; you’ll see there’s not one single mention of the Album
or AlbumView
classes. That’s excellent, because this means that the new scroller is truly independent and reusable.
Build your project to make sure everything compiles properly.
Now that HorizontalScroller
is complete, it’s time to use it in your app. First, open Main.storyboard. Click on the top gray rectangular view and click on the identity inspector. Change the class name to HorizontalScroller as shown below:
Next, open the assistant editor and control drag from the gray rectangular view to ViewController.swift to create an outlet. Name the name the outlet scroller, as shown below:
Next, open ViewController.swift. It’s time to start implementing some of the HorizontalScrollerDelegate
methods!
Add the following extension to the bottom of the file:
extension ViewController: HorizontalScrollerDelegate { func horizontalScrollerClickedViewAtIndex(scroller: HorizontalScroller, index: Int) { //1 let previousAlbumView = scroller.viewAtIndex(currentAlbumIndex) as AlbumView previousAlbumView.highlightAlbum(didHighlightView: false) //2 currentAlbumIndex = index //3 let albumView = scroller.viewAtIndex(index) as AlbumView albumView.highlightAlbum(didHighlightView: true) //4 showDataForAlbum(index) } } |
Let’s go over the delegate method you just implemented line by line:
- First you grab the previously selected album, and deselect the album cover.
- Store the current album cover index you just clicked
- Grab the album cover that is currently selected and highlight the selection.
- Display the data for the new album within the table view.
Next, add the following method to the extension:
func numberOfViewsForHorizontalScroller(scroller: HorizontalScroller) -> (Int) { return allAlbums.count } |
This, as you’ll recognize, is the protocol method returning the number of views for the scroll view. Since the scroll view will display covers for all the album data, the count is the number of album records.
Now, add this code:
func horizontalScrollerViewAtIndex(scroller: HorizontalScroller, index: Int) -> (UIView) { let album = allAlbums[index] let albumView = AlbumView(frame: CGRectMake(0, 0, 100, 100), albumCover: album.coverUrl) if currentAlbumIndex == index { albumView.highlightAlbum(didHighlightView: true) } else { albumView.highlightAlbum(didHighlightView: false) } return albumView } |
Here you create a new AlbumView
, next check to see whether or not the user has selected this album. Then you can set it as highlighted or not depending on whether the album is selected. Lastly, you pass it to the HorizontalScroller
.
That’s it! Only three short methods to display a nice looking horizontal scroller.
Yes, you still need to actually create the scroller and add it to your main view but before doing that, add the following method to the main class definition:
func reloadScroller() { allAlbums = LibraryAPI.sharedInstance.getAlbums() if currentAlbumIndex < 0 { currentAlbumIndex = 0 } else if currentAlbumIndex >= allAlbums.count { currentAlbumIndex = allAlbums.count - 1 } scroller.reload() showDataForAlbum(currentAlbumIndex) } |
This method loads album data via LibraryAPI
and then sets the currently displayed view based on the current value of the current view index. If the current view index is less than 0, meaning that no view was currently selected, then the first album in the list is displayed. Otherwise, the last album is displayed.
Now, initialize the scroller by adding the following code to the end of viewDidLoad
:
scroller.delegate = self reloadScroller() |
Since the HorizontalScroller
was created in the storyboard, all you need to do is set the delegate, and call reloadScroller()
, which will load the subviews for the scroller to display album data.
UITableViewDelegate
and UITableViewDataSource
are a good example, since they are both protocols of UITableView
. Try to design your protocols so that each one handles one specific area of functionality.Build and run your project and take a look at your awesome new horizontal scroller:
Uh, wait. The horizontal scroller is in place, but where are the covers?
Ah, that’s right — you didn’t implement the code to download the covers yet. To do that, you’ll need to add a way to download images. Since all your access to services goes through LibraryAPI
, that’s where this new method would have to go. However, there are a few things to consider first:
AlbumView
shouldn’t work directly withLibraryAPI
. You don’t want to mix view logic with communication logic.- For the same reason,
LibraryAPI
shouldn’t know aboutAlbumView
. LibraryAPI
needs to informAlbumView
once the covers are downloaded since theAlbumView
has to display the covers.
Sounds like a conundrum? Don’t despair, you’ll learn how to do this using the Observer pattern! :]
The Observer Pattern
In the Observer pattern, one object notifies other objects of any state changes. The objects involved don’t need to know about one another – thus encouraging a decoupled design. This pattern’s most often used to notify interested objects when a property has changed.
The usual implementation requires that an observer registers interest in the state of another object. When the state changes, all the observing objects are notified of the change.
If you want to stick to the MVC concept (hint: you do), you need to allow Model objects to communicate with View objects, but without direct references between them. And that’s where the Observer pattern comes in.
Cocoa implements the observer pattern in two familiar ways: Notifications and Key-Value Observing (KVO).
Notifications
Not be be confused with Push or Local notifications, Notifications are based on a subscribe-and-publish model that allows an object (the publisher) to send messages to other objects (subscribers/listeners). The publisher never needs to know anything about the subscribers.
Notifications are heavily used by Apple. For example, when the keyboard is shown/hidden the system sends a UIKeyboardWillShowNotification
/UIKeyboardWillHideNotification
, respectively. When your app goes to the background, the system sends a UIApplicationDidEnterBackgroundNotification
notification.
Note: Open up UIApplication.swift, at the end of the file you’ll see a list of over 20 notifications sent by the system.
How to Use Notifications
Go to AlbumView.swift and insert the following code to the end of the init(frame: CGRect, albumCover: String)
initializer:
NSNotificationCenter.defaultCenter().postNotificationName("BLDownloadImageNotification", object: self, userInfo: ["imageView":coverImage, "coverUrl" : albumCover]) |
This line sends a notification through the NSNotificationCenter
singleton. The notification info contains the UIImageView
to populate and the URL of the cover image to be downloaded. That’s all the information you need to perform the cover download task.
Add the following line to init
in LibraryAPI.swift, directly after super.init()
:
NSNotificationCenter.defaultCenter().addObserver(self, selector:"downloadImage:", name: "BLDownloadImageNotification", object: nil) |
This is the other side of the equation: the observer. Every time an AlbumView
class posts a BLDownloadImageNotification
notification, since LibraryAPI
has registered as an observer for the same notification, the system notifies LibraryAPI
. Then LibraryAPI
calls downloadImage()
in response.
However, before you implement downloadImage() you must remember to unsubscribe from this notification when your class is deallocated. If you do not properly unsubscribe from a notification your class registered for, a notification might be sent to a deallocated instance. This can result in application crashes.
Add the following method to LibraryAPI.swift:
deinit { NSNotificationCenter.defaultCenter().removeObserver(self) } |
When this object is deallocated, it removes itself as an observer from all notifications it had registered for.
There’s one more thing to do. It would probably be a good idea to save the downloaded covers locally so the app won’t need to download the same covers over and over again.
Open PersistencyManager.swift and add the methods below:
func saveImage(image: UIImage, filename: String) { let path = NSHomeDirectory().stringByAppendingString("/Documents/\(filename)") let data = UIImagePNGRepresentation(image) data.writeToFile(path, atomically: true) } func getImage(filename: String) -> UIImage? { var error: NSError? let path = NSHomeDirectory().stringByAppendingString("/Documents/\(filename)") let data = NSData(contentsOfFile: path, options: .UncachedRead, error: &error) if let unwrappedError = error { return nil } else { return UIImage(data: data!) } } |
This code is pretty straightforward. The downloaded images will be saved in the Documents directory, and getImage()
will return nil
if a matching file is not found in the Documents directory.
Now add the following method to LibraryAPI.swift:
func downloadImage(notification: NSNotification) { //1 let userInfo = notification.userInfo as [String: AnyObject] var imageView = userInfo["imageView"] as UIImageView? let coverUrl = userInfo["coverUrl"] as NSString //2 if let imageViewUnWrapped = imageView { imageViewUnWrapped.image = persistencyManager.getImage(coverUrl.lastPathComponent) if imageViewUnWrapped.image == nil { //3 dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), { () -> Void in let downloadedImage = self.httpClient.downloadImage(coverUrl) //4 dispatch_sync(dispatch_get_main_queue(), { () -> Void in imageViewUnWrapped.image = downloadedImage self.persistencyManager.saveImage(downloadedImage, filename: coverUrl.lastPathComponent) }) }) } } } |
Here’s a breakdown of the above code:
downloadImage
is executed via notifications and so the method receives the notification object as a parameter. TheUIImageView
and image URL are retrieved from the notification.- Retrieve the image from the
PersistencyManager
if it’s been downloaded previously. - If the image hasn’t already been downloaded, then retrieve it using
HTTPClient
. - When the download is complete, display the image in the image view and use the
PersistencyManager
to save it locally.
Again, you’re using the Facade pattern to hide the complexity of downloading an image from the other classes. The notification sender doesn’t care if the image came from the web or from the file system.
Build and run your app and check out the beautiful covers inside your HorizontalScroller
:
Stop your app and run it again. Notice that there’s no delay in loading the covers because they’ve been saved locally. You can even disconnect from the Internet and your app will work flawlessly. However, there’s one odd bit here: the spinner never stops spinning! What’s going on?
You started the spinner when downloading the image, but you haven’t implemented the logic to stop the spinner once the image is downloaded. You could send out a notification every time an image has been downloaded, but instead, you’ll do that using the other Observer pattern, KVO.
Key-Value Observing (KVO)
In KVO, an object can ask to be notified of any changes to a specific property; either its own or that of another object. If you’re interested, you can read more about this on Apple’s KVO Programming Guide.
How to Use the KVO Pattern
As mentioned above, the KVO mechanism allows an object to observe changes to a property. In your case, you can use KVO to observe changes to the image
property of the UIImageView
that holds the image.
Open AlbumView.swift and add the following code to init(frame:albumCover:)
, just after you add coverImage
as a subView:
coverImage.addObserver(self, forKeyPath: "image", options: nil, context: nil) |
This adds self
, which is the current class, as an observer for the image
property of coverImage
.
You also need to unregister as an observer when you’re done. Still in AlbumView.swift, add the following code:
deinit { coverImage.removeObserver(self, forKeyPath: "image") } |
Finally, add this method:
override func observeValueForKeyPath(keyPath: String, ofObject object: AnyObject, change: [NSObject : AnyObject], context: UnsafeMutablePointer<Void>) { if keyPath == "image" { indicator.stopAnimating() } } |
You must implement this method in every class acting as an observer. The system executes this method every time an observed property changes. In the above code, you stop the spinner when the “image” property changes. This way, when an image is loaded, the spinner will stop spinning.
Build and run your project. The spinner should disappear:
If you play around with your app a bit and terminate it, you’ll notice that the state of your app isn’t saved. The last album you viewed won’t be the default album when the app launches.
To correct this, you can make use of the next pattern on the list: Memento.
The Memento Pattern
The memento pattern captures and externalizes an object’s internal state. In other words, it saves your stuff somewhere. Later on, this externalized state can be restored without violating encapsulation; that is, private data remains private.
How to Use the Memento Pattern
Add the following two methods to ViewController.swift:
//MARK: Memento Pattern func saveCurrentState() { // When the user leaves the app and then comes back again, he wants it to be in the exact same state // he left it. In order to do this we need to save the currently displayed album. // Since it's only one piece of information we can use NSUserDefaults. NSUserDefaults.standardUserDefaults().setInteger(currentAlbumIndex, forKey: "currentAlbumIndex") } func loadPreviousState() { currentAlbumIndex = NSUserDefaults.standardUserDefaults().integerForKey("currentAlbumIndex") showDataForAlbum(currentAlbumIndex) } |
saveCurrentState
saves the current album index to NSUserDefaults
– NSUserDefaults
is a standard data store provided by iOS for saving application specific settings and data.
loadPreviousState
loads the previously saved index. This isn’t quite the full implementation of the Memento pattern, but you’re getting there.
Now, Add the following line to viewDidLoad
in ViewController.swift before the scroller.delegate = self
:
loadPreviousState() |
That loads the previously saved state when the app starts. But where do you save the current state of the app for loading from? You’ll use Notifications to do this. iOS sends a UIApplicationDidEnterBackgroundNotification
notification when the app enters the background. You can use this notification to call saveCurrentState
. Isn’t that convenient?
Add the following line to the end of viewDidLoad
:
NSNotificationCenter.defaultCenter().addObserver(self, selector:"saveCurrentState", name: UIApplicationDidEnterBackgroundNotification, object: nil) |
Now, when the app is about to enter the background, the ViewController
will automatically save the current state by calling saveCurrentState
.
As always, you’ll need to un-register for notifications. Add the following code to the class:
deinit { NSNotificationCenter.defaultCenter().removeObserver(self) } |
This ensures you remove the class as an observer when the ViewController
is deallocated.
Build and run your app. Navigate to one of the albums, send the app to the background with the Home button (Command+Shift+H if you are on the simulator) and then shut down your app from Xcode. Relaunch, and check that the previously selected album is centered:
It looks like the album data is correct, but the scroller isn’t centered on the correct album. What gives?
This is what the optional method initialViewIndexForHorizontalScroller
was meant for! Since that method’s not implemented in the delegate, ViewController
in this case, the initial view is always set to the first view.
To fix that, add the following code to ViewController.swift:
func initialViewIndex(scroller: HorizontalScroller) -> Int { return currentAlbumIndex } |
Now the HorizontalScroller
first view is set to whatever album is indicated by currentAlbumIndex
. This is a great way to make sure the app experience remains personal and resumable.
Run your app again. Scroll to an album as before, put the app in the background, stop the app, then relaunch to make sure the problem is fixed:
If you look at PersistencyManager
‘s init
, you’ll notice the album data is hardcoded and recreated every time PersistencyManager
is created. But it’s better to create the list of albums once and store them in a file. How would you save the Album data to a file?
One option is to iterate through Album
‘s properties, save them to a plist file and then recreate the Album
instances when they’re needed. This isn’t the best option, as it requires you to write specific code depending on what data/properties are there in each class. For example, if you later created a Movie class with different properties, the saving and loading of that data would require new code.
Additionally, you won’t be able to save the private variables for each class instance since they are not accessible to an external class. That’s exactly why Apple created the archiving mechanism.
Archiving
One of Apple’s specialized implementations of the Memento pattern is Archiving. This converts an object into a stream that can be saved and later restored without exposing private properties to external classes. You can read more about this functionality in Chapter 16 of the iOS 6 by Tutorials book. Or in Apple’s Archives and Serializations Programming Guide.
How to Use Archiving
First, you need to declare that Album
can be archived by conforming to the NSCoding
protocol. Open Album.swift and change the class
line as follows:
class Album: NSObject, NSCoding { |
Add the following two methods to Album.swift:
required init(coder decoder: NSCoder) { super.init() self.title = decoder.decodeObjectForKey("title") as String? self.artist = decoder.decodeObjectForKey("artist") as String? self.genre = decoder.decodeObjectForKey("genre") as String? self.coverUrl = decoder.decodeObjectForKey("cover_url") as String? self.year = decoder.decodeObjectForKey("year") as String? } func encodeWithCoder(aCoder: NSCoder) { aCoder.encodeObject(title, forKey: "title") aCoder.encodeObject(artist, forKey: "artist") aCoder.encodeObject(genre, forKey: "genre") aCoder.encodeObject(coverUrl, forKey: "cover_url") aCoder.encodeObject(year, forKey: "year") } |
As part of the NSCoding
protocol, encodeWithCoder
will be called when you ask for an Album
instance to be archived. Conversely, the init(coder:)
initializer will be used to reconstruct or unarchive from a saved instance. It’s simple, yet powerful.
Now that the Album
class can be archived, add the code that actually saves and loads the list of albums.
Add the following method to PersistencyManager.swift:
func saveAlbums() { var filename = NSHomeDirectory().stringByAppendingString("/Documents/albums.bin") let data = NSKeyedArchiver.archivedDataWithRootObject(albums) data.writeToFile(filename, atomically: true) } |
This will be the method that’s called to save the albums. NSKeyedArchiver
archives the album array into a file called albums.bin.
When you archive an object which contains other objects, the archiver automatically tries to recursively archive the child objects and any child objects of the children and so on. In this instance, the archival starts with albums
, which is an array of Album instances. Since Array
and Album
both support the NSCopying interface, everything in the array is automatically archived.
Now replace init
in PersistencyManager.swift with the following code:
override init() { super.init() if let data = NSData(contentsOfFile: NSHomeDirectory().stringByAppendingString("/Documents/albums.bin")) { let unarchiveAlbums = NSKeyedUnarchiver.unarchiveObjectWithData(data) as [Album]? if let unwrappedAlbum = unarchiveAlbums { albums = unwrappedAlbum } } else { createPlaceholderAlbum() } } func createPlaceholderAlbum() { //Dummy list of albums let album1 = Album(title: "Best of Bowie", artist: "David Bowie", genre: "Pop", coverUrl: "http://www.coversproject.com/static/thumbs/album/album_david%20bowie_best%20of%20bowie.png", year: "1992") let album2 = Album(title: "It's My Life", artist: "No Doubt", genre: "Pop", coverUrl: "http://www.coversproject.com/static/thumbs/album/album_no%20doubt_its%20my%20life%20%20bathwater.png", year: "2003") let album3 = Album(title: "Nothing Like The Sun", artist: "Sting", genre: "Pop", coverUrl: "http://www.coversproject.com/static/thumbs/album/album_sting_nothing%20like%20the%20sun.png", year: "1999") let album4 = Album(title: "Staring at the Sun", artist: "U2", genre: "Pop", coverUrl: "http://www.coversproject.com/static/thumbs/album/album_u2_staring%20at%20the%20sun.png", year: "2000") let album5 = Album(title: "American Pie", artist: "Madonna", genre: "Pop", coverUrl: "http://www.coversproject.com/static/thumbs/album/album_madonna_american%20pie.png", year: "2000") albums = [album1, album2, album3, album4, album5] saveAlbums() } |
You have moved the placeholder album creation code into a separate method createPlaceholderAlbum()
for readability. In the new code, NSKeyedUnarchiver
loads the album data from the file, if it exists. If it doesn’t exist, it creates the album data and immediately saves it for the next launch of the app.
You’ll also want to save the album data every time the app goes into the background. This might not seem necessary now but what if you later add the option to change album data? Then you’d want this to ensure that all your changes are saved.
Since the main application accesses all services via LibraryAPI
, this is how the application will let PersistencyManager
know that it needs to save album data.
Now add the method implementation to LibraryAPI.swift:
func saveAlbums() { persistencyManager.saveAlbums() } |
This code simply passes on a call to LibraryAPI
to save the albums on to PersistencyMangaer
.
Add the following code to the end of saveCurrentState
in ViewController.swift:
LibraryAPI.sharedInstance.saveAlbums() |
And the above code uses LibraryAPI
to trigger the saving of album data whenever the ViewController saves its state.
Build your app to check that everything compiles.
Unfortunately, there’s no easy way to check if the data persistency is correct though. You can check the simulator Documents folder for your app in Finder to see that the album data file is created but in order to see any other changes you’d have to add in the ability to change album data.
But instead of changing data, what if you added an option to delete albums you no longer want in your library? Additionally, wouldn’t it be nice to have an undo option if you delete an album by mistake?
Final Touches
You are going to add the final touches to your music application by allowing the user to perform delete actions to remove an album, or undo actions in case they change their mind!
Add the following property to ViewController:
// We will use this array as a stack to push and pop operation for the undo option var undoStack: [(Album, Int)] = [] |
This creates an empty undo stack. The undoStack will hold a tuple of two arguments. The first is an Album
and the second is the index of the album.
Add the following code after reloadScroller()
in viewDidLoad:
:
let undoButton = UIBarButtonItem(barButtonSystemItem: .Undo, target: self, action:"undoAction") undoButton.enabled = false; let space = UIBarButtonItem(barButtonSystemItem: .FlexibleSpace, target:nil, action:nil) let trashButton = UIBarButtonItem(barButtonSystemItem: .Trash, target:self, action:"deleteAlbum") let toolbarButtonItems = [undoButton, space, trashButton] toolbar.setItems(toolbarButtonItems, animated: true) |
The above code creates a toolbar with two buttons and a flexible space between them. The undo button is disabled here because the undo stack starts off empty. Note that the toolbar is already in the storyboard, so all you need to do is set the toolbar items.
You’ll add three method to ViewController.swift for handling album management actions: add, delete, and undo.
The first is the method for adding a new album:
func addAlbumAtIndex(album: Album,index: Int) { LibraryAPI.sharedInstance.addAlbum(album, index: index) currentAlbumIndex = index reloadScroller() } |
Here you add the album, set it as the current album index, and reload the scroller.
Next comes the delete method:
func deleteAlbum() { //1 var deletedAlbum : Album = allAlbums[currentAlbumIndex] //2 var undoAction = (deletedAlbum, currentAlbumIndex) undoStack.insert(undoAction, atIndex: 0) //3 LibraryAPI.sharedInstance.deleteAlbum(currentAlbumIndex) reloadScroller() //4 let barButtonItems = toolbar.items as [UIBarButtonItem] var undoButton : UIBarButtonItem = barButtonItems[0] undoButton.enabled = true //5 if (allAlbums.count == 0) { var trashButton : UIBarButtonItem = barButtonItems[2] trashButton.enabled = false } } |
Consider each commented section below:
- Get the album to delete.
- Create a variable called
undoAction
which stores a tuple ofAlbum
and the index of the album. You then add the tuple into the stack - Use
LibraryAPI
to delete the album from the data structure and reload the scroller. - Since there’s an action in the undo stack, you need to enable the undo button.
- Lastly check to see if there are any albums left; if there aren’t any you can disable the trash button.
Finally, add the method for the undo action:
func undoAction() { let barButtonItems = toolbar.items as [UIBarButtonItem] //1 if undoStack.count > 0 { let (deletedAlbum, index) = undoStack.removeAtIndex(0) addAlbumAtIndex(deletedAlbum, index: index) } //2 if undoStack.count == 0 { var undoButton : UIBarButtonItem = barButtonItems[0] undoButton.enabled = false } //3 let trashButton : UIBarButtonItem = barButtonItems[2] trashButton.enabled = true } |
Finally consider the comments for the method above:
- The method “pops” the object out of the stack, giving you a tuple containing the deleted
Album
and its index. You then proceed to add the album back. - Since you also deleted the last object in the stack when you “popped” it, you now need to check if the stack is empty. If it is, that means that there are no more actions to undo. So you disable the Undo button.
- You also know that since you undid an action, there should be at least one album cover. Hence you enable the trash button.
Build and run your app to test out your undo mechanism, delete an album (or two) and hit the Undo button to see it in action:
This is also a good place to test out whether changes to your album data is retained between sessions. Now, if you delete an album, send the app to the background, and then terminate the app, the next time you start the app the displayed album list should reflect the deletion.
If you want to get all the albums back, just delete the app and run it again from Xcode to install a fresh copy with the starter data.
Where to go from here?
Here’s the source code for the finished project: BlueLibrarySwift-Final
In this tutorial you saw how to harness the power of iOS design patterns to perform complicated tasks in a very straightforward and loosely coupled manner. You’ve learned a lot of iOS design patterns and concepts: Singleton, MVC, Delegation, Protocols, Facade, Observer, and Memento.
Your final code is loosely coupled, reusable, and readable. If another developer looks at your code, they’ll easily be able to understand what’s going on and what each class does in your app.
The point isn’t to use a design pattern for every line of code you write. Instead, be aware of design patterns when you consider how to solve a particular problem, especially in the early stages of designing your app. They’ll make your life as a developer much easier and your code a lot better!
The long-standing classic book on the topic is Design Patterns: Elements of Reusable Object-Oriented Software. For code samples, check out the awesome project Design Patterns implemented in Swift on GitHub for many more design patters coded up in Swift.
Finally, be sure to check out Intermediate Design Patterns in Swift for even more design patterns!
Have more to say or ask about design patterns? Join in on the forum discussion below!
Introducing iOS Design Patterns in Swift – Part 2/2 is a post from: Ray Wenderlich
The post Introducing iOS Design Patterns in Swift – Part 2/2 appeared first on Ray Wenderlich.