DEV Community

Ram Suthar
Ram Suthar

Posted on

I moved the collapsible-tabs header out of JavaScript, and the gap went away

Why every JS collapsible-tab library lags by a frame on a fast fling, and what it took to fix it natively for React Native.

If you have built an Instagram- or Twitter-style profile screen in React Native, you know the layout: a header that scrolls away, a tab bar that pins under it, and a swipeable pager of lists below. You also probably know the bug: fling a list hard and, for a frame or two, a gap opens between the tab bar and the content. It is worst on Android and on iOS whenever the JS thread is busy.

I hit this on the profile screen of the app I work on, and after fixing it a couple of times in JS I decided to stop fixing it and remove the cause instead. The result is react-native-collapsible-tabs-native, a small library where the collapse is owned by UIKit and ViewPager2 rather than by JavaScript or a worklet.

Why the gap exists

Every JS implementation I have used, including the ones on Reanimated, works the same way. The list content is moved by the native scroll view. The header is moved by an animation callback fed by that scroll's event. Those are two update paths, and the second one is always at least one frame behind the first.

Reanimated narrows the gap by running the callback on the UI thread, but it cannot close it. The scroll view has already moved its content by the time the event exists. On a slow frame the header is visibly late, and on Android the event itself is throttled.

The only way to make the header and the list move in the same frame is to move the header from the same native callback that moved the list.

What the library does

The React side is ordinary components. You give it a header, a tab bar and one page per tab. Fabric mounts them as children of one native view, and the native side re-parents them by nativeID into slots: a header band and a tab-bar band drawn above a horizontal pager, which is a paging UIScrollView on iOS and a ViewPager2 on Android.

The active page's vertical scroll view is found natively and observed. Its offset, clamped to the header height, becomes the bands' translation, applied inside the UIScrollViewDelegate or OnScrollChangeListener callback. There is nothing per frame on the JS thread, so JS load cannot desynchronise anything.

Everything else is the long tail of making that feel right:

  • Neighbouring pages are pre-aligned to the header during a swipe, so a page never slides in at the wrong offset.
  • A lazy page mounts the moment it peeks into view, not when the swipe settles, and is aligned as its content grows.
  • Vertical drags on the header itself scroll the active list, with a display-link fling on iOS. Horizontal lists inside the header keep their own gestures.
  • Tabs with little or no content still collapse the header. Native gives a short page exactly the scroll range it lacks.
  • Pull-to-refresh belongs to the container, so it arms only when the header is fully open and the list is at its top.
  • Presses under a finger that scrolled are cancelled, so a swipe that ends on a button does not tap it.

What it looks like in code

import { useState } from 'react';
import { FlashList } from '@shopify/flash-list';
import {
  CollapsibleTabView,
  createTabList,
  TabScrollView,
} from 'react-native-collapsible-tabs-native';

const TabFlashList = createTabList(FlashList);

const routes = [
  { key: 'posts', title: 'Posts' },
  { key: 'about', title: 'About' },
];

export function ProfileScreen() {
  const [index, setIndex] = useState(0);

  return (
    <CollapsibleTabView
      navigationState={{ index, routes }}
      onIndexChange={setIndex}
      renderHeader={() => <ProfileHeader />}
      renderScene={({ route }) =>
        route.key === 'posts' ? (
          <TabFlashList data={posts} renderItem={renderPost} />
        ) : (
          <TabScrollView>
            <About />
          </TabScrollView>
        )
      }
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

createTabList wraps any list that renders a React Native ScrollView, so FlatList, SectionList, FlashList and LegendList all work as pages. It pads the content under the header for you. That is the one rule.

Beyond the basics there is an imperative ref (scrollToTop, collapse, expand, setIndex), a collapseMode="direction" for the home-feed feel where any up-scroll reveals the header, a headerMinHeight for a strip that stays pinned, and two opt-in per-frame events for Reanimated worklets: onPageScroll for a tab indicator that tracks the finger, and onHeaderOffsetChange for a header that shrinks its own avatar. Neither touches the JS thread.

What it deliberately does not do

I would rather you know these before installing than after.

  • It is Fabric only. No Paper. React Native 0.80 and up.
  • Horizontal swipes that start on the header are inert. Swipe on the content.
  • Pull-to-refresh uses the platform's own indicator. You can style it and hide it, but a fully custom native-driven indicator is not there.
  • The header collapses as one band. Sticky sections inside a page are the list's job, and they work under the bands.
  • No web, no Expo Go. It works in Expo dev clients.

Try it

yarn add react-native-collapsible-tabs-native
cd ios && pod install
Enter fullscreen mode Exit fullscreen mode

The repo has an example app for both platforms and a README that spends as much space on limitations and FAQ as on features. The native code is two commented files, one Swift and one Kotlin, and I would genuinely like eyes on them.

If you have a profile screen with the gap, I would like to hear whether this closes it for you.

Top comments (0)