From 5d1fb759860de51ba4ce3c3b83c5d61c6c751cf6 Mon Sep 17 00:00:00 2001 From: Timothy Donnelly Date: Wed, 30 Oct 2019 14:19:31 -0700 Subject: [PATCH] Initial Commit --- .gitignore | 5 ++ Package.swift | 31 +++++++ README.md | 65 ++++++++++++++ .../KeyboardObserver/KeyboardAvoiding.swift | 24 ++++++ .../KeyboardObserver/KeyboardObserver.swift | 86 +++++++++++++++++++ Sources/KeyboardObserver/KeyboardState.swift | 19 ++++ .../KeyboardObserverTests.swift | 15 ++++ .../XCTestManifests.swift | 9 ++ Tests/LinuxMain.swift | 7 ++ 9 files changed, 261 insertions(+) create mode 100644 .gitignore create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/KeyboardObserver/KeyboardAvoiding.swift create mode 100644 Sources/KeyboardObserver/KeyboardObserver.swift create mode 100644 Sources/KeyboardObserver/KeyboardState.swift create mode 100644 Tests/KeyboardObserverTests/KeyboardObserverTests.swift create mode 100644 Tests/KeyboardObserverTests/XCTestManifests.swift create mode 100644 Tests/LinuxMain.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95c4320 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..2d8d590 --- /dev/null +++ b/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version:5.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "KeyboardObserver", + platforms: [ + .iOS(.v13) + ], + products: [ + // Products define the executables and libraries produced by a package, and make them visible to other packages. + .library( + name: "KeyboardObserver", + targets: ["KeyboardObserver"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages which this package depends on. + .target( + name: "KeyboardObserver", + dependencies: []), + .testTarget( + name: "KeyboardObserverTests", + dependencies: ["KeyboardObserver"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b7d729 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# KeyboardObserver + +Keyboard-aware helpers for SwiftUI. + +## `.avoidingKeyboard()` + +This is the simplest way to make UI keyboard-aware. It automatically insets all children to account for the keyboard: + +```swift +VStack { + Text("Hello, world!") + TextField("Title", text: $text) +} +.frame(maxWidth: .infinity, maxHeight: .infinity) +.avoidingKeyboard() +``` + +## `.observingKeyboard(_:)` + +The binding provided to `observingKeyboard(_:)` will be assigned with an animation that matches the system keyboard. + +```swift +import KeyboardObserver + +struct MyView: View { + + @State private var state = KeyboardState() + + var body: some View { + GeometryProxy { proxy in + VStack { + Text("Hello, world!") + TextField("Title", text: self.$text) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.bottom, self.state.height(in: proxy)) + } + .observingKeyboard($state) + } + +} +``` + +## `.onKeyboardChange(_:)` + +Provide a closure to manually respond to keyboard changes. + +```swift +import KeyboardObserver + +struct MyView: View { + + var body: some View { + Text("HelloWorld") + .onKeyboardChange { state, animation in + self.handleKeyboardChange(newState: state, animation: animation) + } + } + + private func handleKeyboardChange(newState: KeyboardState, animation: Animation?) { + // Handle keyboard changes here (`animation` will be non-nil if the keyboard change is animated) + } + +} +``` diff --git a/Sources/KeyboardObserver/KeyboardAvoiding.swift b/Sources/KeyboardObserver/KeyboardAvoiding.swift new file mode 100644 index 0000000..348e4ce --- /dev/null +++ b/Sources/KeyboardObserver/KeyboardAvoiding.swift @@ -0,0 +1,24 @@ +import SwiftUI + +extension View { + + public func avoidingKeyboard() -> some View { + KeyboardAvoidingView(content: self) + } + +} + +fileprivate struct KeyboardAvoidingView: View { + + var content: Content + + @State var state = KeyboardState() + + var body: some View { + GeometryReader { proxy in + self.content.padding(.bottom, self.state.height(in: proxy)) + } + .observingKeyboard(self.$state) + } + +} diff --git a/Sources/KeyboardObserver/KeyboardObserver.swift b/Sources/KeyboardObserver/KeyboardObserver.swift new file mode 100644 index 0000000..8e395ac --- /dev/null +++ b/Sources/KeyboardObserver/KeyboardObserver.swift @@ -0,0 +1,86 @@ +import SwiftUI +import Combine + +extension View { + + // Calls the provided action when the system keyboard changes. + public func onKeyboardChange(_ action: @escaping (KeyboardState, Animation?) -> Void) -> some View { + KeyboardObserverView(content: self, action: action) + } + + // Updates the provided keyboard state when the system keyboard changes. + // The change is performed with an animation that matches the duration + // and timing of the keyboard transition animation. + public func observingKeyboard(_ state: Binding) -> some View { + onKeyboardChange { newState, animation in + withAnimation(animation) { + state.wrappedValue = newState + } + } + + } + +} + +fileprivate struct KeyboardObserverView: View { + + var content: Content + var action: (KeyboardState, Animation?) -> Void + + var body: some View { + content.onReceive(publisher, perform: handle(keyboardNotification:)) + } + + private var publisher: AnyPublisher { + NotificationCenter + .default + .publisher(for: UIResponder.keyboardWillChangeFrameNotification) + .eraseToAnyPublisher() + } + + private func handle(keyboardNotification: Notification) { + guard let userInfo = keyboardNotification.userInfo else { return } + let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + let animation = Animation(keyboardNotification: keyboardNotification) + action(KeyboardState(frame: frame), animation) + } + +} + + + +extension Animation { + + fileprivate init?(keyboardNotification: Notification) { + guard let userInfo = keyboardNotification.userInfo else { return nil } + + guard let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue, duration > 0.0 else { return nil } + + if let rawAnimationCurve = (userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.intValue, + let animationCurve = UIView.AnimationCurve(rawValue: rawAnimationCurve) { + switch animationCurve { + case .easeIn: + self = .easeIn(duration: duration) + case .easeOut: + self = .easeOut(duration: duration) + case .linear: + self = .linear(duration: duration) + case .easeInOut: + self = .easeInOut(duration: duration) + @unknown default: + // The 'hidden' private keyboard curve is the integer 7, which does not + // map to a known case. @unknown default handles this nicely. + self = .systemKeyboardAnimation + } + } else { + self = .systemKeyboardAnimation + } + } + + // These values are used with a CASpringAnimation to drive the default + // system keyboard animation as of iOS 13. + fileprivate static var systemKeyboardAnimation: Animation { + .interpolatingSpring(mass: 3, stiffness: 1000, damping: 500, initialVelocity: 0.0) + } + +} diff --git a/Sources/KeyboardObserver/KeyboardState.swift b/Sources/KeyboardObserver/KeyboardState.swift new file mode 100644 index 0000000..8eff139 --- /dev/null +++ b/Sources/KeyboardObserver/KeyboardState.swift @@ -0,0 +1,19 @@ +import SwiftUI + +// Represents the current state of the system keyboard. +public struct KeyboardState: Equatable { + + // The keyboard frame in global (screen) coordinates + public var frame: CGRect? + + // Returns the keyboard height relative to the bottom of the view + // represented by the given geometry proxy. + public func height(in proxy: GeometryProxy) -> CGFloat { + if let frame = frame, proxy.frame(in: .global).intersects(frame) { + return proxy.frame(in: .global).maxY - frame.minY + } else { + return 0.0 + } + } + +} diff --git a/Tests/KeyboardObserverTests/KeyboardObserverTests.swift b/Tests/KeyboardObserverTests/KeyboardObserverTests.swift new file mode 100644 index 0000000..27a1db8 --- /dev/null +++ b/Tests/KeyboardObserverTests/KeyboardObserverTests.swift @@ -0,0 +1,15 @@ +import XCTest +@testable import KeyboardObserver + +final class KeyboardObserverTests: XCTestCase { + func testExample() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct + // results. + XCTAssertTrue(true) + } + + static var allTests = [ + ("testExample", testExample), + ] +} diff --git a/Tests/KeyboardObserverTests/XCTestManifests.swift b/Tests/KeyboardObserverTests/XCTestManifests.swift new file mode 100644 index 0000000..fc37b26 --- /dev/null +++ b/Tests/KeyboardObserverTests/XCTestManifests.swift @@ -0,0 +1,9 @@ +import XCTest + +#if !canImport(ObjectiveC) +public func allTests() -> [XCTestCaseEntry] { + return [ + testCase(KeyboardObserverTests.allTests), + ] +} +#endif diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift new file mode 100644 index 0000000..fe7d3f8 --- /dev/null +++ b/Tests/LinuxMain.swift @@ -0,0 +1,7 @@ +import XCTest + +import KeyboardObserverTests + +var tests = [XCTestCaseEntry]() +tests += KeyboardObserverTests.allTests() +XCTMain(tests)