FO
ForgeObservers

KeyboardObserver

Observes keyboard visibility and frame changes via UIResponder keyboard notifications.

Model

Use KeyboardState.hidden as a default when initializing state.

KeyboardState
public struct KeyboardState: Sendable, Equatable {
    /// Whether the keyboard is currently visible.
    public let isVisible: Bool
    /// The height of the keyboard in points. Zero when hidden.
    public let height: CGFloat
    /// The system animation duration for the keyboard transition.
    public let animationDuration: TimeInterval

    public static let hidden = KeyboardState(
        isVisible: false, height: 0, animationDuration: 0.25
    )
}

Protocol

KeyboardObserving
public protocol KeyboardObserving: Sendable {
    /// The current keyboard state.
    var state: KeyboardState { get }

    /// An AsyncStream that emits keyboard state changes.
    var stateStream: AsyncStream<KeyboardState> { get }
}

Usage

Keyboard-aware layout
let keyboard = KeyboardObserver()

// Check if keyboard is visible
if keyboard.state.isVisible {
    print("Keyboard height: \(keyboard.state.height)")
}

// Adjust layout when keyboard appears
for await state in keyboard.stateStream {
    withAnimation(.easeOut(duration: state.animationDuration)) {
        keyboardHeight = state.height
    }
}

Testing

The observer accepts an injected NotificationCenter so tests can post synthetic keyboard notifications with fake frames and durations.

Test with injected NotificationCenter
import UIKit
import ForgeObservers

@Test
func keyboardObserverParsesFrame() async throws {
    let center = NotificationCenter()
    let observer = KeyboardObserver(notificationCenter: center)

    let frame = CGRect(x: 0, y: 600, width: 390, height: 291)
    center.post(
        name: UIResponder.keyboardWillShowNotification,
        object: nil,
        userInfo: [
            UIResponder.keyboardFrameEndUserInfoKey: frame,
            UIResponder.keyboardAnimationDurationUserInfoKey: TimeInterval(0.3),
        ]
    )
    try await Task.sleep(for: .milliseconds(50))

    #expect(observer.state.isVisible == true)
    #expect(observer.state.height == 291)
    #expect(observer.state.animationDuration == 0.3)
}

SwiftUI note -- SwiftUI provides @FocusState and keyboard avoidance out of the box. This observer is most useful in ViewModels, non-UI code, or when you need precise control over keyboard animation coordination.