Skip to content

fix: bottom sheet not appearing with android reduce motion#2652

Open
coolsoftwaretyler wants to merge 1 commit intogorhom:masterfrom
coolsoftwaretyler:tw/fix-bottom-sheet-not-animating-with-android-reduced-motion
Open

fix: bottom sheet not appearing with android reduce motion#2652
coolsoftwaretyler wants to merge 1 commit intogorhom:masterfrom
coolsoftwaretyler:tw/fix-bottom-sheet-not-animating-with-android-reduced-motion

Conversation

@coolsoftwaretyler
Copy link
Copy Markdown

@coolsoftwaretyler coolsoftwaretyler commented Apr 14, 2026

Motivation

This fixes bottom sheets not appearing when Android has reduce motion on, without disabling the reduced motion for users who have opted in.

As opposed to the approach in #1743, which forces the setting off in certain circumstances, this PR would trigger one more rerender on the BottomSheet component to force the screen to commit the changes.

I traced through the source code of @gorhom/bottom-sheet and react-native-reanimated (I'm on verison 3.19, but I imagine the core mechanism is the same across v4). I believe I've pieced together the root cause of the issue, and a simple (albeit kind of clunky) fix.

With reduce motion enabled and animateOnMount=true (the default), the BottomSheet component calls animateToPosition. Source

if (animateOnMount) {
  animateToPosition(
    proposedPosition,
    ANIMATION_SOURCE.MOUNT,
    undefined,
    animationConfigs,
  );
} else {
  setToPosition(proposedPosition);
  isAnimatedOnMount.value = true;
}

animateToPosition calls animate from utilities:

animatedPosition.value = animate({
  point: position,
  configs: configs || _providedAnimationConfigs,
  velocity,
  overrideReduceMotion: _providedOverrideReduceMotion,
  onComplete: animateToPositionCompleted,
});

Which in turn calls withTiming or withSpring here:

if (type === ANIMATION_METHOD.TIMING) {
  return withTiming(point, configs as WithTimingConfig, onComplete);
}

return withSpring(
  point,
  Object.assign({ velocity }, configs) as WithSpringConfig,
  onComplete,
);

withSpring/withTiming return an AnimationObject — an object with an onFrame method. Reanimated's shared value has a custom setter that intercepts every assignment and branches on this:

// valueSetter.ts
if (
  typeof value === 'function' ||
  (value !== null &&
    typeof value === 'object' &&
    (value as unknown as AnimationObject).onFrame !== undefined) // AnimationObject check
) {
  // animation path — initialize and run the first frame
  initializeAnimation(currentTimestamp); // calls animation.onStart(...)

  const step = (newTimestamp: number) => {
    const finished = animation.onFrame(animation, timestamp);
    mutable._value = animation.current!;
    if (finished) {
      animation.callback && animation.callback(true);
    } else {
      requestAnimationFrame(step); // line 72 — schedules next frame
    }
  };

  step(currentTimestamp); // called synchronously
} else {
  // direct assignment path
  mutable._value = value; // line 85
}

Inside initializeAnimation, reanimated calls animation.onStart. Reanimated wraps every animation's onStart with a reduce-motion check in animation/util.ts:

// animation/util.ts — called during initializeAnimation
if (animation.reduceMotion === undefined) {
  animation.reduceMotion = getReduceMotionFromConfig(); // reads system setting → true
}
if (animation.reduceMotion) {
  animation.current = animation.toValue; // jump to target value immediately
  animation.startTime = 0;
  animation.onFrame = () => true; // ← replace onFrame with one that always returns finished
  return;
}

When reduce motion is active, onStart replaces onFrame with () => true and pre-sets animation.current to the target value before step ever runs. Back in valueSetter, step(currentTimestamp) is then called synchronously:

const step = (newTimestamp: number) => {
  const finished = animation.onFrame(animation, timestamp); // → () => true → finished immediately
  mutable._value = animation.current!; // direct assignment to target
  if (finished) {
    animation.callback && animation.callback(true);
    // requestAnimationFrame is never reached
  } else {
    requestAnimationFrame(step); // ← NEVER REACHED
  }
};

step(currentTimestamp); // synchronous, finishes in one call

animateToPosition under reduce motion ends up with no requestAnimationFrame call. From here, the Choreographer chain that would flush native view updates is never entered.

Why This Breaks Android

requestAnimationFrame in a Reanimated worklet is an override installed on the UI thread by Reanimated during initialization:

// initializers.ts
global.requestAnimationFrame = (
  callback: (timestamp: number) => void,
): number => {
  animationFrameCallbacks.push(callback);
  if (!flushRequested) {
    flushRequested = true;
    // nativeRequestAnimationFrame is the next important call in the diagnosis
    nativeRequestAnimationFrame((timestamp) => {
      flushRequested = false;
      global.__flushAnimationFrame(timestamp);
    });
  }
  return -1;
};

nativeRequestAnimationFrame is a JSI binding that calls into Reanimated's C++ layer:

// ReanimatedModuleProxy.cpp
void ReanimatedModuleProxy::requestAnimationFrame(
    jsi::Runtime &rt,
    const jsi::Value &callback) {
  frameCallbacks_.push_back(std::make_shared<jsi::Value>(rt, callback));
  maybeRequestRender(); // line 578
}

void ReanimatedModuleProxy::maybeRequestRender() {
  if (!renderRequested_) {
    renderRequested_ = true;
    requestRender_(onRenderCallback_); // line 584 calls into Java
  }
}

requestRender_ is a function pointer bound to the Java side:

// NativeProxyCommon.java
@DoNotStrip
public void requestRender(AnimationFrameCallback callback) {
  mNodesManager.postOnAnimation(callback); // line 92
}

postOnAnimation queues the callback and calls startUpdatingOnAnimationFrame, which registers a callback with Android's ReactChoreographer:

// NodesManager.java
public void postOnAnimation(OnAnimationFrame onAnimationFrame) {
  mFrameCallbacks.add(onAnimationFrame);
  startUpdatingOnAnimationFrame(); // line 342
}

public void startUpdatingOnAnimationFrame() {
  if (!mCallbackPosted.getAndSet(true)) {
    mReactChoreographer.postFrameCallback(
        ReactChoreographer.CallbackType.NATIVE_ANIMATED_MODULE,
        mChoreographerCallback); // line 227-228
  }
}

The Android Choreographer is the system class responsible for coordinating all drawing on the main thread. It fires registered callbacks in sync with the display's vsync signal. Only work that runs inside a Choreographer callback is guaranteed to be committed to the screen in the next frame.

When mReactChoreographer.postFrameCallback is called, Android schedules onAnimationFrame to run at the next vsync. That callback evaluates all useAnimatedStyle hooks that depend on changed shared values and commits the resulting style updates to native views.

Because reduce motion causes step() to return finished=true on its first synchronous call, requestAnimationFrame is never invoked. The entire chain of nativeRequestAnimationFramemaybeRequestRenderpostOnAnimationpostFrameCallback is never entered. The Choreographer is never notified, useAnimatedStyle is never re-evaluated, and the native view is never updated, even though BottomSheet believes it has completed its mount animation.

Perhaps we should submit this to reanimated itself, but the bottom sheet library can workaround the problem by directly setting the value and flushing the animation.

@coolsoftwaretyler coolsoftwaretyler force-pushed the tw/fix-bottom-sheet-not-animating-with-android-reduced-motion branch from ddd8af3 to 9294d5d Compare April 14, 2026 15:51
@coolsoftwaretyler coolsoftwaretyler force-pushed the tw/fix-bottom-sheet-not-animating-with-android-reduced-motion branch from 9294d5d to e5927ff Compare April 14, 2026 16:11
Comment thread src/components/bottomSheet/BottomSheet.tsx
@coolsoftwaretyler
Copy link
Copy Markdown
Author

coolsoftwaretyler commented Apr 15, 2026

I've got a minimal reproduction that demonstrates the bad interaction between BottomSheet and Reanimated v3: https://github.com/coolsoftwaretyler/motionreproducer

If you use the .present method on a bottom sheet, it doesn't make its full animation. As soon as you re-render React (like changing a text value), it goes where it ought to. See repro video:

With reduced motion (bug)

reanimated-v3-repro.webm

Without reduced motion (expected behavior)

with-animations-on.webm

I'd expect the reduced motion to go to the same snap point as the non-reduced motion video. It makes sense that the animation is gone itself, but it seems the position of the sheet is broken.

This issue does not seem present with Reanimated v4, which behaves uniformly across reduced motion/not reduced motion from what I can see.

@gorhom gorhom self-requested a review April 21, 2026 16:13
@gorhom gorhom self-assigned this Apr 21, 2026
@gorhom gorhom added the v5 label Apr 21, 2026
@gorhom
Copy link
Copy Markdown
Owner

gorhom commented Apr 21, 2026

that's a very interesting deep dive you did @coolsoftwaretyler , thanks for that!

I am trying to repro the issue, but i do not have access to https://github.com/coolsoftwaretyler/motionreproducer

@coolsoftwaretyler
Copy link
Copy Markdown
Author

Whoops, sorry about that @gorhom. I just updated it to be public.

@gorhom
Copy link
Copy Markdown
Owner

gorhom commented Apr 21, 2026

@coolsoftwaretyler is it possible to setup a simplified repro example on the official template: https://snack.expo.dev/@gorhom/bottom-sheet---issue-reproduction-template

@coolsoftwaretyler
Copy link
Copy Markdown
Author

@gorhom I made one here, but I'm not sure if the Snack reproducer will work. The reduce motion setting in the provided Android emulator seems to be behaving differently than when I run adb shell settings put global window_animation_scale 0 && adb shell settings put global transition_animation_scale 0" (notice the bottom sheet still animates smoothly in the Snack).

Video of Snack: https://www.dropbox.com/scl/fi/4hnf7vzqsnsue6acb3iml/CleanShot-2026-04-21-at-14.00.19.mp4?rlkey=eieqe85mig666kwifppa9y4hm&st=86xzhpyp&dl=0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants