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 #499

Merged
merged 6 commits into from
Sep 1, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
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`.
Copy link
Collaborator

Choose a reason for hiding this comment

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

God bless


### 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.
//
Copy link
Collaborator

Choose a reason for hiding this comment

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

Wow rude


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
Loading