Skip to content

Commit

Permalink
Update KeyboardObserver (#463)
Browse files Browse the repository at this point in the history
* Update KeyboardObserver

* Update CHANGELOG.md

* Used shared instance of `KeyboardObserver` in `ScrollView`

* Lenient screen parsing. Fallback to `window.screen`
  • Loading branch information
robmaceachern authored Sep 1, 2023
1 parent 99b6bba commit 13309a5
Show file tree
Hide file tree
Showing 5 changed files with 267 additions and 43 deletions.
147 changes: 123 additions & 24 deletions BlueprintUICommonControls/Sources/Internal/KeyboardObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ protocol KeyboardObserverDelegate: AnyObject {
func keyboardFrameWillChange(
for observer: KeyboardObserver,
animationDuration: Double,
options: UIView.AnimationOptions
animationCurve: UIView.AnimationCurve
)
}

Expand All @@ -30,29 +30,46 @@ protocol KeyboardObserverDelegate: AnyObject {
Notes
-----
Implementation borrowed from Listable:
https://github.com/kyleve/Listable/blob/master/Listable/Sources/Internal/KeyboardObserver.swift
iOS Docs for keyboard management:
https://developer.apple.com/library/archive/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/KeyboardManagement/KeyboardManagement.html
*/
final class KeyboardObserver {

/// The global shared keyboard observer. Why is it a global shared instance?
/// We can only know the keyboard position via the keyboard frame notifications.
///
/// If a keyboard observing view is created while a keyboard is already on-screen, we'd have no way to determine the
/// keyboard frame, and thus couldn't provide the correct content insets to avoid the visible keyboard.
///
/// Thus, the `shared` observer is set up on app startup
/// (see `SetupKeyboardObserverOnAppStartup.m`) to avoid this problem.
static let shared: KeyboardObserver = KeyboardObserver(center: .default)

/// Allow logging to the console if app startup-timed shared instance startup did not
/// occur; this could cause bugs for the reasons outlined above.
fileprivate static var didSetupSharedInstanceDuringAppStartup = false

private let center: NotificationCenter

weak var delegate: KeyboardObserverDelegate?
private(set) var delegates: [Delegate] = []

struct Delegate {
private(set) weak var value: KeyboardObserverDelegate?
}

//
// MARK: Initialization
//

init(center: NotificationCenter = .default) {
init(center: NotificationCenter) {

self.center = center

/// We need to listen to both `will` and `keyboardDidChangeFrame` notifications. Why?
///
/// When dealing with an undocked or floating keyboard, moving the keyboard
/// around the screen does NOT call `willChangeFrame`; only `didChangeFrame` is called.
///
/// Before calling the delegate, we compare `old.endingFrame != new.endingFrame`,
/// which ensures that the delegate is notified if the frame really changes, and
/// prevents duplicate calls.
Expand All @@ -73,6 +90,35 @@ final class KeyboardObserver {

private var latestNotification: NotificationInfo?

//
// MARK: Delegates
//

func add(delegate: KeyboardObserverDelegate) {

if delegates.contains(where: { $0.value === delegate }) {
return
}

delegates.append(Delegate(value: delegate))

removeDeallocatedDelegates()
}

func remove(delegate: KeyboardObserverDelegate) {
delegates.removeAll {
$0.value === delegate
}

removeDeallocatedDelegates()
}

private func removeDeallocatedDelegates() {
delegates.removeAll {
$0.value == nil
}
}

//
// MARK: Handling Changes
//
Expand All @@ -90,15 +136,20 @@ final class KeyboardObserver {
/// or the observer has not yet learned about the keyboard's position, this method returns nil.
func currentFrame(in view: UIView) -> KeyboardFrame? {

guard view.window != nil else {
guard let window = view.window else {
return nil
}

guard let notification = latestNotification else {
return nil
}

let frame = view.convert(notification.endingFrame, from: nil)
let screen = notification.screen ?? window.screen

let frame = screen.coordinateSpace.convert(
notification.endingFrame,
to: view
)

if frame.intersects(view.bounds) {
return .overlapping(frame: frame)
Expand All @@ -123,19 +174,13 @@ final class KeyboardObserver {
return
}

/**
Create an animation curve with the correct curve for showing or hiding the keyboard.
This is unfortunately a private UIView curve. However, we can map it to the animation options' curve
like so: https://stackoverflow.com/questions/26939105/keyboard-animation-curve-as-int
*/
let animationOptions = UIView.AnimationOptions(rawValue: new.animationCurve << 16)

delegate?.keyboardFrameWillChange(
for: self,
animationDuration: new.animationDuration,
options: animationOptions
)
delegates.forEach {
$0.value?.keyboardFrameWillChange(
for: self,
animationDuration: new.animationDuration,
animationCurve: new.animationCurve
)
}
}

//
Expand All @@ -148,7 +193,7 @@ final class KeyboardObserver {
let info = try NotificationInfo(with: notification)
receivedUpdatedKeyboardInfo(info)
} catch {
assertionFailure("Blueprint could not read system keyboard notification. This error needs to be fixed in Blueprint. Error: \(error)")
assertionFailure("Could not read system keyboard notification: \(error)")
}
}
}
Expand All @@ -159,7 +204,21 @@ extension KeyboardObserver {
var endingFrame: CGRect = .zero

var animationDuration: Double = 0.0
var animationCurve: UInt = 0
var animationCurve: UIView.AnimationCurve = .easeInOut

/// The `UIScreen` that the keyboard appears on.
///
/// This may influence the `KeyboardFrame` calculation when the app is not in full screen,
/// such as in Split View, Slide Over, and Stage Manager.
///
/// - note: In iOS 16.1 and later, every `keyboardWillChangeFrameNotification` and
/// `keyboardDidChangeFrameNotification` is _supposed_ to include a `UIScreen`
/// in a the notification, however we've had reports that this isn't always the case (at least when
/// using the iOS 16.1 simulator runtime). If a screen is _not_ included in an iOS 16.1+ notification,
/// we do not throw a `ParseError` as it would cause the entire notification to be discarded.
///
/// [Apple Documentation](https://developer.apple.com/documentation/uikit/uiresponder/1621623-keyboardwillchangeframenotificat)
var screen: UIScreen?

init(with notification: Notification) throws {

Expand All @@ -179,11 +238,15 @@ extension KeyboardObserver {

self.animationDuration = animationDuration

guard let animationCurve = (userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue else {
guard let curveValue = (userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.intValue,
let animationCurve = UIView.AnimationCurve(rawValue: curveValue)
else {
throw ParseError.missingAnimationCurve
}

self.animationCurve = animationCurve

screen = notification.object as? UIScreen
}

enum ParseError: Error, Equatable {
Expand All @@ -195,3 +258,39 @@ extension KeyboardObserver {
}
}
}


extension KeyboardObserver {
private static let isExtensionContext: Bool = {
// This is our best guess for "is this executable an extension?"
if let _ = Bundle.main.infoDictionary?["NSExtension"] {
return true
} else if Bundle.main.bundlePath.hasSuffix(".appex") {
return true
} else {
return false
}
}()

/// This should be called by a keyboard-observing view on setup, to warn developers if something has gone wrong with
/// keyboard setup.
static func logKeyboardSetupWarningIfNeeded() {
guard !isExtensionContext else {
return
}

if KeyboardObserver.didSetupSharedInstanceDuringAppStartup {
return
}

print(
"""
WARNING: The shared instance of the `KeyboardObserver` was not instantiated during
app startup. While not fatal, this could result in a view being created that does
not properly position itself to account for the keyboard, if the view is created
while the keyboard is already visible.
"""
)
}
}

11 changes: 6 additions & 5 deletions BlueprintUICommonControls/Sources/ScrollView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ extension ScrollView {
fileprivate final class ScrollerWrapperView: UIView {

let scrollView = UIScrollView()
let keyboardObserver = KeyboardObserver()
let keyboardObserver = KeyboardObserver.shared

/// The current `ScrollView` state we represent.
private var representedElement: ScrollView
Expand Down Expand Up @@ -341,7 +341,7 @@ fileprivate final class ScrollerWrapperView: UIView {

super.init(frame: frame)

keyboardObserver.delegate = self
keyboardObserver.add(delegate: self)

addSubview(scrollView)
}
Expand Down Expand Up @@ -564,11 +564,12 @@ extension ScrollerWrapperView: KeyboardObserverDelegate {
func keyboardFrameWillChange(
for observer: KeyboardObserver,
animationDuration: Double,
options: UIView.AnimationOptions
animationCurve: UIView.AnimationCurve
) {
UIView.animate(withDuration: animationDuration, delay: 0.0, options: options, animations: {
UIViewPropertyAnimator(duration: animationDuration, curve: animationCurve) {
self.updateBottomContentInsetWithKeyboardFrame()
})
}
.startAnimation()
}
}

Expand Down
Loading

0 comments on commit 13309a5

Please sign in to comment.