2015年7月9日木曜日

UITableViewを簡単に使うライブラリ(本編)

iOSのAPIには、いろいろなタイプのものが含まれます。その中でも少し面倒なのが、デリゲートを含むAPIです。とくにUITableViewは使用頻度が高く、私が独自開発したライブラリの中でも、何度か使いました。

以前に紹介した、フォトアルバムの中から画像を得るライブラリでも、2つのUITableViewを使っています。それを作った後で、もっと簡単に使えるようにと、UITableViewを使う機能だけのライブラリ化を行いました。UITableViewを超簡単に使えるライブラリとして作ったわけです。凝った使い方をしなければ、たった数行のコードを書くだけで、UITableViewが普通に使えてしまいます。もちろん、デリゲートを書く必要はありません。少し変わったライブラリの例として面白いので、詳しく紹介します。

 

いつものように作り始める前には、大まかな仕様を決定します。とは言うものの、UITableViewを使うだけのライブラリなので、検討する内容はほとんどありませんでした。

大事なのは、デリゲートを書かなくて済む点だけでしょう。すると必然的に、UIViewに貼り付けたUITableViewという形が浮かんできます。つまり、UIViewのサブクラスの形で作り、その中にUITableViewを付ける形です。もっとも簡単な実現方法を選ぶとすると、それしかないと思います。

他に考慮すべき点としては、ある程度の拡張性を持たせることです。UITableViewを使う際、機能を追加しそうな箇所がいくつかあるので、そこに関数を挿入します。その関数を、後から書き換える形にすれば、拡張性は確保できそうです。また、UITableViewのインスタンスをアプリ側で取得できれば、様々な設定変更や機能追加が可能でしょう。この方法でも、拡張性を持たせられます。両方とも採用して、拡張性を高めることにしました。

という感じのことだけ決めて、作り始めました。UITableViewを使った直後だったので、あっさりと仕上がったのを覚えています。いつものように、Swiftコードを挙げながら説明しましょう。

 

まず最初は、入れ物となるクラスからです。UIViewのサブクラスとして作り、UITableViewのデリゲートを作るので、次のような形になりました。

// UITableViewを簡単に使うためのクラス(この中に、後述するコードが入る)
class ZPTableView: UIView, UITableViewDelegate, UITableViewDataSource {
    // クラス内の主要変数
    var cTableView: UITableView!     // TableView
    var cAryTableStr: [String] = []  // TableViewの表示内容
    var cArySelected: [Bool] = []    // 各セルの選択状態
    let ID_CELL: String = "zptvCell"
    // UITableViewデリゲートの中で呼ばれる関数
    var cFuncEditCell: ((UITableViewCell) -> Void)? = nil
    var cFuncSelectCell: ((UITableViewCell, Int) -> Void)? = nil
    var cFuncUnselectCell: ((UITableViewCell, Int) -> Void)? = nil
}

クラス宣言の部分では、UIViewのサブクラスであることと、UITableViewのデリゲートなどを指定しています。

クラス内に用意した変数は、UITableViewに加えて、セル内に表示する文字列の配列、各セルの選択状態を記録するBool型の配列、セルのIDとなる文字列です。

さらに、デリゲートの中で呼ばれる関数も3つ用意しました。より多く用意できるのですが、最低限のカスタマイズを可能にする形として、3つの箇所に関数を挿入しています。どの関数もOptional型で、初期値にはnilを入れてあります。なぜOptional型にしたのかは、後で説明します。

 

続いて、クラスの初期化です。いつものように、initとは別な名前で用意しています。

// ======================================== 初期化
// UIViewの最小サイズ
let VIEW_WIDTH_MIN: CGFloat = 100
let VIEW_HEIGHT_MIN: CGFloat = 350

func setupF(rX:CGFloat, _ rY:CGFloat, _ rWidth:CGFloat, _ rHeight:CGFloat, _ rAryTableStr:[String]) {
    if (rWidth < VIEW_WIDTH_MIN) { zSendErrMsgF("ERROR:ZPTV_STUP:Viewの幅が不足"); return }
    if (rHeight < VIEW_HEIGHT_MIN) { zSendErrMsgF("ERROR:ZPTV_STUP:Viewの高さが不足"); return }
    self.frame = CGRectMake(rX, rY, rWidth, rHeight)
    // その他の処理
    cAryTableStr = rAryTableStr    // テーブル表示内容の設定
    setupTblViewF()                // TableViewの生成
    setupEtcF()                    // その他
}
// ======================================== TableView生成
private func setupTblViewF() {
    // TableViewの生成:UIViewと同じ位置とサイズで生成する
    let iX: CGFloat = 0
    let iY: CGFloat = 0
    let iWidth: CGFloat = self.frame.size.width
    let iHeight: CGFloat = self.frame.size.height
    cTableView = zCreateTblViewF(iX, iY, iWidth, iHeight)
    cTableView.registerClass(UITableViewCell.self, forCellReuseIdentifier:ID_CELL)
    cTableView.delegate = self
    cTableView.dataSource = self
    self.addSubview(cTableView)
}
// ======================================== その他の初期化
private func setupEtcF() {
    // TableViewに表示する文字列配列と同じ要素数の、false配列を作る
    cArySelected = [Bool](count:cAryTableStr.count, repeatedValue:false)
}

短いので3つに分ける必要はないのですが、長さに関係なく、このようなスタイルで作るのが癖になっています。3つ目の処理だけは、別な処理でも呼ばれるので分けてあります。

初期化の最初では、ビューの大きさを検査しています。最小値より小さければ、エラーメッセージを出して終了します。単純な指定ミスを発見するための検査です。エラー検査を通ると、ビューの位置と大きさを設定して、テーブルビューの文字列も保存します。

続いて、テーブルビューを生成します。テーブルビューの大きさは、クラス自体のビューと同一です。テーブルビューの生成関数を呼び出して生成し、最低限の属性設定も行います。最後に、クラスのビューに追加して終わりです。

その他の初期化では、各セルが選択中かを保存する配列を、初期化しています。同じ値を入れるだけなので、配列に備わっている初期化機能を使いました。

 

テーブルビューができたので、次はデリゲート関係です。

// ======================================== TableViewデリゲート
// Cellの総数を返す
func tableView(rTbvName:UITableView, numberOfRowsInSection rSection:Int) -> Int {
    return cAryTableStr.count
}
// Cellに値を設定
func tableView(rTbvName:UITableView, cellForRowAtIndexPath rIndexPath:NSIndexPath) -> UITableViewCell {
    let iCell = rTbvName.dequeueReusableCellWithIdentifier(ID_CELL, forIndexPath:rIndexPath) as! UITableViewCell
    let iIdx: Int = rIndexPath.row
    iCell.textLabel!.text = cAryTableStr[iIdx]
    if let iFunc = cFuncEditCell { iFunc(iCell, iIdx) }       // nilでなければ実行
    return iCell
}
// Cellが選択されたとき
func tableView(rTbvName:UITableView, didSelectRowAtIndexPath rIndexPath:NSIndexPath) {
    let iCell: UITableViewCell = rTbvName.cellForRowAtIndexPath(rIndexPath)!
    let iIdx: Int = rIndexPath.row
    cArySelected[iIdx] = true // 選択された
    if let iFunc = cFuncSelectCell { iFunc(iCell, iIdx) }    // nilでなければ実行
}
// Cellの選択が外れたとき
func tableView(rTbvName:UITableView, didDeselectRowAtIndexPath rIndexPath:NSIndexPath) {
    let iCell: UITableViewCell = rTbvName.cellForRowAtIndexPath(rIndexPath)!
    let iIdx: Int = rIndexPath.row
    cArySelected[iIdx] = false // 選択が解除された
    if let iFunc = cFuncUnselectCell { iFunc(iCell, iIdx) }  // nilでなければ実行
}

どの処理も、テーブルビューを使う際の最低限のことしか行っていません。テーブルビューを素の状態で使うために必要なことばかりです。

ただし2点だけ、機能を追加しています。その1つが、各セルの選択状態を記録する機能です。選択されたらtrueに、選択が解除されたらfalseに設定しています。

もう1つの追加機能は、関数の挿入です。ただ関数を実行する形ではなく、条件によって実行する形です。具体的には、関数を入れる変数の値がnilなら実行せず、変数に処理が入っていれば実行しています。つまり、関数用の変数にnilを入れることで、関数の機能をオフできる形なのです。このように動作さセルために、関数用の変数をOptional型にしました。

これとは逆に、関数用の変数をOptional型にしない場合は、何もしない関数を入れておく必要があります。そして、何もしない関数が毎回実行されることにもなります。そのような動作を嫌って、関数変数をOptional型にしたわけです。何もさせたくないなら、ただnilを入れておけば、その判定だけしか実行されないはずです。

また、このクラスを使う側にも良い点があります。一度設定した関数をオフしたい場合、関数をnilに設定すれば済みます。何もしない関数を用意する必要はありません。そのように使う機会は少ないと思いますが。

実は、このライブラリを作った時点では、Optional型の変数を使わずに、何もしない関数をデフォルトで入れていました。別なライブラリを作った際に、変数をOptional型で作れば良いと気付きました。そして、このライブラリも同様に修正したという流れです。いろいろなライブラリを作っていると、より良い方法が少しずつ見付かります。そして、別なライブラリも同様の形に修正するというのが、よくあるパターンですね。

 

セルが選択されている状態は、クラス内にBool型の配列を用意して保存しています。そのため、セルが選択されたときと解除されたときに、この配列の値を更新しています。少し面倒です。

その代わりのメリットとして、セルの選択状態を調べたいとき、個々のセルにアクセスしなくて済みます。当然ですが、セルへアクセスする処理では、iOSのAPIを使います。もし使っているAPIの仕様が変われば、アクセスする処理も変更を求められます。

こんな点まで考慮すると、Bool型の配列を使う良さが理解できます。APIを使わないことで、APIの仕様変更による修正を、少しでも減らす意味があるわけです。もちろん、Bool型の配列も、何かの仕様変更で変更になる可能性はあります。ただ、今回のようなコードでは、可能性がかなり低いでしょうし、あったとしても面倒な変更にはならないでしょう。

単純な機能ですが、こんな感じで考えて作りました。私の場合は、少し面倒な方法であっても、APIにアクセスするコードを減らす方針でプログラミングしています。

 

デリゲートに挿入した関数を、設定するためのメソッドも必要です。関数が3つあるので、メソッドも3つ用意しました。それぞれの関数はOptional型なので、nilを設定することもできます。

// ======================================== 関数の設定
// セルに値を設定するときに呼ばれる関数
func setFuncEditCellF(rFunc:((UITableViewCell, Int) -> Void)?) {
    cFuncEditCell = rFunc
}
// セルが選択されたときに呼ばれる関数
func setFuncSelF(rFunc:((UITableViewCell, Int) -> Void)?) {
    cFuncSelectCell = rFunc
}
// セルの選択が解除されたときに呼ばれる関数
func setFuncUnselF(rFunc:((UITableViewCell, Int) -> Void)?) {
    cFuncUnselectCell = rFunc
}

3つの関数はデータ型が同じなので、1つの関数にまとめて、配列で渡す方法も考えました。しかし、このクラスを拡張する場合、おそらく関数が増えるでしょう。その際、関数のデータ型が違うと、まとめるのが面倒になります。メソッドを最初から3つに分けて、拡張時に関数が追加されたときも、各関数の同じ位置付けになるようにしておいたほうが良いと判断しました。

 

テーブルビューのデフォルトは、1つのセルだけ選択できるモードです。しかし設定を変更すると、複数を同時に選択するモードでも使えます。このようなモード変更は、使用頻度が高いので、専用のメソッドを用意しました。

// ======================================== 選択可能数のモード変更
func setModeSingleSelectionF() {
    cTableView.allowsMultipleSelection = false   // 初期状態では、こちらになっている
}
func setModeMultiSelectionF() {
    cTableView.allowsMultipleSelection = true
}

処理内容としては、UITableViewのプロパティを変更しているだけです。このようなメソッドを用意すれば、長いプロパティ名を調べる手間が省けるでしょう。

このメソッドの一般的な使い方は、このクラスのインスタンスを生成した後、すぐに実行するものです。けっして、途中で切り替えるものではありません。また、デフォルトのままで構わない場合は、おそらく使うことはないでしょう。

 

ライブラリとして作るわけですから、取得関係のメソッドも何種類か用意しました。大きく分けて、以下の3種類です。

// ======================================== TableViewの取得
func getTableViewObjF() -> UITableView? {
    return cTableView
}
// ======================================== 全要素の取得
func getCellCountF() -> Int {     // 要素数を返す
    return cAryTableStr.count
}
func getAllStrF() -> [String] {   // 全セルの文字列を、配列で返す
    return cAryTableStr
}
// ======================================== 選択された要素の取得
func getSelectionNumF() -> [Int] {  // 選択されたセルのインデックス番号を、配列で返す
    var iAryInt: [Int] = []
    for i in 0..<cArySelected.count {
        if cArySelected[i] == true {
            iAryInt.append(i)
        }
    }
    return iAryInt
}
func getSelectionStrF() -> [String] {  // 選択されたセルの文字列を、配列で返す
    var iAryStr: [String] = []
    for i in 0..<cArySelected.count {
        if cArySelected[i] == true {
            iAryStr.append(cAryTableStr[i])
        }
    }
    return iAryStr
}

最初のメソッドは、UITableView自体のインスタンスを返します。初期化が失敗するとインスタンスが生成されていませんから、戻り値をOptional型にしてあります。Optional型にしないと、このクラス自身がクラッシュしますので。

UITableViewのインスタンスを返すメソッドには、他のメソッドとは根本的に異なる、大きな役割があります。アプリ側は、UITableViewのインスタンスを得られることで、UITableViewに対する様々な設定変更が可能になります。おかげで、このクラスが用意していない機能や設定も、ある程度までですが、実現できる手段が得られます。このようなメソッドを用意することで、クラスの機能不足を補うことが可能となります。そんなわけで、かなり重要なメソッドなのです。

全要素の取得は2つのメソッドです。1つめのメソッドは、インデックス番号の配列を返しても構わないのですが、ゼロから始まる連続数になるだけです。それは無駄なので、要素数を返す形にしました。セルの文字列を返すほうのメソッドは、文字列の配列を返しています。

選択された要素を返すメソッドも2つです。インデックス番号を返すメソッドでは、番号の配列を返します。セルの文字列を返すメソッドでは、文字列の配列となります。どちらのメソッドも、1つだけ選択するモードと、複数を選択するモードの両方に対応しています。1つだけ選択するモードでは、空の配列か、要素が1つだけの配列を返します。

 

最後は、テーブルを更新するメソッドです。セルに表示する文字列の配列を受け取って、テーブルビューをリセットします。

// ======================================== テーブル更新
func updateTableF(rAryTableStr:[String]) {
    cAryTableStr = rAryTableStr
    cTableView.reloadData()
    setupEtcF()     // その他の初期化:全セルの状態記録を非選択に
}

処理内容としては、まず文字列の配列をクラス内に保存します。続いて、そのデータを、テーブルビューにリロードさせます。

この状態では、セルが何も選択されていません。セルの選択状態を保存しているBool型の配列を、初期化する必要があります。クラスを初期化する際に使った関数を呼び出して、初期化しています。

 

以上で、今回のクラスに含まれるソースコードを全部紹介しました。このライブラリのおかげで、テーブルビューがどれだけ簡単に使えるのか、それこそが最大のポイントでしょう。その話に関係する使用例は、次回の投稿にて。

 

(使用開発ツール:Xcode 6.3.2, SDK iOS 8.3)

0 件のコメント:

コメントを投稿