Skip to content

Commit

Permalink
Initial Commit
Browse files Browse the repository at this point in the history
  • Loading branch information
timdonnelly committed Oct 30, 2019
0 parents commit 5d1fb75
Show file tree
Hide file tree
Showing 9 changed files with 261 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
31 changes: 31 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -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"]),
]
)
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
}

}
```
24 changes: 24 additions & 0 deletions Sources/KeyboardObserver/KeyboardAvoiding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import SwiftUI

extension View {

public func avoidingKeyboard() -> some View {
KeyboardAvoidingView(content: self)
}

}

fileprivate struct KeyboardAvoidingView<Content: View>: 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)
}

}
86 changes: 86 additions & 0 deletions Sources/KeyboardObserver/KeyboardObserver.swift
Original file line number Diff line number Diff line change
@@ -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<KeyboardState>) -> some View {
onKeyboardChange { newState, animation in
withAnimation(animation) {
state.wrappedValue = newState
}
}

}

}

fileprivate struct KeyboardObserverView<Content: View>: View {

var content: Content
var action: (KeyboardState, Animation?) -> Void

var body: some View {
content.onReceive(publisher, perform: handle(keyboardNotification:))
}

private var publisher: AnyPublisher<Notification, Never> {
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)
}

}
19 changes: 19 additions & 0 deletions Sources/KeyboardObserver/KeyboardState.swift
Original file line number Diff line number Diff line change
@@ -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
}
}

}
15 changes: 15 additions & 0 deletions Tests/KeyboardObserverTests/KeyboardObserverTests.swift
Original file line number Diff line number Diff line change
@@ -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),
]
}
9 changes: 9 additions & 0 deletions Tests/KeyboardObserverTests/XCTestManifests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import XCTest

#if !canImport(ObjectiveC)
public func allTests() -> [XCTestCaseEntry] {
return [
testCase(KeyboardObserverTests.allTests),
]
}
#endif
7 changes: 7 additions & 0 deletions Tests/LinuxMain.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import XCTest

import KeyboardObserverTests

var tests = [XCTestCaseEntry]()
tests += KeyboardObserverTests.allTests()
XCTMain(tests)

0 comments on commit 5d1fb75

Please sign in to comment.