Skip to content

Commit ac7dee1

Browse files
authored
fix: wrong selection coordinates on focus (#1234)
## 📜 Description Fixed non-working `KeyboardAwareScrollView` on iOS. ## 💡 Motivation and Context Turns out that selection is not available straight after input focus. To fix this problem I decided to query selection in next frame: ```swift DispatchQueue.main.async { updateSelectionPosition(textInput: textInput, sendEvent: self.onSelectionChange) } ``` And it works, and works pretty well, but it introduces a regression on iOS < 16. The thing is that starting from iOS 16 Apple started to use TextKit 2. And in TextKit 2 all operations are async, so for iOS 16+ we'll get events like layout updated -> selection updated -> onStart -> onMove, but on iOS 15 it will be layout updated -> onStart -> selection updated -> onMove. The original JS code assumed `selection` always arrives before onStart. On iOS < 16, `onStart` now fires first, so `updateLayoutFromSelection()` reads stale `lastSelection.value` from the previous focus session - producing a wrong `ayout.value` used by `maybeScroll` throughout the keyboard animation. So I added `pendingSelectionForFocus` flag - a shared value that tracks whether `onStart` fired for a focus change but the corresponding selection event hasn't arrived yet. In `onStart` - when focus changes, check if `lastSelection.value?.target` matches the new target: - **matches** (iOS 16+ flow — selection arrived first): call `updateLayoutFromSelection()` as before - **doesn't match** (iOS < 16 flow — selection is late): set `layout.value = input.value` as a safe fallback (full input height instead of cursor-precise height), set the pending flag The immediate scroll for focus-change-without-keyboard-appearing (`focusWasChanged && !keyboardWillAppear`) is skipped when pending, since we don't have accurate layout data yet. In `onSelectionChange` - when the target changes and the pending flag is set: - clear the flag - call `updateLayoutFromSelection()` with the now-correct selection data - If keyboard was already visible (no `onMove` expected), perform the deferred scroll This ensures `layout.value` is correct before `onMove` starts using it for scroll interpolation. In onEnd — `lastSelection.value` is cleared to `null `when the keyboard fully hides (`e.height === 0)`. This prevents a subtle bug: when re-focusing the same input at a different cursor position, `lastSelection.value?.target` would still match `e.target` from the previous session, making `onStart` incorrectly think fresh selection data was available. Closes #1218 ## 📢 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 --> ### JS - added `pendingSelectionForFocus` flag; - added logic for proper selection updates depending on the flag state; ### iOS - dispatch selection in next frame on focus so that it becomes available; ### E2E - re-generated Android 28 test assets; ## 🤔 How Has This Been Tested? Tested manually on: - iPhone 16 Pro (iOS 26.2, simulator); - e2e_emulator_28 (API 28, emulator); - iPhone 13 Pro (iOS 15.5, simulator). ## 📸 Screenshots (if appropriate): |iOS 15|iOS 26| |-------|------| |<video src="https://github.com/user-attachments/assets/36f4cc76-74be-4190-8ca6-f80b341333d9">|<video src="https://github.com/user-attachments/assets/cbeb2c96-541d-447a-844c-8082399737d1">| ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent 5ef74d9 commit ac7dee1

3 files changed

Lines changed: 46 additions & 8 deletions

File tree

-6.53 KB
Loading

ios/observers/FocusedInputObserver.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,9 @@ public class FocusedInputObserver: NSObject {
142142
setupObservers()
143143
// dispatch onSelectionChange on focus
144144
if let textInput = responder as? UITextInput {
145-
updateSelectionPosition(textInput: textInput, sendEvent: onSelectionChange)
145+
DispatchQueue.main.async {
146+
updateSelectionPosition(textInput: textInput, sendEvent: self.onSelectionChange)
147+
}
146148
}
147149

148150
syncUpLayout()

src/components/KeyboardAwareScrollView/index.tsx

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ const KeyboardAwareScrollView = forwardRef<
149149
const lastSelection =
150150
useSharedValue<FocusedInputSelectionChangedEvent | null>(null);
151151
const ghostViewSpace = useSharedValue(-1);
152+
const pendingSelectionForFocus = useSharedValue(false);
152153

153154
const { height } = useWindowDimensions();
154155

@@ -324,7 +325,18 @@ const KeyboardAwareScrollView = forwardRef<
324325
lastSelection.value = e;
325326

326327
if (e.target !== lastTarget) {
327-
// ignore this event, because "focus changed" event handled in `useSmoothKeyboardHandler`
328+
if (pendingSelectionForFocus.value) {
329+
// selection arrived after onStart - complete the deferred setup
330+
pendingSelectionForFocus.value = false;
331+
updateLayoutFromSelection();
332+
333+
// if keyboard was already visible (focus change, no onMove expected),
334+
// perform the deferred scroll now
335+
if (!keyboardWillAppear.value && keyboardHeight.value > 0) {
336+
position.value += maybeScroll(keyboardHeight.value, true);
337+
}
338+
}
339+
328340
return;
329341
}
330342
// caret in the end + end coordinates has been changed -> we moved to a new line
@@ -342,7 +354,12 @@ const KeyboardAwareScrollView = forwardRef<
342354

343355
onChangeTextHandler();
344356
},
345-
[scrollFromCurrentPosition, onChangeTextHandler],
357+
[
358+
scrollFromCurrentPosition,
359+
onChangeTextHandler,
360+
updateLayoutFromSelection,
361+
maybeScroll,
362+
],
346363
);
347364

348365
useFocusedInputHandler(
@@ -375,6 +392,7 @@ const KeyboardAwareScrollView = forwardRef<
375392
// on back transition need to interpolate as [0, keyboardHeight]
376393
initialKeyboardSize.value = 0;
377394
scrollPosition.value = scrollBeforeKeyboardMovement.value;
395+
pendingSelectionForFocus.value = false;
378396
}
379397

380398
if (
@@ -391,17 +409,31 @@ const KeyboardAwareScrollView = forwardRef<
391409
// focus was changed
392410
if (focusWasChanged) {
393411
tag.value = e.target;
394-
// save position of focused text input when keyboard starts to move
395-
updateLayoutFromSelection();
412+
413+
if (lastSelection.value?.target === e.target) {
414+
// selection arrived before onStart - use it to update layout
415+
updateLayoutFromSelection();
416+
pendingSelectionForFocus.value = false;
417+
} else {
418+
// selection hasn't arrived yet for the new target.
419+
// use input layout as-is; will be refined when selection arrives.
420+
if (input.value) {
421+
layout.value = input.value;
422+
}
423+
pendingSelectionForFocus.value = true;
424+
}
425+
396426
// save current scroll position - when keyboard will hide we'll reuse
397427
// this value to achieve smooth hide effect
398428
scrollBeforeKeyboardMovement.value = position.value;
399429
}
400430

401431
if (focusWasChanged && !keyboardWillAppear.value) {
402-
// update position on scroll value, so `onEnd` handler
403-
// will pick up correct values
404-
position.value += maybeScroll(e.height, true);
432+
if (!pendingSelectionForFocus.value) {
433+
// update position on scroll value, so `onEnd` handler
434+
// will pick up correct values
435+
position.value += maybeScroll(e.height, true);
436+
}
405437
}
406438

407439
ghostViewSpace.value =
@@ -435,6 +467,10 @@ const KeyboardAwareScrollView = forwardRef<
435467
keyboardHeight.value = e.height;
436468
scrollPosition.value = position.value;
437469

470+
if (e.height === 0) {
471+
lastSelection.value = null;
472+
}
473+
438474
syncKeyboardFrame(e);
439475
},
440476
},

0 commit comments

Comments
 (0)