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:
designPatternDataTable
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 UITableViewDelegateUIScrollViewDelegateNSCoding 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:
swiftDesignPattern7
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:
  1. 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.
  2. Create the scroll view containing the views.
  3. 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:
  1. Create’s a new UIScrollView instance and add it to the parent view.
  2. Turn off autoresizing masks. This is so you can apply your own constraints
  3. Apply constraints to the scrollview. You want the scroll view to completely fill the HorizontalScroller
  4. 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:
  1. Checks to see if there is a delegate before we perform any reload.
  2. 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.
  3. Remove all the subviews previously added to the scroll view.
  4. 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.
  5. 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.
  6. Store the view in viewArray to keep track of all the views in the scroll view.
  7. 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.
  8. The HorizontalScroller checks if its delegate implements initialViewIndex(). 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:
swiftDesignPattwern9
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:
swiftDesignPattern10
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:
  1. First you grab the previously selected album, and deselect the album cover.
  2. Store the current album cover index you just clicked
  3. Grab the album cover that is currently selected and highlight the selection.
  4. 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:
swiftDesignPattern12
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:
  1. AlbumView shouldn’t work directly with LibraryAPI. You don’t want to mix view logic with communication logic.
  2. For the same reason, LibraryAPI shouldn’t know about AlbumView.
  3. LibraryAPI needs to inform AlbumView once the covers are downloaded since the AlbumView has to display the covers.

Comments

  1. This is a very nice article. thank you for publishing this. i can understand this easily.!!..iOS Swift Online Training Hyderabad

    ReplyDelete

Post a Comment

Popular posts from this blog

iOS Architecture

Performance Tips for IOS Application

setNeedsLayout vs layoutIfNeeded Explained