2015年4月16日木曜日

UI部品の繰り返し配置Viewをライブラリ化

業務アプリの特徴として、画面いっぱいにUI部品を配置する場合が多いことが挙げられます。とくに入力画面や更新画面で多く使います。ラベルやボタンも含めると、UI部品の数が100を超えるのは、いたって普通のことです。

UI部品の数は多いのですが、全部が異なる項目というわけではありません。同じ項目が縦に並ぶというケースも多いです。プログラム内部では配列として変数を用意し、それぞれにUI部品を入れた後、画面上で縦に配置するという形になります。異なるUI部品をそれぞれ別な配列に入れ、それらを縦に並べて画面上に配置し、複数行のUI部品の並びを作るという使い方です。

先日、そんな使い方に役立つレイアウト機能をライブラリ化してみました。小さな機能ですが、業務アプリでは意外と便利に使えそうです。今回は、それを紹介しましょう。

 

まず最初は、全体設計です。どのような形で作るべきか、少し悩みました。1つ目の候補としては、UIViewの形でクラスを作り、そのViewに配置するメソッドを用意する作り方もあります。もう1つ候補としては、配置する座標の計算機能だけを用意して、配列内のUI部品をまとめて計算するという作り方も可能です。

どちらが良いのか、両方を使ってみないと判断できそうもありません。仕方がないので、直感で選びました。最終的には、ブループ分けしたUIViewに貼り付けます。そのため、UIViewの形が使いやすいかもしれないと予想し、UiViewのサブクラスとして作ることにしました。

配置するUI部品ですが、ラベルやボタンなどの部品だけでなく、他のUIViewを配置できると利用範囲が広がります。ラベルやボタンも、UIViewのサブクラスと位置付けられます。というわけで、UIView以下の全部を配置対象とすることに決めました。

貼り付ける単位ですが、ラベルなら1つだけ、ボタンやフィールドなどの配列は複数を一括してとなります。1つだけの部品と、配列に入った複数の部品の両方を対象とします。また、配列の場合は、最初から全部ではなく、途中から必要な個数だけの指定も可能とします。実際、1つの配列に入ったUI部品を、画面上の2列や3列に分割してレイアウトする場合がありますから。

配置する位置は、メソッドの中で開始座標として指定します。配列のレイアウトでは、繰り返しでの間隔も指定する必要があるでしょう。

以上をまとめると、次のような内容に整理できます。

// UI部品の繰り返し配置を実現するクラス
・基本的な形式:UIViewのサブクラスとして作る
・配置対象:UIView以下のクラス
・メソッド
 ・初期化:Viewの大きさと位置を指定する
 ・単品の配置:UI部品と配置座標を指定する
 ・配列の配置:UI部品配列と開始インデックスと配置数、配置座標、配置間隔を指定する

ここまで決めると、あとは作り始めたほうが早いでしょう。こんな感じで作り始め、意外にすんなりと作れました。

 

実際のコーディングでは、引数の検査なども入れながら作ります。出来上がったSwiftコードは、次のようになりました。

// 
class RepeatItemsView: UIView {
    // Viewの最小サイズ(単純ミスで変な値を設定した場合の発見用)
    let W_VIEW_MIN: CGFloat = 100
    let H_VIEW_MIN: CGFloat = 30
    // 1行分の高さの最小値(単純ミスの発見用)
    let HEIGHT_LINE_MIN: CGFloat = 5
    // 配列の開始位置座標(左側と上側の余白指定)
    var beginX: CGFloat!
    var beginY: CGFloat!

    // 初期化:Viewサイズと開始位置座標を設定
    func setupF(rX:CGFloat, _ rY:CGFloat, _ rWidth:CGFloat, _ rHeight:CGFloat, _ rBeginX:CGFloat, _ rBeginY:CGFloat) {
        if (rWidth < W_VIEW_MIN) { zSendErrMsgF("ERROR:RV_STUP:Viewの幅が不足"); return }
        if (rHeight < H_VIEW_MIN) { zSendErrMsgF("ERROR:RV_STUP:Viewの高さが不足"); return }
        self.frame = CGRectMake(rX, rY, rWidth, rHeight)
        self.clipsToBounds = true // 領域外は表示させない
        beginX = rBeginX
        beginY = rBeginY
    }
    // 1つのUI部品を配置する
    func addItemF(
        rObj:UIView,         // 配置するUI部品
        _ rPosX:CGFloat,     // UI部品1の横位置X
        _ rPosY:CGFloat      // UI部品1の縦位置Y
        ) {
        // 要素をViewに追加する
        layoutItemF(rObj, beginX + rPosX, beginY + rPosY)
    }
    // 複数のUI部品(配列)を配置する
    func addItemsF(
        rObj:[UIView],       // 配置するUI部品の配列
        _ rBeginIdx:Int,     // 配列の開始インデックス(配列の途中の要素から配置を開始できるように)
        _ rCnt:Int,          // 配置する配列内の要素数
        _ rPosX:CGFloat,     // UI部品1の横位置X
        _ rPosY:CGFloat,     // UI部品1の縦位置Y
        _ rHeightY:CGFloat   // UI部品を配列する1行分の高さ
        ) {
        // 主要な引数の検査
        if rCnt < 1 { zSendErrMsgF("ERROR:RV_ADI:繰り返し数が小さすぎ"); return }
        if rObj.count < rCnt { zSendErrMsgF("ERROR:RV_ADI:配列の要素数が不足"); return }
        if rBeginIdx < 0 { zSendErrMsgF("ERROR:RV_ADI:開始位置が小さすぎ"); return }
        if rBeginIdx > (rObj.count - rCnt) { zSendErrMsgF("ERROR:RV_ADI:開始位置が大きすぎ"); return }
        if rHeightY < HEIGHT_LINE_MIN { zSendErrMsgF("ERROR:RV_ADI:1行の高さが小さすぎ"); return }
        // 複数要素をViewに追加する
        for i in 0..<rCnt {
            let iFinalPosX: CGFloat = beginX + rPosX
            let iFinalPosY: CGFloat = beginY + rPosY + (rHeightY * CGFloat(i))
            layoutItemF(rObj[rBeginIdx + i], iFinalPosX, iFinalPosY)
        }
    }
    // UI部品をUIViewに追加する
    private func layoutItemF(rObj:UIView, _ rFinalPosX:CGFloat, _ rFinalPosY:CGFloat) {
        rObj.frame.origin = CGPointMake(rFinalPosX, rFinalPosY)
        self.addSubview(rObj)
    }
}

いつものように、UIView自体は拡張してないので、UIViewの初期化処理には触らない形で作っています。メソッドsetupFとして、このクラスの初期化処理を付けました。iOSの描画の座標系では、境界ギリギリまで描くと太めの線などで一部が欠けるため、余白は必須です。余白の計算を考えなくて済むように、初期化の中で左型と上側の余白量も指定します。また、UIViewのサイズ指定を単純ミスで間違えたときのために、サイズの最小値を設けて検査を入れました。さらに、領域外にはUI部品を描かない設定にもしてあります。

今回のメソッド引数は、似たような値が含まれるので、正しく理解できるようにと、引数の説明を一緒に入れました。

1つのUI部品をレイアウトするaddItemFメソッドは、指定された値で単純に描くだけです。UI部品のデータ型をUIViewに設定してあるので、UIView以外のインスタンスを指定すると、コンパイルエラーになります。そのため、メソッド側での型チェックは入れてません。

複数のUI部品をレイアウトするaddItemsFメソッドは、指定する引数が多く必要です。指定された範囲が配列から外れないように、それぞれの引数を値を検査しています。また念のために、1行分の高さも極端に小さな値でないか検査しています。

これら2つのメソッドでは、座標位置を計算して、layoutItemF関数を呼び出します。その関数では、座標の値を設定し、UIViewに貼り付けているだけです。

 

いろいろなエラー検査を入れていますが、座標位置に関する検査は、あえて入れないで作ってあります。検査を入れなかった理由は、座標にマイナス値などを入れてアニメーションで画面へ入ってくるとか、いろいろな技を使えるように配慮してのことです。あまりに検査が厳しすぎると、凝った使い方を思いついたとき、制限となってしまいますので。

ここで配置したUI部品は、呼び出し側の配列に入れてあります。当たり前ですが、その配列からUI部品へアクセスして、座標位置などを変更することも可能です。また、他の表示属性も変更できます。あくまで、最初の配置を決めるためのメソッドです。

 

以上のように単純な作りのクラスですが、使う際の自由度は意外に多くあります。

1つの特徴は、座標を細かく指定できる点です。ラベルとフィールドの位置を美しく整えるには、実際に表示して微調整が必要となります。座標を数値で指定できるので、細かな調整が容易です。

通常の使用方法では、UI部品の行数を揃えて、横に一直線に並ばせるでしょう。しかし、UIViewを横に2分割して、片方の行間を小さく作り、より多くの行数を入れるような場合もあるでしょう。配列ごとに1行の高さを指定できるため、そんな要望にも対応可能です。

 

レイアウトに凝ってくると、貼り付けたUI部品を分類するために、区切り線、枠線、部分的に四角く塗りつぶす背景など、情報整理用の部品を描く機能を付けると面白いかもしれません。

また、別な考え方も可能です。このクラスには付けずに、区切り線や枠線などに特化した、分類用描画付きの上位UIViewのクラスを用意する方法です。そのクラスに、いろいろなUIViewのクラスを貼り付けて、全体のレイアウトを整える作り方のほうが良いかもしれません。

どちらにしろ、もし必要になったら、その時点で追加したいと思います。

 

実際に使った例も、いちおう紹介します。具体的な例は複雑すぎるので、単純なテスト用Swiftコードで。全部を掲載すると長すぎるので、配列に値を設定する部分などを省略しています。

// 以下のテスト用コードを、UIViewControllerを継承したクラスに作る
// テストに使う主要な変数
var iRIView: RepeatItemsView!
var aryBtn21: [UIButton] = [UIButton]()          // ボタンの配列
var aryTxtFld21: [UITextField] = [UITextField]() // テキストフィールドの配列
let iLabelBtn: UILabel = zCreateLblF("ボタン列", 14, ALIGN_LEFT, 0, 0, 60, 24)
let iLabelTxf: UILabel = zCreateLblF("フィールド列", 14, ALIGN_LEFT, 0, 0, 100, 24)

// 配列にUI部品を入れる
aryBtn21.append(zCreateBtnF("Btn1", 15, 10, 10, iW, iH))  // これを数回繰り返す
aryTxtFld21.append(zCreateTxtFldF("Field1", 18, ALIGN_LEFT, 10, 10, iW2, iH2)) // これも数回繰り返す
// ボタンへタップ動作を設定
aryBtn21[0].addTarget(self, action:"btn21Go01F:", forControlEvents: .TouchUpInside) // これも数回繰り返す

// Viewの生成とレイアウト
iRIView = RepeatItemsView()
iRIView.setupF(0, 0, 600, 300, 10, 10)
iRIView.addItemF(iLabelBtn, 10, 0)                                 // ボタン列のラベル
iRIView.addItemsF(aryBtn21, 0, aryBtn21.count, 10, 30, 40)         // ボタン列
iRIView.addItemF(iLabelTxf, 110, 0)                                // フィールド列のラベル
iRIView.addItemsF(aryTxtFld21, 0, aryTxtFld21.count, 110, 30, 40)  // フィールド列
viewBase.addSubview(iRIView)    // 生成したViewの表示

見てのとおり、UI部品の生成関数を使って短くし、さらに細部を省略した準備のコード行数と、レイアウトするコード行数が同じぐらいです。それだけ、最小限の手間で使えるという証拠でしょう。

UI部品の表示位置は、座標の数値指定なので、実際に画面を表示させて確認します。ボタンとボタンの間隔、ボタンとラベルの間隔など、表示が整って見えるような位置を探しながら微調整することになります。

 

それぞれのUI部品に関係する処理内容は、このクラスを呼び出す側で作ります。ボタンをタップした場合の処理とか、フィールドの値を変更した時の処理とか、すべての呼び出し側の責任でUI部品に関連付けます。

このクラスの役割は、繰り返し配置を手助けすることと、配置した結果のUIViewを提供することです。それだけの機能でも、呼び出す側のコードを単純化でき、結果として修正しやすいコードにつながります。

 

今回は、UIViewのサブクラスとして作りましたが、最初に悩んだように、表示位置だけを設定する関数の形でも作れます。簡単に作り終わったので、両方あって使い分けても良いかなと思うようになりました。当面は、今回のクラスで十分に間に合うので、また機会があれば関数バージョンも作ってみようと考えています。

今回のような小さな機能は、探せばまだ見付かりそうです。また何か見つけたら、作って独自ライブラリに追加しようと思います。

 

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

0 件のコメント:

コメントを投稿