Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update KeyboardObserver #463

Merged
merged 4 commits into from
Sep 1, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 141 additions & 23 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 @@ -98,7 +144,19 @@ final class KeyboardObserver {
return nil
}

let frame = view.convert(notification.endingFrame, from: nil)
let frame: CGRect

if #available(iOS 16.1, *) {
frame = notification.screen.coordinateSpace.convert(
notification.endingFrame,
to: view
)
} else {
frame = view.convert(
notification.endingFrame,
from: nil
)
}

if frame.intersects(view.bounds) {
return .overlapping(frame: frame)
Expand All @@ -123,19 +181,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 +200,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 +211,25 @@ extension KeyboardObserver {
var endingFrame: CGRect = .zero

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

@available(iOS 16.1, *)
var screen: UIScreen {
get {
guard let screen = _screen else {
fatalError("UIScreen value was not initialized from notification object.")
}
return screen
}
set {
_screen = newValue
}
}

// Note: Using this to work around: "Stored properties cannot be marked
// potentially unavailable with '@available'"
// Can be removed when deployment target is >= 16.1. @available(iOS 16.1, *)
private var _screen: UIScreen?

init(with notification: Notification) throws {

Expand All @@ -179,11 +249,22 @@ 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

if #available(iOS 16.1, *) {
guard let screen = notification.object as? UIScreen else {
throw ParseError.missingScreen
}

self.screen = screen
}

}

enum ParseError: Error, Equatable {
Expand All @@ -192,6 +273,43 @@ extension KeyboardObserver {
case missingEndingFrame
case missingAnimationDuration
case missingAnimationCurve
case missingScreen
}
}
}


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) {
Copy link
Member Author

@robmaceachern robmaceachern Aug 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could stick with UIView.animate - we'd just need to do a UIView.AnimationOptions(rawValue: ... << 16).

self.updateBottomContentInsetWithKeyboardFrame()
})
}
.startAnimation()
}
}

Expand Down
Loading
Loading