markdown

はじめに (対象読者・この記事でわかること)

この記事は、iOS アプリ開発を行っているエンジニア、特に UITableViewCell 内に横長の UIScrollView を配置し、そこに並べたボタンのタップ情報を外部へ伝搬させたい 方を対象としています。Swift の基本的な文法や UIKit の使い方は既に理解している前提です。本記事を読むことで、以下が実現できるようになります。

  • UIScrollView を横スクロールさせたレイアウトを UITableViewCell に組み込む手順
  • セル内部のボタンタップを ViewController に安全に通知するデリゲートパターンとクロージャの両方の実装例
  • メモリリークを防ぐための weak 参照や capture list の使い方、よくある落とし穴とその対策

背景として、横スクロールの UI は画像カルーセルやカテゴリ選択などで頻出しますが、セル内部の UI 要素と外部ロジックを結びつける際に「どのセルのどのボタンが押されたか」情報の取得が曖昧になりがちです。本記事はその悩みを解決することを目的に執筆しました。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。

  • Swift(5.7 以降)基本文法とクロージャの概念
  • UIKit の UITableView, UITableViewCell, UIScrollView の基本的な使い方
  • Xcode によるプロジェクト作成とビルド手順

横スクロールビューを含むセルの概要と課題

UITableViewCell に横長の UIScrollView(以下「横スクロールビュー」)を埋め込み、そこに複数の UIButton を横並びで配置するケースは、例えば「商品カテゴリーの横スクロールリスト」や「タグ選択 UI」などでよく見られます。実装上のポイントは大きく分けて三つです。

  1. レイアウト
    Auto Layout を用いて UIScrollViewcontentSize が自動的に決まるようにし、ボタンは UIStackView で横方向に並べます。これにより、デバイス横幅に合わせてスクロール可能な領域が自動調整されます。

  2. タップイベントの伝搬
    ボタンはセル内部に配置されるため、タップ時にセルが持つデリゲートやクロージャを通して外部(通常は UITableView を管理する ViewController)へ情報を渡す必要があります。ここでの課題は「セルが再利用される」ことと「循環参照(retain cycle)を作らない」ことです。

  3. メモリ管理
    デリゲートは weak に、クロージャは capture list[weak self] を付与することで、セルが解放された際にメモリリークを防止します。

以下では、上記三つのポイントを具体的に実装する手順を示します。

実装手順:デリゲート+クロージャで安全にボタン情報を取得

本セクションは記事のメインパートです。コード例は Swift 5.7 以降、iOS 15 以上を前提にしています。Xcode の新規プロジェクトで「Single View App」を作成し、Storyboard ではなくコードベースで UI を構築する構成です。

1. カスタムセルの作成

まず、横スクロールビューとボタンを持つカスタムセル HorizontalButtonCell を定義します。

Swift
import UIKit protocol HorizontalButtonCellDelegate: AnyObject { /// ボタンタップ時に呼び出される /// - Parameters: /// - cell: タップが起きたセル /// - index: ボタンのインデックス(0 始まり) func horizontalButtonCell(_ cell: HorizontalButtonCell, didTapButtonAt index: Int) } final class HorizontalButtonCell: UITableViewCell { // MARK: - Public Props weak var delegate: HorizontalButtonCellDelegate? /// クロージャで通知したい場合に利用 var buttonTapHandler: ((Int) -> Void)? // MARK: - Private UI private let scrollView = UIScrollView() private let stackView = UIStackView() private var buttonCount = 0 // MARK: - Init override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setupViews() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - Setup private func setupViews() { // ScrollView の設定 scrollView.showsHorizontalScrollIndicator = false contentView.addSubview(scrollView) scrollView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ scrollView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), scrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), scrollView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), scrollView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), scrollView.heightAnchor.constraint(equalToConstant: 44) // ボタンの高さに合わせる ]) // StackView の設定 stackView.axis = .horizontal stackView.alignment = .fill stackView.distribution = .fillEqually stackView.spacing = 12 scrollView.addSubview(stackView) stackView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) ]) } // MARK: - Public API /// ボタンのタイトル配列を渡すだけで UI が自動生成される func configure(with titles: [String]) { // 既存ボタンを全削除 stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } buttonCount = titles.count for (index, title) in titles.enumerated() { let btn = UIButton(type: .system) btn.setTitle(title, for: .normal) btn.layer.cornerRadius = 8 btn.backgroundColor = UIColor.systemGray5 btn.tag = index // タグでインデックス保持 btn.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside) stackView.addArrangedSubview(btn) } } // MARK: - Action @objc private func buttonTapped(_ sender: UIButton) { let index = sender.tag // デリゲート通知 delegate?.horizontalButtonCell(self, didTapButtonAt: index) // クロージャ通知(nil でも安全) buttonTapHandler?(index) } // MARK: - Reuse override func prepareForReuse() { super.prepareForReuse() delegate = nil buttonTapHandler = nil // 必要ならスタイルリセット } }

ポイント解説

  • delegateweak にし、循環参照を防止しています。
  • buttonTapHandler はクロージャ版の通知手段で、セル側で [weak self] を付けて呼び出すことが想定されています。
  • UIButtontag にインデックスを入れることで、タップ時にどのボタンが押されたかを簡易に判定できます。
  • configure(with:) でタイトル配列を渡すだけで UI が自動生成され、再利用時に余計なビューが残らないよう prepareForReuse でリセットしています。

2. ViewController 側での受け取り実装

次に、UITableView を管理する ViewController でセルを登録し、デリゲートまたはクロージャでタップ情報を受け取ります。

Swift
import UIKit final class ViewController: UIViewController { private let tableView = UITableView() private let dataSource: [[String]] = [ ["赤", "青", "緑", "黄", "紫"], ["Apple", "Google", "Microsoft", "Amazon", "Meta"], ["iPhone", "iPad", "Mac", "Watch", "TV", "AirPods"] ] override func viewDidLoad() { super.viewDidLoad() title = "横スクロールボタンリスト" view.backgroundColor = .systemBackground setupTableView() } private func setupTableView() { view.addSubview(tableView) tableView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.topAnchor.constraint(equalTo: view.topAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) tableView.register(HorizontalButtonCell.self, forCellReuseIdentifier: "HorizontalButtonCell") tableView.dataSource = self tableView.delegate = self tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 60 } } // MARK: - UITableViewDataSource extension ViewController: UITableViewDataSource { func numberOfSections(in tableView: UITableView) -> Int { 1 } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { dataSource.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let cell = tableView.dequeueReusableCell(withIdentifier: "HorizontalButtonCell", for: indexPath) as? HorizontalButtonCell else { fatalError("Cell cast failed") } let titles = dataSource[indexPath.row] cell.configure(with: titles) // ★ デリゲート方式 ★ cell.delegate = self // ★ クロージャ方式 ★(どちらかだけでも OK) cell.buttonTapHandler = { [weak self] buttonIndex in guard let self = self else { return } print("クロージャ - Section \(indexPath.section) Row \(indexPath.row) Button \(buttonIndex) tapped") // ここで遷移やデータ更新など実装可能 } return cell } } // MARK: - UITableViewDelegate (任意で実装) extension ViewController: UITableViewDelegate {} // MARK: - HorizontalButtonCellDelegate extension ViewController: HorizontalButtonCellDelegate { func horizontalButtonCell(_ cell: HorizontalButtonCell, didTapButtonAt index: Int) { // セルから IndexPath を取得 guard let indexPath = tableView.indexPath(for: cell) else { return } print("デリゲート - Section \(indexPath.section) Row \(indexPath.row) Button \(index) tapped") // ここで画面遷移や API 呼び出しなど行う } }

ポイント解説

  • cell.delegate = self で ViewController がデリゲートを受け取ります。HorizontalButtonCellDelegate に準拠していることが必要です。
  • buttonTapHandler はクロージャ版です。[weak self] を忘れずに書くことで、セルが解放されたときにクロージャが ViewController を強参照し続けることを防げます。
  • tableView.indexPath(for:) でセルから現在の IndexPath を取得し、どの行のボタンが押されたかを明確にできる点が重要です。
  • デリゲート方式とクロージャ方式は併用可能ですが、プロジェクトのコードスタイルに合わせてどちらかを選択すれば十分です。

3. ハマりやすいポイントとその対策

(1) セルの再利用でボタンが増える

症状: スクロールすると同じセルにボタンが二重に表示される。
原因: configure(with:) が呼ばれるたびに stackView の子ビューを削除していない。
解決策: prepareForReuse または configure の冒頭で stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } を実施する。上記コードでは configure にて実装済み。

(2) タップ時に indexPath が取得できない

症状: tableView.indexPath(for: cell)nil を返す。
原因: セルがテーブルビューにまだ追加されていないタイミング(例: cellForRowAt の直前)で呼び出す。
解決策: タップハンドラは UI イベントが発生した後(セルが表示中)に呼ばれるため、通常は nil になることはありません。もし nil が出る場合は、セルが非表示になった後にタップされた可能性があるので、willDisplaydidEndDisplaying で状態管理する。

(3) 循環参照によるメモリリーク

症状: デバッグ時に HorizontalButtonCell が解放されない。
原因: delegatestrong になっている、またはクロージャが self を強参照している。
解決策: delegateweak に宣言し、クロージャでは [weak self] を付与する。上記実装はこの点を遵守しています。

(4) Auto Layout が崩れる

症状: スクロールビューのコンテンツが期待した幅より狭くなる。
原因: stackViewdistributionfillEqually でない、または scrollView.contentLayoutGuideframeLayoutGuide の制約が不足。
解決策: 上記コードのように stackView の高さを scrollView.frameLayoutGuide.heightAnchor に合わせ、横方向は leadingtrailingcontentLayoutGuide に接続する。

4. 発展的な実装例

  • Dynamic Height
    ボタンのテキスト長が変わる場合は distribution = .fillProportionally に変更し、UIButtoncontentEdgeInsets で余白を調整すれば自動的に幅が変化します。

  • 選択状態の保持
    ボタンが選択されたかどうかを UIButton.isSelected で管理し、セルが再利用された際に状態を復元するために、モデル側に selectedIndices: Set<Int> を持たせると便利です。

  • Combine / RxSwift との統合
    クロージャの代わりに PassthroughSubject<(Int, IndexPath), Never> をセルに持たせ、ViewController がサブスクライブすることでリアクティブプログラミングが可能です。

まとめ

本記事では UITableViewCell 内に横長 UIScrollView を配置し、そこに並べたボタンのタップ情報を安全に取得する方法 を解説しました。

  • デリゲートとクロージャの二通りの通知手段 を実装し、どちらでも柔軟に選択可能
  • セルの再利用やメモリリーク防止 のために weak デリゲートと [weak self] のクロージャを必ず使用
  • Auto Layout のポイント(contentLayoutGuide と frameLayoutGuide の使い分け)で横スクロールが崩れないように実装

これらを活用すれば、横スクロール UI を持つリストでも 「どのセルのどのボタンが押されたか」 を正確に把握でき、画面遷移やデータ更新といったロジックをシンプルに実装できます。次回は Combine を使ったリアクティブなボタンイベントハンドリング をテーマに、さらに洗練されたコード例をご紹介する予定です。

参考資料