Skip to content

Instantly share code, notes, and snippets.

@pbk20191
Created April 28, 2025 03:10
Show Gist options
  • Save pbk20191/f59f24c2cb041930ada1bcb5ca8d3e58 to your computer and use it in GitHub Desktop.
Save pbk20191/f59f24c2cb041930ada1bcb5ca8d3e58 to your computer and use it in GitHub Desktop.
CompatKeyboardLayoutGuide
//
// CompatKeyboardLayoutGuide.swift
//
import UIKit
import Combine
// Backport LayoutGuide for UIKeyboardLayout (This only works for Docked Keyboard)
@MainActor
public class CompatDockedKeyboardLayoutGuide: UILayoutGuide {
private var backing = ContiguousArray<NSLayoutConstraint>()
private var previouseRect = CGRect.null
private let arrays = (
UIResponder.keyboardWillShowNotification,
UIResponder.keyboardWillHideNotification
)
private var managed = false
public override weak var owningView: UIView? {
didSet {
if oldValue != owningView {
if let owningView {
self.configure(owningView)
installForInitialState()
if !previouseRect.isNull {
self.dispatchChange(previouseRect)
}
}
}
}
willSet {
if newValue != owningView {
backing.forEach{
$0.isActive = false
}
showConstraint?.isActive = false
}
precondition(!(newValue is UIWindow), "You can not attach the KeyboardLayoutGuide directly to the Window")
}
}
private let useLegacyKeyboard = false
private var hideConstraint:NSLayoutConstraint?
private var showConstraint:NSLayoutConstraint?
// private var leading:NSLayoutConstraint?
// private var bottom: NSLayoutConstraint?
// private var trailing:NSLayoutConstraint?
//
// // Keyboard height constraint
// private var heightConstraint: NSLayoutConstraint?
//
private func installForInitialState() {
guard !managed, previouseRect.isNull else {
return
}
// if this is called before `UIApplicationMain`
guard let _ = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) else {
managed = true
return
}
// no first responder for now
guard UIResponder.searchFirstResponder() != nil else {
managed = true
return
}
let keyWindow = UIApplication.shared.connectedScenes
.compactMap{ $0 as? UIWindowScene }
.compactMap{
if #available(iOS 15.0, *) {
$0.keyWindow
} else {
$0.windows.first(where: \.isKeyWindow)
}
}.first
if let keyWindow, let scene = keyWindow.windowScene {
let inputWindow = scene.windows.first {
NSStringFromClass(type(of: $0)).hasPrefix("UIText")
}
if let rect = inputWindow?.rootViewController?.view.subviews.first?.frame {
previouseRect = rect
}
}
managed = true
}
public override init() {
super.init()
installObserver()
}
private func installObserver() {
if #available(iOS 15.0, *) {
if useLegacyKeyboard {
NotificationCenter.default.addObserver(self, selector: #selector(handleKeyboardNotification), name: arrays.0, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(handleKeyboardNotification), name: arrays.1, object: nil)
}
} else {
NotificationCenter.default.addObserver(self, selector: #selector(handleKeyboardNotification), name: arrays.0, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(handleKeyboardNotification), name: arrays.1, object: nil)
}
}
public required init?(coder: NSCoder) {
super.init(coder: coder)
installObserver()
}
deinit {
NotificationCenter.default.removeObserver(self, name: arrays.0, object: nil)
NotificationCenter.default.removeObserver(self, name: arrays.1, object: nil)
}
private func configure(_ view:UIView) {
if #available(iOS 15, *), !useLegacyKeyboard {
backing = []
let guide = view.keyboardLayoutGuide
backing.append(
guide.centerXAnchor.constraint(equalTo: centerXAnchor)
)
backing.append(
guide.centerYAnchor.constraint(equalTo: centerYAnchor)
)
backing.append(
guide.widthAnchor.constraint(equalTo: widthAnchor)
)
backing.append(
guide.heightAnchor.constraint(equalTo: heightAnchor)
)
backing.forEach {
$0.isActive = true
}
} else {
backing = []
backing.append(
view.bottomAnchor.constraint(equalTo: bottomAnchor)
)
backing.append(
view.centerXAnchor.constraint(equalTo: centerXAnchor)
)
backing.append(
view.widthAnchor.constraint(equalTo: widthAnchor)
)
let constraint = view.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: topAnchor)
backing.append(
constraint
)
backing.forEach{
$0.isActive = true
}
self.hideConstraint = constraint
self.showConstraint = view.bottomAnchor.constraint(equalTo: topAnchor)
showConstraint?.isActive = false
}
}
@objc private func handleKeyboardNotification(_ notification: Notification) {
guard Thread.isMainThread, notification.name == UIResponder.keyboardWillHideNotification || notification.name == UIResponder.keyboardWillShowNotification else {
return
}
managed = true
let isHide = notification.name == UIResponder.keyboardWillHideNotification
guard let owningView = owningView,
let userInfo = notification.userInfo,
let keyboardFrameEnd = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
let animationDuration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double,
let animationCurveRawValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt,
let keyboardFrameBegin = userInfo[UIResponder.keyboardFrameBeginUserInfoKey] as? CGRect
else {
return
}
self.previouseRect = keyboardFrameEnd
let animationOption = if animationCurveRawValue == 0 {
[] as UIView.AnimationOptions
} else {
UIView.AnimationOptions(rawValue: animationCurveRawValue << 16)
}
// "UIKeyboardCenterBeginUserInfoKey"
// "UIKeyboardCenterEndUserInfoKey"
// "UIKeyboardBoundsUserInfoKey"
let convertedKeyboardFrameEnd = UIScreen.main.coordinateSpace.convert(keyboardFrameEnd, to: owningView)
let viewIntersection = owningView.bounds.intersection(convertedKeyboardFrameEnd)
let bottomOffset = if isHide {
0.0 as CGFloat
} else if !viewIntersection.isEmpty {
viewIntersection.height
} else {
0.0 as CGFloat
}
UIView.animate(withDuration: animationDuration, delay: 0, options: animationOption) { [showConstraint, owningView, hideConstraint] in
if bottomOffset.isZero {
showConstraint?.isActive = !bottomOffset.isZero
hideConstraint?.isActive = bottomOffset.isZero
} else {
hideConstraint?.isActive = bottomOffset.isZero
showConstraint?.isActive = !bottomOffset.isZero
}
showConstraint?.constant = bottomOffset
owningView.layoutIfNeeded()
}
}
@usableFromInline
func dispatchChange(_ rect:CGRect) {
guard let owningView else {
return
}
let converted = UIScreen.main.coordinateSpace.convert(rect, to: owningView)
let intersection = owningView.bounds.intersection(converted)
let offset = if !intersection.isEmpty {
intersection.height
} else {
0.0 as CGFloat
}
hideConstraint?.isActive = offset.isZero
showConstraint?.isActive = !offset.isZero
showConstraint?.constant = offset
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment