I'm back! With a huge update.
I'll try to keep it short this time: I redesigned Breathly's UI and architecture from the ground up to make it easy to implement many of the requests I got in the past. I'm talking about things like pure OLED theme, being able to use decimals in the exercise step lengths, adding white noise, and so on.
From a technical standpoint, I moved Breathly to Expo. This change should make it easier to update to major versions of React Native.
Check out updated README.md for more info!
- Expo, expo, expo. And several expo-related-libraries like
expo-haptics
,expo-keep-awake
, etc. I'm so happy I can finally use these kind of things without wasting hours tuning them on the native side (check the previous DEVLOG entries for examples of what I mean). - I ditched my custom routing solution for React Navigation (which is pretty good nowadays!).
- I'm using NativeWind to style the app. I love Tailwind and NativeWind worked really well for me so far.
- I replaced the messy React Context with Zustand. I'm mainly using it to store and persist the user customizations.
- This ancient React Native bug is still alive: in release mode on iOS the starry background was mistakenly defaulting to
resizeMode=”cover”
even if I explicitly set it toresizeMode=“repeat”
. Had to edit the image to solve it. - Talking about ancient issues, I still couldn’t get
Animated.sequence
+Animated.loop
to work correctly together with animation using the native driver. I’m still using my own little util as a workaround (src/utils/loop-animations.ts
). - On Android, animating the opacity of images with the native driver sometimes causes a flash of the image even when completely transparent. This is also an old issue that I’ve been solving for ages with this ugly workaround.
- Transparent Android navbars are cool but are wonky on Expo/RN. To make them work you must set their background to a dumb value like
"rgba(0, 0, 0, 0.002)"
("transparent"
or hex vals don’t work). Also, the transparency breaks when the app transitions from the background to the foreground. I'm manually forcing the transparency back by checking theappState
and avoiding race conditions with ugly timers/next-ticks but it's inconsistent and you can still see it flash a bit. And it also makes the splash screen jump a bit because the navigation bar is hidden while the splash screen is being shown 🙄. - I enjoyed using NativeWind on this project. I only had a couple of issues with
useColorScheme
returning stale data but I patched it manually (seepatches/nativewind+2.0.11.patch
). I noticed it’s being solved in the next version (v3) anyway.
It has been a long time since my last update to the codebase.
There are two main changes:
React-Native 0.62 has been available for a couple of months now.
The major highlights from this release are the new Flipper developer tool being enabled by default, the dark-mode support shipped out-of-the box, and several other improvements and bug-fixes.
Updating Breathly to [email protected]
was... well, definitely not painless.
- I started from the web upgrade-helper, updating by hand all the impacted files by checking each diff. A bunch of changes to the native codebase are required this time, mostly to support Flipper.
- In this upgrade there's one main change that's very hard to implement by just checking the diffs: the
*.xcodeproj
file. Thanksfully, the web upgrade-helper links a great thread describing in details each step involved in the file update.
- Once done with the changes I cleaned up the xCode project, updated the pods and tried an iOS build. It failed. Duh.
- First, I got a "Use of undeclared identifier client" in the Flipper client. I was able to fix it following react-native-community/upgrade-support#24.
- Next, it was the turn of the "Undefined symbol: _swift_getFunctionReplacement", fixed following react-native-community/upgrade-support#25.
- At this point I was finally able to build the project and create a debug build.
- I immediately tried building production one, and it "worked"... with a minor, problem: the IPA size is now double the size it was before (facebook/react-native#28890). Everything seems to be working fine though 🤷♂️
Update: also notice building on a real device is not working for me. Had to completely disable Flipper to make it work invertase/react-native-firebase#3384
- I was able to immediately build successfully Android in debug mode. Unfortunately, the release build failed because it was still trying to bundle Flipper, but following facebook/react-native#28736 I was able to fix it.
The other main change I worked on is a new "custom" technique pattern. I'm still not 100% convinced by the UI/UX, but I think it can still be a good starting point.
- Updated React-Native to
0.61.2
: this has been a painless update 🙌 - Added sound effects for guided audio exercises. I personally requested and bought the audio voice lines from voicebunny.
- Automatically switch to dark/light mode theme based on the iOS 13 theme settings
- Updated React-Native to
0.60.4
and the official hooks support. - Added
react-native-keep-awake
to keep the screen on during the exercise. - Tried to enable Hermes but I discovered that it is not compatible with
abb
packages yet - enabling makes the app freeze on the splash screen - On iOS there's an issue with the
0.60.4
RN default native tests that causes an error when building in release mode. A PR to fix it has been merged into master but we don't know yet when it will be officially published. In the meanwhile you can manually patch the test file.
The directory structure of the project is the following:
src
├── assets
│ └── techniques // JSON data of the breathing techniques
│ ├── awake.json
│ ├── deep-calm.json
│ └── ...
│
├── components // The building blocks of the UI
│ ├── App
│ │ ├── App.tsx
│ │ ├── AppMain.tsx
│ │ └── ... // Other "App" related components
│ ├── ButtonAnimator
│ │ ├── ButtonAnimator.tsx
│ │ └── ... // Other "ButtonAnimator" related components
│ ├── Exercise
│ │ ├── Exercise.tsx
│ │ ├── ExerciseCircle.tsx
│ │ ├── ExerciseCircleDots.tsx
│ │ ├── ExerciseCircleComplete.tsx
│ │ └── ... // Other "Exercise" related components
│ └── ...
│
├── config
│ ├── constants.ts // Constants used across the app
│ ├── fonts.ts // Fonts settings definitions
│ ├── images.ts // Exports all the images/icons used in the app
│ ├── techniques.ts // Exports all the available techniques
│ ├── themes.ts // Dark and light themes settings
│ └── timerLimits.ts // Exports all available timer limits settings
│
├── context
│ └── AppContext.tsx // A Redux-like approach to state management
│
├── hooks
│ ├── useInterval.ts
│ ├── useOnMount.ts
│ └── useOnUpdate.ts
│
├── types // TypeScript type definitions
│
└── utils // Common utils used across the app
A few interesting notes:
Breathing techniques directory
I preferred to not hard-code the breathing techniques in the app, they can be defined using the following JSON format:
// ./src/assets/techniques/awake.json
{
"id": "awake",
"name": "Awake",
"durations": [6, 0, 2, 0],
"description": "Use this technique first thing in the morning for quick burst of energy and alertness.",
"color": "#F1646C"
}
And are exported in src/config/techniques.ts
.
Components directory
The React components used in the app are grouped by context/usage:
src/components/App
: The entry point of the app, handles the app initialization and routingsrc/components/ButtonAnimator
: Animates the main screen button expansionsrc/components/Exercise
: Breathing exercise componentssrc/components/Menu
: Main menu componentssrc/components/PageContainer
: A few components to handle the top header used in the Settings/TechniquePicker screens (by wrapping them)src/components/Settings
: Settings screen componentssrc/components/StarsBackground
: The animated stars backgroundsrc/components/TechniquePicker
: Technique picker screen components
All the app components are exported using a named export (instead of the default export) because VSCode TypeScript auto-import works incredibly well with it.
This was not my first test drive for the React hooks, I already had the chance to use them in a few other side projects in the past, and one of the main reasons I wanted to add them to this project was to create an abstraction hook over the Animated API to make the code cleaner and more re-usable.
...and I failed.
I tried a few different approaches, but I wasn't able to strike a balance between making it generic and flexible enough, so I just kept working with the plain Animated API.
Breathly uses just a few external libraries to keep the bundle size to the minimum.
The libraries used are:
react-native-haptic
to handle the haptic feedback on iOS.react-native-splash-screen
to hide programmatically the native splash screen from JS.react-native-navigation-bar-color
to programmatically change the Android navigation bar color from JS.
Might sound funny, but I had a some issues with all of these libraries.
Luckily enough, I already worked with them in the past so I already knew some workarounds for solving them:
react-native-haptic
has a missing podspec and is shipping the example with NPM, so I'm using this branch instead.react-native-splash-screen
required manually linking the library on both iOS and Android and it also has a few issues that must be solved before running it in production (making sure the assets aren't too big, adding a specific color to theres
)- For some reasons Gradle didn't like
react-native-navigation-bar-color
's build file when building a release APK, so I just extreacted the interested native methods and added them directly in the native Android source code (hence why you won't see it in thepackage.json
).
Being careful with the number of libraries was helpful to reduce the bundle size, but to keep it as small as possible on Android I also enabled ProGuard and the separate CPU architecture build.
In this way the bundle size on Android is way smaller than a standard React-Native's app one (~8MB instead of ~20MB):
Protip: Enable ProGuard and the separate CPU architecture split build from the beginning of your development: they might be incompatible with some code/libraries so by enabling them from start you'll be able to catch these issues soon.
Even if Breathly is quite small I took care of the performance from its inception in a few different ways.
Animations
To ensure good performance on the animations side I'm using the Native Driver on every single animation of the app. Again, I have previous experience on animations with the Native Driver, but there are a few things that can be a time sink for a newcomer:
- Try to not animate a text component (
Animated.Text
) directly: wrap it in anAnimated.View
instead. I'm not sure why but some animated values (opacity
for example) won't work as you expect on anAnimated.Text
component on iOS. - Make sure to check the
finished
parameter of the animationstart()
callback if you're planning to run some code when an animation ends: if you don't do so then the code will try to run even if the animation has been interrupted by a component unmount. - Loops using the Native Driver can cause issues if they use
Animated.Sequence
: as a workaround you can implement your own version of the loop. - Do not give up: using the Native Driver puts a limit on what values you can animate but 99% of the time you can build your animation with them, or at least achieve a similar effect to what you want to do.
Images
Just one rule of thumb here: don't load static assets from the JavaScript bundle.
Loading images from the JavaScript side (using import
/require
) from my experience is way slower than loading them from the native side.
In Breathly all the images (logo, icons, background) are bundled as native resources on both iOS and Android. Bundling them as native resources ensures they're loaded as fast as possible, but it was also time consuming:
- I had to resize each asset multiple times for both iOS (
@2, @3
) and Android (hdpi
,xhdpi
, etc...) - I'm keeping track of them in a custom config file
- For rendering the images I had to specify both
width
andheight
(you can't automatically render them based on a single width/height if they're imported from the bundle).
There's not much to say about the app state management.
I'm using Context
+ useContext
+ useReducer
to keep track of the global state (see context/AppContext
). Since I wanted to add side effects (storing to AsyncStorage
) when dispatching some actions I ended up creating my own "action creators" as well.
Am I happy to have built my own state management instead of going with Redux/MobX? Yes.
Was it worth it? Honestly, I don't think so.
I basically created a bunch of boilerplate code when I could have "just" used Redux (notice the irony) and its middlewares or MobX.
I'm really interested in UI, UX and design patterns but I'm not a designer.
That said, I still tried to make the experience pleasant by taking care of a few small details and I'm also quite that I finally had a chance to manually design icons, splash screens and logo using Photosthop (you can find the PSDs in the .assets
dir).
App icon
The app icon was the result of multiple iterations but I'm finally happy with the result:
- It's square and simmetrical so it can be easily re-used in other places (e.g.: the splash screen)
- It's not heavy on details
- The foreground "bubbles" can be used on different backgrounds (because the outer bubbles are using a lower opacity)
Since the icon is square, I was able to easily resize it to the iOS app standard and also to use it with the adaptive icon pattern on Android.
I would have preferred a more "meaningful" icon but I wasn't able to find a less abstract concept to work on.
Splash screen
Implementing the splash screen on iOS was quite easy using the XCode storyboard:
- I created the splash screen background (
2732x2732
, to cover the widest side of the iPad Pro). - I created the splash screen foreground icon/image.
- I placed both the background and foreground in an XCode storyboard and made sure that the foreground image keeps its aspect ratio and scales accordingly with the device size.
- I made sure the Status Bar uses a light content during the splash screen.
On Android it was a different story:
- I created the splash screen background.
- I created the splash screen foreground icon/image.
- I placed both the background and foreground in the Android
res
folder after resizing them manually - I orchestrated the background and foreground positioning using an XML layout
- I made sure the Status Bar color matched the color of the foreground top (which is a vertical gradient) and that the Navigation Bar color matched the color of the foreground bottom.
The splash screen is programmatically hidden from the JS code only once the app has loaded all the data from the Async Storage: in this way if the user is using the "dark mode" he won't see a white flash after the splash screen.
Status bar and navigation bar
The Status Bar and Navigation Bar (Android only) colors match the "screen color".
On iOS the Status Bar background is always transparent, I just had to change the Status Bar content color:
- During the splash screen the Status Bar content is white
- In the main menu the Status Bar content is black by default and white if the user is using the light mode
- In the exercise screen the Status Bar content is always white
On Android the Status Bar and the Navigation Bar have a solid background color (I didn't like the effect of the translucent background) that matches the screen color and their content works exactly like on iOS.
The app is available on both Apple's App Store and Google's Play Store.
I also created a simple landing page. You can find the landing page source code here.
The Breathly text font on iOS is San Francisco, while the text font on Android is Roboto. I prefer the San Francisco one but it cannot be used on apps distributed on the Play Store.
To accomodate the release on the stores Breathly is license under the Mozilla Public License.
Breathly doesn't use any third party software to track/log/debug the app usage.