UITextFieldを使用する時に必要なあれこれ

# はじめに

iOSアプリでUITextFieldを使ったフォーム画面を作る時、一緒に考えるべき項目や必要な処理をまとめていこうと思います。

# 観点

## 何を入力するのか

入力させる内容によって、キーボードの種類を変えてあげると親切です。
その設定を主に行うのが`Text Input Traits`です。

### Text Input Traits

storyboard上でTextFieldを選択すると、画面右側（ユーティリティ領域）にText Input Traitsの設定箇所が表示されます。

![スクリーンショット 2018-07-01 00.20.51.png](https://qiita-image-store.s3.amazonaws.com/0/116677/25234765-4d3d-531f-2951-0e931e4d0dd5.png)

設定の内容や選択肢が結構多いのと、設定の組み合わせによっては結果が若干変わってきたりするので、全部紹介はできません。

私は「まとめるよりも実際に見て選べた方がいいよね」ということで、サンプルアプリを作成していて、どの設定を使おうか選んだりしています。

<img width="375" alt="UITextFieldSample.png" src="https://qiita-image-store.s3.amazonaws.com/0/116677/4d0fd37b-302b-70f1-1f9f-1562d86528ce.png">

GitHubにも上げているので、よければお使いください。
https://github.com/Todate/UITextFieldSample

### よく出るパターン

#### Returnキーになんと表示するか

Returnキーを押す時にユーザーが行いたいアクションに対応させる必要があるでしょう。

- 何か検索条件を入力しているなら`Search`
- URLを入力し終えてサイトにアクセスしたい時は`Go`

...のような感じでしょうか。

#### 入力がなかったらReturnキーを無効にしたい

あるあるですね。

- `Auto-enable Return Key`をtrueにする（storyboard上ではチェックを入れるだけ）

#### 入力補助

何か入力状態の時、キーボード上部に「過去に入力した単語」や「打ち間違いじゃね？」など、ユーザーが入力したいであろう選択肢を出してくれる機能です。
入力の内容によっては出さないであげた方がいいケースが多い...と感じています。

- `Correction`の設定で`Default`/`No`/`Yes`を選べます（`Default`は他の設定によって挙動が変わるので注意）

#### 数字だけの入力

電話番号/クレジットカード番号などを入力するのに、QWERTYキーボードを出されるとイラっとしますよね。

- 電話番号なら、名前的には`Keyboard Type`を`PhonePad`にするのが最有力候補。
- 本当に数字だけの入力なら`NumberPad`でも十分。
- ハイフンなど記号の入力が必要な場合は`Numbers and Punctuation`が妥当。

#### パスワードの入力

入力内容を隠す設定もここで行えます。

- `Secure Text Entry`をtrueにする（storyboard上ではチェックを入れるだけ）

#### 特定シーンの入力

- `Keyboard Type`を`URL`にすると、スペースの部分がピリオドやスラッシュ、ドメインの入力補助キーになる
- `Keyboard Type`を`E-mail Address`にすると、スペースの部分に＠キーとピリオドキーが増える

他にも`Keyboard Type`はたくさんあるので、見てて楽しいです。

### iOS11から出てきた「Smart Dashes/Insert/Quotes」

日本語キーボードを使用しているとなかなかお世話になることはないと思いますが、英語などの入力をしている時の入力補助が強化されました。

|設定|内容|
|---|---|
|Smart Dashes|ハイフンの入力回数でen-dash/em-dashに自動で変換する|
|Smart Insert|単語を選択してCut（Delete）をした時やコピペした単語をPaste(Insert)した時、前後のスペースの数を自動で調整する|
|Smart Quotes|`'`や`"`を入力した時に、region-specific glyphsの形に自動で変換する<br/>（対になるように始まりの方が反転してる形...といえばわかりやすいでしょうか）|

気をつけないと自動変換されるので、注意が必要です。


### その他にもいろいろ

`Contents Type`の使い道はまだ掴みきれてません。（また詳しく調査したいです）
iOS11から増えた設定の`Username`/`Password`は、端末に保存している値の自動入力に対応しているみたいですし、なんか便利そうです。

他にも、大文字にするタイミング（`Capitalization`）や、キーボードの色（`Keyboard Look`）、スペルミスっぽい所を赤罫線で表示する（`Spell Checking`）などがありますが...ここでは割愛します。


## TextField外をタップしたら、キーボードが消えて欲しい

iOSはAndroidと違ってBackボタンがないので、自前で書かないとキーボードが消えてくれません。

でも、毎回処理を書くのは面倒...

そんなときはProtocol Extensionですね。

```swift
import UIKit

extension UIViewController {
    func hideKeyboardWhenTappedAround() {
        let tap: UITapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(UIViewController.hideKeyboard))
        tap.cancelsTouchesInView = false
        view.addGestureRecognizer(tap)
    }

    @objc func hideKeyboard() {
        view.endEditing(true)
    }
}
```

あとは、`viewDidLoad`などで設定するためのメソッドを呼んであげれば、大丈夫です。


## TextFieldに入力できる文字数を制限したい

パスワードや年月の入力で、「xx文字まで」と決まっているケース。

TextFieldにdelegateをつけて、入力内容の変化を監視して、指定長を超えたら入力を無効にする...んですけど、画面に複数のTextFieldがあると、途端にソースが長くなってしまいます。

それに、毎回処理を書くのは面倒...

そんなときは拡張しちゃいましょう。

```swift
import UIKit

private var maxLengths = [UITextField: Int]()

extension UITextField {

    @IBInspectable var maxLength: Int {
        get {
            guard let length = maxLengths[self] else {
                return Int.max
            }

            return length
        }
        set {
            maxLengths[self] = newValue
            addTarget(self, action: #selector(limitLength), for: .editingChanged)
        }
    }

    @objc func limitLength(textField: UITextField) {
        guard let prospectiveText = textField.text, prospectiveText.count > maxLength else {
            return
        }

        let selection = selectedTextRange
        let maxCharIndex = prospectiveText.index(prospectiveText.startIndex, offsetBy: maxLength)

        #if swift(>=4.0)
            text = String(prospectiveText[..<maxCharIndex])
        #else
            text = prospectiveText.substring(to: maxCharIndex)
        #endif

        selectedTextRange = selection
    }

}
```

すると、なんということでしょう。
Storyboard上からTextFieldを選択すると、Max Lengthが設定できるようになりました。

![スクリーンショット 2018-06-23 01.48.48.png](https://qiita-image-store.s3.amazonaws.com/0/116677/f53abeae-4ac7-cfc7-c1f5-1d6b0a055936.png)


~~同じような感じで、正規表現のパターンとかも設定できるように出来るんじゃないか...と思ってます。~~

**(2018/11/17) 追記**

正規表現もOKなTextFieldを、今度は継承で作ってみました。

```RegexTextField.swift
import UIKit

class RegexTextField: UITextField {
    
    private var length: Int = Int.max
    
    private var pattern: String = ".*"
    
    private var tmpText: String?
    
    init() {
        super.init(frame: .zero)
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        registerForNotifications()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        registerForNotifications()
    }
    
    private func registerForNotifications() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(textDidChange),
            name: NSNotification.Name(rawValue: "UITextFieldTextDidChangeNotification"),
            object: self
        )
    }
    
    deinit {
        NotificationCenter.default.removeObserver(self)
    }
    
    @IBInspectable var maxLength: Int {
        get { return length }
        set { length = newValue }
    }
    
    @IBInspectable var regexPattern: String {
        get { return pattern }
        set { pattern = newValue }
    }
    
    @objc func textDidChange() {
        let target = text ?? ""
        
        // 正規表現でチェック
        if !isMatch(target: target, pattern: pattern) {
            text = tmpText
            return
        }
        
        // 長さでチェック
        if target.count > length {
            text = tmpText
            return
        }
        
        // 次回の比較用に退避
        tmpText = text
    }
    
    private func isMatch(target: String, pattern: String) -> Bool {
        do {
            let re = try NSRegularExpression(pattern: pattern)
            let matches = re.matches(in: target, range: NSMakeRange(0, target.count))
            return matches.count > 0
        } catch {
            return false
        }
    }
}
```

自分の変更を自分で受け取ってゴニョゴニョしているだけです。
`textDidChange()`の中の処理を変更すれば、自分の思い通りに制御できますね！

※入力の度にパターンとマッチするか調べているので、使用するときは`^[0-9]*$`みたいに許可する文字を制限するパターンを使いましょう。
（いずれマッチするかもしれないチェックって出来るのかな...？）


## キーボードが表示されても、スクロールで全体を表示されるようにしたい

たとえば、こんな画面。

![スクリーンショット 2018-06-23 01.32.27.png](https://qiita-image-store.s3.amazonaws.com/0/116677/32be5dc5-9a78-e7e1-95ff-8510825de244.png)

うんざりするほど長いフォーム画面ですね。
キーボードが出てきたら、下の青いボタンが隠れそうなのは想像できると思います。

では、「キーボードを表示している状態でも、下の青いボタンを押せるようにする」にはどうするか。

私なりのベストプラクティスは...

- 画面全体にScrollViewを配置
- ScrollView内にViewを配置して、その中に表示内容を入れていく

そして、ここでも登場。Protocol Extensionですね。

```swift
import UIKit

protocol ContentScrollable {
    /// ViewController上で@IBOutletでStoryboardと接続されている前提
    var scrollView: UIScrollView! { get }

    /// Notificationを設定
    /// （viewWillAppearで呼ぶ）
    func configureObserver()

    /// Notificationを削除
    /// (viewWillDisappearで呼ぶ)
    func removeObserver()
}

extension ContentScrollable where Self: UIViewController {
    func configureObserver() {
        NotificationCenter.default.addObserver(forName: .UIKeyboardWillShow, object: nil, queue: nil) { notification in
            self.keyboardWillShow(notification)
        }
        NotificationCenter.default.addObserver(forName: .UIKeyboardWillHide, object: nil, queue: nil) { notification in
            self.keyboardWillHide(notification)
        }
    }

    func removeObserver() {
        NotificationCenter.default.removeObserver(self)
    }

    /// キーボードが表示される時の処理
    func keyboardWillShow(_ notification: Notification) {
        guard let userInfo = notification.userInfo else { return }
        let keyboardSize = (userInfo[UIKeyboardFrameEndUserInfoKey] as! NSValue).cgRectValue.height
        scrollView.contentInset.bottom = keyboardSize
    }

    /// キーボードが隠れる時の処理
    func keyboardWillHide(_ notification: Notification) {
        scrollView.contentInset = .zero
        scrollView.scrollIndicatorInsets = .zero
    }
}
```

キーボードが表示/非表示になる時に発火するNotificationをobserveして、ViewControllerにあるscrollViewの表示領域をキーボードの高さ分だけ縮めたり戻したりしています。

あとは、必要なViewControllerにこのProtocolを適用させ、`viewWillAppear`や`viewWillDisappear`のライフサイクルメソッドで、監視の設定/解除をしてあげればOKです。

```swift
import UIKit

class ViewController: UIViewController, ContentScrollable {

    // Protocolで強制されているので、storyboardと連携する際でも変数名は揃えてください
    @IBOutlet weak var scrollView: UIScrollView!

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        configureObserver()
    }

    override func viewWillDisappear(_ animated: Bool) {
        removeObserver()
        super.viewWillDisappear(animated)
    }

}
```

これで、キーボードが表示中でも残りの領域がスクロールされます。

# おわりに

他にも様々なケースがあるかと思いますが、思いつき次第追記して拡充していきたいと思っています。
（コメントもお待ちしております）