Skip to content
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
4 changes: 2 additions & 2 deletions .github/workflows/validations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
48 changes: 37 additions & 11 deletions KeyboardObserver/Sources/KeyboardObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -158,10 +158,12 @@ public final class KeyboardObserver {
return nil
}

let screen = notification.screen ?? window.screen
guard notification.frameScreen == window.screen else {
return .nonOverlapping
}
Comment on lines +161 to +163

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

👍


let frame = screen.coordinateSpace.convert(
notification.endingFrame,
let frame = notification.frameScreen.fixedCoordinateSpace.convert(
notification.frameInFixedCoordinateSpace,
to: view
)

Expand All @@ -176,21 +178,25 @@ 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 {

guard let notification = latestNotification else {
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
}

//
Expand All @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Comment on lines +309 to +310

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

+1 to moving this into the notification extension.

}

enum ParseError: Error, Equatable {
Expand Down
147 changes: 147 additions & 0 deletions KeyboardObserver/Tests/KeyboardObserverTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,103 @@
}
}

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
Expand All @@ -385,6 +482,56 @@
}
}

/// 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 }

Check warning on line 515 in KeyboardObserver/Tests/KeyboardObserverTests.swift

View workflow job for this annotation

GitHub Actions / development-tests (UnitTests)

setter for 'screen' was deprecated in iOS 13.0
}
}

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 {

Expand Down
Loading