✍️

UITextViewでもリターンキーでキーボードを閉じたい

2023/07/25に公開

本稿では、行数制限のあるテキストビューにて以下の挙動の提案とその実装を紹介します

  • 行数制限を超えた場合にリターンキーをトリガーにキーボードを閉じる
  • テキストビューいっぱいに文字を入力したらキーボードを閉じる

モチベーション

「テキストビュー編集時もテキストフィールドのようにリターンキーで入力完了できたらな〜」と思ったことはありませんか?
僕はふとそう感じたのでこの記事を書くに至りました。

行数制限のあるテキストビューを使う理由

"行数制限がないとどこでキーボードを閉じていいのか分からない"という挙動的都合があります。
しかし、単純に、行数制限テキストビューは挙動がシンプルであるという魅力もあります。
"目で見えている空きスペース分しか文字を書けませんよ"という直感的な仕様は他に代え難い体験です。
ある種のデザインでは好まれそうですよね?

提案挙動の詳細

提案挙動の実際の挙動例について紹介します。
まず、前提として行数制限が3行のテキストビューで入力を行うこととします。
行数制限を超えるには以下の3つのケースが考えられます。

  1. 改行文字が3つ以上存在する場合
  2. 折り返しによって3行を超えた場合
  3. 折り返しと改行文字の組み合わせによって3行を超えた場合

それぞれのパターンを視覚化すると下記のようになります。

1,3のケースでリターンキーが入力された場合にキーボードを閉じるようにして、2のケースでは文字が入力されたらキーボードを閉じるようにしています。

パターン1 パターン2 パターン3

提案挙動に期待すること

入力完了をスムーズにすることと、UI要素を抑えることです。
それぞれについて説明します。

入力完了がスムーズになる

前者については、リターンキーをトリガーにキーボードを閉じれることが大きく影響しています。
リターンキーはタッチ範囲が広く設定されており、入力回数も多いことから自然に手が伸びやすいです。
行数制限を超えた時にリターンキーを入力してキーボードを閉じるのは、UITextFieldと同様の挙動であり実は親しみ深い体験であると言えるかもしれません。

UI要素が抑えられる

後者については、inputAccessoryViewを用いないことや文字数カウンタを用いないことが影響しています。
通常、テキストビューでキーボードを閉じる場合はinputAccessoryViewを用いて"完了ボタン"を設置することが多いです。提案挙動ではリターンキーで閉じれるため必須にはなりません。
また、行数制限では視覚的に制限状況が分かるため、文字数制限パターンでよく見る"文字数カウンタ"のような補足パーツが必要になりません。
そのため、よりミニマムなUI要素数でキーボード入力を実現できます。

提案挙動ではキーボード終了がスムーズになりUI要素数を抑えることができるため、想定入力文字数が少なく挙動や外見をシンプルにしたい場合に向いています。

実装

コメントで解説を済ませて恐縮ですが、以下で実現しています。

extension UITextView {
    func allLineCount(text: String) -> Int {
        guard let font = self.font else { return 0 }
        guard !text.isEmpty else { return 0 }
        var lines = [String]()
	// 入力されたテキストをlinesへ行ごとに格納
        text.enumerateLines { line, _ in lines.append(line) }
	// reduceを用いて折り返し含めた行数を求める
        let lineCount = lines.reduce(0) { current, line in
	    // 空文字のときは改行文字のみということで現在の行数+1する
            guard !line.isEmpty else { return current + 1 }
	    // テキストビューの幅で高さ最大のCGSizeを用意
            let sizeForHeightCheck = CGSize(width: Int(bounds.width), height: Int.max)
	    // 分割したテキストをテキストビューに入力した場合の高さを求める
	    // 折り返し部分を正しく反映させるために.usesLineFragmentOriginを設定
	    // 参考:https://developer.apple.com/documentation/foundation/nsstring/1524729-boundingrect
            let oneLineHeight = line.boundingRect(
                with: sizeForHeightCheck,
                options: .usesLineFragmentOrigin,
                attributes: [NSAttributedString.Key.font: font],
                context: nil
            ).height
	    // 分割したテキストをテキストビューに入力した場合の高さをフォントの高さで割ることで、結果として何行になったかを求める。
	    // その結果を現在の行数へ足してリターンする。
            return current + Int(ceil(oneLineHeight / font.pointSize))
        }
        return lineCount
    }
}

class SampleViewController {
   // テキストビューのデリゲートメソッド
   func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        // テキストビューに表示されている文字を全て取得する
	// range.lengthが0の場合(テキストの入力を完了した時)はtextに変更した文字が全て返ってくるので無視している
        let newText = range.length == 0 ? textView.text + text : textView.text ?? ""
	// 折り返し含めテキストが3行を超えた時、
	// もしくは、テキストが3行で改行文字が入力された時に
	// キーボードを閉じて、テキストビューへの変更をキャンセルする
        if textView.allLineCount(text: newText) > 3 || textView.allLineCount(text: newText) == 3 && text == "\n" {
            textView.resignFirstResponder()
            return false
        }
        return true
    }
}

まとめ

  • 行数制限のあるテキストビューは入力文字数を大体制限したい時に採用することができ、文字数制限の場合に比べて視覚的に分かりやすい。
  • テキストフィールドのように"行数制限を超えた場合にリターンキーをトリガーにキーボードを閉じる"ことで、入力完了時のフローがシンプルになり、UI要素数も抑えられるためデザインもシンプルにできる。

結び

メリットデメリットあると思います。
向いていないケースで言えば、以下が挙げられます。

  • 行数制限が10行など多めで最終行に辿り着く前にキーボードを閉じたいケース
  • SNSへの投稿など慎重さが求められるケース

ですが提案挙動を採用したいケースもあると思うので、その時はぜひ使ってみてください😉

おまけ

語尾の改行は不要かつ、再入力する時に違和感があるので削除した方が良いと思います。
以下のコードをtextView.resignFirstResponder()の前に追加していただくと、修正可能です。

// 語尾の改行は不要なので削除する
for char in textView.text.reversed() {
    guard char == "\n" else { break }
    textView.text.removeLast()
}

Discussion