From 7496e8e12cb28e3561c2350776dfc4ec636797b7 Mon Sep 17 00:00:00 2001 From: Rob MacEachern Date: Thu, 1 Oct 2026 23:37:35 -0500 Subject: [PATCH 1/2] Preserve cached keyboard positions across screen rotation Convert notification frames into fixed screen coordinates at receipt. Keep floating classification tied to the same geometry, preserve existing changed-frame callbacks, and ignore keyboards on another display. Cover the real hide-in-landscape then rotate sequence with UIKit coordinate-space regressions, alongside callback and fallback behavior. --- *This response was drafted with AI assistance.* --- .../Sources/KeyboardObserver.swift | 48 ++++-- .../Tests/KeyboardObserverTests.swift | 147 ++++++++++++++++++ 2 files changed, 184 insertions(+), 11 deletions(-) diff --git a/KeyboardObserver/Sources/KeyboardObserver.swift b/KeyboardObserver/Sources/KeyboardObserver.swift index b7117a7..8ae66f8 100644 --- a/KeyboardObserver/Sources/KeyboardObserver.swift +++ b/KeyboardObserver/Sources/KeyboardObserver.swift @@ -93,9 +93,8 @@ public final class KeyboardObserver { /// `willChangeFrame` with a `zero` frame, then it follows with `didChangeFrame` when /// the keyboard is done moving. Both delegates will cover all cases. /// - /// 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. + /// Before calling the delegate, we compare positions in fixed screen coordinates, + /// which prevents duplicate calls without conflating equal rectangles from different orientations. self.center.addObserver( self, @@ -148,6 +147,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. + /// Notifications that omit their screen are assumed to describe the main display. public func currentFrame(in view: UIView) -> KeyboardFrame? { guard let window = view.window else { @@ -158,10 +158,12 @@ public final class KeyboardObserver { return nil } - let screen = notification.screen ?? window.screen + guard notification.frameScreen == window.screen else { + return .nonOverlapping + } - let frame = screen.coordinateSpace.convert( - notification.endingFrame, + let frame = notification.frameScreen.fixedCoordinateSpace.convert( + notification.frameInFixedCoordinateSpace, to: view ) @@ -176,7 +178,9 @@ public final class KeyboardObserver { /// This returns true if the on-screen keyboard is a floating iPad keyboard. This is done by /// comparing the keyboard frame against the bounds of the screen. - /// - Parameter view: The screen of this view is used if the latest notification has no screen. + /// Classification uses the screen bounds at notification time, so later rotation cannot + /// turn a cached docked keyboard into a floating one (or vice versa). + /// - Parameter view: Used to establish that a view is on screen when the notification has no screen. /// - Returns: `true` if the keyboard is floating. public func isKeyboardFloating(using view: UIView) -> Bool { @@ -184,13 +188,15 @@ public final class KeyboardObserver { return false } - guard let screen = notification.screen ?? view.window?.screen else { + guard notification.screen != nil || view.window != nil else { return false } - let frame = notification.endingFrame + if let window = view.window, notification.frameScreen != window.screen { + return false + } - return frame.maxY < screen.bounds.maxY && frame.width < screen.bounds.width / 2 + return notification.isKeyboardFloating } // @@ -205,7 +211,12 @@ public final class KeyboardObserver { /// Only communicate a frame change to the delegate if the frame actually changed. - if let old, old.endingFrame == new.endingFrame { + if let old, + old.endingFrame == new.endingFrame, + old.frameScreen == new.frameScreen, + old.frameInFixedCoordinateSpace == new.frameInFixedCoordinateSpace, + old.isKeyboardFloating == new.isKeyboardFloating + { return } @@ -255,6 +266,10 @@ extension KeyboardObserver { /// [Apple Documentation](https://developer.apple.com/documentation/uikit/uiresponder/1621623-keyboardwillchangeframenotificat) var screen: UIScreen? + var frameScreen: UIScreen + var frameInFixedCoordinateSpace: CGRect + var isKeyboardFloating: Bool + init(with notification: Notification) throws { guard let userInfo = notification.userInfo else { @@ -282,6 +297,17 @@ extension KeyboardObserver { self.animationCurve = animationCurve screen = notification.object as? UIScreen + + // Keyboard rectangles use the screen's orientation at delivery. That coordinate space + // can rotate before a later query, so retain the position in orientation-independent coordinates. + // https://developer.apple.com/documentation/uikit/uiscreen/fixedcoordinatespace + frameScreen = screen ?? .main + frameInFixedCoordinateSpace = frameScreen.coordinateSpace.convert( + endingFrame, + to: frameScreen.fixedCoordinateSpace + ) + isKeyboardFloating = endingFrame.maxY < frameScreen.bounds.maxY + && endingFrame.width < frameScreen.bounds.width / 2 } enum ParseError: Error, Equatable { diff --git a/KeyboardObserver/Tests/KeyboardObserverTests.swift b/KeyboardObserver/Tests/KeyboardObserverTests.swift index 746d0fc..6d8a21a 100644 --- a/KeyboardObserver/Tests/KeyboardObserverTests.swift +++ b/KeyboardObserver/Tests/KeyboardObserverTests.swift @@ -366,6 +366,103 @@ class KeyboardObserverTests: XCTestCase { } } + func test_cachedHiddenLandscapeFrame_remainsNonOverlappingAfterRotation() { + let fixture = RotatingScreenFixture() + fixture.setLandscape(true) + postFrame(CGRect(x: 0, y: 820, width: 1180, height: 364), on: fixture.screen) + XCTAssertEqual(observer.currentFrame(in: fixture.view), .nonOverlapping) + + fixture.setLandscape(false) + XCTAssertEqual(observer.currentFrame(in: fixture.view), .nonOverlapping) + } + + func test_cachedVisibleFrame_preservesItsPositionAfterRotation() { + let fixture = RotatingScreenFixture() + fixture.setLandscape(true) + let frame = CGRect(x: 0, y: 520, width: 1180, height: 300) + let expected = fixture.screen.coordinateSpace.convert(frame, to: fixture.view) + postFrame(frame, on: fixture.screen) + XCTAssertEqual(observer.currentFrame(in: fixture.view), .overlapping(frame: expected)) + + fixture.setLandscape(false) + XCTAssertEqual(observer.currentFrame(in: fixture.view), .overlapping(frame: expected)) + } + + func test_equalRawFrames_notifyWhenTheirCoordinateSpaceChanges() { + let fixture = RotatingScreenFixture() + let delegate = Delegate() + observer.add(delegate: delegate) + let frame = CGRect(x: 0, y: 520, width: 1180, height: 300) + fixture.setLandscape(true) + postFrame(frame, on: fixture.screen) + XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 1) + + fixture.setLandscape(false) + postFrame(frame, on: fixture.screen) + XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 2) + } + + func test_cachedFloatingClassification_doesNotChangeWithOrientation() { + let fixture = RotatingScreenFixture() + fixture.setLandscape(true) + postFrame(CGRect(x: 0, y: 100, width: 500, height: 300), on: fixture.screen) + XCTAssertTrue(observer.isKeyboardFloating(using: fixture.view)) + + fixture.setLandscape(false) + XCTAssertTrue(observer.isKeyboardFloating(using: fixture.view)) + } + + func test_changedRawFrame_notifiesEvenWhenFixedPositionIsUnchanged() { + let fixture = RotatingScreenFixture() + let delegate = Delegate() + observer.add(delegate: delegate) + fixture.setLandscape(true) + let landscapeFrame = CGRect(x: 0, y: 520, width: 1180, height: 300) + let fixedFrame = fixture.screen.coordinateSpace.convert( + landscapeFrame, + to: fixture.screen.fixedCoordinateSpace + ) + postFrame(landscapeFrame, on: fixture.screen) + XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 1) + + // Views rotate too. Preserve the callback so clients can recalculate their local overlap. + fixture.setLandscape(false) + postFrame(fixedFrame, on: fixture.screen) + XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 2) + } + + func test_keyboardOnAnotherScreen_doesNotAffectView() { + let fixture = RotatingScreenFixture() + postFrame(CGRect(x: 0, y: 100, width: 150, height: 300), on: fixture.screen) + XCTAssertTrue(observer.isKeyboardFloating(using: fixture.view)) + + let otherWindow = UIWindow(frame: fixture.window.bounds) + let otherView = UIView(frame: otherWindow.bounds) + otherWindow.addSubview(otherView) + XCTAssertNotEqual(otherWindow.screen, fixture.screen) + XCTAssertEqual(observer.currentFrame(in: otherView), .nonOverlapping) + XCTAssertFalse(observer.isKeyboardFloating(using: otherView)) + } + + func test_notificationWithoutScreen_usesMainScreenCoordinates() { + let frame = CGRect(x: 0, y: 500, width: 400, height: 300) + postFrame(frame, on: nil) + XCTAssertEqual(observer.currentFrame(in: windowedView), .overlapping(frame: frame)) + XCTAssertFalse(observer.isKeyboardFloating(using: UIView())) + } + + private func postFrame(_ frame: CGRect, on screen: UIScreen?) { + center.post( + name: UIResponder.keyboardWillChangeFrameNotification, + object: screen, + userInfo: [ + UIResponder.keyboardFrameEndUserInfoKey: NSValue(cgRect: frame), + UIResponder.keyboardAnimationDurationUserInfoKey: 0, + UIResponder.keyboardAnimationCurveUserInfoKey: UIView.AnimationCurve.easeInOut.rawValue, + ] + ) + } + final class Delegate: KeyboardObserverDelegate { var keyboardFrameWillChange_callCount: Int = 0 @@ -385,6 +482,56 @@ class KeyboardObserverTests: XCTestCase { } } +/// Models a screen's mutable oriented coordinates using UIKit's real coordinate conversions. +/// The window stays in portrait coordinates so that querying the cache cannot change the fixture's geometry. +private final class RotatingScreenFixture { + let window = ScreenWindow(frame: CGRect(x: 0, y: 0, width: 820, height: 1180)) + let view = UIView(frame: CGRect(x: 0, y: 0, width: 820, height: 1180)) + let orientedSpace = UIView() + let screen: Screen + + init() { + screen = Screen(fixedSpace: window, orientedSpace: orientedSpace) + window.observedScreen = screen + window.addSubview(orientedSpace) + window.addSubview(view) + setLandscape(false) + } + + func setLandscape(_ landscape: Bool) { + orientedSpace.transform = .identity + orientedSpace.bounds = CGRect( + origin: .zero, + size: landscape ? CGSize(width: 1180, height: 820) : window.bounds.size + ) + orientedSpace.center = CGPoint(x: window.bounds.midX, y: window.bounds.midY) + orientedSpace.transform = landscape ? CGAffineTransform(rotationAngle: .pi / 2) : .identity + } + + final class ScreenWindow: UIWindow { + weak var observedScreen: UIScreen? + override var screen: UIScreen { + get { observedScreen ?? super.screen } + set { super.screen = newValue } + } + } + + final class Screen: UIScreen { + let fixedSpace: UICoordinateSpace + let orientedSpace: UIView + + init(fixedSpace: UICoordinateSpace, orientedSpace: UIView) { + self.fixedSpace = fixedSpace + self.orientedSpace = orientedSpace + super.init() + } + + override var bounds: CGRect { orientedSpace.bounds } + override var coordinateSpace: UICoordinateSpace { orientedSpace } + override var fixedCoordinateSpace: UICoordinateSpace { fixedSpace } + } +} + class KeyboardObserver_NotificationInfo_Tests: XCTestCase { From 6a0c9254689a65a3c220682cec6f8afdc6c209f0 Mon Sep 17 00:00:00 2001 From: Rob MacEachern Date: Thu, 1 Oct 2026 23:50:26 -0500 Subject: [PATCH 2/2] Update the CI test simulator to an available runtime The current macOS runner no longer includes iOS 18.6 or an iPad 10th generation simulator. Run the suite on its available iPad A16 with iOS 26.2, matching the selected Xcode version. --- *This response was drafted with AI assistance.* --- .github/workflows/validations.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validations.yaml b/.github/workflows/validations.yaml index 7a48883..3f8aff2 100644 --- a/.github/workflows/validations.yaml +++ b/.github/workflows/validations.yaml @@ -8,9 +8,9 @@ on: env: XCODE_VERSION: 26.2 - TUIST_TEST_DEVICE: iPad (10th generation) + TUIST_TEST_DEVICE: iPad (A16) TUIST_TEST_PLATFORM: iOS - TUIST_TEST_OS: 18.6 + TUIST_TEST_OS: 26.2 jobs: development-tests: