at://samuel.bsky.team/com.whtwnd.blog.entry/3l777nhz4h32w
Back to Collection
Record JSON
{
"$type": "com.whtwnd.blog.entry",
"blobs": [
{
"blobref": {
"mimeType": "image/png",
"original": {
"$type": "blob",
"ref": {
"$link": "bafkreie475dkoxnibrnaapzakldx3is335xh7uwcbiasyh2e47ev2r3bwu"
},
"mimeType": "image/png",
"size": 31290
},
"ref": {
"$link": "bafkreie475dkoxnibrnaapzakldx3is335xh7uwcbiasyh2e47ev2r3bwu"
},
"size": 31290
},
"encoding": "image/png",
"name": "Screenshot 2024-10-23 at 21.58.54.png"
},
{
"blobref": {
"mimeType": "image/png",
"original": {
"$type": "blob",
"ref": {
"$link": "bafkreiaap64ijgxrivlrx44nzlfwpskjvekjigd63jhgd6nnjsaeuczfg4"
},
"mimeType": "image/png",
"size": 1274119
},
"ref": {
"$link": "bafkreiaap64ijgxrivlrx44nzlfwpskjvekjigd63jhgd6nnjsaeuczfg4"
},
"size": 1274119
},
"encoding": "image/png",
"name": "Untitled design(1).png"
},
{
"blobref": {
"mimeType": "image/png",
"original": {
"$type": "blob",
"ref": {
"$link": "bafkreididub2e2q6psfv42qurvxijv6hfbkhwcwuf6g7av2hwmezpn3uly"
},
"mimeType": "image/png",
"size": 756559
},
"ref": {
"$link": "bafkreididub2e2q6psfv42qurvxijv6hfbkhwcwuf6g7av2hwmezpn3uly"
},
"size": 756559
},
"encoding": "image/png",
"name": "bellop.png"
},
{
"blobref": {
"mimeType": "image/png",
"original": {
"$type": "blob",
"ref": {
"$link": "bafkreih3t7xdcucpjzwsvrsxxpaby3a3xcazenziejgitd4i42auzsmmfe"
},
"mimeType": "image/png",
"size": 1169018
},
"ref": {
"$link": "bafkreih3t7xdcucpjzwsvrsxxpaby3a3xcazenziejgitd4i42auzsmmfe"
},
"size": 1169018
},
"encoding": "image/png",
"name": "1.png"
},
{
"blobref": {
"mimeType": "image/png",
"original": {
"$type": "blob",
"ref": {
"$link": "bafkreid34rdstrr6q3cfhj2txscurvaqnwlub4x2lgi7ift4nji2l57yfa"
},
"mimeType": "image/png",
"size": 1094284
},
"ref": {
"$link": "bafkreid34rdstrr6q3cfhj2txscurvaqnwlub4x2lgi7ift4nji2l57yfa"
},
"size": 1094284
},
"encoding": "image/png",
"name": "2.png"
}
],
"content": "When I joined Bluesky in March 2024, our [flagship app](https://bsky.app/download) was in a strange place. Written in React Native, it was a real \"native\" app on paper, yet it didn't *feel* native.\n\nThis 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.\n\nFor the past 6 months, we've undertaken various projects to improve the native feel, and people are starting to notice:\n\n![Jane Manchun Wong: This app certainly feels a lot more “native” compared to last year :)](https://amanita.us-east.host.bsky.network/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Ap2cp5gopk7mgjegy6wadk3ep\u0026cid=bafkreie475dkoxnibrnaapzakldx3is335xh7uwcbiasyh2e47ev2r3bwu)\n\n\u003eThanks [Jane](https://bsky.app/profile/wongmjane.com)!\n\nGetting here was no small task, and it took a lot of relatively small changes across all parts of the app.\n\n## Thinning out the borders\n\nComing 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.\n\n![Before/After: feeds screen](https://amanita.us-east.host.bsky.network/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Ap2cp5gopk7mgjegy6wadk3ep\u0026cid=bafkreih3t7xdcucpjzwsvrsxxpaby3a3xcazenziejgitd4i42auzsmmfe)\n\n![Before/After: notifications](https://amanita.us-east.host.bsky.network/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Ap2cp5gopk7mgjegy6wadk3ep\u0026cid=bafkreid34rdstrr6q3cfhj2txscurvaqnwlub4x2lgi7ift4nji2l57yfa)\n\nBeware 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!\n\n## Native sheets\n\nA 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.\n\nFor 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 `\u003cModal\u003e` component with `presentation=\"pageSheet\"`. This *greatly* improved the feel of the composer, and gained a new feature for free: swipe to dismiss!\n\nSadly, 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).\n\n### Replacing React Native Bottom Sheet\n\nWe soon found ourselves with a reason to go further. Our standard `\u003cDialog\u003e` component was using [React Native Bottom Sheet](https://github.com/gorhom/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](https://github.com/discord/react-native-bottom-sheet). 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.\n\nIn just a couple of days, [Hailey](https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi) 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 `\u003cDialog\u003e`. 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](https://github.com/lodev09/react-native-true-sheet) is similar—I thoroughly recommend checking it out, especially if RNBS is giving you trouble.\n\nHere's a before and after comparison of the \"Add App Password\" dialog. Which one looks more native to you?\n\n![Comparison of the old and new add App Password dialogs](https://amanita.us-east.host.bsky.network/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Ap2cp5gopk7mgjegy6wadk3ep\u0026cid=bafkreididub2e2q6psfv42qurvxijv6hfbkhwcwuf6g7av2hwmezpn3uly)\n\nHuge props to Hailey for landing that one—I'm still in awe of how good our new sheets are.\n\n### Three things you should know about native sheets\n\n#### 1. Status bar colours\n\nWhen 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!\n\n#### 2. React Navigation\n\nNative 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](https://graysky.app), and it's a great pattern. It's also super easy to do with Expo Router by setting a **group** to use sheet presentation.\n\n#### 3. iPad\n\nWe 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.\n\n## Animations, animations, animations\n\nIt'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.\n\nAnimations 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.\n\n### Layout animations\n\nUnlike React Native's `LayoutAnimation.configureNext()`, which is hard to fire correctly and can often start animating things unintentionally, [React Native Reanimated](https://docs.swmansion.com/react-native-reanimated/)'s layout animations are very easy to use and slot naturally into your app.\n\nIf you want something to move around smoothly, it's as simple as:\n\n```tsx\n\u003cAnimated.View layout={LinearTransition} /\u003e\n```\n\nIf you want something to appear and disappear smoothly, you’d write:\n\n```tsx\n\u003cAnimated.View entering={FadeIn} exiting={FadeOut} /\u003e\n```\n\nSprinkling 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):\n\n```tsx\n\u003cContainer\u003e\n \u003cHeader onSubmit={...} /\u003e\n {error \u0026\u0026 \u003cErrorBanner error={error} /\u003e}\n \u003cScrollView\u003e\n \u003cTextInput /\u003e\n \u003cMediaPreview /\u003e\n {/* etc */}\n \u003c/ScrollView\u003e\n\u003c/Container\u003e\n```\n\nWhen 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.\n\nLayout animations to the rescue:\n\n```tsx\n\u003cContainer\u003e\n \u003cHeader onSubmit={...} /\u003e\n {error \u0026\u0026 (\n \u003cAnimated.View entering={FadeIn} exiting={FadeOut}\u003e\n \u003cErrorBanner error={error} /\u003e\n \u003c/Animated.View\u003e\n )}\n \u003cAnimated.ScrollView layout={LinearTransition}\u003e\n \u003cTextInput /\u003e\n \u003cMediaPreview /\u003e\n {/* etc */}\n \u003c/Animated.ScrollView\u003e\n\u003c/Container\u003e\n```\n\nNow, the `ScrollView` smoothly slides down to make space for the banner, which fades in smoothly.\n\nReanimated's [LayoutAnimationConfig](https://docs.swmansion.com/react-native-reanimated/docs/layout-animations/layout-animation-config) component is very helpful for cases where you want elements to animate when things change, but not when the screen first appears.\n\n### Squishy buttons\n\nAnimations 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!\n\n\u003cblockquote class=\"bluesky-embed\" data-bluesky-uri=\"at://did:plc:p2cp5gopk7mgjegy6wadk3ep/app.bsky.feed.post/3l5r5gffbne2a\" data-bluesky-cid=\"bafyreidoev4obv2gmpxn3zylc753dhmejj6hnu4bm7lo2b53kjc2diq6ba\"\u003e\u003cp lang=\"en\"\u003eyou meet @bacon.bsky.social one time and all of a sudden all your buttons become squishy and native-feeling\u003cbr\u003e\u003cbr\u003e\u003ca href=\"https://bsky.app/profile/did:plc:p2cp5gopk7mgjegy6wadk3ep/post/3l5r5gffbne2a?ref_src=embed\"\u003e[image or embed]\u003c/a\u003e\u003c/p\u003e\u0026mdash; Samuel (\u003ca href=\"https://bsky.app/profile/did:plc:p2cp5gopk7mgjegy6wadk3ep?ref_src=embed\"\u003e@samuel.bsky.team\u003c/a\u003e) \u003ca href=\"https://bsky.app/profile/did:plc:p2cp5gopk7mgjegy6wadk3ep/post/3l5r5gffbne2a?ref_src=embed\"\u003eOctober 5, 2024 at 2:11 PM\u003c/a\u003e\u003c/blockquote\u003e\n\nHere'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!\n\n```tsx\nimport React from 'react'\nimport {Pressable, PressableProps, StyleProp, ViewStyle} from 'react-native'\nimport Animated, {\n cancelAnimation,\n runOnJS,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withTiming,\n} from 'react-native-reanimated'\n\nimport {isTouchDevice} from '#/lib/browser'\nimport {isNative} from '#/platform/detection'\n\nconst DEFAULT_TARGET_SCALE = isNative || isTouchDevice ? 0.98 : 1\n\nconst AnimatedPressable = Animated.createAnimatedComponent(Pressable)\n\nexport function PressableScale({\n targetScale = DEFAULT_TARGET_SCALE,\n children,\n style,\n onPressIn,\n onPressOut,\n ...rest\n}: {\n targetScale?: number\n style?: StyleProp\u003cViewStyle\u003e\n} \u0026 Exclude\u003cPressableProps, 'onPressIn' | 'onPressOut' | 'style'\u003e) {\n const reducedMotion = useReducedMotion()\n\n const scale = useSharedValue(1)\n\n const animatedStyle = useAnimatedStyle(() =\u003e ({\n transform: [{scale: scale.value}],\n }))\n\n return (\n \u003cAnimatedPressable\n accessibilityRole=\"button\"\n onPressIn={e =\u003e {\n 'worklet'\n if (onPressIn) {\n runOnJS(onPressIn)(e)\n }\n cancelAnimation(scale)\n scale.value = withTiming(targetScale, {duration: 100})\n }}\n onPressOut={e =\u003e {\n 'worklet'\n if (onPressOut) {\n runOnJS(onPressOut)(e)\n }\n cancelAnimation(scale)\n scale.value = withTiming(1, {duration: 100})\n }}\n style={[!reducedMotion \u0026\u0026 animatedStyle, style]}\n {...rest}\u003e\n {children}\n \u003c/AnimatedPressable\u003e\n )\n}\n```\n\nThis made the bottom bar feel so much better—I highly recommend this if yours is feeling rather static.\n\n## Native navigation\n\nI 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.\n\nNext 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](https://x.com/Baconbrix/status/1833467707083125222), which feels *extremely* native—it's a distinctive look of SwiftUI apps as it does that automatically.\n\nFinally, 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](https://github.com/okwasniewski/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.\n\n### Beware of `fullScreenGestureEnabled`\n\n`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.\n\n## Conclusion\n\nThere’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.\n\nAs 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. \n\nAnd if you haven't already, [check out Bluesky](https://bsky.app/download) - it's only going to get more native-er!\n\n\u003e Many many thanks to [Alice](https://alice.bsky.sh) for helping with copyediting and digging up old Android APKs to take screenshots of!\n",
"createdAt": "2024-10-25T18:57:15.869Z",
"theme": "github-light",
"title": "React Native, and \"the native feel\"",
"visibility": "public"
}