The Adapter Pattern
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 = 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(CGPoint(x: view.frame.origin.x - self.frame.size.width/2 + view.frame.size.width/2, y: 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 = delegate { //2 - Will keep adding new album views on reload, need to reset. viewArray = [] let views: NSArray = scroller.subviews // 3 - remove all subviews for view in views { view.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(CGPoint(x: CGFloat(initialView)*CGFloat((VIEW_DIMENSIONS + (2 * VIEW_PADDING))), y: 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 = Int(scroller.contentOffset.x) + (VIEWS_OFFSET/2) + VIEW_PADDING let viewIndex = xFinal / (VIEW_DIMENSIONS + (2*VIEW_PADDING)) xFinal = viewIndex * (VIEW_DIMENSIONS + (2*VIEW_PADDING)) scroller.setContentOffset(CGPoint(x: xFinal, y: 0), animated: true) if let delegate = 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.
Lastly don’t forget to set the delegate. Within
initializeScrollView()
add the following code after scroller = UIScrollView()
:scroller.delegate = self; |
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: CGRect(x: 0, y: 0, width: 100, height: 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.
Note: If a protocol becomes too big and is packed with a lot of methods, you should consider breaking it into several smaller protocols.
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.
This is a very nice article. thank you for publishing this. i can understand this easily.!!..iOS Swift Online Training Hyderabad
ReplyDelete