Swiftで遊ぼう! on Hatena

あしたさぬきblogでやってた初心者オヤジのiOSプログラミング奮闘記がHatenaに来ました

Swiftで遊ぼう! - 435 - Swiftでデザインパターン 13 Adapter(アダプター)

2016年12月2日:Swift 3に向けて改訂中*1

最初のページに戻る -> Swiftで遊ぼう! - 423 - Swiftでデザインパターン - Swiftで遊ぼう! on Hatena

yataiblue.hatenablog.com

Introducing iOS Design Patterns in Swift – Part 2/2チュートリアルを使ってSwiftでデザインパターンの勉強に取り組んでいます。少しずつOOP(オブジェクト指向プログラミン)の意味が分かってきたのでチュートリアルの内容も理解できるようになりました。ちょうど1年前、Swiftで遊ぼう! - プログラミングまとめ(ときどき更新):Life-LOG OtherSideの9月19日の追記でデザインパターンの話題にふれましたが、あの頃は全く歯が立ちませんでした。毎日少しずつでも継続して勉強すれば私のようなオヤジでも進歩するものですね。

独学の基本は「解らないところで立ち止まらないで迂回して次に進む」です。解らない部分も回り込んで眺めると、急に理解できることがあります。しかし、私の場合、解らないところが多すぎて困っていますが(^_^;)

閑話休題、音楽ライブラリーのアルバムアートワークを表示させるテーブルビューアプリに取り組んでいます。Cellの内容を表示させるデリゲーションは前回までに組み込みました。

しかし、画面上部にアルバム・アートイメージは表示されていません。Viewは空のままです。

ここにアルバム・アートイメージを横スクロールで表示する仕組みを組み込んでいきます。

オブジェクト指向における再利用のためのデザインパターン

オブジェクト指向における再利用のためのデザインパターン

アダプター・パターン

UIViewに横スクロールをさせるしくみをデリゲーションさせて他のオブジェクトで実装していくのですが、デザインパターンAdaptor(アダプター)パターンを利用します。

いつものようにクラス図を眺めます。

f:id:yataiblue:20150912112531j:plain

これはピーンときました。本来このクラス図のTargetクラスは「Interface」になっていたのですが、Swiftでは明らかに「Protocol」のことです。

既にプロトコールを理解しているので、課題になっているデザインパターンは理解していたとになります。

アダプター・パターンは、Protocol利用なので、Swiftで遊ぼう! - 260 - プロトコールとデリゲーション ProtocolsとDelegation - Swiftで遊ぼう! on Hatenaで説明している6ステップ実装法で説明をしていきます。

空のUIVewクラスを拡張させていきます。

まず、Xcodeのメニューから「File -> New -> File...」を選択して、「iOS -> Source -> Cocoa Tocuh Class」を選び、「subclass...」からUIViewを選択してから、クラス名に「HorizontalScroller」と名前を付けます。

ステップ1:デリゲーションしたい機能をプロトコールとして用意する

HorizontalScrollerクラスの「{}」の上に次のコードを入れます。

@objc protocol HorizontalScrollerDelegate {

}

プロトコールの頭に「@objc」を付けるのですが、その理由は既に説明しています*2

デリゲーション(委任)したい機能(メソッド)を考えます。このチュートリアルでは以下の4つのメソッドを用意します。3つの必須メソッドと1つのオプショナルメソッドです。必須メソッドは具体的な実装をしないとエラーになります。HorizontalScrollerを動かすために必要な情報を集めるメソッドになります。

// 水平スクロールビューで表示させるViewの数を返すデリゲーションメソッド
func numberOfViewsForHorizontalScroller(scroller:HorizontalScroller)
                                                             -> Int
// <index>で指定されたViewをを返すデリゲーションメソッド
func horizontalScrollerViewAtIndex(scroller:HorizontalScroller, 
                                          index:Int) -> UIView
// クリックされたViewの<Index>を知らせるためのデリゲーションメソッド
func horizontalScrollerClickedViewAtIndex(scroller:HorizontalScroller, 
                                                            index:Int)
// 初期ViewをどのIndexに変更するオプショナル・デリゲーションメソッド
// デリゲーションで設定されていないと初期値0
@objc optional func initialViewIndex(scroller:HorizontalScroller) 
                             -> Int

ステップ2:HorizontalScrollerDelegateクラスのプロパティdelegateを用意する

まだHorizontalScrollerクラスにはコードがないので実装する必要があります。しかし、その前にProtocolで用意したメソッドをデリゲーションさせるためのHorizontalScrollerDelegate型delegateプロパティをコードします。必ず弱い参照型で宣言してメモリーリークが生じないようにします。

weak var delegate: HorizontalScrollerDelegate?

次はUIView型のHorizontalScrollerの初期化していきます。クラスをコーディングする時に初期化ステップは非常に重要です*3。しかし、iOSフレームワークを利用してクラスを使用する場合、できるだけイニシャライザを使用しないで、初期化メソッドでプロパティのインスタンス化をすることが推奨されています*4。ViewControllerの場合、viewDidLoad()メソッド内でプロパティをインスタンス化させるのが常套手段です。しかし、新規にクラスを作った場合は、イニシャライザは必須です
まずクラスで使用するプロパティを宣言します。宣言時にインスタンス化すればイニシャライザを単純化できます。

// MARK: Properties

// 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]()

何度も使う値は、ハードコーディングしないで必ず定数化するのも常套手段です。変更があれば1箇所の修正で済むからです。

  1. イメージを保持するコンテナビューのサイズ。100ポイント×100ポイントに10ポイントのマージンの定数
  2. Viewが保持するスクロールビュー
  3. 表示するアルバムアートをすべて保持

独自のカスタムビュー、HorizontalScrillerクラスを作るために初期化(イニシャライザ)をコードしていきます。

普通は、UIViewクラスの実装の場合、オブジェクト・ライブラリから「Ctrl + ドラッグ」するのが便利ですが、ここではコードで実装する方法で説明します。

required init?(coder aDecoder: NSCoder) {
 super.init(coder: aDecoder)
 initializeScrollView()
}
    
override init(frame: CGRect) {
 super.init(frame: frame)
 initializeScrollView()
}

カスタムUIViewの初期化は、Swiftで遊ぼう! - 248 - UIViewの初期化ステップ - Swiftで遊ぼう! on Hatenaで説明をしています。UIViewクラスの初期化は上記の2つを実装するのが決まりごとになっています。特にstoryboarにあるUIViewクラスを使うため、required init?(coder aDecoder: NSCoder)イニシャライザが重要になります。initializeScrollView()は初期化ヘルパーメソッドなので次のように用意します。

func initializeScrollView() {

// 1
    scroller = UIScrollView()
    self.addSubview(scroller)

// 2 
    scroller.translatesAutoresizingMaskIntoConstraints = 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)
}
  1. カスタムビューにUIScrollViewをサブビューとして持たせるステップです。UIScrollViewクラスのインスタンスscrollerを、デフォルトのViewに加えます。
  2. UIScrollViewは、プロパティとしてContent ViewとしてViewクラスを持ちます。UIScrollView自身の配置は、Autolayoutで調整するのですが、UIScrollView内のContent Viewの配置は、少し複雑です。実は、Autolayoutが実装されたのがiOS6以降で、それまではAutoresizingMaskというクラスを使ってレイアウト調整をしていました。しかし、このAutoresizingMaskはレガシークラスで消えていく運命です。取り扱いも変更がありました。Xcode6まで使用できた「オフ」にするメソッド、「setTranslatesAutoresizingMaskIntoConstraints(false)」は、Xcode7.1の環境でエラーになります。UIScrollViewにsetTranslatesAutoresizingMaskIntoConstraintsは存在しないため、 アップルのドキュメントを調べると「setTranslatesAutoresizingMaskIntoConstraints」は「使用すべきでない」という説明がありました。iOS9以降でこのメソッドは廃止されました。よくよく調べて見ると直接プロパティにfalseを入れることで、AutoresizingMaskをオフにできます。「scroller.translatesAutoresizingMaskIntoConstraints = false」を加えます。
  3. ViewとScrollViewの表示領域を一致させるコンストレイントを加えます。このコンストレイントを加えるメソッド... 覚えられないです(T_T) というか覚えたくないですね。コンストレイントの設定はすべてストーリーボードでしてしまいましょう。
  4. 次にscrollerにタップ機能を加えます。TapGestureに関する知識ですが、以前オブジェクトライブラリーから「Ctrl + ドラッグ」して実装したので@IBActionとして取り扱う方法を学びました。ここでは、コードでUITapGestureRecognizerを実装する方法を説明します。パラメーターでtargetは自分自身、scrollerになり、actionは発動するメソッド名「scrollerTapped」を指定します。当然、「scrollerTapped」はカスタムメソッドなので、この時点でエラーになります。

ステップ3:Horizontalデリゲーションメソッドを使って値を操作する

デリゲーションで利用できるメソッド(プロトコールで用意)は4つあります。スクロールでタップした時に使うscrollerTapped()メソッドには、「numberOfViewsForHorizontalScroller()」と「horizontalScrollerClickedViewAtIndex()」を使用して値を利用します。

func scrollerTapped(gesture: UITapGestureRecognizer) {
    let location = gesture.location(in: gesture.view)
    guard let delegate = delegate else {
        return
    }
    for index in 0..<
        delegate.numberOfViewsForHorizontalScroller(scroller: self) {
        let view = scroller.subviews[index] as UIView
        if view.frame.contains(location) {
            delegate.horizontalScrollerClickedViewAtIndex(
                scroller: 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
        }
    }
}

location(in: UIView?)メソッドはターゲットになるViewを引数と渡してCGPointが返すメソッドです。タップした位置がわかれば、デリゲーションメソッドを使ってビューを表示して、スクロールさせるという話です。「guard let delegate = delegate」という使い方も少し戸惑いました。しかし、delegateは、HorizontalScrollerDelegate型として宣言しているのでスクローラーでイベントが駆動されればインスタンス化されます。そして、delegateが存在するのであれば、そのデリゲーションメソッドが存在するので、numberOfViewsForHorizontalScroller()を使って保持するViewの数を返します。

UIScrollerView型のscrollerは[UIView]型のviewArrayを持ちます。scrollerが持っているsubviewsプロパティは[AnyObject]型あります。UIViewにキャストしてviewArrayの数だけ順番にタップされたviewはどれか確認していきます。view.frame.contains(location)メソッドは、CGRectで与えた範囲内にCGPointが含まれていたら「true」を返す便利なメソッドです。これでタップされたsubviewsのどれがタップされたのか判断します。

UIScrollViewクラスの取り扱いでかなり悩みました。レイアウト調整のAutolayout作法で解らないところがかなりあります。UISCrollerView関連のレイアウトに関する知識をまとめたいのですが、デザインパターンのアダプターに関する内容が途切れてしまうので先に進みます。

UIViewクラスを拡張(Extension)させてHorizontalScrollerを実装しています。UIScrollViewクラスのscrollerを持たせました。イニシャライザでscrollerのレイアウト調整とタップ機能も実装させました。どのサブビューをタップしたのか判定するメソッドを書いたので、次はアルバムデータを取り出すメソッドを書きます。

func viewAtIndex(index :Int) -> UIView {
    return viewArray[index]
}

まだまだこのビューの実装が終わりません。スクロールビューをリロードさせるメソッドも用意しなければなりません。この中にもデリゲーションメソッドが使用されています。「horizontalScrollerViewAtIndex()」と「initialViewIndex?()」です。

func reload() {
    
    // 1
    guard let delegate = delegate else {
        return
    }
        
    // 2
    viewArray = []
    let views: Array = scroller.subviews
        
    // 3
    for view in views {
        view.removeFromSuperview()
    }
        
    // 4
    let xValue = VIEWS_OFFSET
    for index in 0..<
        delegate.numberOfViewsForHorizontalScroller(scroller: self) {
            
        // 5 - 
        var xValue = VIEWS_OFFSET
        let view = 
            delegate.horizontalScrollerViewAtIndex(
                scroller: self, index: index)
            view.frame = CGRect(x: CGFloat(xValue), 
                                y: CGFloat(VIEW_PADDING),
                            width: CGFloat(VIEW_DIMENSIONS), 
                           height: CGFloat(VIEW_DIMENSIONS))
        scroller.addSubview(view)
        xValue += VIEW_DIMENSIONS + VIEW_PADDING
           
    // 6
    viewArray.append(view)
    }
        
    // 7
    scroller.contentSize = 
        CGSize(width: CGFloat(xValue + VIEWS_OFFSET), 
              height: frame.size.height)
        
    // 8
    if let initialView = delegate.initialViewIndex?(scroller: self) {
        scroller.setContentOffset(CGPoint(x:
            CGFloat(initialView)*CGFloat((VIEW_DIMENSIONS
                + (2 * VIEW_PADDING))), y: 0), animated: true)
    }
}

このメソッドの説明をします。

  1. デリゲートがあるかどうか確認します。なければ何もリロードされません。
  2. リロードで新しいviewを加えていきます。Array型で宣言したviewsを用意します。
  3. viewsをすべてクリアします。
  4. xValueがscrollerが持っているviewsの最初のポイントになります。
  5. viewの右端に新しいviewを加え、サイズ情報を組み入れ、scrollerに組み込みます。ポジションは右にずれます。
  6. viewの情報を保持して後で使えるようにします。
  7. scrollerのコンテンツサイズを確定します。
  8. 初期viewが定義されたら、それをscrollerの中央にします。

このreloadメソッドでscrollerの入れ籠が完成ということです。具体的な内容はデリゲートされているはずです。

このリロードをがいつ呼ぶか?。

フォーカスがスーパービューに移った時です。

override func didMoveToSuperview() {
    reload()
}

まだまだ不十分です。選択されたviewがsrollerの中心にするメソッドが必要です。

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(
            scroller: self, 
               index: Int(viewIndex))
    }
}

スクロールビューの実装でかなり時間が取られてます。学習の波があるので踏ん張りどころです。

ここまでの説明のまとめ

空のUIViewクラスにHorizontalScrollerとして機能を拡張させるためHorizontalScrollerDelegateプロトコールを用意しました。更にUIScrollerViewをプロパティとして持たせたためUIScrollerViewの機能を実装する必要があります。

プロトコールに準拠させる方法を2パターンで考えます。自作プロトコール(HorizontalScrollerDelegate)は、ViewControllerクラスに準拠させる方法をとってみます。しかし、iOSフレームワークの用意されたプロトコール(UIScrollerViewDelegate)は、前章で説明した「デコレータ・パターン」として「Extension」を利用して実装します。

HorizontalScrollerクラス宣言の「{}」外に次のコードを加えます。

extension HorizontalScroller: UIScrollViewDelegate {
    func scrollViewDidEndDragging(_ scrollView: UIScrollView, 
                     willDecelerate decelerate: Bool) {
        if !decelerate {
            centerCurrentView()
        }
    }
    
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        centerCurrentView()
    }
}

これでUIScrollViewのデリゲーションメソッドを「Extension(拡張)」という機能を使って利用できるようになりました。

次にデリゲーションメソッドを利用するためにプロパティを持つ必要があるので、initializeScrollView()メソッドの中にあるscroller = UIScrollView()の下に次のコードを加えます。

scroller.delegate = self

これでHorizontalScrollerのコーディングはすべて終了です。

ステップ4:HorizontalScrollerDelegateをViewControllerに準拠させる

HorizontalScrollerクラスはジェネリックな存在なので、これをコードの中で利用できるようにするのが次のステップです。


まずMain.storyboardを選択して、グレーのビューを選択してアイデンティティ・インスペクタにあるclassから「HorizontalScroller」を選択します。

f:id:yataiblue:20150923135441j:plain

さらに、HorizontalScrollerDelegateプロトコールに準拠させるためViewControllerのクラス宣言にプロトコールを組み込みます。

class ViewController: UIViewController, HorizontalScrollerDelegate {
....

これがステップ4です。

ステップ5:HorizontalScrollerDelegate型のプロパティに自分自身を指定する

まずUIHorizontalScrollerクラスをインスタンス化するためにViewControllerに繋げます。Main.storyboardのグレービューをViewControllerに「Ctrl + ドラッグ」して@IBOutletのインスタンスscroller」を作ります。

@IBOutlet weak var scroller: HorizontalScroller!

scrollerのプロパティdelegateに何の指定もないため、「delegate.メソッド」が使用できません。delegateがViewControllerに指定されれば、ViewControllerで「delegate.メソッド」を操作できます。

では、どこで指定するのか?答えは想像通り「viewDidLoad()メソッド」内です。

override func viewDidLoad() {
  super.viewDidLoad()
        
  self.navigationController?.navigationBar.translucent = false
  currentAlbumIndex = 0
        
  allAlbums = LibraryAPI.sharedInstance.getAlbums()
        
  dataTable.delegate = self
  dataTable.dataSource = self
        
  dataTable.backgroundView = nil
  view.addSubview(dataTable!)
        
  self.showDataForAlbum(currentAlbumIndex)
        
  scroller.delegate = self
}

既にviewDidLoad()メソッド内には今までのコードが書き込まれていますが、これでステップ5が終わりました。

ステップ5:デリゲーションメソッドのコーディング

最後のステップです。scrollerの具体的な動きをこーディンします。HorizontalScrollerDelegateで用意した3つの必須メソッドの実装です。

  1. horizontalScrollerClickedViewAtIndex()
  2. numberOfViewsForHorizontalScroller()
  3. horizontalScrollerViewAtIndex()

ViewControllerの最後の「}」の前に「// MARK: HorizontalScrollerDelegateMethods」と加えその下に次のコードを入れます。
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)
}
}
|

  1. 前に選択されていたアルバムを掴んで選択を外します。
  2. 現在選択したアルバムのインデックスを保持します。
  3. 選択したデータのアルバムデータを保持します。
  4. そのアルバムデータを表示します。

2つめのメソッドはviewの数を返すだけです。

func numberOfViewsForHorizontalScroller(scroller: HorizontalScroller) -> (Int) {
  return allAlbums.count
}

3つめのメソッドは、新しいalbumViewを作って返します。

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
}

これで実装はすべて終わりと思ったのですが、スクロールビューとテーブルビューの値を一致させないとおかしくなります。ViewControllerのメイン「{}」内に次のリロードメソッドを入れます。

func reloadScroller() {
 allAlbums = LibraryAPI.sharedInstance.getAlbums()
 if currentAlbumIndex < 0 {
  currentAlbumIndex = 0
 } else if currentAlbumIndex >= allAlbums.count {
  currentAlbumIndex = allAlbums.count - 1
 }
 scroller.reload()
 showDataForAlbum(currentAlbumIndex)
}

シングルトンパターンで用意したLibraryAPIからデータとってきてscrollerのリロードとtableViewのリロードを同期させます。

そしてこれをviewDidLoad()メソッドの「scroller.delegate = self」の下に加えます。

reloadScroller()

これでやっと形になったというところです。

f:id:yataiblue:20151207153337j:plain

これでアダプターの実装が終了です。

次はオブサーバー・パターンです。

yataiblue.hatenablog.com