Skip to content

iOS: two detent-state tracking bugs — keyboard-grown settles poison learned offsets; user drags desync the programmatic detent index, making resize() a silent no-op #758

Description

@paulbernius

Before submitting a new issue

  • I tested using the latest version of the library.
  • I tested using a supported version of React Native.
  • I checked for existing issues that might answer my question.

Bug Summary

Two related iOS bugs, both cases of internal detent state not being updated by one of the two mutation paths (programmatic vs. user drag). Found while auditing a production sheet against the 3.11.9 source; exact line references and suggested fixes in Additional Context.

Bug 1 — poisoned offset learning. When a content/footer size change triggers a layout settle while the keyboard has the sheet grown, learnOffsetForDetentIndex: stores an offset inflated by ~keyboardHeight. Afterward, a keyboard-less drag to the top detent reports animatedIndex peaking around 0.4 instead of 1 (animatedDetent misreports equally; raw position stays correct). Expected: learned offsets reflect the resting sheet geometry, and animatedIndex reaches the dragged detent's index.

Bug 2 — resize() no-ops after a user drag. _activeDetentIndex is only written by programmatic paths; user drags never update it, and resizeToDetentIndex: early-returns when the target equals the stale value. Present at index 0 → drag to the top detent → call resize(0): expected the sheet to collapse; instead nothing happens — no motion, no event.

Affected Platforms

  • iOS
  • Android
  • Web
  • Other

Library Version

3.11.9

Environment Info

System:
  OS: macOS 26.5.2
  CPU: (14) arm64 Apple M4 Pro
  Memory: 150.63 MB / 24.00 GB
  Shell:
    version: "5.9"
    path: /bin/zsh
Binaries:
  Node:
    version: 22.17.1
    path: /opt/homebrew/opt/node@22/bin/node
  Yarn: Not Found
  npm:
    version: 10.9.2
    path: /opt/homebrew/opt/node@22/bin/npm
  Watchman: Not Found
Managers:
  CocoaPods:
    version: 1.16.2
    path: /opt/homebrew/bin/pod
SDKs:
  iOS SDK:
    Platforms:
      - DriverKit 25.5
      - iOS 26.5
      - macOS 26.5
      - tvOS 26.5
      - visionOS 26.5
      - watchOS 26.5
  Android SDK: Not Found
IDEs:
  Android Studio: Not Found
  Xcode:
    version: 26.6/17F113
    path: /usr/bin/xcodebuild
Languages:
  Java:
    version: 24.0.2
    path: /usr/bin/javac
  Ruby:
    version: 2.6.10
    path: /usr/bin/ruby
npmPackages:
  "@react-native-community/cli": Not Found
  react:
    installed: 19.2.3
    wanted: 19.2.3
  react-native:
    installed: 0.86.0
    wanted: 0.86.0
  react-native-macos: Not Found

# Expo SDK 57 project (CNG), New Architecture + Hermes enabled —
# the CLI can't detect those from CNG config, hence "Not found" above.

Steps to Reproduce

Bug 2 — reproduces in this repo's own example app, unmodified:

  1. Launch the example, tap TrueSheet View (presents BasicSheet at index 0 → internal _activeDetentIndex = 0).
  2. Drag the sheet to the top detent by the grabber (no programmatic resize in between).
  3. Tap the Peek button (resize(0)).
  4. Nothing happens — expected the sheet to collapse to peek. (Tapping Auto first works, because 1 !== 0; after that, Peek works again — consistent with the stale-index early return.)

Bug 1 — sheet shape: detents={['peek', 0.45]}, dismissible={false}, footer containing a TextInput plus a conditionally rendered bar (footer height changes at runtime), ReanimatedTrueSheet:

  1. Focus the footer input (keyboard up — UIKit grows the sheet under the keyboard).
  2. While the keyboard is up, trigger the footer/content resize (show the conditional bar). The resulting layout settle learns a keyboard-inflated offset for the current detent.
  3. Dismiss the keyboard, collapse to peek, then drag fully up by the grabber.
  4. animatedIndex peaks ≈ 0.4 instead of 1 — any interpolate(animatedIndex, [0, 1], …) opacity stays ~60% hidden at the top detent. Self-heals only once a clean settle re-learns that index.

Repro

https://github.com/lodev09/react-native-true-sheet/tree/main/example

Bug 2 reproduces in the unmodified example app per the steps above. A Snack isn't possible — the library isn't Expo Go compatible. Bug 1 needs a keyboard + runtime footer resize, which no stock example sheet has; its mechanism is fully traceable in source (see Additional Context), and I'm happy to push a minimal repro repo if needed.

Additional Context

Line references are from the published 3.11.9 package.

Bug 1 mechanism

TrueSheetDetentCalculator.learnOffsetForDetentIndex: stores actualHeight − resolverHeight on every settle ("Always update — system offset can change between detent transitions", TrueSheetDetentCalculator.mm:46), where actualHeight = screenHeight − currentPosition.

One settle path is layout-driven: viewWillLayoutSubviewssettleAtDetentIndex: when _pendingContentSizeChange is set (TrueSheetViewController.mm:477) — i.e. a footer/content resize triggers learning, with no keyboard guard. With the keyboard up, UIKit grows the sheet (bottom edge stays under the keyboard, top edge rises), so actualHeight includes the keyboard growth and the stored offset absorbs ~keyboardHeight.

From then on resolvedHeightForIndex: returns resolverHeight + poisonedOffset, so interpolatedIndexForPosition: / interpolatedDetentForPosition: treat the detent as ~keyboardHeight taller than reality. The fallback in offsetForIndex: ("Fall back to any known offset", lines 77–82) propagates a poisoned offset to detents that never learned one. Both animatedIndex and animatedDetent in the reanimated integration ride this math (same onPositionChange payload); only raw position is immune.

Suggested fix: skip (or defer) offset learning while the keyboard has the sheet grown — the keyboard observer already knows. The v4 branch appears to address this (#744: "Offset learning is flag-driven, so keyboard/rotation moves no longer risk corrupting learned detent offsets"; #756 keeps learning at explicit guarded settles) — so this is mainly (a) a v3-line patch candidate and (b) a concrete keyboard regression case to test the v4 rework against.

Bug 2 mechanism

_activeDetentIndex is only written by programmatic paths — setupActiveDetentWithIndex: (present), resizeToDetentIndex:, and the grabber tap/accessibility cycles. The UISheetPresentationControllerDelegate callback (sheetPresentationControllerDidChangeSelectedDetentIdentifier, TrueSheetViewController.mm:1220) emits the detent-change event but does not sync it. resizeToDetentIndex: early-returns when index == _activeDetentIndex (TrueSheetViewController.mm:1019), hence the silent no-op after a drag.

Adjacent risk from the same stale field: applySheetPropsUpdate (TrueSheetView.mm:782) calls applyActiveDetent inside animateChanges on any prop update while presented, re-asserting selectedDetentIdentifier from the stale index — a prop update after a drag-expansion can snap the sheet back to the old detent.

Suggested fix: sync _activeDetentIndex in the sheet-delegate detent-change callback (and/or the drag-end settle), so the early return compares against the sheet's real detent.

Workaround we ship: on onDetentChange/onDragEnd, call resize(e.nativeEvent.index) — when the sheet is already at that detent this only updates the internal index, so it's a motionless sync.


Happy to test patches for either — our sheet exercises both paths heavily (always-presented peek sheet, keyboard-driven footer resizes, drag + programmatic transitions).

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs reproNeed to replicate this issue

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions