1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Swift】カスタムセルに配置したTextFieldやTextViewの再利用バグを防ぐ

Last updated at Posted at 2024-02-06

前提

(まだ勉強を始めて半年にも満たない新人のため、誤った記述や表現があると思いますが、温かい目で見守ってください)

まず、私がやりたかったことです。
①カスタムセルにTextFieldTextViewを配置し、入力した値がセルの再利用によって消えたり、移動したりするのを防ぐ
②入力した値をRealmを使って保存する(今回は割愛)

UIkitTableViewとカスタムセルを使う人なら、悩んだことのあるであろう、セルの再利用。
本来、カスタムセルを再利用することで利便性を図るための機能が、逆に悩みの種になったりしたことがあると思います。
入力した値が勝手に移動したり、セルの初期化を行ったらいつの間にか消えていたり…
今回は、そういったセルの再利用バグを防ぐ、一つの方法を記述したいと思います。
※継承、準拠する際はextentionを使うようにしているので、ご了承ください。

今回のケース

・まず、カスタムセルを作成します。今回はシンプルに、TextLabel,TextField,TextViewを一つずつ配置したセルにします。
Controller側でカスタムセルを取り込み、今回はとりあえずセルを10個返すようにします。

Step1 土台を作成する

基本的な部分は端折ります。
今回のカスタムセルは、以下のようにしました。
スクリーンショット 2024-02-06 14.55.33.png

ここでやっておくことは
・カスタムセルに配置したUIをすべてIBOutlet接続する
・カスタムセルをController側で読み込んで、TableViewで表示するようにする
ViewControllerに対してUItableViewDataSourceを継承させて、必要な処理を書いておく
という点です。

TableViewCell.swift
import UIKit

class TableViewCell: UITableViewCell {

    @IBOutlet weak var countCellLabel: UILabel!
    @IBOutlet weak var textField: UITextField!
    @IBOutlet weak var textView: UITextView!
    
    override func awakeFromNib() {
        super.awakeFromNib()
    }
}
ViewController.swift
import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        //カスタムセルをTableViewに呼び出す処理
        tableView.register(UINib(nibName: "TableViewCell", bundle: nil), forCellReuseIdentifier: "TableViewCell")
        tableView.dataSource = self
    }
}

extension ViewController: UITableViewDataSource{
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 10
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        if let cell = tableView.dequeueReusableCell(withIdentifier: "TableViewCell", for: indexPath) as? TableViewCell {
            return cell
        } else if {
            return UITableViewCell()
        }
    }
}

現状はこんな感じです。
S__4177923.gif

これだと見にくいので、確認しやすいように色をつけておきます。
スクリーンショット 2024-02-07 0.06.34.png

Step2 Modelを作成する

セルの再利用を防ぐには、TableViewに入力した内容を仮保存する必要があります。
その方法として、Modelを作成し、一度そこに保管するという形を取ります。
ここではstructを使います。テキストを仮保存したいので、Stringの変数を作成します。
作成したModelを、Controller側でインスタンス化します。
また、開いたキーボードを閉じる処理も書きます。UIBarにボタンを追加する方法が個人的におすすめですが、今回はキーボード以外をタップした際に閉じるようにします。

TestDataModel.swift
struct TestDataModel {
    var testText: String = ""
}
ViewController.swift
import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        //カスタムセルをTableViewに呼び出す処理
        tableView.register(UINib(nibName: "TableViewCell", bundle: nil), forCellReuseIdentifier: "TableViewCell")
        tableView.dataSource = self
        tapScreen()
    }
    
    var testDataModel = TestDataModel()
    
    //キーボードを閉じる処理。
    func tapScreen() {
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(closedKeyboard))
        tapGesture.cancelsTouchesInView = false
        view.addGestureRecognizer(tapGesture)
    }
    @objc func closedKeyboard() {
        view.endEditing(true)
    }
}
//(省略)

(補足)

キーボード以外の場所をタップした際に、キーボードを閉じる処理は以下のようにするケースが多いと思います。

extension ViewController: UITextFieldDelegate {
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        view.endEditing(true)
    }
}

extension ViewController: UITextViewDelegate {
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        view.endEditing(true)
    }
}

これは、ViewController上にTextFieldTextViewがある場合は問題ないですが、今回はあくまでもカスタムセルのTextFieldTextViewを使用しているので、この方法だとviewDidroad内でカスタムセルのTextFieldTextViewを呼び出す必要があったりして面倒くさくなります。
また、 view.endEditing(true)UIViewControllerを継承したクラスでのみ使用可能なので、カスタムセル側で上記処理を記述することもできません。
(補足終了)

Step3 入力した内容を格納する

ここからがメインになります。
先述した通り、セルに入力した内容を再利用させないようにするためには、入力した内容を再利用させないために、ViewController側で入力内容を随時確認し、その度に仮保存しておく必要があります。
まずカスタムセル側に、Delegeteを使ってTextFieldTextViewで文字の入力があった際に、随時通知を送るように処理を書きます。

TableViewCell.swift
import UIKit

protocol TableViewCellDelegate: AnyObject {
    //TextFieldの変更を通知するdelegateを作成
    func textFieldEditing(cell: TableViewCell, value: String)
    //TextViewの変更を通知するdelegateを作成
    func textViewEditing(cell: TableViewCell, value: String)
}


class TableViewCell: UITableViewCell {
    
    weak var delegate: TableViewCellDelegate?

    @IBOutlet weak var countCellLabel: UILabel!
    @IBOutlet weak var textField: UITextField!
    @IBOutlet weak var textView: UITextView!
    
    override func awakeFromNib() {
        super.awakeFromNib()
        //これを忘れずに
        textField.delegate = self
        textView.delegate = self
    }
}
//通知を送る側の処理。TextFieldとTextViewのdelegateプロトコルを準拠させる
extension TableViewCell: UITextFieldDelegate {
    func textFieldDidChangeSelection(_ textField: UITextField) {
        self.delegate?.textFieldEditing(cell: self, value: textField.text!)
    }
}

extension TableViewCell: UITextViewDelegate {
    func textViewDidChange(_ textView: UITextView) {
        self.delegate?.textViewEditing(cell: self, value: textView.text!)
    }
}

カスタムセル側の処理が終わったので、Controller側で通知を受け取る処理を記述します。
まず、先ほど作成したModel型の変数を作成し、中身を空欄にします。

ViewController.swift
    var textFieldData: [TestDataModel] = []
    var textViewData: [TestDataModel] = []

次に、通知を受け取る処理を記述します。この際に、入力されたデータを逐一、上の変数に入れていきます。

ViewController.swift
    //ViewControllerに先ほど作成したプロトコルを準拠させます
extension ViewController: TableViewCellDelegate {
    func textFieldEditing(cell: TableViewCell, value: String) {
        //入力しているセルの番号を呼び出すための処理
        let path = tableView.indexPathForRow(at: cell.convert(cell.bounds.origin, to: tableView))
        let indexPath = path?.row
        //配列に仮保存する
        textViewData.append(TestDataModel(testText: value))
    }
    
    func textViewEditing(cell: TableViewCell, value: String) {
        let path = tableView.indexPathForRow(at: cell.convert(cell.bounds.origin, to: tableView))
        let indexPath = path?.row
        textViewData.append(TestDataModel(testText: value))
    }
}

これだけだと、入力されたテキスト内容をController側で受け取って配列に対してappendしてはいますが、仮保存ができていません。
仮保存するには、入力した内容を代入してあげる必要があります。

ViewController.swift
//ViewControllerに先ほど作成したプロトコルを準拠させます
extension ViewController: TableViewCellDelegate {
    func textFieldEditing(cell: TableViewCell, value: String) {
        //入力しているセルの番号を呼び出すための処理
        let path = tableView.indexPathForRow(at: cell.convert(cell.bounds.origin, to: tableView))
        let indexPath = path?.row
        //Modelに仮保存する
        textFieldData.append(TestDataModel(testText: value))
        textFieldData[indexPath!] = TestDataModel(testText: value)
    }
    
    func textViewEditing(cell: TableViewCell, value: String) {
        let path = tableView.indexPathForRow(at: cell.convert(cell.bounds.origin, to: tableView))
        let indexPath = path?.row
        textViewData.append(TestDataModel(testText: value))
        textViewData[indexPath!] = TestDataModel(testText: value)
    }
}

これで、入力内容を、選択中の番号のセルに仮保存する処理ができました。

次に、TableViewでの処理です。以下の処理が必要になります。

・セルの再利中身を初期化する処理
→画面外にあるセルを表示した際に、すでに入力してある内容が反映されてしまうというエラーが起きる。
(この処理はカスタムセル側でも記述できるのですが、今回はController側でその処理を書きます)

・セルに仮保存した内容を代入する処理
→入力しても画面に反映させる。

ViewController.swift
extension ViewController: UITableViewDataSource{
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 10
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        if let cell = tableView.dequeueReusableCell(withIdentifier: "TableViewCell", for: indexPath) as? TableViewCell {
            //再利用されるセルの中身の初期化
            cell.textField.text = nil
            cell.textView.text = nil
            
            //仮保存した入力内容の代入
            cell.textField.text = textFieldData[indexPath.row].testText
            cell.textView.text = textViewData[indexPath.row].testText
            return cell
        } else if {
            return UITableViewCell()
        }
    }
}

これで基本的な流れはOKです。が、このままビルドするとコンパイルエラーが起こります。
スクリーンショット 2024-02-06 23.54.38.png
これは何かというと、textFieldData[indexPath.row].testTextを参照しようとしたところ、[indexPath.row]が範囲外、要するに、存在していないindex番号の値を参照している、という内容のエラーになります。

ここで大事になる処理が、予め配列に空の値を追加しておく作業です。

ViewController
//(省略)

var testDataModel = TestDataModel()
//それぞれの、空の配列を作成する
var textFieldData: [TestDataModel] = []
var textViewData: [TestDataModel] = []
    
//(省略)

//表示するカスタムセルの数だけ、配列に対して空の値を追加しておく
func setCell() {
    for i in 1...10 {
        textFieldData.append(testDataModel)
        textViewData.append(testDataModel)
    }
}

こうすることによって、入力したセルを予め用意してある配列の要素に仮保存することで、セルの再利用を防ぐことができます。
ついでに、カスタムセルに配置してある Labelを使って、セルの再利用を防止していることをわかりやすくします。

ViewController.swift
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    if let cell = tableView.dequeueReusableCell(withIdentifier: "TableViewCell", for: indexPath) as? TableViewCell {
        //再利用されるセルの中身の初期化
        cell.textField.text = nil
        cell.textView.text = nil
            
        //仮保存した入力内容の代入
        cell.textField.text = textFieldData[indexPath.row].testText
        cell.textView.text = textViewData[indexPath.row].testText
            
        //cellが一つ増えるごとに、Labelの番号を1増やす
        cell.updateLabel(count: "\(indexPath.row + 1)")
        cell.delegate = self
        return cell
    } else {
        return UITableViewCell()
    }
}
TableViewCell.swift
func updateLabel(count: String) {
    countCellLabel.text = "\(count)番目のセル"
}

総括

以上のことを踏まえると、以下のようになります。

S__4194306.gif
このように、セルをスライドしてもセルの再利用による不具合を防ぐことができました。
今回は1つのカスタムセルに対して1つのTextField,1つのTextViewを配置しているケースなので処理がシンプルでしたが、もし1つのカスタムセルに対して複数のTextFieldTextViewを配置する場合は、通知を送る側と受け取る側で処理を分割してあげる必要があります。
次回はそれについて記述しようと思います。

最終的なコードは以下になります。

ViewController.swift
import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        //カスタムセルをTableViewに呼び出す処理
        tableView.register(UINib(nibName: "TableViewCell", bundle: nil), forCellReuseIdentifier: "TableViewCell")
        tableView.dataSource = self
        tapScreen()
        setCell()
    }
    
    var testDataModel = TestDataModel()
    //それぞれの、空の配列を作成する
    var textFieldData: [TestDataModel] = []
    var textViewData: [TestDataModel] = []
    
    //キーボードを閉じる処理。
    func tapScreen() {
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(closedKeyboard))
        tapGesture.cancelsTouchesInView = false
        view.addGestureRecognizer(tapGesture)
    }
    @objc func closedKeyboard() {
        view.endEditing(true)
    }
    
    func setCell() {
        for i in 1...10 {
            textFieldData.append(testDataModel)
            textViewData.append(testDataModel)
        }
    }
}

extension ViewController: UITableViewDataSource{
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 10
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        if let cell = tableView.dequeueReusableCell(withIdentifier: "TableViewCell", for: indexPath) as? TableViewCell {
            //再利用されるセルの中身の初期化
            cell.textField.text = nil
            cell.textView.text = nil
            
            //仮保存した入力内容の代入
            cell.textField.text = textFieldData[indexPath.row].testText
            cell.textView.text = textViewData[indexPath.row].testText
            
            //cellが一つ増えるごとに、Labelの番号を1増やす
            cell.updateLabel(count: "\(indexPath.row + 1)")
            cell.delegate = self
            return cell
        } else {
            return UITableViewCell()
        }
    }
}

//ViewControllerに先ほど作成したプロトコルを準拠させます
extension ViewController: TableViewCellDelegate {
    func textFieldEditing(cell: TableViewCell, value: String) {
        //入力しているセルの番号を呼び出すための処理
        let path = tableView.indexPathForRow(at: cell.convert(cell.bounds.origin, to: tableView))
        let indexPath = path?.row
        //Modelに仮保存する
        textFieldData.append(TestDataModel(testText: value))
        textFieldData[indexPath!] = TestDataModel(testText: value)
    }
    
    func textViewEditing(cell: TableViewCell, value: String) {
        let path = tableView.indexPathForRow(at: cell.convert(cell.bounds.origin, to: tableView))
        let indexPath = path?.row
        textViewData.append(TestDataModel(testText: value))
        textViewData[indexPath!] = TestDataModel(testText: value)
    }
}
TableViewCell.swift
import UIKit

protocol TableViewCellDelegate: AnyObject {
    //TextFieldの変更を通知するdelegateを作成
    func textFieldEditing(cell: TableViewCell, value: String)
    //TextViewの変更を通知するdelegateを作成
    func textViewEditing(cell: TableViewCell, value: String)
}

class TableViewCell: UITableViewCell {
    
    weak var delegate: TableViewCellDelegate?

    @IBOutlet weak var countCellLabel: UILabel!
    @IBOutlet weak var textField: UITextField!
    @IBOutlet weak var textView: UITextView!
    
    override func awakeFromNib() {
        super.awakeFromNib()
        //これを忘れずに
        textField.delegate = self
        textView.delegate = self
    }
    func updateLabel(count: String) {
        countCellLabel.text = "\(count)番目のセル"
    }
}
//通知を送る側の処理。TextFieldとTextViewのdelegateプロトコルを準拠させる
extension TableViewCell: UITextFieldDelegate {
    func textFieldDidChangeSelection(_ textField: UITextField) {
        self.delegate?.textFieldEditing(cell: self, value: textField.text!)
    }
}

extension TableViewCell: UITextViewDelegate {
    func textViewDidChange(_ textView: UITextView) {
        self.delegate?.textViewEditing(cell: self, value: textView.text!)
    }
}
testDataModel.swift
struct TestDataModel {
    var testText: String = ""
}

最後まで閲覧していただき、ありがとうございました!

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?