Skip to content

fix: wrong selection coordinates on focus#1234

Merged
kirillzyusko merged 7 commits intomainfrom
fix/wrong-selection-on-focus-on-ios-16+
Feb 9, 2026
Merged

fix: wrong selection coordinates on focus#1234
kirillzyusko merged 7 commits intomainfrom
fix/wrong-selection-on-focus-on-ios-16+

Conversation

@kirillzyusko
Copy link
Copy Markdown
Owner

@kirillzyusko kirillzyusko commented Dec 8, 2025

📜 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:

      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

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
Simulator.Screen.Recording.-.iPhone.13.Pro.-.2026-02-09.at.11.51.59.mov
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-02-09.at.11.32.28.mov

📝 Checklist

  • CI successfully passed
  • I added new mocks and corresponding unit-tests if library API was changed

@kirillzyusko kirillzyusko self-assigned this Dec 8, 2025
@kirillzyusko kirillzyusko added 🐛 bug Something isn't working 🍎 iOS iOS specific focused input 📝 Anything about focused input functionality labels Dec 8, 2025
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Dec 8, 2025

📊 Package size report

Current size Target Size Difference
243687 bytes 241741 bytes 1946 bytes 📈

@kirillzyusko kirillzyusko force-pushed the fix/wrong-selection-on-focus-on-ios-16+ branch from ab8a334 to 9bb0cd2 Compare December 8, 2025 18:49
@kirillzyusko kirillzyusko force-pushed the fix/wrong-selection-on-focus-on-ios-16+ branch 2 times, most recently from 3208d44 to dbbbceb Compare December 23, 2025 19:43
@kirillzyusko kirillzyusko force-pushed the fix/wrong-selection-on-focus-on-ios-16+ branch from dbbbceb to 2de07ea Compare February 7, 2026 15:51
@kirillzyusko kirillzyusko added e2e Anything about E2E tests KeyboardAwareScrollView 📜 Anything related to KeyboardAwareScrollView component labels Feb 7, 2026
@kirillzyusko kirillzyusko changed the title fix: wrong selection coordinates on focus (iOS 16+) fix: wrong selection coordinates on focus Feb 9, 2026
@kirillzyusko kirillzyusko merged commit ac7dee1 into main Feb 9, 2026
30 of 35 checks passed
@kirillzyusko kirillzyusko deleted the fix/wrong-selection-on-focus-on-ios-16+ branch February 9, 2026 13:21
kirillzyusko added a commit that referenced this pull request Mar 27, 2026
…ssal (#1403)

## 📜 Description

Fixed a problem, when re-focusing field (after dismiss via system
button) pushes the field significantly higher.

## 💡 Motivation and Context

The `onEnd` handler nulls `lastSelection.value` when the keyboard hides
(`e.height === 0`). This was added intentionally in #1234 to solve an
iOS 15 problem: without nulling, `onStart` couldn't distinguish a fresh
selection (that arrived before `onStart` in the current session) from a
stale selection (leftover from the previous session). On iOS 15,
selection sometimes arrives after `onStart`, so the
`pendingSelectionForFocus` mechanism was introduced to defer layout
setup until the selection arrives.

However, nulling `lastSelection` causes two regressions:

### 1️⃣ Android — refocus same input

Android doesn't re-emit `onSelectionChange` when refocusing the same
input at the same cursor position. After the `null`, `onStart` sees
l`astSelection.value?.target !== e.target` (because `null?.target` is
`undefined`), falls back to `layout.value = input.value` (full input
height, e.g. `180px` instead of caret height `43px`), and sets
`pendingSelectionForFocus = true`. But the selection event never arrives
- so `onMove` runs the entire animation with the **wrong** height.

### 2️⃣ iOS 15 — refocus same input with new cursor

Similar to Android - `lastSelection` is `null`, so `onStart` can't use
the stale selection as a reasonable fallback. It uses the full input
height instead.

The fix consist of several coordinated changes

### 1️⃣ Replace lastSelection.value = null with a flag

Instead of destroying selection data, introduce
`selectionUpdatedSinceHide`:

- Set to `false` in `onEnd` when keyboard **hides**
- Set to `true` in `onSelectionChange` when any selection arrives
- In `onStart`, the "fresh selection" check becomes:

```ts
lastSelection.value?.target === e.target && selectionUpdatedSinceHide.value
```

This preserves the iOS 15 detection (stale selection has
`selectionUpdatedSinceHide = false`, so `onStart` correctly enters the
pending path) while keeping the data available as a fallback.

### 2️⃣ Use stale selection as best-effort fallback in `onStart`

When the selection is stale (not fresh) but targets the same input, use
`updateLayoutFromSelection()` instead of falling back to `input.value`.
The stale caret position (e.g. `y=43`) is much closer to correct than
the full input height (e.g. `180`). If a fresh selection arrives later
(iOS 15), it will overwrite this — but on Android where it never
arrives, the stale value is already correct.

```ts
if (lastSelection.value?.target === e.target) {
  updateLayoutFromSelection(); // stale but same target — use as fallback
} else if (input.value) {
  layout.value = input.value; // different target or no selection at all
}
pendingSelectionForFocus.value = true;
```

### 3️⃣ Handle same-target refocus in onSelectionChange

Since `lastSelection` is no longer nulled, when iOS 15 refocuses the
same input, `onSelectionChange` arrives with `e.target === lastTarget`.
The existing check if (`e.target !== lastTarget`) won't enter the
deferred-setup block. Fix by also checking the pending flag:

```ts
if (e.target !== lastTarget || pendingSelectionForFocus.value) {
```

This ensures the deferred selection setup runs regardless of whether the
target changed, as long as `onStart` flagged it as pending.

### 4️⃣ Conditional cleanup of pendingSelectionForFocus in onEnd

To prevent the pending flag from leaking into the next focus session
(Android case where selection never arrives), clear it in `onEnd` - but
only when the keyboard was actually appearing
(`keyboardWillAppear.value`), not during a focus switch with the same
keyboard height. Otherwise, toolbar focus switching breaks: `onEnd`
fires immediately (no animation), clearing the flag before
`onSelectionChange` has a chance to process the deferred scroll.

```ts
if (e.height === 0) {
  selectionUpdatedSinceHide.value = false;
} else if (keyboardWillAppear.value) {
  pendingSelectionForFocus.value = false;
}
```

Closes
#1394

## 📢 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

- create separate `types.ts` file;
- created `KeyboardAwareScrollView` tests (70% test coverage, texting 5
most critical/hard to catch bugs);
- fixed a problem with re-focus on Android;

## 🤔 How Has This Been Tested?

Tested manually and via this PR.

## 📸 Screenshots (if appropriate):

|Before|After|
|-------|-----|
|<video
src="https://github.com/user-attachments/assets/c6eb29cf-6756-4bda-8f4f-ba7d83e15d4e">|<video
src="https://github.com/user-attachments/assets/7ecc2b46-77cc-4203-8264-63138d325ad5">|

|iOS 15|iOS 26|Android|
|------|-------|--------|
|<video
src="https://github.com/user-attachments/assets/7f867783-ab5d-4a8e-9232-a7a7ad68cdaf">|<video
src="https://github.com/user-attachments/assets/f054efa7-9774-450c-93ab-7a4d90e6c66b">|<video
src="https://github.com/user-attachments/assets/b49bbb15-63f7-4143-ba5c-dcd159159723">|

## 📝 Checklist

- [x] CI successfully passed
- [x] I added new mocks and corresponding unit-tests if library API was
changed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐛 bug Something isn't working e2e Anything about E2E tests focused input 📝 Anything about focused input functionality 🍎 iOS iOS specific KeyboardAwareScrollView 📜 Anything related to KeyboardAwareScrollView component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[iOS] TextInput is hidden by the keyboard even when using KeyboardAwareScrollView

1 participant