Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

【再入門】UICollectionViewとUITableViewのセルを選択したときに見た目を変える方法

こんにちは。iOSアプリを開発している吉岡(rikusouda)です。

こちらは Gunosy Advent Calendar 2019、5日目の記事です。なお、昨日の記事は齊藤さんのA/Bテストの時間短縮に向けて 〜ベイズ統計によるA/Bテスト入門〜 でした。

qiita.com

はじめに

iOSアプリを開発していると、UICollectionViewとかUITableViewでセルを選択状態にしたときに表示を変えたいことがちょくちょくあります。

f:id:rikusouda:20191204183752p:plain:w350

実現方法を検索エンジンで調べて見ると自前で選択状態を管理する方法が複数出てきました。具体的に言うと「 tableView(_:didSelectRowAt:)tableView(_:cellForRowAt:) でcellに対して選択状態の変更通知を出して、そこで表示を切り替える方法」です。

この方法でももちろん機能を実現できるので選択肢の一つではあるのですが、別の方法で実現するとシンプルに書けるのではないかと考えました。

選択状態の表示に関わるプロパティ

UITableViewCellとUICollectionViewCellには選択状態の表示に関わる下記のプロパティがあります。

意味
isHighlighted セルが 押されている最中 かどうかを示す
isSelected セルが 選択状態 かどうかを示す
selectedBackgroundView セルがisHighlightedまたはisSelectedのときに表示されるView。UITableViewCellを選択するとデフォルトで色が変わるのはこれが表示されるため。

UITableViewはUICollectionViewはどのセルが選択されているのかを管理しており、上記のプロパティはそれに連動して状態が変更されます。

これらのプロパティを使うことでCell内の実装だけでセルの選択状態を実現できます。DataSourceDelegateから明示的にCellに対して通知を出す必要はありません。用途によってこれらをどのように使うのが良いかは変わってきます。

ここからはこれらについてそれぞれ説明していきます。 この記事で使っているコードは基本的に下記のリポジトリにおいています

github.com

selectedBackgroundViewを使う方法

selectedBackgroundViewを使うことで選択状態の表現が簡単に実現できます。UITableViewCellをタップしたときに、タップし始めから別のセルをタップするまで色がついて表示されるのはselectedBackgroundViewが表示されているためです。

f:id:rikusouda:20191203195733p:plain:w400

selectedBackgroundViewはセルのcontentViewよりも背面に配置され、セルが isHighlighted または isSelected なときに表示されます。

UICollectionViewCellではデフォルトがnil、UITableViewCellではデフォルトで専用のViewが設定されています。UICollectionViewCellとUITableViewCellともに selectedBackgroundView プロパティに任意のViewを設定することで選択時の見た目を変更することができます。

let selectedBackgroundView = UIView()
selectedBackgroundView.backgroundColor = .systemOrange
self.selectedBackgroundView = selectedBackgroundView

UITableViewCellの生成時にselectedBackgroundViewにnilを設定しても無効になりませんが、backgroundColorにUIColor.clear を指定したUIViewを設定することで見えなくすることができます。

selectedBackgroundViewを使いにくいケース

お手軽に使えるselectedBackgroundViewですが、万能なわけではなく下記のような注意や制限があります

  • contentViewよりも背面にある。またセルの全体サイズとなる
    • セルを独自デザインにする場合には使いにくい
    • セルにチェックマークをつけるような用途には使いにくい
  • isHighlightedとisSelectedで別々の見た目にすることができない ←このケースはあまりないかもしれませんが

上記のような理由でselectedBackgroundViewを使いにくい場合には isHighlightedisSelected に応じて見た目を変える方法を使えます。

isHighlighted と isSelected に応じて見た目を変える方法

isHighlightedisSelected の変更を検知して特定のViewを表示させることで見た目を変更できます。例えば、プロパティをoverrideしてdidSetで処理をする方法があります。

override var isSelected: Bool {
    didSet {
        if !self.useSelectedBackgroundView {
            // セルの選択状態変化に応じて表示を切り替える
            self.selectedFrameView.isHidden = !self.isSelected
        }
    }
}

override var isHighlighted: Bool {
    didSet {
        if !self.useSelectedBackgroundView {
            // セルを押している最中だけ表示を切り替える
            self.highlightView.alpha = self.isHighlighted ? 0.3 : 0.0
        }
    }
}

f:id:rikusouda:20191203201302g:plain

この例では、selectedFrameViewというViewとhighlightViewという2種類のViewを予め用意しておき、表示を切り替えることで操作に対して見た目を追従させています。動作がわかりやすいように isHighlightedisSelected であえて見た目を変えてみました。

UITableViewCellの場合の注意点

UITableViewCellでは上記の方法ではうまくいきません。ユーザー操作によってセルが選択されてもdidSetに処理がやって来ません。その代わり func setSelected(_ selected: Bool, animated: Bool)func setHighlighted(_ highlighted: Bool, animated: Bool) が呼び出されるので、これらをoverrideすることで変更の検知ができます。

override var isSelected: Bool {
    didSet {
        // セルの選択状態変化に応じて表示を切り替える
        self.onUpdateSelection()
    }
}

override func setSelected(_ selected: Bool, animated: Bool) {
    // ユーザー操作の場合はisSelectedのdidSetは呼び出されずこちらが呼び出される
    super.setSelected(selected, animated: animated)
    self.onUpdateSelection()
}

private func onUpdateSelection() {
    self.accessoryType = self.isSelected ? .checkmark : .none
}

override var isHighlighted: Bool {
    didSet {
        // セルを押している最中だけ表示を切り替える
        self.onUpdateHighlight(self.isHighlighted, animated: false)
    }
}

override func setHighlighted(_ highlighted: Bool, animated: Bool) {
    // ユーザー操作の場合はisHighlightedのdidSetは呼び出されずこちらが呼び出される
    super.setHighlighted(highlighted, animated: animated)
    self.onUpdateHighlight(highlighted, animated: animated)
}

private func onUpdateHighlight(_ highlighted: Bool, animated: Bool) {
    UIView.animate(withDuration: animated ? 0.1 : 0) {
        self.highlightView.alpha = highlighted ? 0.3 : 0.0
    }
}

f:id:rikusouda:20191203201915g:plain

Q&A集

セルが再利用されても選択状態の表示は連動するの?

大丈夫です。UICollectionViewとUITableViewは indexPathForSelectedRow というプロパティを持っていることからも分かる通り、内部的にどのセルが選択状態かを保持しています。セルが再利用されたときはこれに従ってisSelectedをセットしてくれます(isSelectedのdidSetでブレークポイントを貼ると設定されていることが確認できます)。そのためセルが再利用されても実際の選択状態を反映した見た目となります。

DataSourceのtableView(_:cellForRowAt:)などで選択状態に対して手動で何かをする必要はありません。

選択状態に応じてなにか処理をするのはどうするの?

前述の indexPathForSelectedRow プロパティを使う方法が考えられますが、MVVMなどのGUIアーキテクチャだとどの要素が選択中なのかをViewModelで保持したいかもしれません。その場合、例えば Delegateの tableView(_:didSelectRowAt:)tableView(_:didDeselectRowAt:)にてViewModelに変更通知を出す方法が考えられます。

コードから選択状態を変更したい場合はどうするの?

UITableView / UICollectionViewの selectRow(at:animated:)deselectRow(at:animated:)を呼び出す方法が考えられます。その場合の注意点として tableView(_:didSelectRowAt:)tableView(_:didDeselectRowAt:)が呼び出されないので必要に応じて手動で通知を出す必要があります。

UITableViewにEditみたいなのあったよね?

ありますね。これはUITableViewで「デフォルトではタップ時になにかアクションをさせる」けど「編集モードではセルを複数選択させてなにかする」ような場合に使われることが多い手法ではないかと思います。

f:id:rikusouda:20191203203727g:plain

UITableViewの allowsMultipleSelectionDuringEditing を使うことで編集状態のときに複数選択ができるようになります。

    override func viewDidLoad() {
        // 略
        
        // Edit状態ではないときに複数選択を許可する
        self.tableView.allowsMultipleSelection = true
        
        // Edit状態のときに複数選択を許可する
        // これを設定すると左側に自動でチェックマークが表示される
        self.tableView.allowsMultipleSelectionDuringEditing = true
    }

    @IBAction func onEdit(_ sender: Any) {
        self.tableView.setEditing(!self.tableView.isEditing, animated: true)
    }

チェックマークは自動で表示されるのでなにか制御をする必要はありません。UITableViewCellのtintColorを変更することで左のチェックマークの色が変更できます。

注意点としては、Cell内の要素が contentView の子供として登録されてないと、編集モードになっても要素が右に移動しないという問題があります。Interface BuilderでViewを作成している場合にはcontentView以下にしかViewを入れられないのでこの問題は発生しませんが、コードから addSubview(_:) する場合には注意が必要です。

複数セルを選択できないんだけど

allowsMultipleSelection プロパティをtrueにしてください。

Interface Builderからも変更ができます。

f:id:rikusouda:20191204145144p:plain:w350

UITableViewやUICollectionViewから選択中のセル一覧を取れないの?

とれます。indexPathsForSelectedRowsindexPathForSelectedRow のようなプロパティがあります。

サンプルコード

今回使ったコードは下記のリポジトリにおいています。ビルドして実際に動作させることもできます。

github.com

おわりに

UITableViewやUICollectionViewは選択状態を表現する機能が備わっているので、その仕組みを使うことでシンプルに選択状態の表示を実現できます。これに限らずAppleが用意してくれたフレームワークの設計意図をなるべく読み取って、実現したい要件に対してコードをシンプルにしたいという気持ちでやっています。

明日の記事は id:tmotegi さんのキーボードの記事の予定です。お楽しみに。