Skip to content

Commit

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

* Lenient screen parsing. Fallback to window.screen.

* Kick CI

* Restore the `public` modifier on `currentFrame(in:`)

* Merge fix: Convert chat demo to `UIViewPropertyAnimator`
  • Loading branch information
robmaceachern authored Sep 1, 2023
1 parent e47336c commit 138f5ad
Show file tree
Hide file tree
Showing 5 changed files with 197 additions and 123 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

### Fixed

- `KeyboardObserver` has been updated to handle iOS 16.1+ changes that use the screen's coordinate space to report keyboard position. This can impact reported values when the app isn't full screen in Split View, Slide Over, and Stage Manager.
- The `verticalLayoutGravity` behavior now takes into account `frame` changes so that the scroll position relative to the bottom remains unchanged when the `frame` changes.

### Added
Expand All @@ -28,6 +29,7 @@
### Changed

- `SwipeAction` property names have been updated to better reflect what they're for. `Completion` also now takes in a more descriptive enum, instead of a boolean, to make reading callsites clearer. Eg, `completion(.expandActions)` instead of `completion(true)`.
- `KeyboardObserverDelegate` now provides `UIView.AnimationCurve` instead of `UIView.AnimationOptions`.

### Internal

Expand Down
14 changes: 6 additions & 8 deletions Demo/Sources/Demos/Demo Screens/ChatDemoViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,12 @@ final class ChatDemoViewController : UIViewController {
self.keyboardHeight = 0
}

UIView.animate(
withDuration: keyboardAnimation.animationDuration,
delay: 0.0,
options: keyboardAnimation.options,
animations: {
self.listView.updateScrollViewInsets()
}
)
UIViewPropertyAnimator(
duration: keyboardAnimation.animationDuration,
curve: keyboardAnimation.animationCurve
) {
self.listView.updateScrollViewInsets()
}.startAnimation()
}

self.view.addSubview(footerView)
Expand Down
138 changes: 77 additions & 61 deletions ListableUI/Sources/KeyboardObserver/KeyboardObserver.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
//
// KeyboardObserver.swift
// ListableUI
//
// Created by Kyle Van Essen on 2/16/20.
//

import UIKit

/// Publicly exposes the current frame provider for consumers
Expand All @@ -30,9 +23,9 @@ extension KeyboardObserver: KeyboardCurrentFrameProvider {}
public protocol KeyboardObserverDelegate : AnyObject {

func keyboardFrameWillChange(
for observer : KeyboardObserver,
animationDuration : Double,
options : UIView.AnimationOptions
for observer: KeyboardObserver,
animationDuration: Double,
animationCurve: UIView.AnimationCurve
)
}

Expand Down Expand Up @@ -65,31 +58,30 @@ public 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 `ListView` 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.
/// 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.
public static let shared : KeyboardObserver = KeyboardObserver(center: .default)
public 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
private let center: NotificationCenter

internal private(set) var delegates : [Delegate] = []
private(set) var delegates: [Delegate] = []

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

//
// MARK: Initialization
//

public init(center : NotificationCenter) {
public init(center: NotificationCenter) {

self.center = center

Expand All @@ -102,37 +94,47 @@ public final class KeyboardObserver {
/// which ensures that the delegate is notified if the frame really changes, and
/// prevents duplicate calls.

self.center.addObserver(self, selector: #selector(keyboardFrameChanged(_:)), name: UIWindow.keyboardWillChangeFrameNotification, object: nil)
self.center.addObserver(self, selector: #selector(keyboardFrameChanged(_:)), name: UIWindow.keyboardDidChangeFrameNotification, object: nil)
self.center.addObserver(
self,
selector: #selector(keyboardFrameChanged(_:)),
name: UIWindow.keyboardWillChangeFrameNotification,
object: nil
)
self.center.addObserver(
self,
selector: #selector(keyboardFrameChanged(_:)),
name: UIWindow.keyboardDidChangeFrameNotification,
object: nil
)
}

private var latestNotification : NotificationInfo?
private var latestNotification: NotificationInfo?

//
// MARK: Delegates
//

public func add(delegate : KeyboardObserverDelegate) {
public func add(delegate: KeyboardObserverDelegate) {

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

self.delegates.append(Delegate(value: delegate))
delegates.append(Delegate(value: delegate))

self.removeDeallocatedDelegates()
removeDeallocatedDelegates()
}

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

self.removeDeallocatedDelegates()
removeDeallocatedDelegates()
}

private func removeDeallocatedDelegates() {
self.delegates.removeAll {
delegates.removeAll {
$0.value == nil
}
}
Expand All @@ -144,17 +146,22 @@ public final class KeyboardObserver {

/// How the keyboard overlaps the view provided. If the view is not on screen (eg, no window),
/// or the observer has not yet learned about the keyboard's position, this method returns nil.
public func currentFrame(in view : UIView) -> KeyboardFrame? {
public func currentFrame(in view: UIView) -> KeyboardFrame? {

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

guard let notification = self.latestNotification else {
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 @@ -167,31 +174,23 @@ public final class KeyboardObserver {
// MARK: Receiving Updates
//

private func receivedUpdatedKeyboardInfo(_ new : NotificationInfo) {
private func receivedUpdatedKeyboardInfo(_ new: NotificationInfo) {

let old = self.latestNotification
let old = latestNotification

self.latestNotification = new
latestNotification = new

/// Only communicate a frame change to the delegate if the frame actually changed.

if let old = old, old.endingFrame == new.endingFrame {
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)

self.delegates.forEach {
delegates.forEach {
$0.value?.keyboardFrameWillChange(
for: self,
animationDuration: new.animationDuration,
options: animationOptions
animationCurve: new.animationCurve
)
}
}
Expand All @@ -200,27 +199,40 @@ public final class KeyboardObserver {
// MARK: Notification Listeners
//

@objc private func keyboardFrameChanged(_ notification : Notification) {
@objc private func keyboardFrameChanged(_ notification: Notification) {

do {
let info = try NotificationInfo(with: notification)
self.receivedUpdatedKeyboardInfo(info)
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)")
}
}
}

extension KeyboardObserver
{
struct NotificationInfo : Equatable {
extension KeyboardObserver {
struct NotificationInfo: Equatable {

var endingFrame: CGRect = .zero

var endingFrame : CGRect = .zero
var animationDuration: Double = 0.0
var animationCurve: UIView.AnimationCurve = .easeInOut

var animationDuration : Double = 0.0
var animationCurve : UInt = 0
/// 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 {
init(with notification: Notification) throws {

guard let userInfo = notification.userInfo else {
throw ParseError.missingUserInfo
Expand All @@ -238,14 +250,18 @@ 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 {
enum ParseError: Error, Equatable {

case missingUserInfo
case missingEndingFrame
Expand All @@ -268,8 +284,8 @@ extension KeyboardObserver {
}
}()

/// Called by `ListView` on setup, to warn developers
/// if something has gone wrong with keyboard setup.
/// 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
Expand Down
11 changes: 6 additions & 5 deletions ListableUI/Sources/ListView/ListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ public final class ListView : UIView
/// Callback for when the keyboard changes
public typealias KeyboardFrameWillChangeCallback = (
KeyboardCurrentFrameProvider,
(animationDuration: Double, options: UIView.AnimationOptions)
(animationDuration: Double, animationCurve: UIView.AnimationCurve)
) -> Void

/// Called whenever a keyboard change is detected
Expand Down Expand Up @@ -1462,7 +1462,7 @@ public extension ListView
@_spi(ListableKeyboard)
extension ListView : KeyboardObserverDelegate
{
public func keyboardFrameWillChange(for observer: KeyboardObserver, animationDuration: Double, options: UIView.AnimationOptions) {
public func keyboardFrameWillChange(for observer: KeyboardObserver, animationDuration: Double, animationCurve: UIView.AnimationCurve) {

guard let frame = self.keyboardObserver.currentFrame(in: self) else {
return
Expand All @@ -1475,14 +1475,15 @@ extension ListView : KeyboardObserverDelegate
self.lastKeyboardFrame = frame

if .custom != behavior.keyboardAdjustmentMode {
UIView.animate(withDuration: animationDuration, delay: 0.0, options: options, animations: {
UIViewPropertyAnimator(duration: animationDuration, curve: animationCurve) {
self.updateScrollViewInsets()
})
}
.startAnimation()
}

self.onKeyboardFrameWillChange?(
self.keyboardObserver,
(animationDuration: animationDuration, options: options)
(animationDuration: animationDuration, animationCurve: animationCurve)
)
}
}
Expand Down
Loading

0 comments on commit 138f5ad

Please sign in to comment.