iOS
iOSDay 15

意外と大変な日付入力の実装

More than 1 year has passed since last update.

みなさんこんにちはFablicでモバイルアプリエンジニアをやっている@huinです。
この記事はiOS Advent Calendar 2017 15日目の記事ですが、
現在2017年12月15日の午前10時をまわったところです。

早く記事を公開しなければ!!
ってことで前置きはすっ飛ばして、iOSの日付入力実装の話にはいっていきます(スミマセン)

iOSの日付入力

iOSで日付入力するためのUIといえば、みなさんきっと「UIDatePicker」(以下ピッカー)を思いつくでしょう。
では、UIDatePickerを使って、どのように日付入力のインタラクションを実装すればいいか考えてみましょう。

iOS標準のアプリには大きく分けて2通りのUIパターンがあります。

1つめはカレンダーやリマインダーのように、インライン形式で画面内にピッカーを埋め込む形式です。

カレンダー リマインダー
iPhone X Calendar app iPhone X Reminder app

もう1つのパターンはキーボードのように画面下からせり上がってきて、表示される形式です。
連絡先(Contacts.app)で誕生日を入力するときや、WebView内で <input type="date" /> のフォームに入力する時にこのパターンになります。

連絡先 Webフォーム(※)
iPhone X Contacts app スクリーンショット 2017-12-15 10.17.53.png

※ ピッカーとツールバーが被ってるのはiOSの不具合です(多分)

ピッカーの表示形式についてはiOS Human Interface Guidelinesでは以下のように説明されています。

A picker is often displayed at the bottom of the screen or in a popover when the user is editing a field or tapping a menu. Pickers can also appear inline, such as while editing a date in a Calendar event.

この説明では、画面下からの表示(あるいはポップオーバー)が一般的な表示パターンのようですが、特に明確は使い分けのガイドラインがあるわけではないようです。ですが、前者のインライン形式はグループスタイルのUITableView意外で利用するイメージがわかず汎用性にかける気がするので、今回は後者のパターンの実装をしてみようと思います。

機能要件

実装するUIが決まったところで、挙動を細かくみてみます。
連絡先の誕生日を入力する時の画面が下のスクリーンショットになります。
"birthday"セルをタップすると、画面したからピッカーがせり上がってきて入力モードとなります。
ピッカーの値を変更すると日付が、セルにセットされます。
ちなみに入力中は外部キーボードが接続されていても、入力できません。

また、セルを長押しすると、入力文字列を選択することができます。
UITextFieldやUITextViewのように選択と同時にCut, Copy, Pasteといったメニューが表示されます。
ただし、Copyを選択した時は他のコンポーネントと同様にペーストボードに値がセットされますが、
Cutはペーストボードに値がセットされるもののラベルの内容はクリアされません。
また、Pasteは選択はできますが、反応しません。
(このあたりAppleの実装がイケてないというか手抜きな印象を受けますね)

また、iPadでは表示の仕方が大きくかわります。
セルをタップするとキーボードとして表示されるのではなくポップオーバーでピッカーが表示されます。
さらにiPadの方は、ラベル長押しをしてもメニューは表示されずコピーやペーストはできません。

入力中 選択状態
iPhone X Contacts app iPhone X Text Selection

iPadのピッカー表示

iPad Air 2 Contact Picking Date

というわけで上記の挙動から実装するピッカーの機能をまとめてみると、
実装したい振る舞いは以下のようになるようです。

  • iPhoneの場合
    • セルをタップすると画面下部からピッカーが表示される
    • ピッカーの背景はグレーである
    • ピッカーの値を変更するとラベルの内容が変化する
    • 入力中はキーボードの入力を受け付けない
    • 画面をスクロールするとキーボードが非表示になり、入力が終了する
    • ラベルを長押しするとメニューが表示される
    • 値のコピーはできるが、カット/ペーストは本来と違う挙動をする.
  • iPadの場合
    • セルをタップするとポップオーバーで、ラベル付近にピッカーが表示される
    • ピッカーの背景はホワイトである
    • ピッカーの値を変更するとラベルの内容が変化する
    • 入力中はキーボードの入力を受け付けない
    • ポップオーバー以外の場所をタップすると入力が終了する
    • 端末が回転した場合は、回転後にポップオーバーが再表示される
    • ラベルを長押ししてもメニューは表示されず、コピーなどの操作はできない

次は、この要件になるように実装してみたいとおもいます。

実装してみる

さて、ここからは幾つかのアプローチで実装を試みていきます。

UITextField

最初に思いつくシンプルな実装は、UITextFieldinputViewプロパティにUIDatePickerをセットする方法です。
StoryboardでUITextFieldUIDatePickerを置き、コード側で接続させてみます。

StoryboardでのUI要素配置

ViewControllerの実装

import UIKit

class SampleAViewController: UITableViewController {

    @IBOutlet private weak var birthdayCell: UITableViewCell!
    @IBOutlet private weak var birthdayTextField: UITextField!

    @IBOutlet private weak var datePicker: UIDatePicker!
    @IBOutlet private weak var datePickerContainerView: UIView!

    lazy private var dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        // 設定は省略
        return formatter
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        // キーボードをUIDatePickerに置換え
        birthdayTextField.inputView = datePickerContainerView
    }

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: false)

        switch tableView.cellForRow(at: indexPath) {
        case birthdayCell?:
            // UITextFieldではなくセルのタップでも入力開始する
            birthdayTextField.becomeFirstResponder()
        default:
            break
        }
    }

    @IBAction func datePickerDidValueChanged(picker: UIDatePicker) {
        let dateString = dateFormatter.string(from: picker.date)
        birthdayTextField.text = dateString
    }
}

extension SampleAViewController: UITextFieldDelegate {

    func textField(_ textField: UITextField,
                   shouldChangeCharactersIn range: NSRange,
                   replacementString string: String) -> Bool {
        // キーボード入力や、カット/ペースによる変更を防ぐ
        return false
    }
}

viewDidLoad()birthdayTextField.inputViewにピッカーをセットしています。
また、UITextField.valueChangedイベント時にdatePickerDidValueChanged(picker:)を呼び出し、
ピッカーの日付を文字列に変換して文字列をセットしています。
さらに、UITextFieldDelegateに適合しておき、textField(_:shouldChangeCharactersIn:replacementString:)で常にfalseを返すようにすることでキーボードやペーストによって日付(を文字列化したもの)以外の入力を防いでいます。

これを実行してみると、セルのタップで入力を開始しピッカーの値をセットするところまで上手く動いているように見えます。
文字列のタップでコピーやカットのメニューも表示されます。

しかし、入力中に青いキャレット(カーソル)が点滅している点がiOS標準の挙動と異なり、気になります。
これを解決するためにbirthdayTextField.tintColor = .clearを追加し、カーソルを見えないように変更します。
そうすると、入力中のキャレットは見えなくなり、要件を満たしているように見えます。

が、テキストを選択した時に青いハンドルが表示されず、選択部分も青ではなくグレーの表示に変化してしまいました。

とても、惜しいところまでいっている気がしますが、完全に同じ挙動までは実装できませんでした。

UITextView

編集状態にならずテキスト選択だけできるUIコンポーネント...ということでUITextViewを使ってみましょう。
UITextViewにはopen var isEditable: Boolopen var isSelectable: Boolというプロパティを持っており、それぞれfalsetrueを設定することで、編集はできないけど選択はできる状態を作り出せます。

というわけで、UITextFieldと同様にinputViewプロパティをピッカーで上書きして実行してみます。
(コードはUITextFieldのパターンとほとんど同じなので割愛します)

実行してみた結果がこちらです。

確かに編集中はキャレットが見えずUITextFieldの問題を解消したようにみえますが、テキストの選択関連のインタラクションがオリジナルと微妙に違っていて、完全に再現できたとは言えない状態です。(このあたりは実際に動かして確かめてみてください)

1歩近づいた気がしましたが、UITextViewでも完全再現には至りませんでした...

Subclass of UILabel

UITextFieldUITextViewもダメならカスタムクラスでどうにかするしかねぇ...ってことで考えてみましょう。
上記以外でテキストを表示できるクラスといえばUILabelが思いつきます。

入力に対応してないUILabelだとキーボードが...と思いますが、実はUILabelinputViewプロパティを持っています。
これはUILabelの継承元であるUIResponderクラスが持っているプロパティでget onlyなプロパティです。
ですが、サブクラスでピッカーを返すようにオーバーライドしてあげればUITextFieldと同じような挙動をさせることができます。

下が、カスタムクラスの実装コードです。初期化時にisUserInteractionEnabled = trueとしている部分、canBecomeFirstRespondertrueを返す部分とinputViewでピッカーを返すところがポイントです。
これで、ラベルに対してbecomeFirstResponder()メソッドを呼んであげればキーボード入力できるようになります。

カスタムラベルクラスの実装

final class DateInputLabel: UILabel {

    var datePickerContainerView: UIView!

    override func awakeFromNib() {
        super.awakeFromNib()
        setupView()
    }

    override var canBecomeFirstResponder: Bool {
        return true
    }

    override var canResignFirstResponder: Bool {
        return true
    }

    override var inputView: UIView? {
        return datePickerContainerView
    }

    private func setupView() {
        isUserInteractionEnabled = true

        // 長押しでメニューを表示するのにロングプレスイベントを追加
        let longTap = UILongPressGestureRecognizer(target: self, 
                                                   action: #selector(handleLongPressGesture(gesture:)))
        addGestureRecognizer(longTap)
    }

    @objc func handleLongPressGesture(gesture: UILongPressGestureRecognizer) {
        guard let text = text, !text.isEmpty else {
            return
        }
        let menuController = UIMenuController.shared
        if !menuController.isMenuVisible {
            let bounds = textRect(forBounds: self.bounds, limitedToNumberOfLines: 1)
            menuController.setTargetRect(bounds, in: self)
            menuController.setMenuVisible(true, animated: true)
        }
    }
}

実行してみると以下のような挙動をします。

UILabelのサブクラスですが、セルのタップでキーボードが表示されて入力できるようになっています。
またロングタップでメニューを表示するところも上手く動いています。

がしかし、テキストを選択した時の青いビューが表示されません。...って、よく考えたらUILabelのサブクラスだからそうですね。
iOS標準のテキスト選択部分まで自分で実装するのは...大変そうなので一旦ここで諦めます。

まとめ

UITextField, UITextView, UILabelサブクラス、という3つのアプローチで実装を試みましたが、
iOSの連絡先連絡先アプリが持っている振る舞いを完全に再現するには至りませんでした。
テキストの選択やメニュー表示関連を実装する必要がないという意味では、
UITextFieldをサブクラス化するなりしてもうちょっと頑張れば実現できそうですが、
いずれにしてもクラスをそのまま組み合わせるだけじゃなくてそれなりにコードを書かないと難しそうです。

今回記事をかくにあたっては時間が足りませんでした。。。

もし、こういう実装がいいのでは?とか、ウチはこういう実装にしてますというアイディアや意見があればコメントいただけるとありがたいです!
(未整理ですが)コードはGitHubにあげていますので、Issueやコメント貰えればと思います。

というわけで、意外と大変な日付入力のお話でした。

16日目の明日は @SatoTakeshiX さんです。お楽しみに!!

【おまけ】 iPadに対応させてみる

記事が長くなってきたので本編には載せませんでしたが、機能要件にあったiPad対応は以下のようなコードで実現できます。
セルもしくはbirthdayTextFieldがタップされたときにbecomeFirstResponder()を呼ぶのではなく、
iPadの時にはViewControllerを新規でつくってPopoverでモーダル表示しています。
iPad向けにコード切り分けるのちょっと...という気もしますが、こちらの方がiOS的だとは思うのでユニバーサル対応しているアプリは頑張って実装しましょう。

class SampleAViewController: UITableViewController {

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: false)

        switch tableView.cellForRow(at: indexPath) {
        case birthdayCell?:
            beginPickingBirtyday()
        default:
            break
        }
    }

    func beginPickingBirtyday() {
        if UIDevice.current.userInterfaceIdiom == .pad {
            datePickerContainerView.frame = CGRect(x: 0, y: 0, width: 320, height: 216)

            let viewController = UIViewController()
            viewController.loadViewIfNeeded()
            viewController.view.addSubview(datePickerContainerView)
            viewController.view.sizeToFit()

            viewController.modalPresentationStyle = .popover
            viewController.popoverPresentationController?.sourceView = birthdayTextField
            viewController.popoverPresentationController?.sourceRect = birthdayTextField.bounds.offsetBy(dx: -8, dy: -8)
            viewController.preferredContentSize = CGSize(width: 320, height: 216)
            present(viewController, animated: true)
        } else {
            birthdayTextField.becomeFirstResponder()
        }
    }
}

extension SampleDViewController: UITextFieldDelegate {

    func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool {
        // UITextFieldがタップした時にPopoverを表示する
        if UIDevice.current.userInterfaceIdiom == .pad {
            beginPickingBirtyday()
            return false
        }
        return true
    }

    func textField(_ textField: UITextField,
                   shouldChangeCharactersIn range: NSRange,
                   replacementString string: String) -> Bool {
        return false
    }
}