Check out my apps: miaocast and Celluloid.

Building Lynx: One Week In

10 min read

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:

Two screenshots of the app showing a brutalist look

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:

Two screenshots of the app showing a more flat look

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.

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:

  1. Pick a theme color
  2. Pick light / dark mode
  3. Pick a pair of fonts to use in the app
  4. 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!


Xiaoming's Blog

Indie developer based in San Jose, California.