-
Notifications
You must be signed in to change notification settings - Fork 15
/
KeyboardGuide.swift
248 lines (199 loc) · 10.7 KB
/
KeyboardGuide.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
//
// KeyboardGuide.swift
// KeyboardGuide
//
// Created by Yoshimasa Niwa on 2/18/20.
// Copyright © 2020 Yoshimasa Niwa. All rights reserved.
//
import Foundation
import ObjectiveC
import UIKit
@objc(KBGKeyboardGuideObserver)
public protocol KeyboardGuideObserver {
@objc
func keyboardGuide(_ keyboardGuide: KeyboardGuide, didChangeDockedKeyboardState dockedKeyboardState: KeyboardState?)
}
@objc(KBGKeyboardGuide)
public final class KeyboardGuide: NSObject {
private let isShared: Bool
@objc(sharedGuide)
public static let shared = KeyboardGuide(shared: true)
public convenience override init() {
self.init(shared: false)
}
private init(shared: Bool) {
isShared = shared
super.init()
}
deinit {
if isShared {
NotificationCenter.default.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil)
}
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
}
@objc
public private(set) var isActive: Bool = false
@objc
public func activate() {
assert(Thread.isMainThread, "Must be called on main thread")
guard !isActive else { return }
isActive = true
if isShared {
NotificationCenter.default.addObserver(self, selector: #selector(applicationDidEnterBackground(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(applicationWillEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
}
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
}
// MARK: - Observer
private let didChangeStateNotification = Notification.Name("KBGKeyboardGuideDidChangeStateNotification")
@objc
private final class ObserverNotificationProxy: NSObject {
weak var observer: KeyboardGuideObserver?
init(observer: KeyboardGuideObserver) {
self.observer = observer
}
@objc
func keyboardGuideDidChangeState(_ notification: Notification) {
guard let keyboardGuide = notification.object as? KeyboardGuide else { return }
observer?.keyboardGuide(keyboardGuide, didChangeDockedKeyboardState: keyboardGuide.dockedKeyboardState)
}
}
private static var notificationProxyAssociationKey: UInt8 = 0
@objc
public func addObserver(_ observer: KeyboardGuideObserver) {
let notificationProxy = ObserverNotificationProxy(observer: observer)
NotificationCenter.default.addObserver(notificationProxy, selector: #selector(ObserverNotificationProxy.keyboardGuideDidChangeState(_:)), name: didChangeStateNotification, object: self)
objc_setAssociatedObject(observer, &KeyboardGuide.notificationProxyAssociationKey, notificationProxy, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
@objc
public func removeObserver(_ observer: KeyboardGuideObserver) {
objc_setAssociatedObject(observer, &KeyboardGuide.notificationProxyAssociationKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
// MARK: - Properties
@objc
public private(set) var dockedKeyboardState: KeyboardState? {
didSet {
let notification = Notification(name: didChangeStateNotification, object: self, userInfo: nil)
NotificationCenter.default.post(notification)
}
}
// MARK: - Notifications
/**
When the application entered in background, iOS may send multiple state change events to the application,
such as trait collection change events to capture screen image in both orientations for the application switcher.
In some cases, the application may change its view structure and the text fields may resign first responder.
However, since the application has entered in background, iOS will _NOT_ send any keyboard notifications to the application.
Therefore, logically, there are no ways to know the current keyboard state after the application is entering background.
To workaround this behavior, it retains the current first responder and restore it if `shouldRestoreFirstResponder` returns `true`
(Default to `true` if it is `UITextInputTraits` such as `UITextView`.)
- SeeAlso:
`UIResponder.shouldRestoreFirstResponder`
*/
private var lastFirstResponder: UIResponder?
@objc
private func applicationDidEnterBackground(_ notification: Notification) {
guard isShared else { return }
lastFirstResponder = UIResponder.currentFirstResponder
}
@objc
private func applicationWillEnterForeground(_ notification: Notification) {
guard isShared else { return }
guard let lastFirstResponder = lastFirstResponder else { return }
self.lastFirstResponder = nil
// Try to restore the first responder to maintain the last keyboard state.
if lastFirstResponder.shouldRestoreFirstResponder, lastFirstResponder.becomeFirstResponder() {
return
}
// In case it doesn't or can't restore the first responder,
// assume that there are no keyboard remaining on the screen.
dockedKeyboardState = nil
}
@objc
private func keyboardWillShow(_ notification: Notification) {
// _MAY BE_ called in `UIView` animation block.
updateKeyboardState(with: notification)
}
@objc
private func keyboardWillHide(_ notification: Notification) {
// _MAY BE_ called in `UIView` animation block.
dockedKeyboardState = nil
}
@objc
private func keyboardWillChangeFrame(_ notification: Notification) {
// _MAY BE_ called in `UIView` animation block.
// Only update docked keyboard state when the keyboard is currently docked.
guard dockedKeyboardState != nil else { return }
updateKeyboardState(with: notification)
}
private func updateKeyboardState(with notification: Notification) {
guard let isLocal = notification.userInfo?[UIResponder.keyboardIsLocalUserInfoKey] as? Bool,
let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
else {
return
}
// `UIResponder.keyboardWillChangeFrameNotification` _MAY BE_ posted with `CGRect.zero` frame.
// Ignore it, which is useless.
if frame == CGRect.zero {
return
}
let coordinateSpace: UICoordinateSpace
let keyboardContainerBounds: CGRect
let keyboardFrame: CGRect
if #available(iOS 16.0, *), UIDevice.current.userInterfaceIdiom == .pad {
// iPadOS 16.0 and later supports Stage Manager that introduced multiple edge cases.
// Note that iPadOS 16.0 was not released and first release version is iPadOS 16.1.
// On iPadOS 16.0 and later, the keyboard frame is on the screen coordinate.
let keyboardScreen = UIScreen.main
coordinateSpace = keyboardScreen.coordinateSpace
// `keyWindow` is deprecated API, however, it gives the right current key window.
if let keyWindow = UIApplication.shared.keyWindow {
// Do not use `window.frame`, which is not in the screen coordinate space.
let keyWindowFrame = coordinateSpace.convert(keyWindow.bounds, from: keyWindow)
// On iPad 16.0 and later, sometimes the keyboard frame is clipped in the key window frame.
// This is an arbitrary condition if it's clipped.
if frame.width == keyWindowFrame.width {
keyboardContainerBounds = keyWindowFrame
keyboardFrame = frame
} else {
keyboardContainerBounds = keyboardScreen.bounds
// In case the keyboard frame is not clipped, sometimes the keyboard frame is positioned
// wrongly such as off the screen, or wrongly using key window frame origin X.
// Use keyboard container origin X instead, since keyboard is always appearing
// in full-width.
keyboardFrame = CGRect(x: keyboardContainerBounds.origin.x, y: frame.origin.y, width: frame.size.width, height: frame.size.height)
}
} else {
// In case we can't find key window, which is unlikely happening.
keyboardContainerBounds = keyboardScreen.bounds
keyboardFrame = frame
}
} else if #available(iOS 13.0, *) {
// On iOS 13.0 and later, the keyboard frame is on the screen coordinate.
let keyboardScreen = UIScreen.main
coordinateSpace = keyboardScreen.coordinateSpace
// The keyboard container is always screen bounds, can be larger than window frame.
keyboardContainerBounds = keyboardScreen.bounds
keyboardFrame = frame
} else if let keyWindow = UIApplication.shared.keyWindow {
// On prior to iOS 13.0, the keyboard frame is on the window coordinate.
let keyboardScreen = keyWindow.screen
coordinateSpace = keyWindow
// The keyboard container is always screen bounds, can be larger than window frame.
keyboardContainerBounds = coordinateSpace.convert(keyboardScreen.bounds, from: keyboardScreen.coordinateSpace)
keyboardFrame = CGRect(x: keyboardContainerBounds.origin.x, y: frame.origin.y, width: frame.size.width, height: frame.size.height)
} else {
return
}
// While the main screen bound is being changed, notifications _MAY BE_ posted with wrong frame.
// Ignore it, because it will be eventual consistent with the following notifications.
if keyboardContainerBounds.width != keyboardFrame.width {
return
}
dockedKeyboardState = KeyboardState(isLocal: isLocal, frame: keyboardFrame, coordinateSpace: coordinateSpace)
}
}