MILLEN BOX 2

個人iOSアプリ開発者hollymotoによる勉強の記録。時々雑記。

メッセージアプリ風テキスト入力があるサンプルアプリを作成した記録【Xcode11.5, iOS12.4, Swift5で確認】

LINEとかのメッセージアプリって、

  • 画面の下部にテキストボックスがあって、

  • キーボードが現れるとその高さ分テキストボックスが上がったり。

  • キーボードが消去されるとテキストボックスは元の位置に戻ったり。

という動作をしますよね。↓こうゆうやつのことです。
f:id:anthrgrnwrld:20200529071357g:plain

キーボードの高さに追従するようなテキストボックスの作成なんですが、これがなかなか難しい。AutoLayoutの制約とかUIResponderをうまく使って作ってあげなきゃいけないんです。
3年くらい前かな?一回作って完成させたんですよ。それを使ったアプリが「ポーン - プロフェッショナルな名言」です。

apps.apple.com

なのですが、、、、
いつの頃からかSwiftかiOSかのバージョンが上がったことを理由にビルドが通らなくなっちゃったんですよね...。
そこで3ヶ月くらい前ですかね、この仕組みを作り直しました。なかなかしんどかった。
今回は、同じようなことがあった時に見失わないように、ブログに記録しておこうと思います。

ただし、一つ一つ書いていくとめちゃくちゃ時間がかかりそうなんで、あくまでスナップショット的な感じで進めます。

1. Storyboard

以下のスクリーンショットとメモを記録として残しておきます。
f:id:anthrgrnwrld:20200529070356p:plain

2. コード全体(InputTextViewController.swift)

まずコード全体を貼っときます。

import UIKit

// MARK: - InputTextViewController
class InputTextViewController: UIViewController {
    // MARK: Outlet
    @IBOutlet weak var inputTextFrameView: UIView!
    @IBOutlet weak var inputTextView: UITextView!
    @IBOutlet weak var placeFolderLabel: UILabel!
    @IBOutlet weak var enterButton: UIButton!
    @IBOutlet weak var textLabel: UILabel!
    
    // MARK: Constraints
    @IBOutlet weak var constraitsSafeAreaBottomEqualInputTextFrameView: NSLayoutConstraint!     //InputTextFrameとSafeAreaのBottom位置
    @IBOutlet weak var constraitsInputTextView: NSLayoutConstraint!         //InputTextViewのHeightのconstrait
    @IBOutlet weak var constraitsInputTextFrameView: NSLayoutConstraint!    //InputTextFrameのHeightのconstrait
    
    // MARK: Enum
    enum InputTextViewTextColor: Int {
        case black = 0
        case gray
        
        static let colorVals = [UIColor.black, UIColor.gray]
        
        mutating func nextColor() -> UIColor {
            let colorVal = (self.rawValue + 1) % InputTextViewTextColor.colorVals.count
            self = InputTextViewTextColor(rawValue: colorVal)!
            return InputTextViewController.InputTextViewTextColor.colorVals[self.rawValue]
        }
    }
    
    // MARK: Private property
    private var positionOfInputTextFrameView: CGFloat = 0;      //inputTextFrameViewのBottomからの位置 (広告などの表示を考慮)
    private var textColor: InputTextViewTextColor = .black      //InputTextViewのテキスト文字色と同期させたいプロパティ
    

    override func viewDidLoad() {
        super.viewDidLoad()
        
        //inputTextFrameViewの調整 (広告などの表示を考慮)
        self.constraitsSafeAreaBottomEqualInputTextFrameView.constant = positionOfInputTextFrameView

        //タップ操作のインスタンス作成
        let mySingleTap = UITapGestureRecognizer(target: self,
                                                 action: #selector(InputTextViewController.tapGesture(_:)))
        //self.viewにタップ操作インスタンスを登録
        self.view.addGestureRecognizer(mySingleTap)
        
        //キーボードの出現を検知するためのObserverに登録
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(keyboardWillBeShown(notification:)),
                                               name: UIResponder.keyboardWillShowNotification,
                                               object: nil)
        
        //キーボードの消去を検知するためのObserverに登録
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(keyboardWillBeHidden(notification:)),
                                               name: UIResponder.keyboardWillHideNotification,
                                               object: nil)
        
        //画面をタップしたことを検知するための準備
        //編集中か否かを判別するためにdelegateをセット
        inputTextView.delegate = self
        
        //該当するViewを角丸にする
        changeCornerRadius()
    }
    
    override func viewWillAppear(_ animated: Bool) {
        //InputTextFrameとSafeAreaのBottom位置を子クラスで設定された値にする(広告などを表示する際のスペースにする)
        self.constraitsSafeAreaBottomEqualInputTextFrameView.constant = positionOfInputTextFrameView
        toggleStateInputTextFrameView(textViewDidChange: false) //InputTextFrameViewの状態を切り替え
    }
    
    // MARK: IBAction
    @IBAction func pressEnterButton(_ sender: Any) {
        textLabel.text = inputTextView.text     //入力したテキストのラベルへの反映
    }
    
    // MARK: Internal method
    /**
     inputTextFrameViewのBottomからの位置をセットする
     */
    func setPositionOfInputTextFrameView(value: CGFloat) {
        positionOfInputTextFrameView = value
    }
    
    /**
     inputTextFrameViewのBottomからの位置をゲットする
     */
    func getPositionOfInputTextFrameView() -> CGFloat {
        return positionOfInputTextFrameView
    }
}

// MARK: - extension InputTextViewController Private method
extension InputTextViewController {
    
    /**
     Viewを角丸にする
     */
    private func changeCornerRadius() {
        enterButton.layer.cornerRadius = 5      //入力ボタン
        inputTextView.layer.cornerRadius = 5    //InputTextView
    }
    
    /**
     placeFolderを表示/非表示、及びInputTextViewの文字色を切り替え
     */
    private func toggleStateInputTextFrameView(textViewDidChange: Bool) {
        self.placeFolderLabel.isHidden = inputTextView.text.count != 0 ? true : false                           //placeFolderを表示/非表示
        self.inputTextView.textColor = textViewDidChange ? self.inputTextView.textColor : textColor.nextColor() //TextViewの文字色を変更
    }
    
}


// MARK: - extension InputTextViewController Private method for selector
extension InputTextViewController {
    
    /**
     selector: UITapGestureRecognizerの通知の時に実行するようにしている
     */
    @objc private func tapGesture(_ sender: AnyObject) {
        closeKeyboard()
    }
    
    /**
     キーボードを閉じる
     */
    private func closeKeyboard() {
        inputTextView.resignFirstResponder()    //キーボード閉じる動作
    }
    
    
    
    /**
     selector: UIResponder.keyboardWillShowNotificationの通知の時に実行するようにしている
     */
    @objc private func keyboardWillBeShown(notification: Notification) {
        guard let userInfo = notification.userInfo else { fatalError("userInfo is nil.") }
        if let keyboard = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue,
            let durationAnimation = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval {
            let keyBoardRect = keyboard.cgRectValue
            animationKeyboardInOut(keyBoardRect: keyBoardRect, duration: durationAnimation, isInOperation: true)
        }
    }
    
    
    
    /**
     selector: UIResponder.keyboardWillHideNotificationの通知の時に実行するようにしている
     */
    @objc private func keyboardWillBeHidden(notification: Notification) {
        guard let userInfo = notification.userInfo else { fatalError("userInfo is nil.") }
        if let keyboard = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue,
            let durationAnimation =  userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval {
            let keyBoardRect = keyboard.cgRectValue
            animationKeyboardInOut(keyBoardRect: keyBoardRect, duration: durationAnimation, isInOperation: false)
        }
    }
    
    
    
    /**
     キーボードアニメーションの実行
     */
    private func animationKeyboardInOut(keyBoardRect: CGRect, duration: TimeInterval, isInOperation: Bool) {
        
        let options: UIView.AnimationOptions    = isInOperation ? .curveEaseIn              : .curveEaseOut
        let offasetY: CGFloat                   = isInOperation ? keyBoardRect.size.height  : self.view.safeAreaInsets.bottom + positionOfInputTextFrameView
        
        UIView.animate(withDuration: duration, delay: 0.0, options: options, animations: {
            self.inputTextFrameView.frame.origin.y = self.view.frame.size.height - self.inputTextFrameView.frame.size.height - offasetY
            //+付随したViewを動かすアニメーション
            
        }) { _ in
            self.constraitsSafeAreaBottomEqualInputTextFrameView.constant = offasetY - self.view.safeAreaInsets.bottom
        }
    }
}


// MARK: - extension InputTextViewController: UITextViewDelegate
extension InputTextViewController: UITextViewDelegate {
    
    func textViewDidBeginEditing(_ textView: UITextView) {
        toggleStateInputTextFrameView(textViewDidChange: false) //InputTextFrameViewの状態を切り替え
    }
    
    func textViewDidEndEditing(_ textView: UITextView) {
        toggleStateInputTextFrameView(textViewDidChange: false) //InputTextFrameViewの状態を切り替え
    }

    func textViewDidChange(_ textView: UITextView) {
        toggleStateInputTextFrameView(textViewDidChange: true)  //InputTextFrameViewの状態を切り替え
        changingInputTextViewSize()
    }
    
    /**
     テキスト入力の改行時にテキストボックスサイズを可変させる
     */
    private func changingInputTextViewSize() {
        //テキスト入力か改行時にテキストボックスサイズを可変させる
        let marginHeight: CGFloat = 12
        let maxHeight: CGFloat = 67
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            let textViewHeight = self.inputTextView.contentSize.height
            let parentViewHeight = textViewHeight + marginHeight
            if maxHeight > textViewHeight {
                self.constraitsInputTextView.constant = textViewHeight
                self.constraitsInputTextFrameView.constant = parentViewHeight
            } else {
                self.constraitsInputTextView.constant = maxHeight
                self.constraitsInputTextFrameView.constant = self.constraitsInputTextView.constant + marginHeight
            }
        }
    }
}

2.1 (キモ1)viewDidLoadを眺めてみる

viewDidLoadの中でキモっぽいのは以下のキーボードの表示非表示の「きっかけ」になる部分です。これらから呼び出された処理たちを追っかけていくとやりたいことが見えてきます。

//タップ操作のインスタンス作成
let mySingleTap = UITapGestureRecognizer(target: self,
                                                 action: #selector(InputTextViewController.tapGesture(_:)))
//self.viewにタップ操作インスタンスを登録
self.view.addGestureRecognizer(mySingleTap)
        
//キーボードの出現を検知するためのObserverに登録
NotificationCenter.default.addObserver(self,
                                               selector: #selector(keyboardWillBeShown(notification:)),
                                               name: UIResponder.keyboardWillShowNotification,
                                               object: nil)
        
//キーボードの消去を検知するためのObserverに登録
NotificationCenter.default.addObserver(self,
                                               selector: #selector(keyboardWillBeHidden(notification:)),
                                               name: UIResponder.keyboardWillHideNotification,
                                               object: nil)

呼び出される処理は以下のextensionで纏められてますね。分離がちゃんと出来てて良い!

// MARK: - extension InputTextViewController Private method for selector
extension InputTextViewController {
    
    /**
     selector: UITapGestureRecognizerの通知の時に実行するようにしている
     */
    @objc private func tapGesture(_ sender: AnyObject) {
        closeKeyboard()
    }
    
    /**
     キーボードを閉じる
     */
    private func closeKeyboard() {
        inputTextView.resignFirstResponder()    //キーボード閉じる動作
    }
    
    
    
    /**
     selector: UIResponder.keyboardWillShowNotificationの通知の時に実行するようにしている
     */
    @objc private func keyboardWillBeShown(notification: Notification) {
        guard let userInfo = notification.userInfo else { fatalError("userInfo is nil.") }
        if let keyboard = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue,
            let durationAnimation = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval {
            let keyBoardRect = keyboard.cgRectValue
            animationKeyboardInOut(keyBoardRect: keyBoardRect, duration: durationAnimation, isInOperation: true)
        }
    }
    
    
    
    /**
     selector: UIResponder.keyboardWillHideNotificationの通知の時に実行するようにしている
     */
    @objc private func keyboardWillBeHidden(notification: Notification) {
        guard let userInfo = notification.userInfo else { fatalError("userInfo is nil.") }
        if let keyboard = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue,
            let durationAnimation =  userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval {
            let keyBoardRect = keyboard.cgRectValue
            animationKeyboardInOut(keyBoardRect: keyBoardRect, duration: durationAnimation, isInOperation: false)
        }
    }
    
    
    
    /**
     キーボードアニメーションの実行
     */
    private func animationKeyboardInOut(keyBoardRect: CGRect, duration: TimeInterval, isInOperation: Bool) {
        
        let options: UIView.AnimationOptions    = isInOperation ? .curveEaseIn              : .curveEaseOut
        let offasetY: CGFloat                   = isInOperation ? keyBoardRect.size.height  : self.view.safeAreaInsets.bottom + positionOfInputTextFrameView
        
        UIView.animate(withDuration: duration, delay: 0.0, options: options, animations: {
            self.inputTextFrameView.frame.origin.y = self.view.frame.size.height - self.inputTextFrameView.frame.size.height - offasetY
            //+付随したViewを動かすアニメーション
            
        }) { _ in
            self.constraitsSafeAreaBottomEqualInputTextFrameView.constant = offasetY - self.view.safeAreaInsets.bottom
        }
    }
}

2.2 (キモ2)キーボードの高さを取得する方法

書いてるうちにだんだん思い出してきました。キーボードの高さを取得する方法を調べた記憶があります。キーボードが表示非表示された時に高さを取得してテキストボックスの移動量を見積もってるんですね。
userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValueで表示されたキーボードのインスタンスが取得でき、そこからcgRectValue``を指定すればキーボードのRectが取得出来ます。因みにuserInfoはnotification.userInfo```です。

guard let userInfo = notification.userInfo else { fatalError("userInfo is nil.") }
if let keyboard = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue,
            let durationAnimation = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval {
    let keyBoardRect = keyboard.cgRectValue
    ...(中の処理 省略)
}

2.3 (キモ3)キーボードの表示非表示のアニメーション時間を取得する方法

また思い出しました。キーボードの表示非表示のアニメーション時間も取得出来ます。これでキーボードの動きとテキストボックスの動きをほぼ同期出来ますね。
userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeIntervalで取得出来ます。コードは2.1と同様なんでそちらを参照のこと。

2.4 (キモ4)キーボードの上げ下げに追従するテキストボックスアニメーション

ここが一番ややこしい。アニメーション自体はお馴染みのUIView.animate(withDuration:を使うので問題無いのですが、

  • どれだけ動かして

  • 動かし終わった後どこに落ち着かせるか

の2点を汎用的に取得する方法がややこしかったのです。
ちょっともうあんまり覚えて無いのですが、TextViewFrameのBottomとSafeAreaの長さの制約constraitsSafeAreaBottomEqualInputTextFrameViewを使うのがミソだった気がします。
以下のようなコードで実現しました。この辺はまた画面周りで新たな概念が出た時に書き直さないといけないかもですね...。

private func animationKeyboardInOut(keyBoardRect: CGRect, duration: TimeInterval, isInOperation: Bool) {
        
    let options: UIView.AnimationOptions    = isInOperation ? .curveEaseIn              : .curveEaseOut
    let offasetY: CGFloat                   = isInOperation ? keyBoardRect.size.height  : self.view.safeAreaInsets.bottom + positionOfInputTextFrameView
        
    UIView.animate(withDuration: duration, delay: 0.0, options: options, animations: {
        self.inputTextFrameView.frame.origin.y = self.view.frame.size.height - self.inputTextFrameView.frame.size.height - offasetY
            //+付随したViewを動かすアニメーション
            
    }) { _ in
        self.constraitsSafeAreaBottomEqualInputTextFrameView.constant = offasetY - self.view.safeAreaInsets.bottom
    }
}

2.5 (キモ5)テキスト入力の改行時や複数にわたる文字列を表示時にテキストボックスサイズを可変させる

これを忘れてはいけない。テキストボックスが複数行になったり改行した時にテキストボックスのサイズを適した大きさに変更します。以下のような感じ。
f:id:anthrgrnwrld:20200529131736g:plain
いつまでも大きくしていってもキリが無いので3行くらいを最大に設定してます。
InputTextViewの高さとPlaceFolderの高さの制約、constraitsInputTextViewconstraitsInputTextFrameViewを変更するような仕組みにしてるはずです(あんまり覚えていない...)。

/**
 テキスト入力の改行時にテキストボックスサイズを可変させる
 */
private func changingInputTextViewSize() {
    //テキスト入力か改行時にテキストボックスサイズを可変させる
    let marginHeight: CGFloat = 12
    let maxHeight: CGFloat = 67
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
        let textViewHeight = self.inputTextView.contentSize.height
        let parentViewHeight = textViewHeight + marginHeight
        if maxHeight > textViewHeight {
            self.constraitsInputTextView.constant = textViewHeight
            self.constraitsInputTextFrameView.constant = parentViewHeight
        } else {
            self.constraitsInputTextView.constant = maxHeight
            self.constraitsInputTextFrameView.constant = self.constraitsInputTextView.constant + marginHeight
        }
    }
}

3. まとめ

記事を書き始めた時は「とりあえず残しとくこと目的の備忘録!コピペするだけの記事でいいや!」って思ってましたが、いざ書いてみると色々思い出して当初想ってよりわかりやすい投稿になりました。振り返りって大事ですね。
にしても超自分用備忘録なのでお役に立てたかどうか。。。