DEV Community

Cover image for The UI Details That Make It Feel Native
Raul Arroyo
Raul Arroyo

Posted on

The UI Details That Make It Feel Native

Part 4 - The UI Layer, Native Chrome, and the Details That Make It Feel Like an App


In Part 3 we converted raw WordPress HTML into native content blocks.

That solved one big problem: the app no longer had to display a website inside a WebView. Posts became structured content that Compose could render natively.

But once the content was native, another problem became obvious.

The app worked, but it still needed a visual identity.

Not just colors. Not just a logo. The whole reading experience needed to feel designed: typography, spacing, hero images, top bars, progress indicators, shapes, transitions, and platform behavior.

This part is about styling the app. Not in the sense of "making it pretty", but in the sense of making the interface support the content and feel intentional.


The Problem With a Technically Correct UI

The first version of the detail screen was functional.

It had an image, a title, the author, and the parsed content blocks from Part 3. Text rendered as text, images rendered as images, embeds had their own components.

Technically, that was a big step forward.

Visually, it was not enough.

The screen still felt too close to a default Compose app. Clean, readable, but generic. And for a content-heavy app, generic is a problem. The UI has to create a reading mood before the user reaches the first paragraph.

The goal became:

  • make articles feel editorial
  • keep content readable
  • avoid a default Material look
  • support dark mode
  • keep Android and iOS feeling native
  • avoid overbuilding a custom design system too early

That last one matters. It is easy to disappear into design-system work before the app has enough real screens to justify it.

So the approach was pragmatic: use Material3 as a base, then override the pieces that affected the product feel the most.


Typography as Branding

The biggest visual change came from typography.

Default Material typography is good, but it is intentionally neutral. For this app, neutral made the UI feel like a template.

The content needed stronger hierarchy:

  • big editorial headlines
  • readable body text
  • compact labels
  • a different feel between covers, sections, and article body

The app defines custom font families once:

data class AppFonts(
    val display: FontFamily,
    val body: FontFamily,
    val label: FontFamily,
)
Enter fullscreen mode Exit fullscreen mode

Then the Material typography scale is adapted instead of ignored:

MaterialTheme(
    typography = appTypography(fonts),
    colorScheme = appColors,
    shapes = appShapes,
) {
    AppContent()
}
Enter fullscreen mode Exit fullscreen mode

This was an important decision.

I did not want every component to manually choose fonts. That becomes messy quickly. But I also did not want the default Material scale to define the personality of the app.

So the theme sets the baseline, and individual branded moments can still be more explicit:

Text(
    text = article.title,
    style = typography.coverTitle
)
Enter fullscreen mode Exit fullscreen mode

This made the hero feel like part of the product, not just a header.

The lesson: typography is not decoration. In a content app, typography is part of the product behavior.

App fonts


The Platform Problem

A real KMP app has to answer an uncomfortable question:

Should the UI be identical on Android and iOS?

For the article content, shared Compose UI made sense. The content model is shared, the rendering logic is shared, and the reading experience should be consistent.

But navigation chrome is different.

iOS users have different expectations around back buttons, safe areas, toolbar spacing, glass effects, and touch behavior. Android has its own expectations too.

Trying to force the exact same chrome everywhere made the app feel less native.

So the decision was hybrid:

  • shared Compose for the content
  • shared Kotlin state and navigation
  • Compose chrome where it feels right
  • native iOS chrome where platform feel matters

Android vs iOS

This is the comparison that best explains the decision.

The article content can stay the same, but the surrounding chrome should respect the platform. On Android, the Compose version can feel natural. On iOS, native toolbar behavior, safe areas, and button styling make a visible difference.

iOS Android
iOS Home Android Home

The split ended up looking roughly like this:

Diagram

The bridge stays small. Compose sends simple chrome state to iOS:

ArticleChromeState(
    title = title,
    collapsed = collapsed,
    progress = readProgress
)
Enter fullscreen mode Exit fullscreen mode

SwiftUI renders the native buttons and calls back into Kotlin:

Button {
    actions.save()
} label: {
    Image(systemName: "heart")
}
Enter fullscreen mode Exit fullscreen mode

The important part is what is not duplicated.

There is no second article screen in Swift. No second saved-state implementation. No separate navigation model.

Native chrome is presentation. Kotlin still owns behavior.

That decision sets up the rest of the styling work: the content can share the same visual language, while each platform keeps the pieces that users expect to feel native.


The Hero Image

The article hero became the first big styling decision.

A normal image at the top of the screen made the layout feel like a feed item expanded into a detail page. That was not the feeling I wanted. The article needed to open more like a cover.

So the detail screen starts with a tall, full-width hero image, with the category and title placed over it.

The problem: real CMS images are unpredictable.

Some images are bright. Some are dark. Some are concert photos with harsh lights. Some have faces near the title area. Some have text inside the image. Some have no clear focal point.

If the title is rendered directly over that, readability breaks immediately.

The fix was to treat the image as hostile until proven otherwise:

Box {
    RemoteCoverImage(url = article.imageUrl)
    ReadabilityGradient()
    CoverTitle(article.title)
}
Enter fullscreen mode Exit fullscreen mode

There is nothing clever here. It is just a gradient.

But this kind of boring styling decision is what makes dynamic content survive real-world data. The image still feels immersive, but the title remains readable.

iOS Android
Hero iOS Hero Android

Escaping the Default Material Look

Material3 is a great foundation, but it has a visual opinion.

That opinion is useful: accessible colors, typography slots, component behavior, interaction states, dark mode support. But if you accept all of it unchanged, your app can start to look like every other Material app.

One of the first things that felt wrong was shape.

The default rounded corners made the interface feel softer than intended. The app needed sharper edges, especially around content surfaces and editorial blocks.

So the theme defines flatter shapes:

val appShapes = Shapes(
    small = sharpCorners,
    medium = sharpCorners,
    large = sharpCorners
)
Enter fullscreen mode Exit fullscreen mode

The gotcha: passing shapes into MaterialTheme is not a magic wand.

Some Material components have their own defaults. Some custom components already had explicit shapes. Some buttons and cards still needed direct styling.

So the decision became:

  • use Material3 for structure and behavior
  • use the app theme as the baseline
  • override specific components when the product identity needs it
  • avoid creating a giant design system before the UI proves what it needs

That balance kept the app moving.

Material Look


The Collapsing Top Bar

The top of the article should feel immersive.

The user lands on the hero image and title first, not a heavy toolbar. But after scrolling, the screen needs to become practical: back button, title, save, share, and progress.

The top bar starts transparent and becomes solid once the reader scrolls past enough of the hero:

val collapsed =
    scrollOffset > heroHeight * collapseThreshold
Enter fullscreen mode Exit fullscreen mode

Then the background and title fade in:

val topBarAlpha by animateFloatAsState(
    targetValue = if (collapsed) 1f else 0f
)
Enter fullscreen mode Exit fullscreen mode

This was less about animation and more about rhythm.

At the beginning, the screen behaves like a cover.

After scrolling, it behaves like a reader.

That transition made the screen feel much more intentional without adding much complexity.

Collapsing


Reading Progress

The reading progress indicator came from a simple observation: posts are not always short, and the body can contain very different block types.

Text, images, galleries, embeds, and related content do not create a uniform scroll experience. A small progress indicator gives users a sense of place without interrupting the article.

The implementation is intentionally approximate:

val readProgress =
    visibleContent / totalContent
Enter fullscreen mode Exit fullscreen mode

It is not pixel-perfect.

But pixel-perfect progress would mean measuring dynamic content that may change size after images or embeds load. That is more complexity for very little user benefit.

The decision was to optimize for feel, not mathematical precision.

Reading progress


Insets: The Detail You Notice When It Fails

Once native chrome enters the picture, layout gets more delicate.

If SwiftUI renders a navigation bar, Compose needs to know how much space that bar occupies. Otherwise content can end up behind it, especially when an article has no hero image.

The fix was to measure the native chrome and send the inset back to Compose:

val topPadding =
    if (usesPlatformChrome) measuredChromeHeight
    else composeToolbarHeight
Enter fullscreen mode Exit fullscreen mode

This is not the exciting part of styling.

But it is one of the most important parts of making the app feel polished. Users may not know what a safe area is, but they absolutely notice when content starts in the wrong place.


The Result

The UI started as technically correct, but generic.

After this pass, it had more identity:

  • full-bleed article heroes
  • protected title readability over unpredictable images
  • custom typography for editorial hierarchy
  • sharper Material shapes
  • a collapsing top bar that changes with reading context
  • lightweight reading progress
  • shared content UI
  • platform-aware chrome where it matters

The biggest lesson was that styling a real app is not one big theme file.

It is a series of decisions:

  • What should be branded?
  • What should stay standard?
  • What should be shared?
  • What should feel native?
  • What complexity is worth it?

That is where KMP became interesting for this part of the app.

It was not about proving that everything could be shared. It was about deciding what should be shared.


What's Next

In Part 5 I will cover the modularization work: splitting the project into client, data, domain, database, and UI modules, and what changed once the app stopped being one big Compose module.


Stack: Kotlin 2.3.10 · Compose Multiplatform 1.10.2 · Ktor 3.4.1 · SQLDelight 2.3.1 · Koin 4.1.1

Top comments (0)