前提
(まだ勉強を始めて半年にも満たない新人のため、誤った記述や表現があると思いますが、温かい目で見守ってください)
まず、私がやりたかったことです。
①カスタムセルにTextField
、TextView
を配置し、入力した値がセルの再利用によって消えたり、移動したりするのを防ぐ
②入力した値をRealm
を使って保存する(今回は割愛)
UIkit
でTableView
とカスタムセルを使う人なら、悩んだことのあるであろう、セルの再利用。
本来、カスタムセルを再利用することで利便性を図るための機能が、逆に悩みの種になったりしたことがあると思います。
入力した値が勝手に移動したり、セルの初期化を行ったらいつの間にか消えていたり…
今回は、そういったセルの再利用バグを防ぐ、一つの方法を記述したいと思います。
※継承、準拠する際はextention
を使うようにしているので、ご了承ください。
今回のケース
・まず、カスタムセルを作成します。今回はシンプルに、TextLabel
,TextField
,TextView
を一つずつ配置したセルにします。
・Controller
側でカスタムセルを取り込み、今回はとりあえずセルを10個返すようにします。
Step1 土台を作成する
基本的な部分は端折ります。
今回のカスタムセルは、以下のようにしました。
ここでやっておくことは
・カスタムセルに配置したUI
をすべてIBOutlet
接続する
・カスタムセルをController
側で読み込んで、TableView
で表示するようにする
・ViewController
に対してUItableViewDataSource
を継承させて、必要な処理を書いておく
という点です。
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()
}
}
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()
}
}
}
これだと見にくいので、確認しやすいように色をつけておきます。
Step2 Modelを作成する
セルの再利用を防ぐには、TableView
に入力した内容を仮保存する必要があります。
その方法として、Model
を作成し、一度そこに保管するという形を取ります。
ここではstruct
を使います。テキストを仮保存したいので、String
の変数を作成します。
作成したModel
を、Controller
側でインスタンス化します。
また、開いたキーボードを閉じる処理も書きます。UIBar
にボタンを追加する方法が個人的におすすめですが、今回はキーボード以外をタップした際に閉じるようにします。
struct TestDataModel {
var testText: String = ""
}
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
上にTextField
やTextView
がある場合は問題ないですが、今回はあくまでもカスタムセルのTextField
やTextView
を使用しているので、この方法だとviewDidroad
内でカスタムセルのTextField
やTextView
を呼び出す必要があったりして面倒くさくなります。
また、 view.endEditing(true)
はUIViewController
を継承したクラスでのみ使用可能なので、カスタムセル側で上記処理を記述することもできません。
(補足終了)
Step3 入力した内容を格納する
ここからがメインになります。
先述した通り、セルに入力した内容を再利用させないようにするためには、入力した内容を再利用させないために、ViewController
側で入力内容を随時確認し、その度に仮保存しておく必要があります。
まずカスタムセル側に、Delegete
を使ってTextField
やTextView
で文字の入力があった際に、随時通知を送るように処理を書きます。
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
型の変数を作成し、中身を空欄にします。
var textFieldData: [TestDataModel] = []
var textViewData: [TestDataModel] = []
次に、通知を受け取る処理を記述します。この際に、入力されたデータを逐一、上の変数に入れていきます。
//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に先ほど作成したプロトコルを準拠させます
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
側でその処理を書きます)
・セルに仮保存した内容を代入する処理
→入力しても画面に反映させる。
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です。が、このままビルドするとコンパイルエラーが起こります。
これは何かというと、textFieldData[indexPath.row].testText
を参照しようとしたところ、[indexPath.row]
が範囲外、要するに、存在していないindex
番号の値を参照している、という内容のエラーになります。
ここで大事になる処理が、予め配列に空の値を追加しておく作業です。
//(省略)
var testDataModel = TestDataModel()
//それぞれの、空の配列を作成する
var textFieldData: [TestDataModel] = []
var textViewData: [TestDataModel] = []
//(省略)
//表示するカスタムセルの数だけ、配列に対して空の値を追加しておく
func setCell() {
for i in 1...10 {
textFieldData.append(testDataModel)
textViewData.append(testDataModel)
}
}
こうすることによって、入力したセルを予め用意してある配列の要素に仮保存することで、セルの再利用を防ぐことができます。
ついでに、カスタムセルに配置してある Label
を使って、セルの再利用を防止していることをわかりやすくします。
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()
}
}
func updateLabel(count: String) {
countCellLabel.text = "\(count)番目のセル"
}
総括
以上のことを踏まえると、以下のようになります。
このように、セルをスライドしてもセルの再利用による不具合を防ぐことができました。
今回は1つのカスタムセルに対して1つのTextField
,1つのTextView
を配置しているケースなので処理がシンプルでしたが、もし1つのカスタムセルに対して複数のTextField
やTextView
を配置する場合は、通知を送る側と受け取る側で処理を分割してあげる必要があります。
次回はそれについて記述しようと思います。
最終的なコードは以下になります。
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)
}
}
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!)
}
}
struct TestDataModel {
var testText: String = ""
}
最後まで閲覧していただき、ありがとうございました!