🐧

iOSでアコーディオンメニューを実装する

2020/11/23に公開

何の話?

最近MENTAというサービスでiOS開発初学者のメンターをしているのですが、数ある質問の中でも非常によく聞かれるものがあったのでリンクを渡して説明できるようにここに記す事にします。
こんな感じの、特定のセルをタップしたらその下にヌッと別のセルが出てくるようなUIを実装します。

今回は、fugaというセルをタップした場合のみfugafugaというセルが表示されるような実装です。

実行環境

Xcode12.2
Swift5
iOS14.2

対象読者

以下のどれかひとつに当てはまるかた

  • XcodeでTableViewの実装はなんとかできる
  • CompositionalLayoutを使ってUIcollectionViewでリストを実装したい

実装方法

UITableViewを使う

サポートするOSバージョン問わず使える方法です。

まずStoryboardを使ってViewControllerUITableViewを配置していきます。
全画面になるように配置しましょう。
今回はついでにここでDataSourceDelegateの設定もしてしまいます。

UITableViewCellも一つ配置しておきましょう。
idはcellとかで大丈夫です。

次に、ViewController.swiftと先ほどのUITableViewIBOutletで繋ぎます。
それができたらまずTableViewhoge, fuga, piyoが表示されるようにしてみます。

大体こんな感じでしょうか。

ViewController.Swift
import UIKit

class ViewController: UIViewController {

    @IBOutlet private weak var tableView: UITableView!
    private var contents = ["hoge", "fuga", "piyo"]
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return contents.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = contents[indexPath.row]
        return cell
    }
}

しっかり表示されていますね。

では次にfugaをタップしたらfugafugaが表示されるようにしていきます。
まずセルのタップを検知するdelegateメソッドを追加して、タップされたセルがfugaかどうか判定します。

ViewController.swift
import UIKit

class ViewController: UIViewController {

    @IBOutlet private weak var tableView: UITableView!
    private var contents = ["hoge", "fuga", "piyo"]
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return contents.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = contents[indexPath.row]
        return cell
    }
}

// ここを追加
extension ViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        if contents[indexPath.row] == "fuga" {

        }
    }
}

次にfugafugaを表示するメソッドを追加して、先ほどのfugaがタップされた際に呼び出します。

ViewController.swift
import UIKit

class ViewController: UIViewController {

    @IBOutlet private weak var tableView: UITableView!
    private var contents = ["hoge", "fuga", "piyo"]
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    // ここを追加
    private func showFugaFuga() {
        guard contents.count < 4 else { return }
        contents.insert("fugafuga", at: 2)

        tableView.beginUpdates()
        tableView.insertRows(at: [IndexPath(row: 2, section: 0)], with: .automatic)
        tableView.endUpdates()
    }
}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return contents.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = contents[indexPath.row]
        return cell
    }
}

extension ViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        if contents[indexPath.row] == "fuga" {
            showFugaFuga()
            return
        }
    }
}

fugafugaを表示するために、まずfugaがタップされたらTableViewに表示するデータのリストであるcontentsfugaのすぐ後にfugafugaを差し込んでいます。

contents.insert("fugafuga", at: 2)

その後、TableViewにセルを追加して表示しています。

tableView.beginUpdates()
tableView.insertRows(at: [IndexPath(row: 2, section: 0)], with: .automatic)
tableView.endUpdates()

このままではfugafugaが表示されっぱなしなので、ついでにfugafugaをタップしたら消えるようにします。
こちらも手順はfugafugaを削除するメソッドを定義して、タップされたセルがfugafugaだったら呼び出すようにします。

ViewController.swift
import UIKit

class ViewController: UIViewController {

    @IBOutlet private weak var tableView: UITableView!
    private var contents = ["hoge", "fuga", "piyo"]
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    private func showFugaFuga() {
        guard contents.count < 4 else { return }
        contents.insert("fugafuga", at: 2)

        tableView.beginUpdates()
        tableView.insertRows(at: [IndexPath(row: 2, section: 0)], with: .automatic)
        tableView.endUpdates()
    }
    
    // ここを追加
    private func hideFugaFuga() {
        guard contents.count > 3 else { return }
        contents.remove(at: 2)
        
        tableView.beginUpdates()
        tableView.deleteRows(at: [IndexPath(row: 2, section: 0)], with: .automatic)
        tableView.endUpdates()
    }
}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return contents.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = contents[indexPath.row]
        return cell
    }
}

extension ViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        if contents[indexPath.row] == "fuga" {
            showFugaFuga()
            return
        }
        
        // ここも追加
        if contents[indexPath.row] == "fugafuga" {
            hideFugaFuga()
            return
        }
    }
}

これで冒頭にあったような画面が実装できます!

UICollectionViewを使う

アプリがサポートするOSがiOS14以降であれば、UICollectionViewを使って同様の機能を実装することが可能です。

まずUICollectionViewを定義します。
今回はStoryboardは使いません。

ViewController.swift
import UIKit

class ViewController: UIViewController {
    
    private lazy var collectionView: UICollectionView = {
        let config = UICollectionLayoutListConfiguration(appearance: .plain)
        let layout = UICollectionViewCompositionalLayout.list(using: config)
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        return collectionView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
}

上記のコードを少し解説いたします。

UICollectionLayoutListConfiguration(appearance: .plain)
UICollectionViewCompositionalLayout.list(using: config)

でグルーピングされないフラットなリスト表示をするように設定しています。
ここを.groupedなどに変更するとセクション毎にグルーピングされたリストになります。

collectionView.translatesAutoresizingMaskIntoConstraints = false
これは後ほどAutoLayoutの制約をコードで付与するために書いています。

では次に、CollectionViewの設定をするメソッドを追加していきます。

ViewController.swift
    private func setUpCollectionView() {
        
        view.addSubview(collectionView)
	
	// AutoLayoutの制約を付与
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
        ])
        
	// セルを設定
        let registration = UICollectionView.CellRegistration<UICollectionViewListCell, String> { cell, indexPath, string in
            var config = cell.defaultContentConfiguration()
            config.text = string
            cell.contentConfiguration = config
        }
    }

続いて、CollectionViewに表示するデータを設定します。

ViewController.swift
import UIKit

class ViewController: UIViewController {
    
    // これを追加
    private var dataSource: UICollectionViewDiffableDataSource<Section, String>?

    private lazy var collectionView: UICollectionView = {
        let config = UICollectionLayoutListConfiguration(appearance: .plain)
        let layout = UICollectionViewCompositionalLayout.list(using: config)
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        return collectionView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setUpCollectionView()
    }


    private func setUpCollectionView() {
        
        view.addSubview(collectionView)
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
        ])
        
        let registration = UICollectionView.CellRegistration<UICollectionViewListCell, String> { cell, indexPath, string in
            var config = cell.defaultContentConfiguration()
            config.text = string
            cell.contentConfiguration = config
        }
        
	// ここも追加
        dataSource = UICollectionViewDiffableDataSource<Section, String>(collectionView: collectionView) { cv, indexPath, string in
            cv.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: string)
        }
    }
}

// これも追加
extension ViewController {
    private enum Section {
        case main
    }
}

続いて、CollectionViewに表示するデータを動的に変更するためのメソッドを定義します。

ViewController.swift
    private func update(list: [String]) {
        var snapShot = NSDiffableDataSourceSnapshot<Section, String>()
        snapShot.appendSections([.main])
        snapShot.appendItems(list)
        dataSource?.apply(snapShot)
    }

これを最初にviewDidLoadから呼ぶようにして一度実行してみます。

ViewController.swift
    override func viewDidLoad() {
        super.viewDidLoad()
        setUpCollectionView()
        update(list: ["hoge", "fuga", "piyo"])
    }

しっかり表示されていますね!

では最後にセルをタップした際のdelegateメソッドを実装して、タップしたセルによって動作を分岐させます。

ViewController.swift
import UIKit

class ViewController: UIViewController {
    
    private var dataSource: UICollectionViewDiffableDataSource<Section, String>?

    private lazy var collectionView: UICollectionView = {
        let config = UICollectionLayoutListConfiguration(appearance: .plain)
        let layout = UICollectionViewCompositionalLayout.list(using: config)
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        return collectionView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setUpCollectionView()
        update(list: ["hoge", "fuga", "piyo"])
    }


    private func setUpCollectionView() {
        //ここを追加
        collectionView.delegate = self
        
        view.addSubview(collectionView)
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
        ])
        
        let registration = UICollectionView.CellRegistration<UICollectionViewListCell, String> { cell, indexPath, string in
            var config = cell.defaultContentConfiguration()
            config.text = string
            cell.contentConfiguration = config
        }
        
        dataSource = UICollectionViewDiffableDataSource<Section, String>(collectionView: collectionView) { cv, indexPath, string in
            cv.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: string)
        }
    }
    
    private func update(list: [String]) {
        var snapShot = NSDiffableDataSourceSnapshot<Section, String>()
        snapShot.appendSections([.main])
        snapShot.appendItems(list)
        dataSource?.apply(snapShot)
    }
}

// ここも追加
extension ViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        guard let id = dataSource?.itemIdentifier(for: indexPath) else { return }
        
        collectionView.deselectItem(at: indexPath, animated: true)

        if id == "fuga" {
            update(list: ["hoge", "fuga", "fugafuga", "piyo"])
            return
        }
        
        if id == "fugafuga" {
            update(list: ["hoge", "fuga", "piyo"])
            return
        }
    }
}

extension ViewController {
    private enum Section {
        case main
    }
}

では実行してみます。

バッチリですね!!
最後までお読みいただきありがとうございました!

Discussion