Skip to content

Commit e4e6e6e

Browse files
authored
feat: keyboard tracking view (#1047)
## 📜 Description Introduce compat `KeyboardTrackingView` layer. ## 💡 Motivation and Context Starting from iOS beta 4 we got breaking changes that breaks `react-native-keyboard-controller`. ### 1️⃣ `layer.presentation` doesn't update its values during animation When we read `print("kv: \(KeyboardViewLocator.shared.resolve()?.layer.presentation()?.frame.origin.y)")` it will always give us values as "keyboard already opened": ```bash kv: Optional(528.0) kv: Optional(528.0) kv: Optional(528.0) kv: Optional(528.0) kv: Optional(528.0) kv: Optional(528.0) kv: Optional(528.0) ``` So it will cause "instant" animations and it's not something that developers expect to see. ### 2️⃣ `layer` doesn't contain the information about its `CAAnimation` While this problem is not such a critical, because we use fallback approach: if `animation` is `nil` then we just previous keyboard frame without additional frame correction. But it'll produce unsynchronized animation (we literally will come back to `react-native-keyboard-controller@1.11` version or earlier). So eventually we need to have an access to these variables. <hr> It's obvious that we need to get a tracking view to deliver smooth animations. I had few ideas how to add it: ### 1️⃣ Use `Notification` approach The first approach was to use keyboard notifications, put tracking view outside of visible screen area and track it. It works, but it'll give `CASpringAnimation` after interactive gesture dismissal (when keyboard goes up). I've tried various ideas, like changing frame without `UIView.animate` etc., but in all cases I got the same outcome: - animation can be `nil`; - animation may be `CASpringAnimation` instead of `CABasicAnimation`. ### 2️⃣ Use `inputAccessoryView` as tracking view The other idea was to setup listener on `inputAccessoryView`. In reality this view doesn't have any animation - I assume it's because of the fact, that it's attached to keyboard and keyboard drives the animation. _**Theoretically**_ we could lookup to `superview` and track its animation, but I didn't verify it - that solution would be pretty complex because we had to inject `inputAccessoryView` for each `TextInput`... ### 3️⃣ Use `keyboardLayoutGuide` for creating tracking view The other idea to try was usage of `keyboardLayoutGuide`. The idea the same as with `Notifications`. BUT! Fortunately that approach always gives proper animations and I can see `CABasicAnimation` for interactive dismissal case. This is how it looks if we make the view visible: https://github.com/user-attachments/assets/efef1976-1940-4a98-82d9-114f10df4022 The benefit of this approach is that we always can see how a native view behaves - react-native just replicates the native view. One limitation is that `keyboardLayoutGuide` is available from iOS 15. Moreover we use `usesBottomSafeArea` property, which is available starting from iOS 17. Taking all these facts into consideration and keeping in mind, that everything was pretty stable until iOS 26, I decided to use old implementation for all iOS version < 26. I don't want to break old code, but discovering bugs in new code/iOS is kind of expected process 😅 <hr> With the new concept of a compat layer (`KeyboardTrackingView`) we can separate concepts and move all interpolation code into `KeyboardTrackingView` class itself. As a proof of concept I fixed interactive keyboard dismissal for iOS 26+ - now we can embed all math for calculating keyboard position into this layer 😎 (so this PR will automatically close #975 - note it doesn't mean that I fully fixed interactive dismissal: this PR is a first step into making `react-native-keyboard-controller` codebase more predictable/enhanceable etc.): https://github.com/user-attachments/assets/5dda7a96-42d1-43ad-8cbd-7ca24828d0d1 Closes #975 ## 📢 Changelog <!-- High level overview of important changes --> <!-- For example: fixed status bar manipulation; added new types declarations; --> <!-- If your changes don't affect one of platform/language below - then remove this platform/language --> ### iOS - created `KeyboardTrackingView` as a compat layer (iOS < 26 and iOS 26+) - use `KeyboardTrackingView` in `KeyboardMovementObserver` and avoid direct usages of `keyboardView` instance; - move interactive interpolation code into `KeyboardTrackingView` from `KeyboardMovementObserver`. ## 🤔 How Has This Been Tested? Tested manually on: - iPhone 6s (iOS 15.8, real device); - iPhone 11 (iOS 26, real device) - iPhone 16 Pro (iOS 26, real device) - iPhone 14 Pro (iOS 18.4, real device) - iPhone 15 Pro (iOS 17.5, simulator) - iPhone 16 Pro (iOS 26, simulator) ## 📸 Screenshots (if appropriate): |Before|After| |-------|-----| |<video src="https://github.com/user-attachments/assets/e2c808dc-2aa5-4768-8974-4df429abba57">|<video src="https://github.com/user-attachments/assets/04448303-be1a-4c0b-b705-fe0c936726ad">| > captured with a fix for `did` events (dispatch them only after keyboard finish its animation) for both before/after cases. Captured on iOS 26 for better proof/visibility. ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent 3ae5e72 commit e4e6e6e

File tree

2 files changed

+145
-19
lines changed

2 files changed

+145
-19
lines changed

ios/observers/KeyboardMovementObserver.swift

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ public class KeyboardMovementObserver: NSObject {
1818
var onRequestAnimation: () -> Void
1919
var onCancelAnimation: () -> Void
2020
// progress tracker
21-
private var keyboardView: UIView? { KeyboardViewLocator.shared.resolve() }
21+
private var keyboardTrackingView = KeyboardTrackingView()
22+
2223
private var prevKeyboardPosition = 0.0
2324
private var displayLink: CADisplayLink!
2425
private var interactiveKeyboardObserver: NSKeyValueObservation?
@@ -93,7 +94,7 @@ public class KeyboardMovementObserver: NSObject {
9394
}
9495

9596
private func setupKVObserver() {
96-
guard interactiveKeyboardObserver == nil, let view = keyboardView else { return }
97+
guard interactiveKeyboardObserver == nil, let view = keyboardTrackingView.view else { return }
9798

9899
interactiveKeyboardObserver = view.observe(\.center, options: .new) { [weak self] _, change in
99100
guard let self = self, let changeValue = change.newValue else { return }
@@ -115,21 +116,13 @@ public class KeyboardMovementObserver: NSObject {
115116
if !displayLink.isPaused {
116117
return
117118
}
118-
// if keyboard height is not equal to its bounds - we can ignore
119-
// values, since they'll be invalid and will cause UI jumps
120-
if floor(keyboardView?.bounds.size.height ?? 0) != floor(_keyboardHeight) {
121-
return
122-
}
123119

124-
let keyboardFrameY = changeValue.y
125-
let keyboardWindowH = keyboardView?.window?.bounds.size.height ?? 0
126-
let keyboardPosition = keyboardWindowH - keyboardFrameY
120+
let interactive = keyboardTrackingView.interactive(point: changeValue)
127121

128-
let position = CGFloat.interpolate(
129-
inputRange: [_keyboardHeight / 2, -_keyboardHeight / 2],
130-
outputRange: [_keyboardHeight, 0],
131-
currentValue: keyboardPosition
132-
) - KeyboardAreaExtender.shared.offset
122+
if interactive == KeyboardTrackingView.invalidPosition {
123+
return
124+
}
125+
let position = interactive - KeyboardAreaExtender.shared.offset
133126

134127
if position == 0 {
135128
// it will be triggered before `keyboardWillDisappear` and
@@ -192,10 +185,11 @@ public class KeyboardMovementObserver: NSObject {
192185

193186
@objc func keyboardDidAppear(_ notification: Notification) {
194187
guard !UIResponder.isKeyboardPreloading else { return }
188+
195189
let timestamp = Date.currentTimeStamp
196190
let (duration, frame) = notification.keyboardMetaData()
197191
if let keyboardFrame = frame {
198-
let (position, _) = keyboardView.frameTransitionInWindow
192+
let (position, _) = keyboardTrackingView.view.frameTransitionInWindow
199193
let keyboardHeight = keyboardFrame.cgRectValue.size.height
200194
tag = UIResponder.current.reactViewTag
201195
self.keyboardHeight = keyboardHeight
@@ -251,7 +245,7 @@ public class KeyboardMovementObserver: NSObject {
251245

252246
func initializeAnimation(fromValue: Double, toValue: Double) {
253247
for key in ["position", "opacity"] {
254-
if let keyboardAnimation = keyboardView?.layer.presentation()?.animation(forKey: key) {
248+
if let keyboardAnimation = keyboardTrackingView.view?.layer.presentation()?.animation(forKey: key) {
255249
if let springAnimation = keyboardAnimation as? CASpringAnimation {
256250
animation = SpringAnimation(animation: springAnimation, fromValue: fromValue, toValue: toValue)
257251
} else if let basicAnimation = keyboardAnimation as? CABasicAnimation {
@@ -263,11 +257,11 @@ public class KeyboardMovementObserver: NSObject {
263257
}
264258

265259
@objc func updateKeyboardFrame(link: CADisplayLink) {
266-
if keyboardView == nil {
260+
if keyboardTrackingView.view == nil {
267261
return
268262
}
269263

270-
let (visibleKeyboardHeight, keyboardFrameY) = keyboardView.frameTransitionInWindow
264+
let (visibleKeyboardHeight, keyboardFrameY) = keyboardTrackingView.view.frameTransitionInWindow
271265
var keyboardPosition = visibleKeyboardHeight - KeyboardAreaExtender.shared.offset
272266

273267
if keyboardPosition == prevKeyboardPosition || keyboardFrameY == 0 {
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
//
2+
// KeyboardTrackingView.swift
3+
// Pods
4+
//
5+
// Created by Kiryl Ziusko on 25/07/2025.
6+
//
7+
8+
import UIKit
9+
10+
/**
11+
* A compatibility view that resolves to `KeyboardView` on iOS < 26
12+
* and uses `keyboardLayoutGuide` on iOS 26+.
13+
*/
14+
final class KeyboardTrackingView: UIView {
15+
private var keyboardView: UIView? { KeyboardViewLocator.shared.resolve() }
16+
private var keyboardHeight = 0.0
17+
18+
static let invalidPosition: CGFloat = -.greatestFiniteMagnitude
19+
20+
override init(frame: CGRect) {
21+
super.init(frame: frame)
22+
setup()
23+
}
24+
25+
required init?(coder: NSCoder) {
26+
super.init(coder: coder)
27+
setup()
28+
}
29+
30+
required init() {
31+
super.init(frame: .zero)
32+
setup()
33+
}
34+
35+
deinit {
36+
NotificationCenter.default.removeObserver(self)
37+
}
38+
39+
private func setup() {
40+
// for debug purposes
41+
// self.backgroundColor = .red
42+
isUserInteractionEnabled = false
43+
isHidden = true
44+
45+
NotificationCenter.default.addObserver(
46+
self,
47+
selector: #selector(keyboardWillAppear),
48+
name: UIResponder.keyboardWillShowNotification,
49+
object: nil
50+
)
51+
NotificationCenter.default.addObserver(
52+
self,
53+
selector: #selector(keyboardDidAppear),
54+
name: UIResponder.keyboardDidShowNotification,
55+
object: nil
56+
)
57+
58+
guard
59+
let window = UIApplication.shared.activeWindow,
60+
let rootView = window.rootViewController?.view
61+
else {
62+
return
63+
}
64+
65+
rootView.addSubview(self)
66+
67+
translatesAutoresizingMaskIntoConstraints = false
68+
69+
if #available(iOS 17.0, *) {
70+
rootView.keyboardLayoutGuide.usesBottomSafeArea = false
71+
}
72+
73+
NSLayoutConstraint.activate([
74+
leadingAnchor.constraint(equalTo: rootView.leadingAnchor, constant: 0),
75+
trailingAnchor.constraint(equalTo: rootView.trailingAnchor, constant: 0),
76+
bottomAnchor.constraint(equalTo: rootView.keyboardLayoutGuide.topAnchor, constant: 0),
77+
heightAnchor.constraint(equalToConstant: 0),
78+
])
79+
}
80+
81+
@objc private func keyboardWillAppear(_ notification: Notification) {
82+
updateHeightFromNotification(notification)
83+
}
84+
85+
@objc private func keyboardDidAppear(_ notification: Notification) {
86+
updateHeightFromNotification(notification)
87+
}
88+
89+
private func updateHeightFromNotification(_ notification: Notification) {
90+
let (_, frame) = notification.keyboardMetaData()
91+
if let keyboardFrame = frame {
92+
let keyboardHeight = keyboardFrame.cgRectValue.size.height
93+
self.keyboardHeight = keyboardHeight
94+
}
95+
}
96+
97+
@objc var view: UIView? {
98+
if #available(iOS 26.0, *) {
99+
return self
100+
} else {
101+
return keyboardView
102+
}
103+
}
104+
105+
func interactive(point: CGPoint) -> CGFloat {
106+
guard let trackedView = view else { return Self.invalidPosition }
107+
108+
let keyboardFrameY = point.y
109+
let keyboardWindowH = trackedView.window?.bounds.size.height ?? 0
110+
let keyboardPosition = keyboardWindowH - keyboardFrameY
111+
112+
// for `keyboardLayoutGuide` case we can just read keyboard position directly - no interpolation needed
113+
if #available(iOS 26.0, *) {
114+
return keyboardPosition
115+
}
116+
117+
// if keyboard height is not equal to its bounds - we can ignore
118+
// values, since they'll be invalid and will cause UI jumps
119+
// valid only for non-`keyboardLayoutGuide` case
120+
if floor(trackedView.bounds.size.height) != floor(keyboardHeight) {
121+
return Self.invalidPosition
122+
}
123+
124+
let position = CGFloat.interpolate(
125+
inputRange: [keyboardHeight / 2, -keyboardHeight / 2],
126+
outputRange: [keyboardHeight, 0],
127+
currentValue: keyboardPosition
128+
)
129+
130+
return position
131+
}
132+
}

0 commit comments

Comments
 (0)