Skip to content

Commit 16fe4b7

Browse files
author
Semen Osipov
committed
Initial implementation of KeyboardControl package
1 parent acf7a2d commit 16fe4b7

7 files changed

Lines changed: 675 additions & 2 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ playground.xcworkspace
2727
#
2828
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
2929
# hence it is not needed unless you have added a package configuration file to your project
30-
# .swiftpm
30+
.swiftpm/
3131

3232
.build/
3333

Package.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// swift-tools-version: 5.7
2+
3+
import PackageDescription
4+
5+
let package = Package(
6+
name: "KeyboardControl",
7+
platforms: [.iOS(.v13)],
8+
products: [
9+
.library(name: "KeyboardControl", targets: ["KeyboardControl"])
10+
],
11+
targets: [
12+
.target(name: "KeyboardControl")
13+
]
14+
)

README.md

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,136 @@
1-
# ARKeyboardControl
1+
# ARKeyboardControl
2+
3+
Lightweight Swift library that adds keyboard awareness and interactive dismissal to any `UIView`. Inspired by [DAKeyboardControl](https://github.com/danielamitay/DAKeyboardControl), rewritten from scratch in pure Swift without method swizzling.
4+
5+
## Features
6+
7+
- **Interactive dismiss** — drag down to dismiss keyboard, like in iMessage
8+
- **Non-panning mode** — track keyboard appearance/disappearance without gestures
9+
- **Two callback modes** — frame-based (inside animation block) and constraint-based (before animation)
10+
- **iOS 15+ optimization** — uses `keyboardLayoutGuide` for non-panning mode when available
11+
- **Minimal footprint** — single associated object per view, clean teardown
12+
- **No swizzling** — no `+load`, no method exchange, no global side effects
13+
14+
## Requirements
15+
16+
- iOS 13.0+
17+
- Swift 5.7+
18+
19+
## Installation
20+
21+
### Swift Package Manager
22+
23+
Add the package dependency:
24+
25+
```swift
26+
dependencies: [
27+
.package(url: "https://github.com/AppleArealidea/ARKeyboardControl.git", from: "1.0.0")
28+
]
29+
```
30+
31+
Or add it as a local package in Xcode via File > Add Package Dependencies.
32+
33+
## Usage
34+
35+
### Interactive keyboard dismiss (chat screens)
36+
37+
```swift
38+
import KeyboardControl
39+
40+
override func viewDidAppear(_ animated: Bool) {
41+
super.viewDidAppear(animated)
42+
43+
view.addKeyboardPanning { beginFrame, endFrame, opening, closing in
44+
// Called inside UIView.animate — update frames here
45+
if opening {
46+
tableView.contentOffset.y += beginFrame.minY - endFrame.minY
47+
}
48+
} constraintBasedActionHandler: { _, endFrame, _, _ in
49+
// Called before animation — update constraints here
50+
let offset = view.frame.height - endFrame.minY - view.safeAreaInsets.bottom
51+
bottomConstraint.constant = offset > 0 ? -offset : 0
52+
}
53+
}
54+
55+
override func viewWillDisappear(_ animated: Bool) {
56+
super.viewWillDisappear(animated)
57+
view.removeKeyboardControl()
58+
}
59+
```
60+
61+
### Keyboard awareness only (no gestures)
62+
63+
```swift
64+
view.addKeyboardNonpanning { _, endFrame, opening, closing in
65+
let offset = view.frame.height - endFrame.origin.y - view.safeAreaInsets.bottom
66+
bottomConstraint.constant = offset > 0 ? -offset : 0
67+
view.layoutIfNeeded()
68+
}
69+
```
70+
71+
On iOS 15+ this automatically uses `keyboardLayoutGuide` under the hood.
72+
73+
### Gesture coordination
74+
75+
Access the pan gesture recognizer to coordinate with other gestures:
76+
77+
```swift
78+
if let keyboardPan = view.keyboardPanRecognizer {
79+
myGesture.require(toFail: keyboardPan)
80+
}
81+
```
82+
83+
### Utilities
84+
85+
```swift
86+
view.hideKeyboard() // Programmatically dismiss
87+
view.isKeyboardOpened // Current state
88+
view.keyboardFrameInView // Keyboard frame in view's coordinate space
89+
```
90+
91+
### Cleanup
92+
93+
Always call `removeKeyboardControl()` when the view is going away:
94+
95+
```swift
96+
// In viewWillDisappear or deinit
97+
view.removeKeyboardControl()
98+
```
99+
100+
## API
101+
102+
```swift
103+
public extension UIView {
104+
func addKeyboardPanning(
105+
frameBasedActionHandler: @escaping KeyboardDidMoveBlock,
106+
constraintBasedActionHandler: @escaping KeyboardDidMoveBlock
107+
)
108+
func addKeyboardNonpanning(actionHandler: @escaping KeyboardDidMoveBlock)
109+
func removeKeyboardControl()
110+
func hideKeyboard()
111+
112+
var keyboardPanRecognizer: UIPanGestureRecognizer? { get }
113+
var keyboardFrameInView: CGRect { get }
114+
var isKeyboardOpened: Bool { get }
115+
}
116+
117+
public typealias KeyboardDidMoveBlock = (
118+
_ keyboardBeginFrame: CGRect,
119+
_ keyboardEndFrame: CGRect,
120+
_ opening: Bool,
121+
_ closing: Bool
122+
) -> Void
123+
```
124+
125+
## Callback parameters
126+
127+
| Parameter | Description |
128+
|-----------|-------------|
129+
| `keyboardBeginFrame` | Keyboard frame before the transition (in view's coordinates) |
130+
| `keyboardEndFrame` | Keyboard frame after the transition (in view's coordinates) |
131+
| `opening` | `true` when keyboard is appearing |
132+
| `closing` | `true` when keyboard is disappearing |
133+
134+
## License
135+
136+
MIT. See [LICENSE](LICENSE) for details.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import UIKit
2+
import ObjectiveC
3+
4+
private var keyboardObserverKey: UInt8 = 0
5+
6+
public extension UIView {
7+
8+
// MARK: - Panning (interactive keyboard dismiss)
9+
10+
func addKeyboardPanning(
11+
frameBasedActionHandler: @escaping KeyboardDidMoveBlock,
12+
constraintBasedActionHandler: @escaping KeyboardDidMoveBlock
13+
) {
14+
let observer = getOrCreateObserver()
15+
observer.frameBasedBlock = frameBasedActionHandler
16+
observer.constraintBasedBlock = constraintBasedActionHandler
17+
18+
if let scrollView = self as? UIScrollView {
19+
scrollView.keyboardDismissMode = .interactive
20+
}
21+
22+
observer.panHandler = KeyboardPanHandler(view: self, observer: observer)
23+
observer.setupNotificationObservers()
24+
}
25+
26+
// MARK: - Non-panning (keyboard awareness only)
27+
28+
func addKeyboardNonpanning(actionHandler: @escaping KeyboardDidMoveBlock) {
29+
let observer = getOrCreateObserver()
30+
observer.frameBasedBlock = actionHandler
31+
32+
if #available(iOS 15.0, *) {
33+
let adapter = KeyboardLayoutGuideAdapter(view: self, actionBlock: actionHandler)
34+
observer.layoutGuideAdapter = adapter
35+
observer.setupNotificationObservers()
36+
} else {
37+
observer.setupNotificationObservers()
38+
}
39+
}
40+
41+
// MARK: - Cleanup
42+
43+
func removeKeyboardControl() {
44+
guard let observer = keyboardObserver else { return }
45+
observer.tearDown()
46+
objc_setAssociatedObject(self, &keyboardObserverKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
47+
}
48+
49+
// MARK: - Keyboard utilities
50+
51+
func hideKeyboard() {
52+
keyboardObserver?.hideKeyboard()
53+
}
54+
55+
var keyboardPanRecognizer: UIPanGestureRecognizer? {
56+
keyboardObserver?.panHandler?.panRecognizer
57+
}
58+
59+
var keyboardFrameInView: CGRect {
60+
keyboardObserver?.keyboardFrameInView() ?? CGRect(x: 0, y: UIScreen.main.bounds.height, width: 0, height: 0)
61+
}
62+
63+
var isKeyboardOpened: Bool {
64+
keyboardObserver?.isKeyboardVisible ?? false
65+
}
66+
67+
// MARK: - Private
68+
69+
private var keyboardObserver: KeyboardObserver? {
70+
objc_getAssociatedObject(self, &keyboardObserverKey) as? KeyboardObserver
71+
}
72+
73+
private func getOrCreateObserver() -> KeyboardObserver {
74+
if let existing = keyboardObserver { return existing }
75+
let observer = KeyboardObserver(view: self)
76+
objc_setAssociatedObject(self, &keyboardObserverKey, observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
77+
return observer
78+
}
79+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import UIKit
2+
3+
@available(iOS 15.0, *)
4+
final class KeyboardLayoutGuideAdapter {
5+
6+
weak var view: UIView?
7+
var actionBlock: KeyboardDidMoveBlock?
8+
private var observation: NSKeyValueObservation?
9+
private var lastLayoutFrame: CGRect = .zero
10+
private var wasVisible = false
11+
12+
init(view: UIView, actionBlock: @escaping KeyboardDidMoveBlock) {
13+
self.view = view
14+
self.actionBlock = actionBlock
15+
16+
let guide = view.keyboardLayoutGuide
17+
observation = guide.observe(\.layoutFrame, options: [.new, .old]) { [weak self] _, change in
18+
self?.handleLayoutChange(oldFrame: change.oldValue, newFrame: change.newValue)
19+
}
20+
}
21+
22+
deinit {
23+
observation?.invalidate()
24+
}
25+
26+
private func handleLayoutChange(oldFrame: CGRect?, newFrame: CGRect?) {
27+
guard let view = view, newFrame != nil else { return }
28+
29+
let screenHeight = view.bounds.height
30+
guard screenHeight > 0 else { return }
31+
32+
let guide = view.keyboardLayoutGuide
33+
let guideFrame = guide.layoutFrame
34+
35+
let isVisible = guideFrame.height > view.safeAreaInsets.bottom
36+
let beginFrame = lastLayoutFrame.isEmpty ? CGRect(x: 0, y: screenHeight, width: view.bounds.width, height: 0) : lastLayoutFrame
37+
let endFrame = CGRect(x: 0, y: guideFrame.minY, width: guideFrame.width, height: guideFrame.height)
38+
39+
let opening = isVisible && !wasVisible
40+
let closing = !isVisible && wasVisible
41+
42+
if opening || closing || beginFrame != endFrame {
43+
actionBlock?(beginFrame, endFrame, opening, closing)
44+
}
45+
46+
wasVisible = isVisible
47+
lastLayoutFrame = endFrame
48+
}
49+
}

0 commit comments

Comments
 (0)