React Native, and "the native feel"

@samuel.bsky.team

When I joined Bluesky in March 2024, our flagship app was in a strange place. Written in React Native, it was a real "native" app on paper, yet it didn't feel native.

This is not uncommon, yet it’s hard to put into words what that means. Detractors will point the blame at React Native itself, which I think is unfair—React Native is uniquely placed to excel at this, unlike, say, Flutter. That said, feeling native does not come for free—it requires a lot of hard work and attention to detail.

For the past 6 months, we've undertaken various projects to improve the native feel, and people are starting to notice:

Jane Manchun Wong: This app certainly feels a lot more “native” compared to last year :)

Thanks Jane!

Getting here was no small task, and it took a lot of relatively small changes across all parts of the app.

Thinning out the borders

Coming from a web background, you might instinctively set the borderWidth to 1px when you want a border. Surprisingly, that's wrong—you’re likely looking for StyleSheet.hairlineWidth! It may seem like a small change, but when your app has lots of borders—between posts, around quote-posts, around link cards—they add up. We changed every single border in the app to hairline width and it was an unexpectedly huge improvement. Nearly imperceptibly, the entire app became more detailed and precise and reduced the visual clutter.

Before/After: feeds screen

Before/After: notifications

Beware though—you might be expecting 1px borders in unexpected places. We had a couple of places where we were absolutely positioning items with a 1px inset—to account for the border—which caused a gap when the border got thinner. So double-check after you replace them all!

Native sheets

A distinctive characteristic of iOS apps is the "sheet" presentation—a modal view where the backdrop drops back a little. It feels slick, and really native—you don't see websites doing this.

For the post composer, we were originally absolutely positioning a view above the app with a manual fade/slide animation. It was fine, but didn't feel very special. We swapped it out with a real native sheet using React Native's built-in <Modal> component with presentation="pageSheet". This greatly improved the feel of the composer, and gained a new feature for free: swipe to dismiss!

Sadly, this was not without problems. First, it didn't work on Android. Trying to auto-focus the text input was straight-up failing, so we went back to the original implementation there—which was super easy, thanks to platform-split files. But there was also a lot of strange, awkward behaviour which made us reticent to use this in other places, and some of the more interesting features of native sheets are not available (medium/custom detents and swiping to dismiss without resistance come to mind).

Replacing React Native Bottom Sheet

We soon found ourselves with a reason to go further. Our standard <Dialog> component was using React Native Bottom Sheet, causing us a lot of issues. The worst offender was broken accessibility on Android, but the problems (and hacky fixes) just kept on piling up and up. We spent months tracking down a bug where dismissing a sheet wrong would cause the Android back button to stop working. Content in the bottom sheet requires special components, especially TextInput. For reasons I can't remember, we ended up using Discord's fork. RNBS is a very common library that you will likely find in most React Native apps but was causing us a huge amount of pain and generating a lot of complexity.

In just a couple of days, Hailey sat down and implemented a completely native bottom-sheet implementation on both iOS and Android, seamlessly swapping out our existing implementation with minimal changes needed in terms of using the <Dialog>. This incredible engineering work massively improved the native feel of the app, fixed all our accessibility issues and various janky fixes that had accumulated in the JS-based sheet implementation, and added a feature that even the native sheets don't have out of the box—dynamically sizing the sheets based on content height. We haven't (yet?) published this implementation as a separate package, but True Sheet is similar—I thoroughly recommend checking it out, especially if RNBS is giving you trouble.

Here's a before and after comparison of the "Add App Password" dialog. Which one looks more native to you?

Comparison of the old and new add App Password dialogs

Huge props to Hailey for landing that one—I'm still in awe of how good our new sheets are.

Three things you should know about native sheets

1. Status bar colours

When you open a sheet, make sure that the status bar style is set to "light", otherwise the status bar won't be visible when the black background is revealed. We do this by keeping a count of the number of active sheets, and setting it to "light" if it's greater than zero. We also increment this count when opening a system sheet, like the image picker, since that has the same presentation!

2. React Navigation

Native sheets can also be used via React Navigation—it lets you set the presentation of a screen to formSheet or pageSheet. This is powerful since you can nest a stack navigator within a sheet. I used this a bunch when I was working on Graysky, and it's a great pattern. It's also super easy to do with Expo Router by setting a group to use sheet presentation.

3. iPad

We don't support iPad in the Bluesky app yet but be wary that iPad sheets can display very differently. For large iPads, custom detents are not supported, and the status bar needs to stay as-is since it doesn't reveal the background. The worst part is, Platform.isPad doesn't always help detect this change in behaviour since the iPad Mini will still display sheets like on iOS! I have yet to find a good answer for this; part of why we've never enabled iPad support.

Animations, animations, animations

It's a bit of a no-brainer that animations make apps feel more polished, but when it comes to React Native apps specifically, it's often one of the biggest factors in the elusive sense of "feeling native". SwiftUI apps naturally come with a lot of little animations, but in React Native you've got to do all of it by hand. This is a large contributing factor in what I'd call the "webby" feel of a lot of React Native apps.

Animations are a huge topic; I could talk about the new animation for when you like a post or the new swipe-to-dismiss interactions on the chat screen. That said, many low-hanging fruits are simple to take advantage of which can drastically improve the perceived quality of your app.

Layout animations

Unlike React Native's LayoutAnimation.configureNext(), which is hard to fire correctly and can often start animating things unintentionally, React Native Reanimated's layout animations are very easy to use and slot naturally into your app.

If you want something to move around smoothly, it's as simple as:

<Animated.View layout={LinearTransition} />

If you want something to appear and disappear smoothly, you’d write:

<Animated.View entering={FadeIn} exiting={FadeOut} />

Sprinkling those across your app can hugely improve its feel. We use this in the composer, for example: if there's a problem with your post, you get a little banner at the top. The component structure looked somewhat like this (simplified):

<Container>
    <Header onSubmit={...} />
    {error && <ErrorBanner error={error} />}
    <ScrollView>
        <TextInput />
        <MediaPreview />
        {/* etc */}
    </ScrollView>
</Container>

When an error happened, it would simply snap into place. This is somewhat fine for the banner, but pretty jarring for the rest of the content in the scrollview to suddenly snap down by 50 or so pixels.

Layout animations to the rescue:

<Container>
    <Header onSubmit={...} />
    {error && (
        <Animated.View entering={FadeIn} exiting={FadeOut}>
            <ErrorBanner error={error} />
        </Animated.View>
    )}
    <Animated.ScrollView layout={LinearTransition}>
        <TextInput />
        <MediaPreview />
        {/* etc */}
    </Animated.ScrollView>
</Container>

Now, the ScrollView smoothly slides down to make space for the banner, which fades in smoothly.

Reanimated's LayoutAnimationConfig component is very helpful for cases where you want elements to animate when things change, but not when the screen first appears.

Squishy buttons

Animations will often make something feel more polished, but what makes a particular impact in native apps is a feeling of tactility. Haptics is one part of that, but another is "squishiness". Used sparingly, a scale-on-press animation can make buttons feel very dynamic. We use these in a few places, most prominently on the bottom navigation tabs. Compared to the old TouchableOpacity animation, the app feels so much more alive!

you meet @bacon.bsky.social one time and all of a sudden all your buttons become squishy and native-feeling

[image or embed]

— Samuel (@samuel.bsky.team) October 5, 2024 at 2:11 PM

Here's the complete implementation of our PressableScale component. We can drop this into our higher-level Button component to replace its underlying Pressable to instantly make a button squishy!

import React from 'react'
import {Pressable, PressableProps, StyleProp, ViewStyle} from 'react-native'
import Animated, {
  cancelAnimation,
  runOnJS,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated'

import {isTouchDevice} from '#/lib/browser'
import {isNative} from '#/platform/detection'

const DEFAULT_TARGET_SCALE = isNative || isTouchDevice ? 0.98 : 1

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

export function PressableScale({
  targetScale = DEFAULT_TARGET_SCALE,
  children,
  style,
  onPressIn,
  onPressOut,
  ...rest
}: {
  targetScale?: number
  style?: StyleProp<ViewStyle>
} & Exclude<PressableProps, 'onPressIn' | 'onPressOut' | 'style'>) {
  const reducedMotion = useReducedMotion()

  const scale = useSharedValue(1)

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{scale: scale.value}],
  }))

  return (
    <AnimatedPressable
      accessibilityRole="button"
      onPressIn={e => {
        'worklet'
        if (onPressIn) {
          runOnJS(onPressIn)(e)
        }
        cancelAnimation(scale)
        scale.value = withTiming(targetScale, {duration: 100})
      }}
      onPressOut={e => {
        'worklet'
        if (onPressOut) {
          runOnJS(onPressOut)(e)
        }
        cancelAnimation(scale)
        scale.value = withTiming(1, {duration: 100})
      }}
      style={[!reducedMotion && animatedStyle, style]}
      {...rest}>
      {children}
    </AnimatedPressable>
  )
}

This made the bottom bar feel so much better—I highly recommend this if yours is feeling rather static.

Native navigation

I touched on this briefly earlier, but leaning on React Navigation is a great way to get your app to feel more native. First off: use the native stack navigator. This needs no further explanation.

Next native headers. We don't yet do this at Bluesky, but resist the temptation to roll your own headers—it's hard to perceive, but you lose a lot of subtle interactions when you do that. The easiest way to tell if headers are native (on iOS) is to long press the back button, which lets you quickly jump back through previous pages. Native headers unlock doing things like dynamic blurry backdrops after you start scrolling, which feels extremely native—it's a distinctive look of SwiftUI apps as it does that automatically.

Finally, another thing we don't use at Bluesky but I wanted to give a quick shout-out to: Oskar Kwaśniewski's brand new React Native Bottom Tabs library. This uses real native tab bars and is super easy to slot into React Navigation or Expo Router. If you want to go all-in on the native look (which is a good idea!) I highly recommend you check it out.

Beware of fullScreenGestureEnabled

fullScreenGestureEnabled lets you swipe anywhere on the screen to go back. This feels really good, and a lot of other apps do it: Threads, Twitter (subsequently X), and Instagram, to name a few. React Navigation's implementation, however, leaves many things desired and has the significant drawback of changing the back animation to use the custom simple_push rather than the native one, which is far more janky. We ultimately decided to keep fullScreenGestureEnabled enabled for Bluesky, but it's a substantial trade-off and I hope we’ll find a better solution.

Conclusion

There’s no silver bullet for making your app "feel native". None of these changes moved the needle by themselves, but adding them all up makes a huge difference, as I hope you noticed over the past few months in the app. My one overarching piece of advice is: be curious. Look at other apps close, really close, ask yourself how they did that, and try and do it yourself. It's worth the effort, and it's a lot more fun than it sounds at first.

As for me, my next goal is improving the headers and navigation—I’m confident that that will bring a subtle, yet substantial improvement to the feel.

And if you haven't already, check out Bluesky - it's only going to get more native-er!

Many many thanks to Alice for helping with copyediting and digging up old Android APKs to take screenshots of!

samuel.bsky.team
Samuel

@samuel.bsky.team

https://mozzius.dev
dev at bluesky. react native is good actually
📍London

Post reaction in Bluesky

*To be shown as a reaction, include article link in the post or add link card

Reactions from everyone (0)