Building Lynx: One Week In
It’s been a week since I started building Lynx and here is the state of the project.
Look and Feel
On day 4, I took a detour down in neo-brutalism and made the app look like this:
After living with the look for a day, I realized that this is the wrong path, for a few reasons:
- The app is already too complicated to follow this design and all the decoration makes it harder to focus on the content.
- It will become harder and harder to implement and maintain this design as more features are added, and I probably need to create a full custom design system for this.
- It’s probably not what the majority of users would want.
So I switched back to a more flat and material3-ish look. Here is what the app looks like now:
Even though this app will not have a unique design, I think it started to look more distinctive from the official Mastodon app as I work on it more. Eventually I believe it will offer more unique features to users that would like an alternative.
Navigation
The app uses compose navigation and I’ve started to adopt the type safe navigation feature that were recently added.
Custom NavType
Type safe navigation DSL is definitely an improvement over the old way of just using strings and but I did run into a minor issue at first: some extra work is needed to support custom argument types.
For example, I wanted to define a route destination for the timeline screen like this:
@Serializable
sealed interface TimelineKind {
@Serializable
data object Home: TimelineKind
@Serializable
data class User(val accountId: String): TimelineKind
}
@Serializable
data class Timeline(val kind: TimelineKind)
And use it in the navigation like this:
NavHost(...) {
composable<Timeline> { navBackStackEntry ->
val kind = navBackStackEntry.toRoute<Timeline>().kind
// ...
}
}
However, this will throw an exception at runtime:
could not find any NavType for argument kind ...
Turns out, for types that are not one of the built-in NavType
types, I need to
explicitly map those to a custom NavType
implementation. For the above example,
I need to provide a custom NavType
for TimelineKind
:
val timelineKindNavType = CustomNavType(TimelineKind.serializer())
composable<Timeline>(
typeMap = mapOf(typeOf<TimelineKind>() to timelineKindNavType)
) {
// ...
}
And CustomNavType
is defined like this:
private class CustomNavType<T>(private val serializer: KSerializer<T>) :
NavType<T>(isNullableAllowed = false) {
override fun get(bundle: Bundle, key: String): T? {
val value = bundle.getString(key) ?: return null
return Json.decodeFromString(serializer, value)
}
override fun parseValue(value: String): T =
Json.decodeFromString(serializer, value)
// It's important to override this method, since the
// default implementation just calls `toString` on the
// value, which will not work.
override fun serializeAsValue(value: T): String {
return Json.encodeToString(serializer, value)
}
override fun put(bundle: Bundle, key: String, value: T) {
bundle.putString(key, Json.encodeToString(serializer, value))
}
}
Initially I thought that custom nav types are not supported with the new type safe navigation feature, and this section is going to document this issue.
However, as I was trying to reproduce the exception I got earlier, I discovered that actually this is supported. I wish the document had made this clearer but at least now I know for next time.
Passing Complex Data
As user navigate between screens, I’d like to avoid showing a blank loading screen while the data is being fetched.
For example, when navigating from the home screen to the a user’s profile screen, I’d like to show the profile data on the first frame of the profile screen, even though the user’s posts are still being fetched.
This is doable because some of the data for rendering the screen is already fetched and I just need to pass that from one screen to the next. It’s probably possible to just stuff those into the navigation argument but that will break if the amount of data grows and this probably already breaks for the case where I need to pass a list of posts from the profile screen to the user’s timeline screen and there isn’t technically a limit on the size of a single post.
Eventually, I settled on a approach where I define a NavigationCache
singleton
object that can store arbitrary data for each navigation destination. And as part
of the navigation action, I can put the data into the cache before navigating and
then retrieve it from the cache on the destination composable.
fun goTo(destination: Destination, cacheValue: Any) {
navigationCache.put(destination, cacheValue)
navController.navigate(destination)
}
// In the destination screen's ViewModel
val route = savedStateHandle.toRoute<FooBarDestination>()
val cacheValue = navigationCache.get<FooBar>(route)
This works but not very type safe since this relies on an unchecked cast and it
also does not check that the cache value is of the correct type when calling goTo
.
I solved this by defining a sealed interface for the cache value and make the cache value’s type part of the destination’s route type:
sealed interface Destination<T : DestinationCache> {
// Nothing is used when there is no cache value
@Serializable
data object Home : Destination<Nothing>
@Serializable
data class Profile(val accountId: String)
: Destination<DestinationCache.Profile>
}
sealed interface DestinationCache {
data class Profile(val account: Account?) : DestinationCache
}
And then the navigation function can be updated to:
fun goTo(destination: Destination<Nothing>)
fun <T : DestinationCache> goTo(
destination: Destination<T>,
cacheValue: T
)
And then calling goTo
will ensure that the correct cacheValue
type is passed in.
With this, navigating from the home screen to the profile screen will show the profile data immediately:
Preparing for Themes Support
Right now the app does not have any customization feature but I plan to add that soon. The customization feature will allow users to:
- Pick a theme color
- Pick light / dark mode
- Pick a pair of fonts to use in the app
- Pick a set of icons to use in the app
I’ve added all of those theming options beside icon packs in my podcast app miaocast and I will cover those when I added them to the app. For now, I’ve done the preparation work for supporting different icon pack by adding a custom composition local for the icon pack, and reference it everywhere an icon is used (instead of accessing the drawable directly).
data class LynxIcons(
@DrawableRes val arrowBack: Int,
@DrawableRes val arrowForward: Int,
@DrawableRes val close: Int,
// ... a lot more icons
) {
companion object {
// Default icon set uses material symbols rounded
val MATERIAL = LynxIcons(
// ...
)
}
}
And this is provided as a composition local in the app:
val LocalLynxIcons = staticCompositionLocalOf { LynxIcons.MATERIAL }
object LynxTheme {
val icons: LynxIcons
@ReadOnlyComposable
@Composable
get() = LocalLynxIcons.current
}
And then in the UI components, I can use the icons
property to get the correct
icon for the current theme:
Icon(
painter = painterResource(id = LynxTheme.icons.arrowBack),
// ...
)
This approach does add a bit of extra work each time I add a new icon but I think it’s manageable. I hope that the app can offer at least 3 sets of icons to choose from.
Wrapping Up
I’ve gotten a lot of work done in the past week and I’m already using the app for majority of my doom-scrolling on Mastodon. Next week, I’ll focus on building out the rest of the UIs such as:
- settings screen
- follower list screen
- following list screen
- getting started on the compose screen for posting
I’ve also started to realize that there are so much work to do on this that I might have overestimated my ability to build the app in a month, but let’s see where it ends up by the end of month one.
Cheers!