Skip to content

Instantly share code, notes, and snippets.

@keitaoouchi
Last active December 2, 2020 02:38
Show Gist options
  • Save keitaoouchi/a047d25e8c73692d20b19f1e0a9b5949 to your computer and use it in GitHub Desktop.
Save keitaoouchi/a047d25e8c73692d20b19f1e0a9b5949 to your computer and use it in GitHub Desktop.
まぁまぁ再利用しがちなキーボードマネージャー
import UIKit
/// キーボードイベントを監視し、キーボードの開閉に合わせてviewをtransformで上下移動させる
final class KeyboardManager {
private var view: UIView
/// キーボード開閉時に可能なら表示されるように画面スライド位置が調整されるビュー
/// 縦長フォームの一番下にあるサブミットボタンみたいなやつを想定
private var subFocusView: UIView?
/// キーボードスライド時の最下部ビューとキーボード間のマージン
private var margin: CGFloat = 8.0
private var keyboardFrameCache: CGRect?
init(in view: UIView, subFocusView: UIView? = nil, margin: CGFloat = 8.0) {
self.view = view
self.subFocusView = subFocusView
self.margin = margin
self.observeKeyboardEvents()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
private extension KeyboardManager {
/// キーボードの開閉に合わせてMessageInputViewControllerがキーボードに隠されないようにする
/// - Note: [Managing the Keyboard](https://developer.apple.com/library/content/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/KeyboardManagement/KeyboardManagement.html)
/// - Note: [キーボードイベント通知](https://developer.apple.com/documentation/uikit/uiwindow/keyboard_notification_user_info_keys?language=objc)
func observeKeyboardEvents() {
NotificationCenter.default.addObserver(self,
selector: #selector(onKeyboardWillShow(notification:)),
name: UIWindow.keyboardWillShowNotification,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(onKeyboardWillShow(notification:)),
name: UIWindow.keyboardWillChangeFrameNotification,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(onKeyboardWillHide(notification:)),
name: UIWindow.keyboardWillHideNotification,
object: nil)
/// キーボード表示中にテキストフィールドのフォーカスが変更されたときにキーボードスライド量の再計算をする
NotificationCenter.default.addObserver(self,
selector: #selector(onKeyboardWillShow(notification:)),
name: UITextField.textDidBeginEditingNotification,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(onKeyboardWillShow(notification:)),
name: UITextView.textDidBeginEditingNotification,
object: nil)
}
/// フォーカスされたサブビューを画面内から探し、フォーカスされたサブビューが表示されるように画面全体を縦方向にスライドさせる
@objc private func onKeyboardWillShow(notification: Notification) {
if let keyboardFrame = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
// UITextField.textDidBeginEditingNotificationで画面スライドを実行するためにキャッシュする
self.keyboardFrameCache = keyboardFrame
}
guard let keyboardRect = self.keyboardFrameCache else { return }
// safeAreaのボトムインセット高さをキーボード高さとボトム位置から除外する
let insetBottomAdjustment: CGFloat
if #available(iOS 11.0, *) {
insetBottomAdjustment = view.safeAreaInsets.bottom
} else {
insetBottomAdjustment = 0.0
}
// 画面スライド時にsafeAreaのトップにかかぶらないようにする
let insetTop: CGFloat
if #available(iOS 11.0, *) {
insetTop = view.safeAreaInsets.top
} else {
insetTop = 0.0
}
let subFocusViewsBottomYFromBottom: CGFloat
if let subFocusView = subFocusView {
let rect = subFocusView.convert(subFocusView.bounds, to: view)
subFocusViewsBottomYFromBottom = view.bounds.height - (rect.maxY + margin + insetBottomAdjustment)
} else {
subFocusViewsBottomYFromBottom = CGFloat.greatestFiniteMagnitude
}
if let firstResponder = self.findFirstResponder(in: self.view) {
let firstRespondersRect = firstResponder.convert(firstResponder.bounds, to: view)
// 画面底からフォーカスビューの底までの高さ(マージン考慮)
let bottomYFromBottom = view.bounds.height - (firstRespondersRect.maxY + margin + insetBottomAdjustment)
// 画面底からフォーカスビューのトップまでの高さ
let topYFromBottom = bottomYFromBottom + firstRespondersRect.height
let keyboardHeight = keyboardRect.height - insetBottomAdjustment
let deltaY = min(bottomYFromBottom, subFocusViewsBottomYFromBottom)
// 一番底のViewを調整位置とした画面スライド量
var adjustmentHeight: CGFloat = keyboardHeight - deltaY
// フォーカスビューとサブフォーカスビューが表示領域に収まらなければフォーカスビューを基準位置としてスライド量修正
let threashold = view.bounds.height - insetBottomAdjustment - insetTop
if topYFromBottom - subFocusViewsBottomYFromBottom + adjustmentHeight > threashold {
adjustmentHeight = keyboardHeight - topYFromBottom
}
if adjustmentHeight < 0 {
// キーボードを表示しきってもフォーカスビューが隠れないのでスライド位置をデフォルト位置に戻す
self.animateKeyboard(.identity, with: notification)
} else {
let transform = CGAffineTransform(translationX: 0, y: -adjustmentHeight)
self.animateKeyboard(transform, with: notification)
}
}
}
/// キーボード非表示時にviewのスライドを元に戻す
@objc func onKeyboardWillHide(notification: Notification) {
self.animateKeyboard(.identity, with: notification)
}
/// フォーカスされているビューを探して返す
func findFirstResponder(in view: UIView) -> UIView? {
for subView in view.subviews {
if subView.isFirstResponder {
return subView
} else if let firstResponder = self.findFirstResponder(in: subView) {
return firstResponder
}
}
return nil
}
/// Notificationからアニメーション情報を取得して与えられたtransformでアニメーションを実行
func animateKeyboard(_ transform: CGAffineTransform, with notification: Notification) {
self.view.layer.removeAllAnimations()
guard
let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval,
let curve = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt else {
UIView.animate(withDuration: 0.3, delay: 0.0, options: .curveEaseInOut, animations: {
self.view.transform = transform
})
return
}
let animationCurve = UIView.AnimationOptions(rawValue: curve)
UIView.animate(withDuration: duration, delay: 0.0, options: animationCurve, animations: {
self.view.transform = transform
})
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment