From 6aebeae65d18b09c117f321a32bef0d51cbe5a2d Mon Sep 17 00:00:00 2001 From: Robert MacEachern Date: Wed, 2 Aug 2023 16:32:46 -0500 Subject: [PATCH 1/5] Update KeyboardObserver --- CHANGELOG.md | 5 +- .../KeyboardObserver/KeyboardObserver.swift | 153 ++++++++++------- ListableUI/Sources/ListView/ListView.swift | 11 +- .../Internal/KeyboardObserverTests.swift | 155 ++++++++++++------ 4 files changed, 209 insertions(+), 115 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e22cd5084..fe4d78f18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,12 @@ ### 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. + ### Added - `ApplyItemContentInfo` and `ApplyHeaderFooterContentInfo` are now available in the Blueprint `Environment` for each `BlueprintItemContent`. - - A new `isContentScrollable` property is added to `ListView` to determine if the content size is large enough that the list can be scrolled to a new position without springing back to it's original position. - - A new `custom` case is added to `KeyboardAdjustmentMode` which allows the consumer to fully customize the inset behavior. `onKeyboardFrameWillChange` and `customScrollViewInsets` have been added to `ListView` and `updateScrollViewInsets` is now public. All of these can be utilized in conjunction to respond to the keyboard and fully control the insets. For now, these are available through `ListView` only. ### Removed @@ -15,6 +15,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`. ### Misc diff --git a/ListableUI/Sources/KeyboardObserver/KeyboardObserver.swift b/ListableUI/Sources/KeyboardObserver/KeyboardObserver.swift index 04feccf1a..9500c464d 100644 --- a/ListableUI/Sources/KeyboardObserver/KeyboardObserver.swift +++ b/ListableUI/Sources/KeyboardObserver/KeyboardObserver.swift @@ -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 @@ -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 ) } @@ -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 @@ -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 } } @@ -150,11 +152,23 @@ public final class KeyboardObserver { return nil } - guard let notification = self.latestNotification else { + guard let notification = latestNotification else { 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) @@ -167,11 +181,11 @@ 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. @@ -179,19 +193,11 @@ public 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) - - self.delegates.forEach { + delegates.forEach { $0.value?.keyboardFrameWillChange( for: self, animationDuration: new.animationDuration, - options: animationOptions + animationCurve: new.animationCurve ) } } @@ -200,27 +206,44 @@ 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 animationDuration: Double = 0.0 + var animationCurve: UIView.AnimationCurve = .easeInOut - var endingFrame : CGRect = .zero + @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 + } + } - var animationDuration : Double = 0.0 - var animationCurve : UInt = 0 + // 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 { + init(with notification: Notification) throws { guard let userInfo = notification.userInfo else { throw ParseError.missingUserInfo @@ -238,19 +261,31 @@ 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 { + enum ParseError: Error, Equatable { case missingUserInfo case missingEndingFrame case missingAnimationDuration case missingAnimationCurve + case missingScreen } } } @@ -268,8 +303,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 diff --git a/ListableUI/Sources/ListView/ListView.swift b/ListableUI/Sources/ListView/ListView.swift index 996dcedd3..13cde5f10 100644 --- a/ListableUI/Sources/ListView/ListView.swift +++ b/ListableUI/Sources/ListView/ListView.swift @@ -318,7 +318,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 @@ -1434,7 +1434,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 @@ -1447,14 +1447,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) ) } } diff --git a/ListableUI/Tests/Internal/KeyboardObserverTests.swift b/ListableUI/Tests/Internal/KeyboardObserverTests.swift index 58ce3995a..245d8b8d4 100644 --- a/ListableUI/Tests/Internal/KeyboardObserverTests.swift +++ b/ListableUI/Tests/Internal/KeyboardObserverTests.swift @@ -1,17 +1,15 @@ -import XCTest import UIKit +import XCTest @_spi(ListableKeyboard) @testable import ListableUI - class KeyboardObserverTests: XCTestCase { - func test_add() - { + func test_add() { let center = NotificationCenter() let observer = KeyboardObserver(center: center) - var delegate1 : Delegate? = Delegate() + var delegate1: Delegate? = Delegate() weak var weakDelegate1 = delegate1 let delegate2 = Delegate() @@ -36,7 +34,7 @@ class KeyboardObserverTests: XCTestCase { delegate1 = nil - self.waitFor { + waitFor { weakDelegate1 == nil } @@ -44,17 +42,16 @@ class KeyboardObserverTests: XCTestCase { XCTAssertEqual(observer.delegates.count, 2) } - func test_remove() - { + func test_remove() { let center = NotificationCenter() let observer = KeyboardObserver(center: center) - let delegate1 : Delegate? = Delegate() + let delegate1: Delegate? = Delegate() - var delegate2 : Delegate? = Delegate() + var delegate2: Delegate? = Delegate() weak var weakDelegate2 = delegate2 - let delegate3 : Delegate? = Delegate() + let delegate3: Delegate? = Delegate() // Register all 3 observers @@ -68,7 +65,7 @@ class KeyboardObserverTests: XCTestCase { delegate2 = nil - self.waitFor { + waitFor { weakDelegate2 == nil } @@ -88,14 +85,23 @@ class KeyboardObserverTests: XCTestCase { let delegate = Delegate() observer.add(delegate: delegate) - let userInfo : [AnyHashable:Any] = [ - UIResponder.keyboardFrameEndUserInfoKey : NSValue(cgRect: CGRect(x: 10.0, y: 10.0, width: 100.0, height: 200.0)), - UIResponder.keyboardAnimationDurationUserInfoKey : NSNumber(value: 2.5), - UIResponder.keyboardAnimationCurveUserInfoKey : NSNumber(value: 123) + let userInfo: [AnyHashable: Any] = [ + UIResponder.keyboardFrameEndUserInfoKey: NSValue(cgRect: CGRect( + x: 10.0, + y: 10.0, + width: 100.0, + height: 200.0 + )), + UIResponder.keyboardAnimationDurationUserInfoKey: NSNumber(value: 2.5), + UIResponder.keyboardAnimationCurveUserInfoKey: NSNumber(value: 123), ] XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 0) - center.post(Notification(name: UIWindow.keyboardWillChangeFrameNotification, object: nil, userInfo: userInfo)) + center.post(Notification( + name: UIWindow.keyboardWillChangeFrameNotification, + object: UIScreen.main, + userInfo: userInfo + )) XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 1) } @@ -106,14 +112,23 @@ class KeyboardObserverTests: XCTestCase { let delegate = Delegate() observer.add(delegate: delegate) - let userInfo : [AnyHashable:Any] = [ - UIResponder.keyboardFrameEndUserInfoKey : NSValue(cgRect: CGRect(x: 10.0, y: 10.0, width: 100.0, height: 200.0)), - UIResponder.keyboardAnimationDurationUserInfoKey : NSNumber(value: 2.5), - UIResponder.keyboardAnimationCurveUserInfoKey : NSNumber(value: 123) + let userInfo: [AnyHashable: Any] = [ + UIResponder.keyboardFrameEndUserInfoKey: NSValue(cgRect: CGRect( + x: 10.0, + y: 10.0, + width: 100.0, + height: 200.0 + )), + UIResponder.keyboardAnimationDurationUserInfoKey: NSNumber(value: 2.5), + UIResponder.keyboardAnimationCurveUserInfoKey: NSNumber(value: 123), ] XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 0) - center.post(Notification(name: UIWindow.keyboardDidChangeFrameNotification, object: nil, userInfo: userInfo)) + center.post(Notification( + name: UIWindow.keyboardDidChangeFrameNotification, + object: UIScreen.main, + userInfo: userInfo + )) XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 1) } @@ -121,56 +136,82 @@ class KeyboardObserverTests: XCTestCase { do { let observer = KeyboardObserver(center: center) - let delegate = Delegate() + let delegate = Delegate() observer.add(delegate: delegate) - let userInfo : [AnyHashable:Any] = [ - UIResponder.keyboardFrameEndUserInfoKey : NSValue(cgRect: CGRect(x: 10.0, y: 10.0, width: 100.0, height: 200.0)), - UIResponder.keyboardAnimationDurationUserInfoKey : NSNumber(value: 2.5), - UIResponder.keyboardAnimationCurveUserInfoKey : NSNumber(value: 123) - ] + let userInfo: [AnyHashable: Any] = [ + UIResponder.keyboardFrameEndUserInfoKey: NSValue(cgRect: CGRect( + x: 10.0, + y: 10.0, + width: 100.0, + height: 200.0 + )), + UIResponder.keyboardAnimationDurationUserInfoKey: NSNumber(value: 2.5), + UIResponder.keyboardAnimationCurveUserInfoKey: NSNumber(value: 123), + ] - XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 0) - center.post(Notification(name: UIWindow.keyboardDidChangeFrameNotification, object: nil, userInfo: userInfo)) - XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 1) + XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 0) + center.post(Notification( + name: UIWindow.keyboardDidChangeFrameNotification, + object: UIScreen.main, + userInfo: userInfo + )) + XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 1) - XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 1) - center.post(Notification(name: UIWindow.keyboardDidChangeFrameNotification, object: nil, userInfo: userInfo)) - XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 1) + XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 1) + center.post(Notification( + name: UIWindow.keyboardDidChangeFrameNotification, + object: UIScreen.main, + userInfo: userInfo + )) + XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 1) } } - final class Delegate : KeyboardObserverDelegate { + final class Delegate: KeyboardObserverDelegate { - var keyboardFrameWillChange_callCount : Int = 0 + var keyboardFrameWillChange_callCount: Int = 0 - func keyboardFrameWillChange(for observer: KeyboardObserver, animationDuration: Double, options: UIView.AnimationOptions) { + func keyboardFrameWillChange( + for observer: KeyboardObserver, + animationDuration: Double, + animationCurve: UIView.AnimationCurve + ) { - self.keyboardFrameWillChange_callCount += 1 + keyboardFrameWillChange_callCount += 1 } } } -class KeyboardObserver_NotificationInfo_Tests : XCTestCase { +class KeyboardObserver_NotificationInfo_Tests: XCTestCase { func test_init() { - let defaultUserInfo : [AnyHashable:Any] = [ - UIResponder.keyboardFrameEndUserInfoKey : NSValue(cgRect: CGRect(x: 10.0, y: 10.0, width: 100.0, height: 200.0)), - UIResponder.keyboardAnimationDurationUserInfoKey : NSNumber(value: 2.5), - UIResponder.keyboardAnimationCurveUserInfoKey : NSNumber(value: 123) + let defaultUserInfo: [AnyHashable: Any] = [ + UIResponder.keyboardFrameEndUserInfoKey: NSValue(cgRect: CGRect( + x: 10.0, + y: 10.0, + width: 100.0, + height: 200.0 + )), + UIResponder.keyboardAnimationDurationUserInfoKey: NSNumber(value: 2.5), + UIResponder.keyboardAnimationCurveUserInfoKey: NSNumber(value: 123), ] // Successful Init do { let info = try! KeyboardObserver.NotificationInfo( - with: Notification(name: UIResponder.keyboardDidShowNotification, object: nil, userInfo: defaultUserInfo) + with: Notification( + name: UIResponder.keyboardDidShowNotification, + object: UIScreen.main, + userInfo: defaultUserInfo + ) ) XCTAssertEqual(info.endingFrame, CGRect(x: 10.0, y: 10.0, width: 100.0, height: 200.0)) XCTAssertEqual(info.animationDuration, 2.5) - XCTAssertEqual(info.animationCurve, 123) + XCTAssertEqual(info.animationCurve, UIView.AnimationCurve(rawValue: 123)!) } // Failed Inits @@ -179,7 +220,11 @@ class KeyboardObserver_NotificationInfo_Tests : XCTestCase { do { XCTAssertThrowsError( try _ = KeyboardObserver.NotificationInfo( - with: Notification(name: UIResponder.keyboardDidShowNotification, object: nil, userInfo: nil) + with: Notification( + name: UIResponder.keyboardDidShowNotification, + object: UIScreen.main, + userInfo: nil + ) ) ) { error in XCTAssertEqual(error as? KeyboardObserver.NotificationInfo.ParseError, .missingUserInfo) @@ -193,7 +238,11 @@ class KeyboardObserver_NotificationInfo_Tests : XCTestCase { XCTAssertThrowsError( try _ = KeyboardObserver.NotificationInfo( - with: Notification(name: UIResponder.keyboardDidShowNotification, object: nil, userInfo: userInfo) + with: Notification( + name: UIResponder.keyboardDidShowNotification, + object: UIScreen.main, + userInfo: userInfo + ) ) ) { error in XCTAssertEqual(error as? KeyboardObserver.NotificationInfo.ParseError, .missingEndingFrame) @@ -207,7 +256,11 @@ class KeyboardObserver_NotificationInfo_Tests : XCTestCase { XCTAssertThrowsError( try _ = KeyboardObserver.NotificationInfo( - with: Notification(name: UIResponder.keyboardDidShowNotification, object: nil, userInfo: userInfo) + with: Notification( + name: UIResponder.keyboardDidShowNotification, + object: UIScreen.main, + userInfo: userInfo + ) ) ) { error in XCTAssertEqual(error as? KeyboardObserver.NotificationInfo.ParseError, .missingAnimationDuration) @@ -221,7 +274,11 @@ class KeyboardObserver_NotificationInfo_Tests : XCTestCase { XCTAssertThrowsError( try KeyboardObserver.NotificationInfo( - with: Notification(name: UIResponder.keyboardDidShowNotification, object: nil, userInfo: userInfo) + with: Notification( + name: UIResponder.keyboardDidShowNotification, + object: UIScreen.main, + userInfo: userInfo + ) ) ) { error in XCTAssertEqual(error as? KeyboardObserver.NotificationInfo.ParseError, .missingAnimationCurve) From 27d578d368f8e02eed3f04a68ea2a923a3f5707e Mon Sep 17 00:00:00 2001 From: Robert MacEachern Date: Mon, 28 Aug 2023 14:43:03 -0500 Subject: [PATCH 2/5] Lenient screen parsing. Fallback to window.screen. --- .../KeyboardObserver/KeyboardObserver.swift | 61 +++++++------------ 1 file changed, 21 insertions(+), 40 deletions(-) diff --git a/ListableUI/Sources/KeyboardObserver/KeyboardObserver.swift b/ListableUI/Sources/KeyboardObserver/KeyboardObserver.swift index 9500c464d..6c1f6027c 100644 --- a/ListableUI/Sources/KeyboardObserver/KeyboardObserver.swift +++ b/ListableUI/Sources/KeyboardObserver/KeyboardObserver.swift @@ -146,9 +146,9 @@ 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? { + func currentFrame(in view: UIView) -> KeyboardFrame? { - guard view.window != nil else { + guard let window = view.window else { return nil } @@ -156,19 +156,12 @@ public final class KeyboardObserver { return nil } - let frame: CGRect + let screen = notification.screen ?? window.screen - if #available(iOS 16.1, *) { - frame = notification.screen.coordinateSpace.convert( - notification.endingFrame, - to: view - ) - } else { - frame = view.convert( - notification.endingFrame, - from: nil - ) - } + let frame = screen.coordinateSpace.convert( + notification.endingFrame, + to: view + ) if frame.intersects(view.bounds) { return .overlapping(frame: frame) @@ -225,23 +218,19 @@ extension KeyboardObserver { var animationDuration: Double = 0.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? + /// 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 { @@ -269,14 +258,7 @@ extension KeyboardObserver { self.animationCurve = animationCurve - if #available(iOS 16.1, *) { - guard let screen = notification.object as? UIScreen else { - throw ParseError.missingScreen - } - - self.screen = screen - } - + screen = notification.object as? UIScreen } enum ParseError: Error, Equatable { @@ -285,7 +267,6 @@ extension KeyboardObserver { case missingEndingFrame case missingAnimationDuration case missingAnimationCurve - case missingScreen } } } From 93282b797981c323571714f686bf0f20724d3c5b Mon Sep 17 00:00:00 2001 From: Robert MacEachern Date: Tue, 29 Aug 2023 08:49:52 -0500 Subject: [PATCH 3/5] Kick CI From 428e0e9d8957e7bb014a027846933eb113f5136d Mon Sep 17 00:00:00 2001 From: Robert MacEachern Date: Fri, 1 Sep 2023 08:35:22 -0500 Subject: [PATCH 4/5] Restore the `public` modifier on `currentFrame(in:`) --- ListableUI/Sources/KeyboardObserver/KeyboardObserver.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ListableUI/Sources/KeyboardObserver/KeyboardObserver.swift b/ListableUI/Sources/KeyboardObserver/KeyboardObserver.swift index 6c1f6027c..a49d5ddda 100644 --- a/ListableUI/Sources/KeyboardObserver/KeyboardObserver.swift +++ b/ListableUI/Sources/KeyboardObserver/KeyboardObserver.swift @@ -146,7 +146,7 @@ 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. - func currentFrame(in view: UIView) -> KeyboardFrame? { + public func currentFrame(in view: UIView) -> KeyboardFrame? { guard let window = view.window else { return nil From 42e4bd113b77eb6071cf7a119f2c05e630de3e54 Mon Sep 17 00:00:00 2001 From: Robert MacEachern Date: Fri, 1 Sep 2023 08:51:13 -0500 Subject: [PATCH 5/5] Merge fix: Convert chat demo to `UIViewPropertyAnimator` --- .../Demo Screens/ChatDemoViewController.swift | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/Demo/Sources/Demos/Demo Screens/ChatDemoViewController.swift b/Demo/Sources/Demos/Demo Screens/ChatDemoViewController.swift index fb39bdc65..2f9ea3d9c 100644 --- a/Demo/Sources/Demos/Demo Screens/ChatDemoViewController.swift +++ b/Demo/Sources/Demos/Demo Screens/ChatDemoViewController.swift @@ -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)