diff --git a/.gitignore b/.gitignore index 0ccef4c4d..99e38cb03 100644 --- a/.gitignore +++ b/.gitignore @@ -37,9 +37,11 @@ playground.xcworkspace # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. # Packages/ # Package.pins -# Package.resolved .build/ +# Resolved dependencies are managed by Tuist in Development/Tuist/Package.resolved. +Package.resolved + # Carthage # # Add this line if you want to avoid checking in source code from Carthage dependencies. diff --git a/CHANGELOG.md b/CHANGELOG.md index 14ce5d325..f9e092a83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Changed +- Replaced the internal `KeyboardObserver` with https://github.com/square/swift-keyboard-observer. + ### Misc ### Internal diff --git a/Development/Tuist/Package.resolved b/Development/Tuist/Package.resolved index 65ec357d6..95e6b7835 100644 --- a/Development/Tuist/Package.resolved +++ b/Development/Tuist/Package.resolved @@ -8,6 +8,15 @@ "revision" : "6e4698ba60752155fc62f39c1fea74cd1d3f18cc", "version" : "6.0.0" } + }, + { + "identity" : "swift-keyboard-observer", + "kind" : "remoteSourceControl", + "location" : "https://github.com/square/swift-keyboard-observer", + "state" : { + "revision" : "a4f4de6b0f7dacca7132672004ece83d027f4ad4", + "version" : "1.1.0" + } } ], "version" : 2 diff --git a/ListableUI/Sources/KeyboardObserver/KeyboardObserver.swift b/ListableUI/Sources/KeyboardObserver/KeyboardObserver.swift deleted file mode 100644 index a49d5ddda..000000000 --- a/ListableUI/Sources/KeyboardObserver/KeyboardObserver.swift +++ /dev/null @@ -1,320 +0,0 @@ -import UIKit - -/// Publicly exposes the current frame provider for consumers -/// that enable `KeyboardAdjustmentMode.custom` and need to calculate -/// their own content insets. -public protocol KeyboardCurrentFrameProvider { - - func currentFrame(in view : UIView) -> KeyboardFrame? -} - -public enum KeyboardFrame : Equatable { - - /// The current frame does not overlap the current view at all. - case nonOverlapping - - /// The current frame does overlap the view, by the provided rect, in the view's coordinate space. - case overlapping(frame: CGRect) -} - -extension KeyboardObserver: KeyboardCurrentFrameProvider {} - -@_spi(ListableKeyboard) -public protocol KeyboardObserverDelegate : AnyObject { - - func keyboardFrameWillChange( - for observer: KeyboardObserver, - animationDuration: Double, - animationCurve: UIView.AnimationCurve - ) -} - -/** - Encapsulates listening for system keyboard updates, plus transforming the visible frame of the keyboard into the coordinates of a requested view. - - You use this class by providing a delegate, which receives callbacks when changes to the keyboard frame occur. You would usually implement - the delegate somewhat like this: - - ``` - func keyboardFrameWillChange( - for observer : KeyboardObserver, - animationDuration : Double, - options : UIView.AnimationOptions - ) { - UIView.animate(withDuration: animationDuration, delay: 0.0, options: options, animations: { - // Use the frame from the keyboardObserver to update insets or sizing where relevant. - }) - } - ``` - - Notes - ----- - iOS Docs for keyboard management: - https://developer.apple.com/library/archive/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/KeyboardManagement/KeyboardManagement.html - */ -@_spi(ListableKeyboard) -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 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) - - /// 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(set) var delegates: [Delegate] = [] - - struct Delegate { - private(set) weak var value: KeyboardObserverDelegate? - } - - // - // MARK: Initialization - // - - public init(center: NotificationCenter) { - - self.center = center - - /// We need to listen to both `will` and `keyboardDidChangeFrame` notifications. Why? - /// - /// When dealing with an undocked or floating keyboard, moving the keyboard - /// around the screen does NOT call `willChangeFrame`; only `didChangeFrame` is called. - /// - /// 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. - - 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? - - // - // MARK: Delegates - // - - public func add(delegate: KeyboardObserverDelegate) { - - if delegates.contains(where: { $0.value === delegate }) { - return - } - - delegates.append(Delegate(value: delegate)) - - removeDeallocatedDelegates() - } - - public func remove(delegate: KeyboardObserverDelegate) { - delegates.removeAll { - $0.value === delegate - } - - removeDeallocatedDelegates() - } - - private func removeDeallocatedDelegates() { - delegates.removeAll { - $0.value == nil - } - } - - // - // MARK: Handling Changes - // - - - /// 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? { - - guard let window = view.window else { - return nil - } - - guard let notification = latestNotification else { - return 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) - } else { - return .nonOverlapping - } - } - - // - // MARK: Receiving Updates - // - - private func receivedUpdatedKeyboardInfo(_ new: NotificationInfo) { - - let old = latestNotification - - latestNotification = new - - /// Only communicate a frame change to the delegate if the frame actually changed. - - if let old = old, old.endingFrame == new.endingFrame { - return - } - - delegates.forEach { - $0.value?.keyboardFrameWillChange( - for: self, - animationDuration: new.animationDuration, - animationCurve: new.animationCurve - ) - } - } - - // - // MARK: Notification Listeners - // - - @objc private func keyboardFrameChanged(_ notification: Notification) { - - do { - let info = try NotificationInfo(with: notification) - receivedUpdatedKeyboardInfo(info) - } catch { - assertionFailure("Could not read system keyboard notification: \(error)") - } - } -} - -extension KeyboardObserver { - struct NotificationInfo: Equatable { - - var endingFrame: CGRect = .zero - - var animationDuration: Double = 0.0 - var animationCurve: UIView.AnimationCurve = .easeInOut - - /// 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 { - - guard let userInfo = notification.userInfo else { - throw ParseError.missingUserInfo - } - - guard let endingFrame = (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else { - throw ParseError.missingEndingFrame - } - - self.endingFrame = endingFrame - - guard let animationDuration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue else { - throw ParseError.missingAnimationDuration - } - - self.animationDuration = animationDuration - - 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 { - - case missingUserInfo - case missingEndingFrame - case missingAnimationDuration - case missingAnimationCurve - } - } -} - - -extension KeyboardObserver { - private static let isExtensionContext: Bool = { - // This is our best guess for "is this executable an extension?" - if let _ = Bundle.main.infoDictionary?["NSExtension"] { - return true - } else if Bundle.main.bundlePath.hasSuffix(".appex") { - return true - } else { - return false - } - }() - - /// 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 - } - - if KeyboardObserver.didSetupSharedInstanceDuringAppStartup { - return - } - - print( - """ - LISTABLE WARNING: The shared instance of the `KeyboardObserver` was not instantiated - during app startup. While not fatal, this could result in a list being created - that does not properly position itself to account for the keyboard, if the list is created - while the keyboard is already visible. - """ - ) - } -} - -extension ListView { - - /// This should be called in UIApplicationDelegate.application(_:, didFinishLaunchingWithOption:) - /// It ensures that all ListViews will correctly avoid the keyboard - /// - Note: CocoaPods automatically calls this method - @available(iOSApplicationExtension, unavailable, message: "This cannot be used in application extensions") - @objc(configureWithApplication:) - public static func configure(with application: UIApplication) { - _ = KeyboardObserver.shared - KeyboardObserver.didSetupSharedInstanceDuringAppStartup = true - } -} diff --git a/ListableUI/Sources/KeyboardObserver/SetupKeyboardObserverOnAppStartup.m b/ListableUI/Sources/KeyboardObserver/SetupKeyboardObserverOnAppStartup.m deleted file mode 100644 index 5abce4c74..000000000 --- a/ListableUI/Sources/KeyboardObserver/SetupKeyboardObserverOnAppStartup.m +++ /dev/null @@ -1,66 +0,0 @@ -// -// SetupKeyboardObserverOnAppStartup.m -// ListableUI -// -// Created by Kyle Van Essen on 8/24/20. -// - -@import Foundation; -@import UIKit; - -#if __has_include () - #import -#elif __has_include ("ListableUI-Swift.h") - #import "ListableUI-Swift.h" -#endif - - -@interface __LST_SetupKeyboardObserverOnAppStartup : NSObject -@end - - -@implementation __LST_SetupKeyboardObserverOnAppStartup - -/// Register for `applicationDidFinishLaunching`, so we can set up -/// our keyboard observer to always know when the keyboard is visible. -/// Yes, I know, and I am sorry. -+ (void)load { - if (self != __LST_SetupKeyboardObserverOnAppStartup.class) { - return; - } - - [self sharedInstance]; -} - -+ (instancetype)sharedInstance; -{ - static __LST_SetupKeyboardObserverOnAppStartup *loader = nil; - - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - loader = [[__LST_SetupKeyboardObserverOnAppStartup alloc] init]; - }); - - return loader; -} - -- (instancetype)init; -{ - self = [super init]; - NSParameterAssert(self); - - [NSNotificationCenter.defaultCenter addObserver:self - selector:@selector(applicationDidFinishLaunchingNotification) - name:UIApplicationDidFinishLaunchingNotification - object:nil]; - - return self; -} - -- (void)applicationDidFinishLaunchingNotification NS_EXTENSION_UNAVAILABLE_IOS("Use view controller based solutions where appropriate instead."); -{ - /// Application has now finished launching, so set up the keyboard - [ListView configureWithApplication:UIApplication.sharedApplication]; -} - -@end diff --git a/ListableUI/Sources/ListView/ListView.swift b/ListableUI/Sources/ListView/ListView.swift index 2a91f7366..651b6b30c 100644 --- a/ListableUI/Sources/ListView/ListView.swift +++ b/ListableUI/Sources/ListView/ListView.swift @@ -5,6 +5,7 @@ // Created by Kyle Van Essen on 6/16/19. // +import KeyboardObserver import UIKit @@ -1743,7 +1744,6 @@ public extension ListView } -@_spi(ListableKeyboard) extension ListView : KeyboardObserverDelegate { public func keyboardFrameWillChange(for observer: KeyboardObserver, animationDuration: Double, animationCurve: UIView.AnimationCurve) { diff --git a/ListableUI/Tests/Internal/KeyboardObserverTests.swift b/ListableUI/Tests/Internal/KeyboardObserverTests.swift deleted file mode 100644 index 245d8b8d4..000000000 --- a/ListableUI/Tests/Internal/KeyboardObserverTests.swift +++ /dev/null @@ -1,289 +0,0 @@ -import UIKit -import XCTest - -@_spi(ListableKeyboard) @testable import ListableUI - -class KeyboardObserverTests: XCTestCase { - - func test_add() { - let center = NotificationCenter() - let observer = KeyboardObserver(center: center) - - var delegate1: Delegate? = Delegate() - weak var weakDelegate1 = delegate1 - - let delegate2 = Delegate() - let delegate3 = Delegate() - - // Validate that delegates are only registered once. - - XCTAssertEqual(observer.delegates.count, 0) - - observer.add(delegate: delegate1!) - XCTAssertEqual(observer.delegates.count, 1) - - observer.add(delegate: delegate1!) - XCTAssertEqual(observer.delegates.count, 1) - - // Register a second observer - - observer.add(delegate: delegate2) - XCTAssertEqual(observer.delegates.count, 2) - - // Register a third, but deallocate the first. Should be removed. - - delegate1 = nil - - waitFor { - weakDelegate1 == nil - } - - observer.add(delegate: delegate3) - XCTAssertEqual(observer.delegates.count, 2) - } - - func test_remove() { - let center = NotificationCenter() - let observer = KeyboardObserver(center: center) - - let delegate1: Delegate? = Delegate() - - var delegate2: Delegate? = Delegate() - weak var weakDelegate2 = delegate2 - - let delegate3: Delegate? = Delegate() - - // Register all 3 observers - - observer.add(delegate: delegate1!) - observer.add(delegate: delegate2!) - observer.add(delegate: delegate3!) - - XCTAssertEqual(observer.delegates.count, 3) - - // Nil out the second delegate - - delegate2 = nil - - waitFor { - weakDelegate2 == nil - } - - // Should only have 1 left - - observer.remove(delegate: delegate3!) - XCTAssertEqual(observer.delegates.count, 1) - } - - func test_notifications() { - let center = NotificationCenter() - - // Will Change Frame - do { - let observer = KeyboardObserver(center: center) - - 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), - ] - - XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 0) - center.post(Notification( - name: UIWindow.keyboardWillChangeFrameNotification, - object: UIScreen.main, - userInfo: userInfo - )) - XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 1) - } - - // Did Change Frame - do { - let observer = KeyboardObserver(center: center) - - 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), - ] - - XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 0) - center.post(Notification( - name: UIWindow.keyboardDidChangeFrameNotification, - object: UIScreen.main, - userInfo: userInfo - )) - XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 1) - } - - // Only calls delegate for changed frame - do { - let observer = KeyboardObserver(center: center) - - 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), - ] - - 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: UIScreen.main, - userInfo: userInfo - )) - XCTAssertEqual(delegate.keyboardFrameWillChange_callCount, 1) - } - } - - final class Delegate: KeyboardObserverDelegate { - - var keyboardFrameWillChange_callCount: Int = 0 - - func keyboardFrameWillChange( - for observer: KeyboardObserver, - animationDuration: Double, - animationCurve: UIView.AnimationCurve - ) { - - keyboardFrameWillChange_callCount += 1 - } - } -} - - -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), - ] - - // Successful Init - do { - let info = try! KeyboardObserver.NotificationInfo( - 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, UIView.AnimationCurve(rawValue: 123)!) - } - - // Failed Inits - do { - // No userInfo - do { - XCTAssertThrowsError( - try _ = KeyboardObserver.NotificationInfo( - with: Notification( - name: UIResponder.keyboardDidShowNotification, - object: UIScreen.main, - userInfo: nil - ) - ) - ) { error in - XCTAssertEqual(error as? KeyboardObserver.NotificationInfo.ParseError, .missingUserInfo) - } - } - - // No end frame - do { - var userInfo = defaultUserInfo - userInfo.removeValue(forKey: UIResponder.keyboardFrameEndUserInfoKey) - - XCTAssertThrowsError( - try _ = KeyboardObserver.NotificationInfo( - with: Notification( - name: UIResponder.keyboardDidShowNotification, - object: UIScreen.main, - userInfo: userInfo - ) - ) - ) { error in - XCTAssertEqual(error as? KeyboardObserver.NotificationInfo.ParseError, .missingEndingFrame) - } - } - - // No animation duration - do { - var userInfo = defaultUserInfo - userInfo.removeValue(forKey: UIResponder.keyboardAnimationDurationUserInfoKey) - - XCTAssertThrowsError( - try _ = KeyboardObserver.NotificationInfo( - with: Notification( - name: UIResponder.keyboardDidShowNotification, - object: UIScreen.main, - userInfo: userInfo - ) - ) - ) { error in - XCTAssertEqual(error as? KeyboardObserver.NotificationInfo.ParseError, .missingAnimationDuration) - } - } - - // No animation curve - do { - var userInfo = defaultUserInfo - userInfo.removeValue(forKey: UIResponder.keyboardAnimationCurveUserInfoKey) - - XCTAssertThrowsError( - try KeyboardObserver.NotificationInfo( - with: Notification( - name: UIResponder.keyboardDidShowNotification, - object: UIScreen.main, - userInfo: userInfo - ) - ) - ) { error in - XCTAssertEqual(error as? KeyboardObserver.NotificationInfo.ParseError, .missingAnimationCurve) - } - } - } - } -} diff --git a/ListableUI/Tests/ListView/ListViewTests.swift b/ListableUI/Tests/ListView/ListViewTests.swift index 71852aeee..4846e55de 100644 --- a/ListableUI/Tests/ListView/ListViewTests.swift +++ b/ListableUI/Tests/ListView/ListViewTests.swift @@ -7,7 +7,7 @@ import XCTest -@_spi(ListableKeyboard) @testable import ListableUI +@testable import ListableUI diff --git a/Package.swift b/Package.swift index 314dabfdf..85ad8d293 100644 --- a/Package.swift +++ b/Package.swift @@ -21,13 +21,16 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/square/Blueprint", from: "6.0.0"), + .package(url: "https://github.com/square/swift-keyboard-observer", from: "1.1.0"), ], targets: [ .target( name: "ListableUI", + dependencies: [ + .product(name: "KeyboardObserver", package: "swift-keyboard-observer"), + ], path: "ListableUI", exclude: [ - "Sources/KeyboardObserver/SetupKeyboardObserverOnAppStartup.m", "Sources/Layout/Paged/PagedAppearance.monopic", "Sources/ContentBounds/ListContentBounds.monopic", "Sources/Layout/Table/TableAppearance.monopic",