Loading
BLOG 開発者ブログ

2024年3月15日

UITableViewを使ってCellの各辺にボーダーが設定された表を作ってみる

サムネイル

以前、『複数のカスタムしたUITableViewCellをUITableViewで表示させる』というタイトルでカスタマイズをしたUITableViewCellを使ったUITableViewの実装方法に触れました。

今回はその記事の内容をもとに、ちょっとだけ良い感じの表にブラッシュアップをする記事になります。

はじめに

こんにちは。クラウドソリューショングループのtakinami.sです。

UITableViewは簡単にリストを作成できるとても便利なクラスですが、仕様をきちんと押さえておかないと表示がおかしいことになってしまいます。

UITableViewの仕様に振り回された自分の経験をベースに、エクセルで作るようないわゆる『表』を作っていきます。

動作確認環境

Xcode 15.2
Swift v5.9.2

以前の記事の振り返り

詳細は以前の記事を見ていただければと思いますが、簡単に作成したコードと完成イメージを振り返っておきましょう。

タイトル用のCell

ファイル追加の際に『Cocoa Touch Class』を選択し、Swiftファイルと一緒にxibファイルを作っておきます。

UILabelのようなパーツの配置はxibファイルで行い、細かい実装はSwiftファイルの中で行っていきます。

AutoLayoutの制約をつけて、UILabelはCellいっぱいに表示されるようにしています。

また、StoryBoardからUILabelのAlignmentを中央寄せにしています。

import UIKit

class TopicTableViewCell: UITableViewCell {

    @IBOutlet weak var topicLabel: UILabel!

    override func awakeFromNib() {
        super.awakeFromNib()
    }

    override func setSelected(_ selected: Bool, animated: Bool) {
        super.setSelected(selected, animated: animated)
    }
}

内容表示用のCell

タイトル用のCellと同様、こちらもSwiftファイルと一緒にxibファイルを作って実装していきます。

こちらは項目名とそこに適した内容の2つを表示したいので、UILabelを2つ配置しています。

AutoLayoutの制約で、UILabel同士が隙間なく隣り合うようにしています。

import UIKit

class DataTableViewCell: UITableViewCell {

    @IBOutlet weak var logicNameLabel: UILabel!
    @IBOutlet weak var valueLabel: UILabel!
    override func awakeFromNib() {
        super.awakeFromNib()
    }
    override func setSelected(_ selected: Bool, animated: Bool) {
        super.setSelected(selected, animated: animated)
    }
}

UITableViewを配置したUIViewController

作成した2つのCellを実際にUITableViewと紐付けて画面に表示できるよう設定を行います。

今回は特にAPIから情報を受け取るといったことはしないので、表に入れるためのデータもここで書いていきます。

import UIKit

class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
    struct Item {
        var logicName : String
        var value : String
    }

   @IBOutlet weak var displayFavoriteTableView: UITableView!

    var labelTextArray : [Any] = []

    let usersFavorite = [
        [
            "id": "1",
            "name": "ブログ書き太郎",
            "favorite": [
                "fruits": "りんご",
                "number": 3,
                "subject": "国語"
            ]
        ],
        [
            "id": "2",
            "name": "ブログ読み次郎",
            "favorite": [
                "fruits": "みかん",
                "number": 100,
                "subject": "数学"
            ]
        ]
    ]

    override func viewDidLoad() {
        super.viewDidLoad()
        displayFavoriteTableView.delegate = self
        displayFavoriteView.dataSource = self
        displayFavoriteTableView.separatorStyle = .none

        displayFavoriteTableView.register(UINib(nibName: "TopicTableViewCell", bundle: nil), forCellReuseIdentifier: "topic_cell")
        displayFavoriteTableView.register(UINib(nibName: "DataTableViewCell", bundle: nil), forCellReuseIdentifier: "data_cell")

        self.prepareLabelArray()
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return labelTextArray.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let topicCell = displayFavoriteTableView.dequeueReusableCell(withIdentifier: "topic_cell") as! TopicTableViewCell
        let dataCell = displayFavoriteTableView.dequeueReusableCell(withIdentifier: "data_cell") as! DataTableViewCell

        switch type(of: labelTextArray[indexPath.row]) {
        case is String.Type:
            topicCell.topicLabel.text = labelTextArray[indexPath.row] as? String
            return topicCell

        case is Item.Type:
            dataCell.logicNameLabel.text = (labelTextArray[indexPath.row] as? Item)?.logicName
            dataCell.valueLabel.text = (labelTextArray[indexPath.row] as? Item)?.value
            return dataCell

        default:
            return dataCell
    }

    func prepareLabelArray() {
        for i in usersFavorite {
            labelTextArray.append("ユーザー情報")
            labelTextArray.append(Item(logicName: "id", value: "\(i["id"] ?? "-")"))
            labelTextArray.append(Item(logicName: "名前", value: "\(i["name"] ?? "-")"))

            if (i["favorite"] != nil) {
                labelTextArray.append("お気に入り")
                let favoriteInfoData = (i["favorite"] as? [String : Any])!
                labelTextArray.append(Item(logicName: "フルーツ", value: "\(favoriteInfoData["fruits"] ?? "-")"))
                labelTextArray.append(Item(logicName: "数字", value: "\(favoriteInfoData["number"] ?? "-")"))
                labelTextArray.append(Item(logicName: "教科", value: "\(favoriteInfoData["subject"] ?? "-")"))
            }
        }
    }
}

ここまでいくと下のような画面ができあがります。
UITableView

より『表』らしくしてみよう

では、ここからが本題です。

ボーダーラインを作ろう

各CellのUILabelにボーターラインを設定していきたいのですが、そのまま

topicLabel.layer.borderColor = borderColor
topicLabel.layer.borderWidth = 1.0

というようにボーダーラインを設定すると、4辺に線がつくようになります。

しかし、これではUILabel同士が隣り合っている箇所では線が太くなっているように見えてしまいます。

この問題を避け全て均等な太さの線にするべく、今回は自分でボーダーラインを作っていきます。

ボーダー線を作るために、UIViewの拡張クラスを作りました。

import UIKit

enum BorderPosition {
    case top
    case left
    case right
    case bottom
}

extension UIView {
    func addBorder(width: CGFloat, position: BorderPosition, color: UIColor) {

        let border = UIView()
        border.tag = 100

        switch position {
        case .top:
            border.frame = CGRect(x: 0, y: 0, width: self.frame.width, height: width)
            border.backgroundColor = color
            self.addSubview(border)
        case .left:
            border.frame = CGRect(x: 0, y: 0, width: width, height: self.frame.height)
            border.backgroundColor = color
            self.addSubview(border)
        case .right:
            border.frame = CGRect(x: self.frame.width - width, y: 0, width: width, height: self.frame.height)
            border.backgroundColor = color
            self.addSubview(border)
        case .bottom:
            border.frame = CGRect(x: 0, y: self.frame.height - width, width: self.frame.width, height: width)
            border.backgroundColor = color
            self.addSubview(border)
            
        }
    }
}


func removeBorder(view: UIView) {
    view.subviews.forEach {
        if $0.tag == 100 {
            $0.removeFromSuperview()
        }
    }
}

拡張クラスの作成にあたって下記のページを参考にさせていただきました。

【Swift】枠線を任意の場所につける

作成したボーダー線を消すための関数removeBorder()も併せて入れていますが、こちらは後ほど使用します。

Cellを整えよう

「では、早速先ほど作った拡張クラスを活かしてUILabelにボーダーラインをつけましょう」と言いたいところですが、画面の枠と表の間やボーダーラインと文字の間にスペースがないと見づらくなってしまうので、xibファイル上で余白を設けてあげましょう。

先に配置してあったUILabelをちょっとどかしてあげて、新しくUIViewを2つ左右に隣り合うようCell上に配置します。

2つのUIViewはこのように制約を設定しています。
左側Viewの制約
右側のViewの制約
ここではUIView同士の制約は設定しなくて大丈夫です。

UIViewの準備ができたら、先ほどどかしていたUILabelをそれぞれのUIView上に配置していきましょう。

今回は左右に10ずつ余白を取る形としました。

xibファイルで準備ができたらコードにも手を加えていきます。


import UIKit

class DataTableViewCell: UITableViewCell {
    
    @IBOutlet weak var logicNameView: UIView!
    @IBOutlet weak var valueView: UIView!
    @IBOutlet weak var logicNameLabel: UILabel!
    @IBOutlet weak var valueLabel: UILabel!
    
    let borderColor: UIColor = UIColor(red: 153/255, green: 153/255, blue: 153/255, alpha: 153/255)
    
    override func awakeFromNib() {
        super.awakeFromNib()
        
        // 余白として設けた10を考慮しながら、4:6の比率でlogicNameとvalueを表示させる
        logicNameView.widthAnchor.constraint(equalTo: self.widthAnchor, multiplier: 0.4, constant: -10.0).isActive = true
        valueView.widthAnchor.constraint(equalTo: self.widthAnchor, multiplier: 0.6, constant: -10.0).isActive = true
        
        // UILabelで複数行表示可能にする
        logicNameLabel.numberOfLines = 0
        valueLabel.numberOfLines = 0
    }
    
    override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
        
        let size = super.systemLayoutSizeFitting(
            targetSize,
            withHorizontalFittingPriority: horizontalFittingPriority,
            verticalFittingPriority: verticalFittingPriority)
        
        self.layoutIfNeeded()
        
        logicNameView.frame.size.height = size.height
        valueView.frame.size.height = size.height
        
        // ボーダーラインを追加
        logicNameView.addBorder(width: 1.0, position: .bottom, color: borderColor)
        logicNameView.addBorder(width: 1.0, position: .right, color: borderColor)
        logicNameView.addBorder(width: 1.0, position: .left, color: borderColor)
        valueView.addBorder(width: 1.0, position: .bottom, color: borderColor)
        valueView.addBorder(width: 1.0, position: .right, color: borderColor)
        
        return size
    }

    override func setSelected(_ selected: Bool, animated: Bool) {
        super.setSelected(selected, animated: animated)

    }
    
}

全部を解説すると長くなるので、重点だけピックアップします。

先ほどUIView同士での制約はxibファイル上では設定しないと書きましたが、コード上で制約を追加していきます。

上記のコードで左側のUIViewが親Viewの横幅の40%の幅になるように設定、右側のViewの横幅が60%になるように設定しています。

先ほどxibファイルで設定した10の余白もconstantの部分で考慮に入れています。

logicNameView.widthAnchor.constraint(equalTo: self.widthAnchor, multiplier: 0.4, constant: -10.0).isActive = true
valueView.widthAnchor.constraint(equalTo: self.widthAnchor, multiplier: 0.6, constant: -10.0).isActive = true

UILabelの文字数によっては改行が行われ、Cellの高さが変わることが想定されます。

そこでsystemLayoutSizeFitting()の中で正しいCellの高さを取得してUIViewの高さとして設定してあげることで、ボーダーラインの表示が不自然なことにならないようにしてあげましょう。

let size = super.systemLayoutSizeFitting(
            targetSize,
            withHorizontalFittingPriority: horizontalFittingPriority,
            verticalFittingPriority: verticalFittingPriority)
        
// 再描画をしてあげる
self.layoutIfNeeded()
        
logicNameView.frame.size.height = size.height
valueView.frame.size.height = size.height

タイトル用のCellは必要なUILabelが1つなので多少異なる部分はありますが、考え方としては同様になります。

後ほどサンプルコードをまとめたものを紹介しますので、参考にしていただければと思います。

UITableView全体の設定をしよう

Cellの設定が完了したら、UITableView全体での設定も入れていきます。

まずは、viewDidLoad()の中で下記を記述します。

大体のCellの高さを見積もった上でestimatedRowHeightでデフォルトの値として設定します。

実際のCellの高さとデフォルトの高さの差をできるだけ小さくすることで、描画の際のパフォーマンスを上げることが可能です。

今回は基本的に1行で表示されることが想定できたので、大体1行分の高さをデフォルトとしています。

rowHeightにUITableView.automaticDimensionを設定することでCellの高さを可変にすることができます。

displayFavoriteTableView.estimatedRowHeight = 23
displayFavoriteTableView.rowHeight = UITableView.automaticDimension

次に、Cellの生成に用いられるtableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath)の中で、各Cellを読み込んだ直後に下記の記述を入れます。

タイトルCellはUILabelが1つだけだったのでそのままUILabelにボーダーラインを付与していますが…

大事なのはCellの生成処理の中で、自作したボーダーラインのところで記述したボーダーラインを取り除くための関数removeBorder()を呼び出しているということです。

iOSではパフォーマンスを向上させるため、Cellのインスタンスを1つ1つ立てるのではなく、画面内からスクロールによって画面外にいったCellを再利用するという動きが行われています。

そのためボーダーラインを始めに取り除いておかないと、想定外のところにボーダーラインが表示されてしまうという事象が発生する可能性があります。

今回はボーダーラインでしたが、例えばCellの中で画像を出したいといった場合にも同様に再利用による不都合が生じる可能性が考えられます。

removeBorder(view: topicCell.topicLabel)
removeBorder(view: dataCell.logicNameView)
removeBorder(view: dataCell.valueView)

ここまでできたら実機やシミュレータでビルドしてみましょう。

このような表が表示されたら、バッチリです!

UITableViewで作る表の完成イメージ

表に出すデータを増やしてスクロールできる状態にし、ボーダーラインに不都合が生じていないかも確認しておくとより良いと思います。

最後に

今回作成したものは下記のGitHubリポジトリに反映したので、全体像を見たい方はぜひ。

UITableViewCellBorderLineSample

ボーダーラインを設定することを取り上げあげましたが、Cellの再利用の考え方はUITableViewを扱うにあたって欠かせないものになってくるので、この記事の内容を今後に活かしていただけると嬉しいです。

takinamisのブログ