Amol Pawar

new ripple api in jetpack compose

New Ripple API in Jetpack Compose: What Changed and How to Use It (Complete Guide)

Jetpack Compose continues to evolve, and one of the most interesting updates is the new Ripple API. If you’ve been building modern Android UIs, you’ve probably used ripple effects to give users visual feedback when they tap on buttons, cards, or other interactive elements. That subtle wave animation plays a big role in making interactions feel responsive and intuitive.

With the latest updates, Google has refined how ripple indications work in Compose. The new approach makes ripple effects more efficient, more customizable, and better aligned with Material Design 3.

In this article, we’ll explore what changed, why these updates matter, and how you can start using the new Ripple API in Jetpack Compose in your apps.

What’s Covered in This Guide

We’ll walk through:

  • What ripple effects are in Jetpack Compose
  • Why the ripple API was updated
  • How the new Ripple API works
  • How to implement it using Kotlin
  • Best practices for customizing ripple behavior

By the end, you’ll have a clear understanding of how the new ripple system works and how to apply it effectively in your Compose UI.

What Is Ripple in Jetpack Compose?

Ripple is the touch feedback animation shown when a user taps or presses a UI component.

For example:

  • Buttons
  • Cards
  • List items
  • Icons
  • Navigation items

When the user taps an element, a circular wave spreads from the touch point.

This animation improves:

  • User experience
  • Accessibility
  • Visual feedback
  • Interaction clarity

In Material Design, ripple is the default interaction effect.

In Jetpack Compose, ripple is typically used with clickable modifiers.

Kotlin
Modifier.clickable { }

By default, this modifier automatically adds ripple feedback.

Why the Ripple API Changed

For a long time, ripple effects in Jetpack Compose were implemented through the Indication system, typically using rememberRipple(). While this approach worked well, it came with a few limitations.

Composition overhead:
 Since rememberRipple() was a composable function, it participated in the recomposition cycle. In some cases, this introduced unnecessary overhead for something that should ideally remain lightweight.

Memory usage:
 Each usage created new state objects, which could increase memory usage when ripple effects were applied across many UI components.

Tight coupling with Material themes:
 The implementation was closely tied to Material 2 and Material 3. This made it less flexible for developers building custom design systems or UI frameworks.

To address these issues, the ripple implementation has been redesigned using the Modifier.Node architecture. This moves ripple handling closer to the rendering layer, allowing it to be drawn more efficiently without triggering unnecessary recompositions.

As a result, the updated API makes ripple behavior:

  • More performant
  • More consistent with Material 3
  • Easier to customize
  • Better aligned with the modern Indication system

Overall, this change simplifies how ripple effects are handled while improving performance and flexibility for Compose developers.

Old Ripple Implementation (Before the Update)

Before the New Ripple API in Jetpack Compose, developers often used rememberRipple().

Kotlin
Modifier.clickable(
    indication = rememberRipple(),
    interactionSource = remember { MutableInteractionSource() }
) {
    // Handle click
}
  • indication → defines the visual feedback
  • rememberRipple() → creates ripple animation
  • interactionSource → tracks user interactions (press, hover, focus)

Although this worked well, it required extra setup for customization.

The New Ripple API in Jetpack Compose

The New Ripple API in Jetpack Compose simplifies ripple creation and aligns it with Material3 design system updates.

The ripple effect is now managed through Material ripple APIs and better indication handling.

In most cases, developers no longer need to manually specify ripple.

Default Material components automatically apply ripple.

Kotlin
Button(onClick = { }) {
    Text("Click Me")
}

This button already includes ripple.

However, when working with custom layouts, you may still need to configure ripple manually.

Key Changes from Old to New

Key changes in Compose Ripple APIs (1.7+)

  • rememberRipple() is deprecated. Use ripple() instead.
     The old API relied on the legacy Indication system, while ripple() works with the new node-based indication architecture.
  • RippleTheme and LocalRippleTheme are deprecated.
     Material components no longer read LocalRippleTheme. For customization use RippleConfiguration / LocalRippleConfiguration or implement a custom ripple.
  • Many components now default interactionSource to null, allowing lazy creation of MutableInteractionSource to reduce unnecessary allocations.
  • The indication system moved to the Modifier.Node architecture.
     Indication#rememberUpdatedInstance was replaced by IndicationNodeFactory for more efficient rendering.

Key Differences at a Glance:

Basic Example Using the New Ripple API

Let’s start with a simple example by creating a clickable Box with a ripple effect. This demonstrates how touch feedback appears when a user interacts with a UI element.

Before looking at the new approach, here’s how ripple was typically implemented in earlier versions of Compose.

Old implementation (Deprecated):

Kotlin
Box(
    modifier = Modifier.clickable(
        onClick = { /* action */ },
        interactionSource = remember { MutableInteractionSource() },
        indication = rememberRipple()
    )
) {
    Text("Tap me!")
}

The previous implementation relied on rememberRipple(), which has now been replaced by the updated ripple API.

Using the New Ripple API:

Here’s how you can implement the same behavior using the updated ripple system.

Kotlin
@Composable
fun RippleBox() {
    val interactionSource = remember { MutableInteractionSource() }  // Or pass null to lazy-init   
    
    Box(
        modifier = Modifier
            .size(120.dp)
            .background(Color.LightGray)
            .clickable(
                interactionSource = interactionSource,
                indication = ripple(), // From material3 or material
                onClick = {}
            )
    ){
      Text("Tap me!")
    }
}

In many cases you can simply pass interactionSource = null, which allows Compose to lazily create it only when needed.

Understanding the Key Components

MutableInteractionSource

Kotlin
val interactionSource = remember { MutableInteractionSource() }

MutableInteractionSource emits interaction events such as:

  • Press
  • Focus
  • Hover
  • Drag

Indications like ripple observe these events to trigger animations.

clickable modifier

Kotlin
Modifier.clickable()

This makes the composable interactive and triggers ripple on tap.

ripple()

Kotlin
indication = ripple()

ripple() is the new ripple API in Jetpack Compose and replaces the deprecated rememberRipple() implementation.

By default:

  • The ripple color is derived from MaterialTheme
  • The ripple originates from the touch point
  • The ripple is bounded within the component by default

Unlike the previous API, ripple() is not a composable function and works with the newer Modifier.Node-based indication system, which reduces allocations and improves performance.

Benefits of the New Ripple API

The updated API offers several improvements:

  • Simpler API — fewer concepts to manage
  • Better performance — avoids unnecessary recompositions
  • Cleaner syntax — easier to read and maintain
  • More flexibility for modern Compose UI architectures

Customizing Ripple in Jetpack Compose

One advantage of the New Ripple API in Jetpack Compose is easier customization.

You can modify:

  • color
  • radius
  • bounded/unbounded ripple

Example: Changing Ripple Color

Kotlin
.clickable(
    interactionSource = interactionSource,
    indication = ripple(
        color = Color.Red
    ),
    onClick = {}
)

Here we customize the ripple color.

When the user taps the component, the ripple will appear red instead of the default theme color.

Example: Unbounded Ripple

By default, ripple is bounded, meaning it stays inside the component.

If you want ripple to spread outside the element:

Kotlin
indication = ripple(
    bounded = false
)

Use Cases

Unbounded ripple works well for:

  • floating action buttons
  • icon buttons
  • circular elements

Example: Setting Ripple Radius

You can also control ripple size.

Kotlin
indication = ripple(
    radius = 60.dp
)

The radius defines how far the ripple spreads from the touch point.

This can help match custom UI designs.

Advanced Customization: RippleConfiguration

If you want to change the color or the alpha (transparency) of your ripples globally or for a specific part of your app, the old LocalRippleTheme is out (deprecated). Instead, we use LocalRippleConfiguration.

The modern approach uses RippleConfiguration and LocalRippleConfiguration. This allows you to customize ripple appearance for a specific component or subtree of your UI.

Example: Custom Ripple

Kotlin
val myCustomRippleConfig = RippleConfiguration(
    color = Color.Magenta,
    rippleAlpha = RippleAlpha(
        pressedAlpha = 0.2f,
        focusedAlpha = 0.2f,
        draggedAlpha = 0.1f,
        hoveredAlpha = 0.4f
    )
)

CompositionLocalProvider(
    LocalRippleConfiguration provides myCustomRippleConfig
) {
    Button(onClick = { }) {
        Text("I have a Magenta Ripple!")
    }
}

RippleConfiguration

A configuration object that defines the visual appearance of ripple effects.

RippleAlpha

Controls the ripple opacity for different interaction states:

  • pressedAlpha
  • focusedAlpha
  • draggedAlpha
  • hoveredAlpha

CompositionLocalProvider

Wraps a section of UI and provides a custom ripple configuration to all child components that read LocalRippleConfiguration.

Disabling Ripple

You can disable ripple effects completely:

Kotlin
CompositionLocalProvider(LocalRippleConfiguration provides null) {
    Button(onClick = {}) {
        Text("No ripple")
    }
}

When You Do NOT Need to Use Ripple Manually

With the new ripple API in Jetpack Compose, many Material components already include ripple feedback by default. This means you usually don’t need to manually specify indication = ripple().

Examples include:

  • Button
  • Card (clickable version in Material3)
  • ListItem
  • IconButton
  • NavigationBarItem

These components internally handle interaction feedback using the ripple system.

Kotlin
Card(
    onClick = { }
) {
    Text("Hello")
}

In Material3, providing onClick automatically makes the Card clickable and displays the ripple effect.

No manual ripple indication is required.

Best Practices for Using the New Ripple API in Jetpack Compose

1. Prefer Default Material Components

Material components already include ripple behavior.

This keeps UI consistent with Material Design.

2. Avoid Over-Customizing Ripple

Too much customization can create inconsistent UX.

Stick with theme defaults unless necessary.

3. Use interactionSource = null Unless You Need It

In modern Compose versions, you usually do not need to create a MutableInteractionSource manually.

Kotlin
Modifier.clickable(
    interactionSource = null,
    indication = ripple(),
    onClick = { }
)

Passing null allows Compose to lazily create the interaction source only when required.

Create your own MutableInteractionSource only if you need to observe interaction events.

4. Keep Ripple Bounded for Most UI

Bounded ripples keep the animation inside the component bounds and generally look cleaner.

This is the default behavior for most Material components.

Use unbounded ripple only when the design specifically requires it.

Performance Improvements in the New Ripple API

The new ripple implementation in Jetpack Compose introduces several internal improvements.

Reduced allocations

Ripple now uses the Modifier.Node architecture, which reduces object allocations compared to the older implementation.

Improved rendering efficiency

Ripple drawing is handled through node-based modifiers, making the rendering lifecycle more efficient.

Updated Indication system

Ripple is now implemented using IndicationNodeFactory, which replaces the older Indication implementation that relied on rememberUpdatedInstance.

Common Mistakes Developers Make

Using old rememberRipple()

Many developers still use:

Kotlin
rememberRipple()

This API is now deprecated.

Use the modern API instead:

Kotlin
ripple()

Manually creating InteractionSource unnecessarily

Older examples often include:

Kotlin
interactionSource = remember { MutableInteractionSource() }

In modern Compose versions, you can usually pass:

Kotlin
interactionSource = null

This allows Compose to lazily create the interaction source only when needed.

Create your own MutableInteractionSource only when you need to observe interaction events.

Adding Ripple to Non-clickable UI

Ripple should be used only on interactive components such as buttons, cards, or clickable surfaces.

Using ripple on static UI elements can create confusing user experiences.

Migration Guide: Old API to New Ripple API

Old implementation:

Kotlin
Modifier.clickable(
    interactionSource = remember { MutableInteractionSource() },
    indication = rememberRipple(),
    onClick = {}
)

New implementation:

Kotlin
Modifier.clickable(
    interactionSource = null,
    indication = ripple(),
    onClick = {}
)

Key changes:

  • rememberRipple() → replaced with ripple()
  • interactionSource can now be null, allowing Compose to lazily create it when needed

This simplifies the code and avoids unnecessary allocations.

If you need to observe interaction events, you can still provide your own MutableInteractionSource.

Conclusion

The New Ripple API in Jetpack Compose simplifies how developers implement touch feedback while improving performance and consistency.

Key takeaways:

  • Ripple provides visual feedback for user interactions
  • The new API replaces rememberRipple() with ripple()
  • Material components already include ripple by default
  • Custom components can easily add ripple using Modifier.clickable
  • The updated system improves performance and flexibility

If you build modern Android apps with Jetpack Compose, understanding the New Ripple API in Jetpack Compose is essential for creating responsive and user-friendly interfaces.

.aiexclude File

How to Use the .aiexclude File in Android Studio for Safer AI Context Sharing

AI-powered coding assistants have completely changed how Android developers write code. Features like Gemini in Android Studio read your project files, understand your codebase structure, and suggest intelligent completions. That’s incredibly helpful — but it also raises an important question:

What exactly is the AI reading?

The answer is often “more than you think.” Configuration files, API keys stored in local .properties files, internal endpoint URLs, analytics tokens — all of these can end up in the AI’s context window if you’re not careful.

That’s exactly where the .aiexclude file comes in. It’s Android Studio’s answer to the .gitignore file, but instead of telling Git what to ignore, it tells the AI assistant what files should stay completely off-limits.

In this guide, we’ll walk you through everything you need to know about the .aiexclude file — what it is, why it matters, how to create and configure it, and real-world patterns to protect your project.

What Is the .aiexclude File?

The .aiexclude file is a plain text configuration file that tells Android Studio’s AI features which files and folders it should never index, read, or use as context when generating suggestions.

Think of it like a privacy wall between your sensitive project files and the AI. When a file is listed in the .aiexclude file, it simply becomes invisible to the AI — it won’t factor into any code completions, refactoring suggestions, or AI-assisted search results.

This feature was introduced as developers started using AI assistants more deeply in their workflows and needed a simple, declarative way to control what data gets shared.

Why Does This Matter?

Here’s a realistic scenario: You’re building a fintech app. You have a local.properties file with a Stripe API key sitting in your project root. Your .gitignore already excludes it from version control. But your AI assistant doesn’t know about .gitignore — it reads every file it can find in your project.

Without a .aiexclude file, that API key could end up in the AI’s context. With one, you can ensure it’s never touched.

Where to Place the .aiexclude File

The .aiexclude file can live in two places, and the location determines its scope:

1. Project root directory — Applies rules across the entire project.

Plaintext
MyAndroidApp/
├── .aiexclude          ← covers the whole project
├── app/
│   └── src/
├── local.properties
└── build.gradle

2. Inside a specific module or subdirectory — Applies rules only to that folder and its contents.

Plaintext
MyAndroidApp/
├── app/
│   ├── .aiexclude      ← covers only the /app module
│   └── src/
└── secrets/
    ├── .aiexclude      ← covers only /secrets
    └── api_keys.txt

You can even have multiple .aiexclude files in the same project, one per folder, with each one managing its own exclusion rules. They all work together, so there’s no conflict — Android Studio respects all of them.

How the .aiexclude File Syntax Works

The .aiexclude file uses a simple pattern syntax, very similar to .gitignore. Let’s break it down.

Basic File Exclusion

To exclude a specific file, just write its name or path:

Plaintext
# Exclude a specific file in the same directory
local.properties

# Exclude a file using a relative path
config/secrets.json

The # character starts a comment — anything after # on that line is ignored by the parser.

Excluding Entire Directories

Add a trailing slash / to target a whole folder:

Plaintext
# Exclude the entire secrets folder
secrets/

# Exclude a nested folder
app/src/main/assets/private/

Every file inside that folder — regardless of name or extension — becomes invisible to the AI.

Wildcard Patterns

Wildcards are your best friends here. The .aiexclude file supports standard glob patterns:

Plaintext
# Exclude all .properties files anywhere in the project
**/*.properties

# Exclude all JSON files in the config directory
config/*.json

# Exclude all files that start with "key_"
**/key_*

# Exclude everything inside any folder named "internal"
**/internal/**

The ** pattern means “match any number of directories,” so **/*.env would catch .env files no matter how deeply nested they are.

Negation with !

You can un-exclude something that was already covered by a broader rule, using !:

Plaintext
# Exclude all .properties files
**/*.properties

# ...but allow gradle.properties back in (it has no secrets)
!gradle.properties

Just like .gitignore, order matters here — later rules override earlier ones. So always put the negation after the broader exclusion.

Creating Your First .aiexclude File

Let’s walk through setting up a .aiexclude file from scratch in a typical Android project.

Step 1: Create the File

Right-click the project root in Android Studio’s Project view, select New → File, and name it exactly:

.aiexclude

No extension. No prefix. Just .aiexclude.

Tip: If you’re on Windows and File Explorer is hiding files starting with a dot, use Android Studio’s built-in file creation — it handles this correctly.

Step 2: Add Your Exclusion Rules

Open the newly created .aiexclude file and start adding your rules. Here’s a practical starter template:

Plaintext
# ─────────────────────────────────────────────
# .aiexclude — AI context exclusion rules
# Keeps sensitive and irrelevant files out of
# Android Studio's AI assistant context.
# ─────────────────────────────────────────────

# Local configuration with API keys or secrets
local.properties
*.env
*.env.*

# Keystores and signing credentials
**/*.jks
**/*.keystore
keystore.properties

# Service account and OAuth credential files
google-services-staging.json
**/credentials/
**/service_account*.json

# Internal analytics or experiment configs
**/internal_experiments/
**/ab_test_config.json

# Build outputs - not useful for AI context
build/
**/build/
.gradle/

# Auto-generated files (reduce AI noise)
**/generated/
**/*Generated.java
**/*Generated.kt

# Raw data or large asset files
**/raw/
**/*.csv
**/*.sqlite
**/*.db

# Private documentation
docs/internal/
INTERNAL_NOTES.md

Step 3: Verify It’s Working

After saving the .aiexclude file, restart Android Studio or invalidate caches (File → Invalidate Caches / Restart). The AI assistant should now skip the excluded files entirely when generating suggestions.

You can confirm this by checking whether the AI references any content from an excluded file — it shouldn’t.

Real-World Use Cases: What to Exclude and Why

Here are common scenarios where the .aiexclude file becomes genuinely essential.

Use Case 1: Protecting API Keys in local.properties

The local.properties file is the most common place Android developers store sensitive keys — Maps API keys, Firebase project IDs, payment gateway tokens. It’s excluded from Git, but not from AI by default.

Plaintext
# .aiexclude

# Keep the AI away from local config with secrets
local.properties
keystore.properties

Why this matters: If the AI reads local.properties, it might include your API key in a generated code snippet or log statement — even innocently, in a test file it suggests.

Use Case 2: Excluding Generated Code

Generated files (like Room database implementations, Hilt component files, or proto-generated classes) create a lot of noise for the AI. The AI might try to “help” by referencing or modifying them, even though they’re auto-generated and will be overwritten on the next build.

Plaintext
# .aiexclude

# Auto-generated files - don't waste AI context on these
**/generated/
**/*_Impl.kt
**/*.pb.java

Generated files can confuse the AI or cause it to suggest changes to code that isn’t meant to be manually edited. Excluding them improves suggestion quality.

Use Case 3: Excluding Proprietary Business Logic

Maybe you’re working on a module that contains proprietary algorithms or confidential business logic — something your company doesn’t want indexed anywhere outside of approved systems.

Plaintext
# .aiexclude placed inside /pricing-engine module

# Protect proprietary pricing logic from AI indexing
algorithms/
models/pricing/

Even if you trust the AI tool itself, having strict boundaries on what it accesses is good security hygiene — especially in regulated industries.

Use Case 4: Large Files That Hurt Performance

The AI doesn’t need to read a 50MB SQLite database file or a massive CSV dataset. Including them wastes AI context budget and can slow things down.

Plaintext
# .aiexclude

# Large files that don't help the AI at all
**/*.sqlite
**/*.db
**/*.csv
assets/large_dataset.json

AI context windows have limits. Keeping them focused on actual source code means better, more relevant suggestions.

Common Mistakes to Avoid with the .aiexclude File

Even experienced developers make these slip-ups when first working with the .aiexclude file. Here’s what to watch out for.

Mistake 1: Using Absolute Paths

This won’t work as expected:

Plaintext
# Absolute paths don't work
/Users/yourname/AndroidStudioProjects/MyApp/local.properties

Always use relative paths from the location of the .aiexclude file itself:

Plaintext
# Correct — relative path
local.properties

Mistake 2: Forgetting Subdirectories

This only excludes secrets.json at the root level:

Plaintext
# Only matches root-level file
secrets.json

If the file might exist deeper in the project:

Plaintext
# Matches the file anywhere in the project
**/secrets.json

Mistake 3: Not Committing the .aiexclude File to Version Control

Unlike local.properties, the .aiexclude file itself is not sensitive — it just describes what’s sensitive. You should absolutely commit it to Git so your whole team benefits from the same exclusion rules.

Plaintext
git add .aiexclude
git commit -m "Add .aiexclude to protect sensitive files from AI context"

Mistake 4: Over-Excluding Everything

It can be tempting to exclude huge chunks of your project “just to be safe,” but that defeats the purpose of the AI assistant. If the AI can’t see your code, it can’t help you write better code.

Be selective. Exclude what’s genuinely sensitive or noisy — not everything.

The .aiexclude File vs. .gitignore: What’s the Difference?

People often ask whether these two files overlap. Here’s a clear side-by-side comparison:

They’re complementary, not replacements for each other. A file can be in .gitignore but still readable by the AI — that’s the exact problem the .aiexclude file solves.

Team Workflow: Making .aiexclude a Team Standard

If you’re leading a team, the .aiexclude file should be part of your project setup checklist — right alongside .gitignore and EditorConfig.

Here’s how to make it a team standard:

Add it to your project template. If your team uses a custom Android project template (or a cookiecutter script), bake in a sensible default .aiexclude file from day one.

Include it in your code review checklist. When a new secret, config, or sensitive module gets added to the project, verify the .aiexclude file is updated accordingly.

Document it in your README. A single line in your project’s README explaining that the project uses a .aiexclude file helps new team members understand the setup quickly.

Treat it like a security document. Additions to the .aiexclude file should go through a quick review — just like changes to SECURITY.md or secrets management configs.

Advanced Pattern: Module-Level .aiexclude Files

In larger, multi-module Android projects, it often makes more sense to manage exclusions at the module level rather than maintaining one giant .aiexclude file at the root.

Plaintext
MyAndroidApp/
├── .aiexclude                    ← project-wide rules
├── app/
│   ├── .aiexclude                ← app module rules
│   └── src/
├── feature-payments/
│   ├── .aiexclude                ← payment module rules (strictest)
│   └── src/
└── feature-onboarding/
    └── src/

Project-root .aiexclude:

Plaintext
# Global rules
local.properties
**/*.jks
**/*.keystore
**/build/

feature-payments/.aiexclude:

Plaintext
# Extra-strict for this module — payment logic is proprietary
src/

This hierarchical approach gives you fine-grained control without cluttering a single file.

Frequently Asked Questions About the .aiexclude File

Q: Does the .aiexclude file affect code completion outside of AI features?

No. The .aiexclude file only affects the AI assistant. Standard IntelliJ code completion, navigation, and refactoring tools are not impacted.

Q: Can I use the .aiexclude file in other JetBrains IDEs?

The .aiexclude file was introduced in the context of Android Studio’s AI integration. Support in other JetBrains IDEs may vary — check the documentation for the specific IDE.

Q: What happens if I have conflicting rules between two .aiexclude files?

Each .aiexclude file applies to its own directory and below. There’s no true “conflict” — rules from parent and child directories stack together. The most specific rule (closest to the file) generally wins, similar to .gitignore behavior.

Q: Will the .aiexclude file protect me from ALL data leakage?

The .aiexclude file is a strong first line of defense for local AI features in Android Studio. However, it does not control what happens when you use external AI tools, paste code into chat interfaces, or use other plugins. Treat it as one layer of a broader security practice.

Q: Should I exclude google-services.json?

It depends. The google-services.json that goes into your app usually contains project IDs and API keys. While it’s not as sensitive as a private key, it’s worth excluding it from AI context — especially the production variant. You might do this:

Plaintext
# .aiexclude
google-services.json
app/google-services.json

Quick Reference: Recommended Default .aiexclude Template

Copy this into any Android project and customize as needed:

Plaintext
# ─────────────────────────────────────
# .aiexclude — Recommended Default
# Android Studio AI Context Exclusions
# ─────────────────────────────────────

# Secrets and local config
local.properties
keystore.properties
*.env
*.env.*
.env.local

# Signing keystores
**/*.jks
**/*.keystore

# Firebase and Google service files
google-services.json
GoogleService-Info.plist

# Service accounts and credentials
**/credentials/
**/service_account*.json

# Build artifacts
**/build/
.gradle/
**/.gradle/

# Auto-generated code
**/generated/
**/*Generated.java
**/*Generated.kt
**/*_Impl.kt

# Large binary or data assets
**/*.sqlite
**/*.db
**/*.csv
**/*.parquet

# Internal documentation
docs/internal/
INTERNAL*.md
CONFIDENTIAL*

# IDE-specific artifacts
.idea/workspace.xml
.idea/tasks.xm

Conclusion

The .aiexclude file is a small file with a big impact. In just a few lines, it lets you control exactly what your AI assistant sees — keeping sensitive keys, proprietary logic, and noisy generated files out of its context while letting it focus on the code that actually matters.

Here’s a quick recap of what we covered:

  • The .aiexclude file acts like a privacy filter between your project and the AI assistant in Android Studio.
  • Place it in your project root for global rules, or in subdirectories for module-level control.
  • It uses glob-style patterns very similar to .gitignore.
  • Always commit it to version control so your whole team benefits.
  • Combine it with other security practices — it’s one layer, not a complete solution.

If you haven’t added a .aiexclude file to your Android project yet, now’s the time. Open Android Studio, create the file, drop in the template above, and customize it for your project’s needs. 

It takes five minutes and pays dividends in security, performance, and peace of mind.

Compose Multiplatform

Compose Multiplatform (CMP) in Production: The Complete Technical Guide for Android, iOS & Web

If you’ve been in the mobile and cross-platform world lately, you’ve probably heard a lot about Compose Multiplatform (CMP). It’s one of the fastest-growing ways to build apps that run on Android, iOS and the Web using a single shared UI approach.

But what exactly is CMP? And why are developers increasingly choosing it over other frameworks? In this post, we’ll break it down, with examples, comparisons and real reasons developers love it.

What Is Compose Multiplatform — Precisely 

Compose Multiplatform (CMP) is a UI framework developed and maintained by JetBrains, built on top of Google’s Jetpack Compose runtime. It extends the Compose programming model — declarative, reactive, composable UI functions — beyond Android to iOS, Desktop (JVM), and Web (Kotlin/Wasm).

CMP is layered on top of Kotlin Multiplatform (KMP), which is the underlying technology for compiling Kotlin code to multiple targets: JVM (Android/Desktop), Kotlin/Native (iOS/macOS), and Kotlin/Wasm (Web). Understanding this layering matters architecturally:

Kotlin
┌─────────────────────────────────────────────────────────┐
│                Compose Multiplatform (CMP)              │
│              (Shared declarative UI layer)              │
├─────────────────────────────────────────────────────────┤
│               Kotlin Multiplatform (KMP)                │
│         (Shared business logic, data, domain)           │
├───────────┬──────────────┬──────────────┬───────────────┤
│  Kotlin/  │ Kotlin/      │ Kotlin/      │ Kotlin/       │
│  JVM      │ Native       │ Wasm         │ JVM           │
│ (Android) │ (iOS/macOS)  │ (Web)        │ (Desktop)     │
└───────────┴──────────────┴──────────────┴───────────────┘

What CMP is not:

  • Not a WebView wrapper
  • Not a JavaScript runtime or bridge
  • Not a pixel-for-pixel clone of native UI widgets on every platform
  • Not a guarantee that code runs identically on all platforms — it compiles and runs on all platforms, with deliberate platform-specific divergences in rendering, gestures, and system behaviors

Current Platform Support: Honest Status

What “iOS API Stable” means precisely: JetBrains has declared the CMP public API surface stable, meaning they will not make breaking changes without a deprecation cycle. It does not mean:

  • Pixel-perfect parity with SwiftUI or UIKit
  • Complete VoiceOver/accessibility support (this is a known gap as of 2026)
  • Identical scroll physics to UIScrollView
  • Equivalent Xcode debugging experience to native Swift development

Teams shipping CMP-based iOS apps in production report success, but they do so with deliberate investment in iOS-specific testing, accessibility audits, and gesture tuning — not by assuming parity.

CMP vs Flutter vs React Native — Engineering Comparison

Compose Multiplatform vs Flutter

Both use a custom rendering engine (not native OS widgets) to draw UI. Key engineering differences:

Honest verdict: Flutter has a more mature cross-platform tooling story and stronger iOS accessibility today. CMP wins decisively if your team is already invested in Kotlin, Jetpack libraries, and Android-first development.

Compose Multiplatform vs React Native

React Native’s new architecture (JSI + Fabric renderer) significantly closes the performance gap that historically plagued the JavaScript bridge. The architectural difference from CMP:

  • CMP compiles Kotlin to native binaries — no runtime JS, no bridge
  • React Native (New Architecture) uses JSI for synchronous JS-to-native calls — faster than the old bridge, but still a JS runtime overhead
  • React Native renders actual native widgets on each platform; CMP renders via Skia
  • React Native is the right choice for web-first teams; CMP is the right choice for Kotlin-first teams

How CMP Works Under the Hood

Rendering Pipeline

CMP uses different rendering approaches per platform, which explains both its strengths and its platform-specific behavioral differences:

Kotlin
commonMain Compose Code

         ├── Android
         │     └── Jetpack Compose Runtime
         │           └── Android RenderNode / Canvas API
         │                 └── Skia (via Android's internal pipeline)

         ├── iOS
         │     └── Skiko (Kotlin/Native bindings to Skia)
         │           └── Metal GPU API
         │                 └── CAMetalLayer embedded in UIView

         ├── Desktop (JVM)
         │     └── Skiko
         │           └── OpenGL / DirectX / Metal (OS-dependent)

         └── Web
               └── Kotlin/Wasm + Skia compiled to WebAssembly
                     └── HTML <canvas> element

Critical implication of this architecture: Because CMP on iOS renders through a CAMetalLayer-backed UIView (not through SwiftUI’s layout engine), layout behaviors, font metrics, shadow rendering, and scroll momentum physics are produced by Skia — not by iOS’s native compositor. This is why experienced iOS users may notice subtle differences. It is also why full SwiftUI NavigationStack integration with CMP-managed screens is architecturally complicated.

The KMP Foundation: expect/actual

The expect/actual mechanism is the primary tool for platform branching. It operates at compile time, not runtime:

Kotlin
// commonMain — declares the contract
expect fun currentTimeMillis(): Long

// androidMain - Android implementation
actual fun currentTimeMillis(): Long = System.currentTimeMillis()

// iosMain - iOS implementation (using Kotlin/Native platform APIs)
actual fun currentTimeMillis(): Long = 
    NSDate().timeIntervalSince1970.toLong() * 1000

expect/actual works for:

  • Top-level functions
  • Classes (with matching constructors)
  • Objects
  • Interfaces (less common; prefer interfaces in commonMain with actual implementations)
  • Typealiases (useful for mapping platform types)

expect class constructor limitation: When you declare expect class Foo(), every actual implementation must match the constructor signature. This creates a real problem for Android classes that require Context. The correct pattern uses dependency injection or a platform-provided factory, not a bare constructor — covered in detail in the DI section.

Project Structure and Modularization 

The single-module structure shown in most tutorials works for demos. Production apps require modularization from the start — it affects build times, team ownership, and testability fundamentally.

Recommended Multi-Module Architecture

Kotlin
root/
├── gradle/
│   └── libs.versions.toml          ← Centralized version catalog

├── build-logic/                    ← Convention plugins
│   └── src/main/kotlin/
│       ├── CmpLibraryPlugin.kt     ← Shared Gradle config for library modules
│       └── CmpAppPlugin.kt         ← Shared Gradle config for app modules

├── core/
│   ├── domain/                     ← Pure Kotlin: entities, use cases, repository interfaces
│   │   └── src/commonMain/
│   ├── data/                       ← Repository implementations, network, cache
│   │   └── src/commonMain/
│   ├── ui-components/              ← Shared design system, reusable composables
│   │   └── src/commonMain/
│   ├── navigation/                 ← Route definitions, navigation contracts
│   │   └── src/commonMain/
│   └── testing/                    ← Shared test utilities and fakes
│       └── src/commonTest/

├── features/
│   ├── product-list/
│   │   └── src/commonMain/
│   ├── product-detail/
│   │   └── src/commonMain/
│   └── cart/
│       └── src/commonMain/

├── composeApp/                     ← Platform entry points + DI wiring
│   └── src/
│       ├── commonMain/             ← App-level navigation graph, DI setup
│       ├── androidMain/            ← Android Activity, platform DI modules
│       ├── iosMain/                ← iOS entry point called from Swift
│       └── wasmJsMain/             ← Web entry point

└── iosApp/                         ← Xcode project
    └── iosApp/
        ├── ContentView.swift       ← Hosts CMP root composable
        └── iOSApp.swift            ← App lifecycle; calls into Kotlin layer

Why this structure matters:

  • :core:domain depends on nothing — it’s pure Kotlin, testable anywhere
  • :core:data depends on :core:domain interfaces only
  • Feature modules depend on :core:domain and :core:ui-components; never on each other
  • Platform entry points wire everything together via DI — they’re the only place with platform-specific imports

Gradle Configuration — The Real Picture

Here is a production-realistic Gradle configuration with current APIs (Kotlin 2.1.x):

Kotlin
// build-logic/src/main/kotlin/CmpLibraryPlugin.kt
// Convention plugin applied to all shared library modules

import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
    id("com.android.library")
    kotlin("multiplatform")
    id("org.jetbrains.compose")
    id("org.jetbrains.kotlin.plugin.compose")
}

kotlin {
    androidTarget {
        compilerOptions {                    // Note: kotlinOptions {} is deprecated
            jvmTarget.set(JvmTarget.JVM_11)
        }
    }

    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64()
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "SharedModule"
            isStatic = true
            // Static frameworks are required for proper Kotlin/Native
            // memory management with Swift ARC interop
        }
    }

    @OptIn(ExperimentalWasmDsl::class)
    wasmJs {
        browser()
        binaries.executable()
    }

    sourceSets {
        commonMain.dependencies {
            implementation(compose.runtime)
            implementation(compose.foundation)
            implementation(compose.material3)
            implementation(compose.ui)
            implementation(compose.components.resources)
            implementation(libs.lifecycle.viewmodel.compose)      // Multiplatform ViewModel
            implementation(libs.lifecycle.runtime.compose)        // collectAsStateWithLifecycle
            implementation(libs.navigation.compose)               // Multiplatform nav
            implementation(libs.kotlinx.coroutines.core)
            implementation(libs.koin.compose)                     // DI
        }

        androidMain.dependencies {
            implementation(libs.androidx.activity.compose)
            implementation(libs.kotlinx.coroutines.android)       // Provides Dispatchers.Main on Android
        }

        iosMain.dependencies {
            implementation(libs.kotlinx.coroutines.core)
            // Note: kotlinx-coroutines-core for Native provides
            // Dispatchers.Main via Darwin integration - requires explicit dependency
        }
        commonTest.dependencies {
            implementation(libs.kotlin.test)
            implementation(libs.kotlinx.coroutines.test)
        }
    }
}

Known Gradle pain points in production:

  • Kotlin/Native compilation is 3–5× slower than JVM compilation. Enable the Kotlin build cache (kotlin.native.cacheKind=static in gradle.properties) and Gradle build cache
  • XCFramework generation for App Store distribution requires a separate XCFramework task — not included in the default template
  • The linkDebugFrameworkIosArm64 Gradle task must be connected to Xcode’s build phase; misconfiguration here is the #1 cause of “works on simulator, fails on device” issues
  • Keep isStatic = true on iOS framework targets. Dynamic frameworks are supported but add complexity to iOS app startup and Xcode integration

Correct Architectural Patterns

The Layered Architecture for CMP

Kotlin
┌─────────────────────────────────────────┐
│              UI Layer (CMP)             │
│  Composables receive UiState, emit      │
│  events/callbacks. No business logic.   │
├─────────────────────────────────────────┤
│           ViewModel Layer               │
│  Holds UiState (single StateFlow).      │
│  Orchestrates use cases. Maps domain    │
│  models to UI models.                   │
├─────────────────────────────────────────┤
│            Domain Layer                 │
│  Use cases (interactors). Pure Kotlin.  │
│  No framework dependencies.             │
├─────────────────────────────────────────┤
│             Data Layer                  │
│  Repository implementations.            │
│  Ktor for network. SQLDelight for DB.   │
│  Platform-specific data sources.        │
└─────────────────────────────────────────┘

MVI with Single UiState (Preferred for CMP)

Multiple independent StateFlow properties in a ViewModel create impossible UI states and double recompositions. Use a single sealed UiState:

Kotlin
// Correct: Single state object prevents impossible states
// and triggers exactly one recomposition per state change

sealed class ProductListUiState {
    object Loading : ProductListUiState()
    
    data class Success(
        val products: List<ProductUiModel>,
        val searchQuery: String = ""
    ) : ProductListUiState()
    
    data class Error(
        val message: String,
        val isRetryable: Boolean
    ) : ProductListUiState()
}

// UiModel - separate from domain model
// Only contains what the UI needs; formatted strings, not raw data
@Immutable  // Tells Compose compiler this is stable - critical for LazyColumn performance
data class ProductUiModel(
    val id: String,
    val name: String,
    val formattedPrice: String,    // "$12.99" not 12.99 - formatting in ViewModel, not Composable
    val description: String,
    val imageUrl: String
)
Kotlin
class ProductListViewModel(
    private val getProductsUseCase: GetProductsUseCase
) : ViewModel() {

private val _uiState = MutableStateFlow<ProductListUiState>(ProductListUiState.Loading)
    val uiState: StateFlow<ProductListUiState> = _uiState.asStateFlow()
    init {
        loadProducts()
    }

    fun loadProducts() {
        viewModelScope.launch {
            _uiState.value = ProductListUiState.Loading
            getProductsUseCase()
                .onSuccess { products ->
                    _uiState.value = ProductListUiState.Success(
                        products = products.map { it.toUiModel() }
                    )
                }
                .onFailure { error ->
                    _uiState.value = ProductListUiState.Error(
                        message = error.toUserFacingMessage(),
                        isRetryable = error is NetworkException
                    )
                }
        }
    }

    fun onSearchQueryChanged(query: String) {
        val currentState = _uiState.value as? ProductListUiState.Success ?: return
        _uiState.value = currentState.copy(searchQuery = query)
    }
}

// Extension to convert domain model to UI model
private fun Product.toUiModel() = ProductUiModel(
    id = id,
    name = name,
    formattedPrice = "$${"%.2f".format(price)}",
    description = description,
    imageUrl = imageUrl
)

Why error.toUserFacingMessage() matters: On Kotlin/Native (iOS), exception.message can be null. Always map exceptions to typed error representations before exposing them to the UI layer.

State Management Done Right 

State Hoisting — The Correct Pattern

The most common architectural mistake in Compose (multiplatform or not) is passing a ViewModel into a composable. This breaks testability, violates unidirectional data flow, and causes incorrect recomposition scoping.

The rule: Composables receive state (immutable data) and emit events (callbacks). They never hold or reference a ViewModel directly.

Kotlin
// Anti-pattern — breaks testability and state hoisting
@Composable
fun ProductListScreen(viewModel: ProductListViewModel) {
    val uiState by viewModel.uiState.collectAsState()
    // ...
}

// Correct - state in, events out
@Composable
fun ProductListScreen(
    uiState: ProductListUiState,
    onRetry: () -> Unit,
    onSearchQueryChanged: (String) -> Unit,
    onProductClick: (String) -> Unit,   // Pass ID, not the whole object
    modifier: Modifier = Modifier        // Always accept a Modifier parameter
) {
    when (uiState) {
        is ProductListUiState.Loading -> LoadingContent(modifier)
        is ProductListUiState.Error -> ErrorContent(
            message = uiState.message,
            isRetryable = uiState.isRetryable,
            onRetry = onRetry,
            modifier = modifier
        )
        is ProductListUiState.Success -> ProductListContent(
            products = uiState.products,
            searchQuery = uiState.searchQuery,
            onSearchQueryChanged = onSearchQueryChanged,
            onProductClick = onProductClick,
            modifier = modifier
        )
    }
}

The ViewModel sits at the navigation/screen level, never inside a composable:

Kotlin
// In your navigation graph 
composable<ProductList> {
    val viewModel: ProductListViewModel = koinViewModel()
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    // collectAsStateWithLifecycle is preferred over collectAsState —
    // it respects platform lifecycle and pauses collection when the app is backgrounded

    ProductListScreen(
        uiState = uiState,
        onRetry = viewModel::loadProducts,
        onSearchQueryChanged = viewModel::onSearchQueryChanged,
        onProductClick = { productId ->
            navController.navigate(ProductDetail(productId))
        }
    )
}

remember vs rememberSaveable

Kotlin
@Composable
fun SearchBar(
    query: String,                        // Lifted state — parent owns it
    onQueryChange: (String) -> Unit,
    onSearch: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    // No local mutableStateOf needed — state is owned by caller
    // Only use remember for objects that are expensive to create
    val focusRequester = remember { FocusRequester() }

    OutlinedTextField(
        value = query,
        onValueChange = onQueryChange,
        modifier = modifier.focusRequester(focusRequester),
        // ...
    )
}

// For state that must survive configuration changes AND process death,
// use rememberSaveable with a Saver if the type is not primitive:
val scrollState = rememberSaveable(saver = ScrollState.Saver) { ScrollState(0) }

Lifecycle-Aware Collection

collectAsState() does not pause collection when the app is backgrounded on iOS. Use collectAsStateWithLifecycle() from lifecycle-runtime-compose:

Kotlin
// Lifecycle-aware — pauses when app is in background on all platforms
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

// Always-on - continues collecting even when app is backgrounded
val uiState by viewModel.uiState.collectAsState()

Type-Safe Navigation Across Platforms

String Routes Are Deprecated — Use Type-Safe Navigation

As of navigation-compose 2.8.x, type-safe navigation using @Serializable route objects is stable and the recommended approach. String-based routes are error-prone, refactoring-unsafe, and lack compile-time guarantees.

Kotlin
// core/navigation/src/commonMain/RouteDefinitions.kt

import kotlinx.serialization.Serializable

@Serializable
object ProductList                          // No-argument destination

@Serializable
data class ProductDetail(val productId: String)  // Typed argument

@Serializable
object Cart

@Serializable
data class Checkout(
    val cartId: String,
    val promoCode: String? = null           // Optional parameters supported
)
Kotlin
// composeApp/src/commonMain/AppNavigation.kt

@Composable
fun AppNavigation(
    navController: NavHostController = rememberNavController()
) {
    NavHost(
        navController = navController,
        startDestination = ProductList
    ) {
        composable<ProductList> {
            val viewModel: ProductListViewModel = koinViewModel()
            val uiState by viewModel.uiState.collectAsStateWithLifecycle()
            ProductListScreen(
                uiState = uiState,
                onRetry = viewModel::loadProducts,
                onSearchQueryChanged = viewModel::onSearchQueryChanged,
                onProductClick = { productId ->
                    navController.navigate(ProductDetail(productId))
                }
            )
        }

        composable<ProductDetail> { backStackEntry ->
            val route: ProductDetail = backStackEntry.toRoute()  // Type-safe extraction
            val viewModel: ProductDetailViewModel = koinViewModel(
                parameters = { parametersOf(route.productId) }
            )
            val uiState by viewModel.uiState.collectAsStateWithLifecycle()
            ProductDetailScreen(
                uiState = uiState,
                onBack = { navController.navigateUp() },
                onAddToCart = viewModel::addToCart
            )
        }

        composable<Cart> {
            val viewModel: CartViewModel = koinViewModel()
            val uiState by viewModel.uiState.collectAsStateWithLifecycle()
            CartScreen(
                uiState = uiState,
                onCheckout = { cartId ->
                    navController.navigate(Checkout(cartId))
                },
                onBack = { navController.navigateUp() }
            )
        }
    }
}

Platform Navigation Caveats

iOS back-swipe gesture: The multiplatform navigation-compose supports interactive back-swipe on iOS, but the animation curve and gesture threshold are Skia-rendered approximations of the native UINavigationController push/pop animation. They are close but distinguishable to trained iOS users. For apps where native-feel is paramount, consider using Decompose (a community navigation library) which supports fully native iOS transitions via UIKit integration.

Android back handling: The hardware back button and predictive back gesture (Android 14+) require explicit handling. Register a BackHandler composable where needed:

Kotlin
BackHandler(enabled = uiState is CheckoutUiState.InProgress) {
    // Prompt user before losing checkout progress
    showExitConfirmationDialog = true
}

Web browser history: Navigation-compose on Wasm integrates with browser history via the History API, but deep link handling (initial URL → correct screen) requires setup in your Wasm entry point that the default template does not provide.

Platform-Specific Features via expect/actual

The Context Problem on Android — Solved Correctly

A common mistake is defining expect class with a no-arg constructor when the Android implementation needs Context. The correct approach uses dependency injection, not constructor parameters in the expect declaration:

Kotlin
// core/domain/src/commonMain/ — Define a pure interface
interface FileStorage {
    suspend fun saveFile(fileName: String, data: ByteArray): Result<Unit>
    suspend fun readFile(fileName: String): Result<ByteArray>
    suspend fun deleteFile(fileName: String): Result<Unit>
}

// core/data/src/androidMain/ - Android implementation with Context via DI
class AndroidFileStorage(
    private val context: Context    // Injected by DI framework
) : FileStorage {
    override suspend fun saveFile(fileName: String, data: ByteArray): Result<Unit> =
        runCatching {
            val file = File(context.filesDir, fileName)
            file.writeBytes(data)
        }
    override suspend fun readFile(fileName: String): Result<ByteArray> =
        runCatching {
            File(context.filesDir, fileName).readBytes()
        }
    override suspend fun deleteFile(fileName: String): Result<Unit> =
        runCatching {
            File(context.filesDir, fileName).delete()
        }
}

// core/data/src/iosMain/ - iOS implementation
class IosFileStorage : FileStorage {
    private val fileManager = NSFileManager.defaultManager
    override suspend fun saveFile(fileName: String, data: ByteArray): Result<Unit> =
        runCatching {
            val documentsDir = fileManager
                .URLsForDirectory(NSDocumentDirectory, NSUserDomainMask)
                .firstOrNull()?.path ?: error("No documents directory")
            val filePath = "$documentsDir/$fileName"
            data.toNSData().writeToFile(filePath, atomically = true)
        }
    // ... other implementations
}

The DI framework (Koin shown below) provides the platform-correct implementation to commonMain code — no expect/actual needed when the interface lives in commonMain.

Embedding Native Views

For platform-native components that cannot be reproduced in Compose (maps, WebViews, camera previews):

Kotlin
// features/map/src/iosMain/ — iOS-specific file
@Composable
fun NativeMapView(
    latitude: Double,
    longitude: Double,
    modifier: Modifier = Modifier
) {
    UIKitView(
        factory = {
            MKMapView().apply {
                // Configure once on creation
                showsUserLocation = true
            }
        },
        update = { mapView ->
            // Called on recomposition when inputs change
            val region = MKCoordinateRegionMake(
                CLLocationCoordinate2DMake(latitude, longitude),
                MKCoordinateSpanMake(0.01, 0.01)
            )
            mapView.setRegion(region, animated = true)
        },
        modifier = modifier
    )
}

Important:UIKitView must be in iosMain, not commonMain. Expose it via an expect/actual composable or via conditional compilation if you need a platform-specific fallback in the shared screen.

iOS-Specific: Lifecycle, Interop, and Debugging 

This section covers the most under-addressed topic in CMP guides. iOS lifecycle management is where most production incidents originate.

The iOS Lifecycle vs Android Lifecycle

On Android, ViewModel.viewModelScope is tied to Lifecycle.State.CREATED — coroutines are automatically cancelled when the ViewModel is cleared. On iOS, the mapping is:

iOS App States          →   CMP/Compose Lifecycle
─────────────────────────────────────────────────
Active (foreground) → Lifecycle.State.RESUMED
Inactive (transitioning)→ Lifecycle.State.STARTED
Background (suspended) → Lifecycle.State.CREATED
Terminated (clean exit) → Lifecycle.State.DESTROYED
Killed by OS (OOM/force)→ DESTROYED not guaranteed

The critical issue: When an iOS app is backgrounded, the OS may suspend it entirely with no further CPU time. Coroutines in viewModelScope do not automatically pause on iOS the way Android’s lifecycle-aware components do. This means:

Kotlin
// Dangerous on iOS — will attempt network calls even when app is suspended
class ProductListViewModel : ViewModel() {
    init {
        viewModelScope.launch {
            // This may run after iOS has suspended your app,
            // causing unexpected behavior or battery drain
            productRepository.startPolling()
        }
    }
}

// Correct - use lifecycle-aware collection
class ProductListViewModel : ViewModel() {
    val uiState: StateFlow<ProductListUiState> = productRepository
        .productsFlow
        .map { it.toUiState() }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            // WhileSubscribed stops the upstream flow when there are no collectors
            // (i.e., when the screen is not visible)
            initialValue = ProductListUiState.Loading
        )
}

SharingStarted.WhileSubscribed(5_000) is the correct production pattern — it stops upstream flows 5 seconds after the last subscriber disappears (the screen leaves composition), which handles both backgrounding and navigation.

iOS App Lifecycle Events in Kotlin

To respond to iOS lifecycle events from Kotlin:

Kotlin
// iosMain — observe iOS lifecycle notifications
class IosLifecycleObserver {
    private var observers: List<NSObjectProtocol> = emptyList()

    fun start(
        onBackground: () -> Unit,
        onForeground: () -> Unit
    ) {
        val center = NSNotificationCenter.defaultCenter
        observers = listOf(
            center.addObserverForName(
                name = UIApplicationDidEnterBackgroundNotification,
                `object` = null,
                queue = NSOperationQueue.mainQueue
            ) { _ -> onBackground() },
            center.addObserverForName(
                name = UIApplicationWillEnterForegroundNotification,
                `object` = null,
                queue = NSOperationQueue.mainQueue
            ) { _ -> onForeground() }
        )
    }

    fun stop() {
        observers.forEach { NSNotificationCenter.defaultCenter.removeObserver(it) }
        observers = emptyList()
    }
}

Swift ↔ Kotlin Interop Boundary

The iOS entry point bridges Swift and Kotlin:

Kotlin
// iosApp/ContentView.swift
import SwiftUI
import ComposeApp  // The generated Kotlin framework

struct ContentView: View {
    var body: some View {
        ComposeView()
            .ignoresSafeArea(.keyboard)  // Let CMP handle keyboard insets itself
    }
}

// ComposeView wraps the Kotlin entry point
struct ComposeView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> UIViewController {
        MainViewControllerKt.MainViewController()  // Kotlin function
    }
    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
Kotlin
// iosMain — Kotlin entry point called from Swift
fun MainViewController() = ComposeUIViewController(
    configure = {
        // Configure the Compose host here
        // For example, register platform-specific implementations
    }
) {
    // Koin DI initialization for iOS
    KoinApplication(
        application = { modules(platformModule(), sharedModule()) }
    ) {
        AppNavigation()
    }
}

SwiftUI NavigationStack + CMP: You cannot simultaneously use SwiftUI NavigationStack for routing AND CMP’s NavHost for routing. Choose one as the source of truth. Mixing both causes double back-stack management and broken state restoration. The recommended approach for CMP-first apps is to let CMP’s NavHost own all navigation and wrap the entire CMP root as a single SwiftUI view.

Debugging Kotlin/Native on iOS

Xcode’s debugger does not understand Kotlin. For production crash debugging:

  • Kotlin/Native crash reports appear in Xcode Organizer as native crashes with mangled Kotlin symbols
  • You must use konan/bin/llvm-symbolizer with your app’s .dSYM file to demangle crash stacks
  • Sentry’s KMP SDK handles crash symbolication automatically and is the most production-proven option
  • For local debugging, enable Kotlin LLDB formatters by adding the Kotlin LLDB plugin to Xcode

Dependency Injection in CMP 

DI is not mentioned in most CMP tutorials and is the first thing that breaks in real projects. Koin is the most production-proven multiplatform DI framework for CMP. Kodein-DI is a capable alternative.

Kotlin
// core/di/src/commonMain/ — Shared DI modules

val domainModule = module {
    factory<GetProductsUseCase> { GetProductsUseCaseImpl(get()) }
    factory<AddToCartUseCase> { AddToCartUseCaseImpl(get()) }
}

val dataModule = module {
    single<ProductRepository> { ProductRepositoryImpl(get(), get()) }
    single<CartRepository> { CartRepositoryImpl(get()) }
    single { HttpClient(/* Ktor config */) }
}

val viewModelModule = module {
    viewModel { ProductListViewModel(get()) }
    viewModel { (productId: String) -> ProductDetailViewModel(productId, get()) }
    viewModel { CartViewModel(get(), get()) }
}
Kotlin
// composeApp/src/androidMain/ — Android platform module
val androidPlatformModule = module {
    single<FileStorage> { AndroidFileStorage(androidContext()) }
    single<AnalyticsTracker> { FirebaseAnalyticsTracker(androidContext()) }
}

// In Android Application class:
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidContext(this@MyApplication)
            modules(androidPlatformModule, dataModule, domainModule, viewModelModule)
        }
    }
}
Kotlin
// composeApp/src/iosMain/ — iOS platform module
val iosPlatformModule = module {
    single<FileStorage> { IosFileStorage() }
    single<AnalyticsTracker> { SentryAnalyticsTracker() }
}

// Called from Swift MainViewController:
fun initKoin() {
    startKoin {
        modules(iosPlatformModule, dataModule, domainModule, viewModelModule)
    }
}
Kotlin
// Usage in navigation — type-safe ViewModel injection
composable<ProductDetail> { backStackEntry ->
    val route: ProductDetail = backStackEntry.toRoute()
    val viewModel: ProductDetailViewModel = koinViewModel(
        parameters = { parametersOf(route.productId) }
    )
    // ...
}

Accessibility — The Non-Negotiable

CMP’s iOS accessibility support is the most significant production gap as of early 2026. This section must be understood before committing to CMP for any app serving users with disabilities or operating in regulated industries (healthcare, finance, government).

Current iOS Accessibility Status

JetBrains is actively improving iOS accessibility. Track progress at youtrack.jetbrains.com — search for “iOS accessibility CMP.”

Semantic Annotations — Always Provide Them

Even where CMP’s accessibility pipeline is strong, you must provide explicit semantics:

Kotlin
@Composable
fun ProductCard(
    product: ProductUiModel,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Card(
        onClick = onClick,
        modifier = modifier
            .fillMaxWidth()
            .semantics(mergeDescendants = true) {  // Merge child semantics into one node
                contentDescription = buildString {
                    append(product.name)
                    append(", ")
                    append(product.formattedPrice)
                    append(". ")
                    append(product.description.take(100))  // Truncate long descriptions
                }
                role = Role.Button
                onClick(label = "View details for ${product.name}") {
                    onClick()
                    true
                }
            },
        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
    ) {
        // Card content
    }
}
Kotlin
// Loading states need explicit a11y announcements
@Composable
fun LoadingContent(modifier: Modifier = Modifier) {
    Box(
        modifier = modifier
            .fillMaxSize()
            .semantics { contentDescription = "Loading products, please wait" },
        contentAlignment = Alignment.Center
    ) {
        CircularProgressIndicator()
    }
}

If iOS Accessibility Is Required Today

For apps where full iOS VoiceOver compliance is non-negotiable right now, consider:

  1. Hybrid approach: Use CMP for Android + Desktop + Web, keep native SwiftUI for iOS
  2. UIKitView fallback: Implement accessibility-critical screens as UIKit views wrapped in UIKitView
  3. Wait for CMP 1.8+: JetBrains has prioritized iOS accessibility — the gap is closing

Performance: Real Numbers and Real Caveats

iOS Rendering Performance

Startup overhead: The Kotlin/Native runtime initialization time is the most cited performance concern. It is real and not fully eliminable, but it can be minimized:

  • Initialize the Kotlin runtime as early as possible in your Swift AppDelegate or @main struct, before any UI is shown
  • Use MainActor in Swift to ensure the CMP compositor is ready before the first frame

Memory Management on iOS

CMP’s memory behavior on iOS requires awareness of three interacting systems:

  • Kotlin/Native’s concurrent garbage collector (introduced in Kotlin 1.9.20) — significantly improved but still runs GC pauses under pressure
  • Swift’s ARC — automatic reference counting at the Swift/Kotlin boundary
  • Skia’s texture cache — GPU memory managed separately

For LazyColumn with image-heavy items:

Kotlin
// Register for iOS memory pressure notifications and clear image caches
// This should be done in your iosMain platform setup

class IosMemoryPressureHandler(
    private val imageLoader: ImageLoader  // Coil's ImageLoader
) {
    fun register() {
        NSNotificationCenter.defaultCenter.addObserverForName(
            name = UIApplicationDidReceiveMemoryWarningNotification,
            `object` = null,
            queue = NSOperationQueue.mainQueue
        ) { _ ->
            imageLoader.memoryCache?.clear()
            imageLoader.diskCache?.clear()
        }
    }
}

Recomposition Performance

Mark your data models @Immutable or @Stable to enable the Compose compiler to skip recomposition when inputs haven’t changed:

Kotlin
@Immutable  // Tells Compose: all properties are val and of stable types
data class ProductUiModel(
    val id: String,
    val name: String,
    val formattedPrice: String,
    val description: String,
    val imageUrl: String
)

// Without @Immutable, a data class with List<> properties will be inferred
// as unstable by the Compose compiler, causing full recomposition of every
// LazyColumn item on every parent recomposition - a major performance issue
@Immutable
data class CartUiState(
    val items: List<CartItemUiModel>,  // List<> requires @Immutable on the containing class
    val totalFormatted: String,
    val itemCount: Int
)

Enable Compose compiler metrics to verify your composables are stable:

Kotlin
// In your app's build.gradle.kts
composeCompiler {
    metricsDestination = layout.buildDirectory.dir("compose-metrics")
    reportsDestination = layout.buildDirectory.dir("compose-reports")
}

Run ./gradlew assembleRelease and inspect the generated reports for unstable markers.

Web (Wasm) Performance Reality

  • Initial Wasm binary: 5–20MB depending on features used
  • Execution speed once loaded: faster than equivalent JavaScript, competitive with native apps for logic-heavy operations
  • Rendering: <canvas>-based — no DOM, no browser text selection, no SEO crawling, no browser accessibility tree
  • Not suitable for: SEO-dependent content, server-side rendering, or apps requiring native browser accessibility
  • Suitable for: Internal tools, dashboards, B2B applications where load time and SEO are not primary concerns

Testing Strategy Across Platforms

Unit Testing (commonTest)

Kotlin
// core/domain/src/commonTest/ — Pure logic tests run on all platforms

class ProductListViewModelTest {
    private val testProducts = listOf(
        Product(id = "1", name = "Widget", price = 9.99, description = "A widget", imageUrl = ""),
        Product(id = "2", name = "Gadget", price = 19.99, description = "A gadget", imageUrl = "")
    )

    @Test
    fun `loadProducts emits Success state with mapped UI models`() = runTest {
        val fakeRepository = FakeProductRepository(products = testProducts)
        val useCase = GetProductsUseCaseImpl(fakeRepository)
        val viewModel = ProductListViewModel(useCase)
        val state = viewModel.uiState.value
        assertTrue(state is ProductListUiState.Success)
        assertEquals(2, (state as ProductListUiState.Success).products.size)
        assertEquals("$9.99", state.products[0].formattedPrice)
    }

    @Test
    fun `loadProducts emits Error state on network failure`() = runTest {
        val fakeRepository = FakeProductRepository(shouldFail = true)
        val useCase = GetProductsUseCaseImpl(fakeRepository)
        val viewModel = ProductListViewModel(useCase)
        val state = viewModel.uiState.value
        assertTrue(state is ProductListUiState.Error)
        assertTrue((state as ProductListUiState.Error).isRetryable)
    }
}

// Fake repository - not a mock, avoids Mockito (JVM-only)
class FakeProductRepository(
    private val products: List<Product> = emptyList(),
    private val shouldFail: Boolean = false
) : ProductRepository {
    override suspend fun getProducts(): Result<List<Product>> = if (shouldFail) {
        Result.failure(NetworkException("Network unavailable"))
    } else {
        Result.success(products)
    }
}

Do not use Mockito in commonTest — it is JVM-only. Use fakes (hand-written test doubles) or MockK’s multiplatform-compatible subset.

UI Testing

CMP UI tests use ComposeUiTest from compose-ui-test:

Kotlin
// composeApp/src/androidTest/ - Android UI tests
class ProductListScreenTest {
    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun productList_showsLoadingIndicator_whenStateIsLoading() {
        composeTestRule.setContent {
            ProductListScreen(
                uiState = ProductListUiState.Loading,
                onRetry = {},
                onSearchQueryChanged = {},
                onProductClick = {}
            )
        }

        composeTestRule.onNodeWithContentDescription("Loading products, please wait")
            .assertIsDisplayed()
    }

    @Test
    fun productList_showsProducts_whenStateIsSuccess() {
        val products = listOf(
            ProductUiModel("1", "Widget", "$9.99", "A widget", "")
        )

        composeTestRule.setContent {
            ProductListScreen(
                uiState = ProductListUiState.Success(products),
                onRetry = {},
                onSearchQueryChanged = {},
                onProductClick = {}
            )
        }

        composeTestRule.onNodeWithText("Widget").assertIsDisplayed()
        composeTestRule.onNodeWithText("$9.99").assertIsDisplayed()
    }
}

iOS UI testing: ComposeUiTest is not yet available for iOS. iOS UI testing for CMP apps is currently done through:

  • XCUITest (tests the iOS binary as a black box — works, but cannot inspect Compose semantics directly)
  • Screenshot testing via Paparazzi on Android + Roborazzi for cross-platform snapshot comparison
  • Manual testing with VoiceOver enabled

CI/CD Configuration

Kotlin
# .github/workflows/ci.yml
name: CI

on: [push, pull_request]
jobs:
  unit-tests:
    runs-on: macos-14  # macOS required for Kotlin/Native iOS compilation
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: 17
          distribution: temurin
      - name: Run common unit tests
        run: ./gradlew :core:domain:allTests
      - name: Run data layer tests
        run: ./gradlew :core:data:allTests
  android-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Android UI tests
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 33
          script: ./gradlew :composeApp:connectedAndroidTest
  ios-build:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - name: Build iOS framework
        run: ./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64
      - name: Build Xcode project
        run: |
          xcodebuild build \
            -project iosApp/iosApp.xcodeproj \
            -scheme iosApp \
            -destination 'platform=iOS Simulator,name=iPhone 15'

Observability and Crash Reporting

Crash Reporting

Sentry has the most mature KMP SDK with multiplatform crash reporting, breadcrumbs, and Kotlin/Native stack trace symbolication:

Kotlin
// composeApp/src/commonMain/ — Shared error reporting interface
interface ErrorReporter {
    fun captureException(throwable: Throwable, context: Map<String, String> = emptyMap())
    fun addBreadcrumb(category: String, message: String)
    fun setUser(userId: String)
}

// In your ViewModel base class:
abstract class BaseViewModel(
    protected val errorReporter: ErrorReporter
) : ViewModel() {
    protected fun launchWithErrorHandling(
        block: suspend CoroutineScope.() -> Unit
    ) = viewModelScope.launch {
        try {
            block()
        } catch (e: CancellationException) {
            throw e  // Never swallow CancellationException
        } catch (e: Exception) {
            errorReporter.captureException(e)
            handleError(e)
        }
    }
    protected open fun handleError(e: Exception) {}
}

Firebase Crashlytics does not have a native KMP SDK. You can integrate it via expect/actual where Android uses the Firebase SDK directly and iOS uses the Crashlytics iOS SDK called via Kotlin/Native interop — but setup is significantly more complex than Sentry.

Structured Logging

Kotlin
// commonMain — platform-agnostic logging interface
interface AppLogger {
    fun debug(tag: String, message: String)
    fun info(tag: String, message: String)
    fun warn(tag: String, message: String, throwable: Throwable? = null)
    fun error(tag: String, message: String, throwable: Throwable? = null)
}

// androidMain
class AndroidLogger : AppLogger {
    override fun debug(tag: String, message: String) = Log.d(tag, message)
    override fun error(tag: String, message: String, throwable: Throwable?) =
        Log.e(tag, message, throwable)
    // ...
}

// iosMain
class IosLogger : AppLogger {
    override fun debug(tag: String, message: String) {
        NSLog("DEBUG [$tag]: $message")
    }
    override fun error(tag: String, message: String, throwable: Throwable?) {
        NSLog("ERROR [$tag]: $message ${throwable?.message ?: ""}")
    }
    // ...
}

Common Pitfalls and Correct Patterns

Pitfall 1: Platform Imports in commonMain

Kotlin
// Will not compile — Android import in shared code
import android.content.Context

// Define an interface in commonMain, implement per platform
interface PlatformContext  // Marker interface or use Koin's module system

Pitfall 2: Using JVM-Only Libraries

Pitfall 3: Keyboard Insets on iOS

Kotlin
// Always use imePadding() for forms — handles iOS keyboard differently than Android
@Composable
fun FormScreen() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .imePadding()           // Pushes content above keyboard on both platforms
            .verticalScroll(rememberScrollState())
            .padding(16.dp)
    ) {
        // Form fields
    }
}

Note: On iOS, imePadding() behavior depends on the window configuration. Ensure your ComposeUIViewController is not configured with ignoresSafeArea(.keyboard) on the Swift side if you want CMP to handle keyboard insets. Choose one approach and apply it consistently.

Pitfall 4: Missing Coroutine Dispatcher Setup on iOS

Kotlin
// iosMain — MUST call this before any coroutine usage on iOS
// Without it, Dispatchers.Main may not be properly initialized

fun initCoroutines() {
    // This is handled automatically when using lifecycle-viewmodel on iOS,
    // but if you use coroutines outside of ViewModels, explicit initialization
    // may be required depending on your kotlinx-coroutines-core version
}

Ensure kotlinx-coroutines-core is in your iosMain dependencies (not just commonMain) to guarantee the Darwin dispatcher (iOS/macOS version of a Coroutine Dispatcher) is available.

Pitfall 5: Skipping Compose Compiler Metrics

Run the Compose compiler metrics on every release build and investigate any composables marked unstable. Unstable composables recompose unnecessarily, degrading performance silently.

Pitfall 6: Forgetting CancellationException

Kotlin
// Swallows coroutine cancellation — causes memory leaks and undefined behavior
try {
    val result = repository.getProducts()
} catch (e: Exception) {
    handleError(e)  // CancellationException caught here!
}

// Always rethrow CancellationException
try {
    val result = repository.getProducts()
} catch (e: CancellationException) {
    throw e  // Must propagate
} catch (e: Exception) {
    handleError(e)
}

Migration Strategy from Native to CMP

Realistic Migration Path

Do not do a big-bang rewrite. Migrate incrementally with feature flags and measurable milestones.

Phase 0 — Foundation (Weeks 1–4)

  • Set up multi-module project structure
  • Migrate data models to commonMain
  • Migrate network layer (Ktor), serialization (kotlinx.serialization), and database (SQLDelight) to KMP
  • Set up DI (Koin) with platform modules
  • Establish CI pipeline building for Android and iOS from day one
  • Measure and baseline: build times, app startup time, binary size, crash rate

Phase 1 — First CMP Screen (Weeks 5–8)

  • Choose a low-risk, low-traffic screen (Settings, About, or a simple list)
  • Implement it in commonMain with full tests
  • Ship behind a feature flag — A/B test CMP vs native version
  • Instrument: performance metrics, crash rate, accessibility reports
  • Collect iOS user feedback specifically

Phase 2 — Expand Coverage (Months 3–6)

  • Migrate screens in order of business risk (lowest risk first)
  • Each migrated screen: unit tests, UI screenshot tests, accessibility audit
  • Track shared code percentage per milestone
  • Platform-specific UI divergences: address immediately, do not defer

Phase 3 — Evaluate and Commit (Month 6+)

  • Measure actual shared code percentage (realistic target: 65–80%)
  • Assess: developer productivity change, bug rate change, iOS user retention change
  • Make a data-driven decision on full commitment vs hybrid approach

What to keep native (permanent exceptions):

  • ARKit / RealityKit scenes
  • Core ML on-device inference UI
  • Custom UIKit animations requiring frame-by-frame UIView manipulation
  • HealthKit / Watch OS integration screens

Production Readiness Checklist

Before shipping a CMP screen to production, verify:

Architecture

  • UiState is a single sealed class (no impossible states)
  • Composables receive state and callbacks — no ViewModel references
  • @Immutable applied to all UiModel data classes
  • All domain code in :core:domain with no platform imports
  • DI configured for all platforms

iOS

  • iOS lifecycle tested: background, foreground, memory warning
  • SharingStarted.WhileSubscribed used for all StateFlows
  • Back-swipe gesture tested on physical device
  • Font rendering reviewed on iOS (San Francisco vs Roboto differences)
  • imePadding() tested with all form screens on iPhone SE (smallest current screen)

Accessibility

  • All interactive elements have contentDescription or semantic roles
  • mergeDescendants = true applied to card-style components
  • TalkBack tested on Android (with CMP screen)
  • VoiceOver tested on iOS (acknowledge any known gaps)
  • Minimum touch target size: 48×48dp enforced

Performance

  • Compose compiler metrics reviewed — no unexpected unstable composables
  • LazyColumn scroll tested at 60fps on target minimum device specs
  • iOS startup time measured and within acceptable threshold
  • IPA size measured and within App Store guidelines

Testing

  • Unit tests in commonTest covering ViewModel state transitions
  • UI tests covering primary happy path and error state
  • Screenshot regression tests configured
  • CI builds both Android and iOS on every PR

Observability

  • Crash reporting integrated (Sentry recommended)
  • Structured logging in place
  • Performance metrics baseline captured

Who Is Using CMP in Production

JetBrains — Uses CMP in JetBrains Toolbox App and Fleet, with ongoing expansion.

Cash App (Block) — KMP used for shared business logic; CMP UI adoption in progress for select screens.

Touchlab — Consultancy with multiple enterprise deployments in healthcare, fintech, and retail; their public case studies are the most detailed available.

IceRock Development — Multiple production CMP deployments; maintains the moko suite of KMP/CMP libraries.

Yandex — Uses KMP for shared business logic in several products; CMP adoption expanding.

Recognized pattern across adopters: Teams that start with shared business logic via KMP report the lowest-risk path to CMP. Direct CMP adoption without prior KMP experience significantly increases migration risk.

Should Your Team Adopt CMP?

Adopt CMP if:

Your team writes Kotlin for Android and you maintain a parallel iOS codebase with feature parity requirements. The marginal cost of adopting CMP is very low; the long-term cost reduction is substantial.

You are starting a new project. The incremental cost of CMP vs Android-only is low, and you avoid the compounding technical debt of two separate UI codebases.

Your product serves non-accessibility-critical markets (B2B tools, internal apps, dashboards) where the iOS VoiceOver gap is manageable today.

You can invest in iOS-specific testing infrastructure from day one, not as an afterthought.

Proceed cautiously or defer if:

Your iOS app is in a regulated industry where WCAG 2.1 / ADA accessibility compliance is legally required. CMP’s iOS accessibility gaps are real and not fully controllable on your timeline.

Your app relies heavily on platform-specific animations, ARKit, Core ML on-device UI, or custom UIKit components that represent a significant portion of your UI surface.

Your team has no Kotlin experience. The KMP learning curve on top of CMP adoption simultaneously is a high-risk combination.

Your iOS app is a primary revenue driver and even a 200–300ms cold startup increase represents a measurable conversion loss at your scale — benchmark first.

The Right Default: Hybrid Approach

The most risk-managed production pattern today is:

  • Android: 100% CMP (builds on your existing Jetpack Compose investment)
  • iOS: CMP for data/logic-heavy screens; native SwiftUI for launch screen, onboarding, and accessibility-critical flows
  • Desktop: CMP if you need desktop support; low-cost add given Android CMP coverage
  • Web: CMP/Wasm for internal tools; native web (React/Vue) for consumer-facing, SEO-dependent products

This hybrid approach maximizes code reuse where CMP is strongest while using native where the gaps are most consequential.

Frequently Asked Questions

Q: Is Compose Multiplatform the same as Kotlin Multiplatform?

No. Kotlin Multiplatform (KMP) is the foundational technology for compiling Kotlin code to multiple targets and sharing business logic, data layers, and domain models across platforms. Compose Multiplatform (CMP) is built on top of KMP and specifically handles the declarative UI layer. You can use KMP without CMP (sharing logic while keeping native UI), but you cannot use CMP without KMP.

Q: Does CMP code run identically on all platforms?

No — and any resource that tells you it does is being imprecise. CMP code compiles and runs on all platforms, but font rendering, scroll physics, shadow appearance, gesture thresholds, and system behavior differ between Android and iOS because the rendering backends (Android’s hardware compositor vs Skia/Metal on iOS) operate differently. These differences require deliberate iOS testing and, in some cases, platform-specific composable implementations.

Q: How does CMP handle accessibility?

On Android, CMP’s accessibility support maps cleanly to Android’s Accessibility API — strong and production-ready. On iOS, CMP’s accessibility integration with UIAccessibility/VoiceOver has known gaps as of CMP 1.7.x. JetBrains is actively improving this. For iOS apps requiring full VoiceOver compliance today, a hybrid approach (native SwiftUI for accessibility-critical screens) is recommended.

Q: What is the realistic shared code percentage?

In production deployments, teams consistently achieve 65–80% shared UI code. The remaining 20–35% is platform-specific handling for: native view interop, platform lifecycle events, accessibility edge cases, and behaviors where native look-and-feel is non-negotiable. Claims of 90%+ shared code are technically possible for simple apps but are not representative of complex, production-quality applications.

Q: Does CMP support Material Design 3?

Yes. Material 3 (compose.material3) is fully supported in commonMain and renders on all platforms. The Material 3 component rendering on iOS is Skia-drawn (not native UIKit), which means it does not automatically adapt to iOS’s Human Interface Guidelines. If HIG compliance is required on iOS, you will need platform-specific theming via the expect/actual pattern or conditional logic using LocalPlatformInfo.

Q: How do I handle different screen sizes and form factors?

Use WindowSizeClass from compose-material3-adaptive, BoxWithConstraints, and responsive Modifier patterns — the same approach as Jetpack Compose on Android, applied in commonMain. These APIs are multiplatform-compatible.

Q: Is CMP free?

Yes. CMP is open-source under the Apache 2.0 license, free for commercial use. JetBrains monetizes through IntelliJ IDEA / Android Studio tooling and Kotlin-based services, not through CMP licensing.

Q: What is the binary size impact on iOS?

Adding CMP to an iOS app adds approximately 15–25MB uncompressed to the app bundle (including the Kotlin/Native runtime and Skia). After Apple’s App Thinning and compression, the incremental App Store download size increase is approximately 8–14MB. This is acceptable for most feature-rich applications; it may be a concern for lightweight utility apps competing on download size.

Conclusion

Compose Multiplatform is a production-viable framework for sharing UI code across Android, iOS, Desktop, and Web when adopted with clear eyes about its genuine tradeoffs.

Its real strengths: True Kotlin compilation (no bridges), zero retraining for Android Kotlin teams, first-class KMP integration, access to all native APIs via expect/actual and native view interop, and a strong trajectory backed by serious JetBrains investment.

Its real limitations today: iOS accessibility gaps requiring active management, startup overhead on iOS from Kotlin/Native runtime initialization, iOS debugging tooling significantly behind Android, and a Web/Wasm target still maturing toward production-grade use for consumer applications.

The teams shipping CMP successfully in production are not doing so because CMP eliminated all platform differences — they are doing so because they invested in proper architecture (Clean + MVI, typed state, state hoisting), iOS-specific testing, accessibility audits, and observability infrastructure. The framework enables code sharing; engineering discipline determines whether that sharing improves or degrades product quality.

Start with a well-scoped pilot. Measure relentlessly. Expand where the data supports it.

ABI in Android

What Is ABI in Android? Why It Matters for APK Size & Performance

If you build Android apps, you’ve probably seen the term ABI in Android at least once. It shows up in Gradle settings, Play Console warnings, and NDK documentation.

But what does it actually mean? And why does it affect your APK size and app performance?

Let’s break it down.

What Is ABI in Android?

ABI stands for Application Binary Interface.

In simple words, ABI in Android defines how your app’s compiled native code interacts with the device’s processor and operating system.

Think of it as a contract between:

  • Your compiled native code (.so files)
  • The Android operating system
  • The device’s CPU architecture

If the ABI doesn’t match the device’s CPU, your app won’t run.

Why Does ABI Exist in Android?

Android devices use different CPU architectures. The most common ones are:

  • arm64-v8a (64-bit ARM)
  • armeabi-v7a (32-bit ARM)
  • x86
  • x86_64

Each architecture understands machine code differently. So if your app includes native C or C++ code using the Android NDK, you must compile it separately for each ABI you want to support.

That’s where ABI in Android becomes important.

What Exactly Does an ABI Define?

An ABI specifies:

  • CPU instruction set (ARM, x86, etc.)
  • Register usage
  • Memory alignment
  • Data type sizes
  • How function calls work
  • How binaries are formatted

When you compile native code, the compiler uses ABI rules to generate machine code that matches the target architecture.

If you build for arm64-v8a, the generated .so file won’t work on an x86 device.

What Is a Native Library in Android?

If your project uses C or C++ (via the NDK), it generates files like this:

libnative-lib.so

These are placed inside your APK under:

lib/arm64-v8a/
lib/armeabi-v7a/
lib/x86/

Each folder corresponds to a specific ABI in Android.

The system loads the correct library at runtime based on the device’s architecture.

Why ABI in Android Matters for APK Size

This is where many developers make mistakes.

If you include all ABIs in a single APK, your app contains multiple versions of the same native library.

For example:

  • 5 MB for arm64
  • 4 MB for armeabi-v7a
  • 6 MB for x86

Now your APK suddenly includes 15 MB of native code, even though a device only needs one version.

That increases:

  • Download size
  • Install time
  • Storage usage

Solution: Split APKs by ABI

You can configure Gradle to generate separate APKs per ABI.

Here’s an example:

Kotlin
// build.gradle (Module level)
android {
    splits {
        abi {
            enable true
            reset() // Clear the default list
            include "arm64-v8a", "armeabi-v7a", "x86", "x86_64"
            universalApk false // Don't generate a fat universal APK
        }
    }
}
  • enable true → Turns on ABI splitting
  • include → Specifies which ABIs to build
  • universalApk false → Prevents creating a large APK with all ABIs

Now each device downloads only the version it needs.

This reduces APK size significantly.

What About Android App Bundles (AAB)?

If you’re using Android App Bundles (which is required for Play Store apps), Google Play automatically delivers the correct native libraries per device.

This is called ABI split delivery.

In this case, you don’t need manual split configuration for Play Store distribution.

However, understanding ABI in Android still matters when:

  • Testing locally
  • Distributing outside Play Store
  • Debugging native crashes
  • Optimizing build size

How ABI in Android Affects Performance

Performance impact comes from two main areas:

1. 32-bit vs 64-bit

Modern devices use arm64-v8a. Running a 64-bit native library provides:

  • Better memory handling
  • More CPU registers
  • Improved performance for heavy computation
  • Better compatibility with modern Android versions

Google Play requires 64-bit support for apps using native code.

If you ship only 32-bit libraries, your app may run in compatibility mode on 64-bit devices. That’s not ideal.

2. CPU-Specific Optimization

When you compile for a specific ABI in Android, the compiler generates instructions optimized for that architecture.

Example:

  • ARM CPUs use ARM instruction sets
  • x86 devices use Intel instruction sets

Native code compiled for ARM won’t run efficiently on x86 without translation.

Better ABI targeting = better runtime performance.

How to Specify ABI in Android (NDK Example)

If you use CMake, you can define supported ABIs like this:

Kotlin
android {
    defaultConfig {
        ndk {
            abiFilters "arm64-v8a", "armeabi-v7a"
        }
    }
}
  • abiFilters restricts which ABIs are built
  • Prevents generating unnecessary .so files
  • Reduces build time
  • Reduces final APK size

Simple but powerful.

How to Check Device ABI

You can check the device ABI programmatically:

Kotlin
val abiList = Build.SUPPORTED_ABIS
for (abi in abiList) {
    Log.d("ABI_CHECK", abi)
}

This returns a list like:

arm64-v8a
armeabi-v7a

The first ABI is the preferred one.

Common Mistakes Developers Make

Including Too Many ABIs

If your analytics show 95% ARM users, shipping x86 may not be worth it unless you need emulator support.

Not Supporting 64-bit

Google Play requires 64-bit native support for apps using the NDK.

Using Universal APKs in Production

Universal APKs contain all ABIs. That’s convenient for testing, but inefficient for production distribution.

Best Practices for ABI in Android

Here’s a practical checklist:

  • Always support arm64-v8a
  • Include armeabi-v7a if you still support older devices
  • Avoid x86 unless required
  • Use Android App Bundles for Play distribution
  • Use abiFilters to control builds
  • Monitor APK size regularly
  • Test native crashes per architecture

When Should You Care About ABI in Android?

You should care if:

  • You use the Android NDK
  • You integrate native SDKs (camera, ML, gaming engines)
  • You want to reduce APK size
  • You optimize performance
  • You publish on Google Play

If your app is pure Kotlin or Java without native libraries, ABI doesn’t directly affect you.

Real-World Example

Let’s say you’re building a photo editing app with native image processing in C++.

If you:

  • Ship only 32-bit libraries
  • Include all ABIs in one APK
  • Don’t optimize build filters

Your APK may grow from 25 MB to 60 MB.

But if you:

  • Use ABI splits
  • Support only ARM architectures
  • Use App Bundles

You can reduce the download size significantly while improving performance on modern devices.

That’s the practical impact of understanding ABI in Android.

Conclusion

ABI in Android is not just a technical term buried in documentation. It directly affects:

  • APK size
  • App performance
  • Play Store compliance
  • User experience

If your app includes native code, ABI decisions matter a lot.

Keep your builds clean. Support 64-bit. Ship only what users need.

And most importantly, understand what’s inside your APK.

That’s how you build lean, fast, production-ready Android apps.

sitLess App

sitLess App: A Smart Ergonomic Sitting Reminder for Better Focus, Posture, and Productivity

Most of us sit longer than we should.

Work meetings. Coding sessions. Online classes. Gaming. Six to ten hours pass before we even notice. Over time, that constant sitting affects posture, drains energy, and increases physical strain.

sitLess is built to solve exactly that.

It’s a simple Android app that gives you timed reminders to stand up, stretch, and reset. No clutter. No complicated dashboards. Just smart reminders that help you protect your body while staying productive.

sitLess helps you reduce sedentary time by sending automated break reminders throughout your workday. It’s simple, focused, and designed to improve both posture and productivity.

If you spend most of your day at a desk, this app can make a real difference.

Why Prolonged Sitting Is a Problem

Prolonged sitting affects far more than your comfort.

Research consistently links extended sitting with:

  • Lower back pain
  • Neck and shoulder stiffness
  • Poor posture
  • Reduced blood circulation
  • Mental fatigue
  • Decreased productivity

Even if you exercise regularly, long, uninterrupted sitting sessions can still negatively impact your body.

That’s why experts recommend standing or moving every 30 to 60 minutes.

The challenge? Most people forget.

A break reminder app solves this by building consistent movement directly into your daily routine.

What Is sitLess?

sitLess is a lightweight sitting reminder app for Android designed to help users develop healthier desk habits.

Instead of relying on memory, the app creates an automatic cycle:

Sit → Break → Repeat

Once activated, sitLess sends reminders at your chosen intervals, helping you move consistently throughout the day without disrupting your workflow.

Unlike complex productivity tools, sitLess focuses on one goal: reducing sedentary time in a simple, sustainable way.

How sitLess Supports Ergonomic Health

Good ergonomics isn’t just about buying a better chair. Movement plays a critical role in protecting your body during long desk sessions.

Here’s how sitLess supports healthier desk habits:

1. Helps Reduce Back Pain from Sitting

Lower back discomfort is one of the most common issues among desk workers.

By prompting you to stand and reset your posture regularly, sitLess helps reduce prolonged pressure on the spine. Over time, these small adjustments can minimize strain buildup.

2. Encourages Better Posture

Extended sitting often leads to rounded shoulders and a forward-leaning neck.

Break reminders interrupt that static position, giving you the opportunity to realign your posture before returning to work. Consistency makes the difference.

3. Improves Circulation and Energy

Sitting slows blood flow, particularly in the legs.

Short movement breaks stimulate circulation and help maintain steady energy levels, reducing the likelihood of the afternoon slump.

4. Enhances Focus and Productivity

It may seem counterintuitive, but short breaks can significantly boost productivity.

When you move regularly:

  • Your brain resets
  • Eye strain decreases
  • Mental clarity improves
  • Decision-making sharpens

This structured break pattern is similar to the Pomodoro Technique, which uses timed intervals to sustain focus and performance.

The difference is that sitLess prioritizes physical health and ergonomics — not just time management.

As a result, a sitting reminder app becomes more than a productivity system. It serves as both a wellness tool and a performance enhancer.

Key Features of sitLess

sitLess keeps things simple while giving you full control over your routine.

Custom Sitting Timer

Choose how long you want to sit before receiving a reminder.

Whether you prefer 25-minute focus sessions or 45-minute deep work blocks, the app adapts to your schedule.

Custom Break Duration

Breaks aren’t one-size-fits-all.

Some days call for a quick stretch, while others may require a longer walk. sitLess lets you set break durations that fit your needs.

Automatic Repeat Cycle

Once started, the sit–break cycle runs automatically.

There’s no need to manually restart timers, making consistency effortless.

Clean, Minimal Interface

Many productivity apps overwhelm users with unnecessary features.

sitLess offers a clean, distraction-free interface focused solely on break reminders, making it easy to use every day.

Who Should Use a Sitting Reminder App?

sitLess is ideal for:

  • Office workers
  • Remote employees
  • Students
  • Developers
  • Designers
  • Writers
  • Gamers
  • Anyone with a sedentary lifestyle

If you sit for more than six hours a day, structured movement can significantly improve both comfort and focus.

How to Use sitLess for Best Results

To maximize benefits:

  • Set your sitting timer to 30 minutes
  • Choose a 5-minute break
  • Stand fully during breaks
  • Stretch your back and shoulders
  • Walk when possible
  • Repeat throughout your workday.

Use this schedule as a starting point and adjust it to fit your workflow.

The goal isn’t perfection — it’s consistency. Even short, regular breaks can make long desk hours more manageable and sustainable.

Why sitLess Is Different from Other Break Reminder Apps

Many apps combine task tracking, analytics, and habit streaks.

However, sitLess stays focused.

It is not overloaded with features. Instead, it concentrates on reducing sedentary time and supporting ergonomic health.

That simplicity increases long-term consistency.

Frequently Asked Questions

How often should I take breaks from sitting?

Most experts recommend standing or moving every 30 to 60 minutes.

Can a sitting reminder app improve productivity?

Yes. Short breaks help reset mental focus and reduce fatigue, leading to better work performance.

Does sitLess treat back pain?

sitLess is not a medical tool. However, regular movement can help reduce discomfort caused by prolonged sitting.

Is sitLess easy to use?

Yes. The app features a clean interface and simple timer customization.

Conclusion

If you work long hours at a desk, reducing sedentary time is essential.

sitLess makes that process simple.

By combining automated reminders, customizable timers, and ergonomic support, this sitting reminder app helps you protect your body while improving focus at work.

You don’t need a complicated system.

You just need consistent movement.

Download sitLess on Google Play

Debug Symbol File

What Is a Debug Symbol File and Why Does Google Play Console Require It for Android Apps?

If you’ve ever uploaded an Android App Bundle to the Google Play Console and seen a warning about a missing Debug Symbol File, you’re not alone.

A lot of developers hit this message and wonder:

  • What exactly is a Debug Symbol File?
  • Why does Google Play Console need it?
  • Is it mandatory?
  • How do I generate and upload it?

In this comprehensive guide, we’ll demystify debug symbol files, explain why Google Play Console needs them, and show you exactly how to generate and upload them. 

Let’s dive in..!

What Is a Debug Symbol File?

A Debug Symbol File is a file that maps compiled machine code back to readable source code.

When you build an Android app, your original code (Kotlin, Java, C++, etc.) gets compiled into low-level machine instructions. During this process, meaningful names like:

fun calculateTotalPrice(items: List<Item>): Double

may get stripped, optimized, or converted into memory addresses.

These processes make your code unreadable to humans — but they also make crash reports impossible to understand.

Without symbols, a crash report might look like this:

#00 pc 000000000004a123  /lib/arm64/libnative-lib.so
#01 pc 000000000003b789 /lib/arm64/libnative-lib.so

That tells you almost nothing.

With a Debug Symbol File, the same crash becomes:

#00 calculateTotalPrice() at PaymentProcessor.kt:42
#01 checkout() at CartManager.kt:88

Now you know exactly what went wrong and where.

That’s the difference.

Why Does Google Play Console Require a Debug Symbol File?

The Google Play Console asks for a Debug Symbol File when your app includes:

  • Native code (C or C++)
  • NDK libraries
  • Game engines (like Unity or Unreal)
  • Any .so native shared libraries

The Core Reason: Better Crash Reporting

Google Play collects crash data from real users. But without symbols, it can’t decode native crash stack traces.

When you upload a Debug Symbol File:

  • Google can deobfuscate native stack traces
  • Crash reports become human-readable
  • You can fix bugs faster
  • Your app stability improves

In short, it’s about observability and reliability.

Is It Mandatory?

Technically, your app can still be published without it.

But if you skip the Debug Symbol File:

  • Native crashes will be unreadable
  • You’ll lose valuable debugging insights
  • You’ll struggle to fix production issues

For any serious production app, it’s strongly recommended.

When Do You Actually Need It?

You need a Debug Symbol File if:

  • Your app uses Android NDK
  • You include native libraries (.so files)
  • You’re building games
  • You’re using certain SDKs that bundle native code

If your app is pure Kotlin or Java with no native layer, you usually won’t see this requirement.

What Happens Without a Debug Symbol File?

Let’s say your app crashes inside native code.

Without symbols:

Fatal signal 11 (SIGSEGV)
pc 00000000000af3b4

You have no idea which function caused it.

With symbols:

Segmentation fault in renderFrame()
File: Renderer.cpp
Line: 214

Now you can:

  • Reproduce the issue
  • Fix the exact line
  • Release a patch
  • Improve user ratings

This is why Google emphasizes it.

What Is Inside a Debug Symbol File?

A Debug Symbol File contains:

  • Function names
  • Variable names
  • Line numbers
  • Memory address mappings

For Android native apps, it usually includes:

  • .so files with symbols
  • Or a zipped folder generated from the NDK build

It does not expose your full source code publicly. It only helps map crash data.

How to Generate a Debug Symbol File (Step-by-Step)

If You’re Using Android Gradle with NDK

Add this to your build.gradle:

Kotlin
android {
    buildTypes {
        release {
            ndk {
                debugSymbolLevel 'FULL'
            }
        }
    }
}

Here,

  • debugSymbolLevel 'FULL' tells Gradle to keep full debug symbols.
  • During build, symbol files are generated.
  • They’re stored inside the build output directory.

After building your release bundle:

app/build/outputs/native-debug-symbols/release/native-debug-symbols.zip

You’ll find a .zip file.

That ZIP file is your Debug Symbol File.

Upload it to Google Play Console under:

App bundle explorer → Native debug symbols

Or during release upload if prompted.

Btw, what’s inside native-debug-symbols.zip?

It contains folders per ABI (Application Binary Interface), for example:

arm64-v8a/
armeabi-v7a/
x86/
x86_64/

Each folder contains your .so files with full debug symbols included.

Example:

arm64-v8a/libyourlibrary.so

If You’re Using CMake

Ensure you build with debug symbols enabled:

set(CMAKE_BUILD_TYPE Release)
set(CMAKE_CXX_FLAGS_RELEASE "-g")

The -g flag tells the compiler to include debug information.

Without it, symbols are stripped.

What About R8 or ProGuard?

That’s slightly different.

If your app uses code shrinking (R8/ProGuard), you also generate a mapping file:

mapping.txt

This is used to deobfuscate Java/Kotlin stack traces.

So in summary:

  • Debug Symbol File → Native crashes
  • mapping.txt → Obfuscated Java/Kotlin crashes

Both improve crash readability.

Security Concerns: Is It Safe to Upload?

Yes.

Google Play:

  • Uses the Debug Symbol File internally
  • Does not distribute it publicly
  • Only applies it to crash deobfuscation

It does not expose your source code to users.

How Debug Symbol Files Improve App Quality

Uploading a Debug Symbol File helps you:

  1. Diagnose production crashes accurately
  2. Reduce mean time to resolution (MTTR)
  3. Improve stability metrics
  4. Increase Play Store ratings
  5. Meet professional engineering standards

From a product perspective, better crash data equals better user retention.

Best Practices for Managing Debug Symbol Files

Here’s what experienced Android teams do:

  • Store symbol files in secure CI/CD artifacts
  • Version them alongside releases
  • Automate uploads during deployment
  • Keep backups for older versions

If you lose the symbol file for a release, you cannot retroactively decode its native crashes.

So treat it like an important build artifact.

Common Developer Questions

Does every Android app need a Debug Symbol File?

No. Only apps using native code (NDK or .so libraries).

Can I regenerate symbols later?

Only if you still have the exact same build artifacts and configuration. Otherwise, no.

Is this required for App Bundles?

Yes, especially when distributing .aab files with native components.

Quick Summary

A Debug Symbol File:

  • Maps machine code back to readable source code
  • Makes native crash reports understandable
  • Is required by Google Play Console for apps using native libraries
  • Helps you fix production crashes faster
  • Improves app quality and reliability

If your app includes NDK or .so files, uploading it is not optional if you care about debugging effectively.

Conclusion

The Debug Symbol File isn’t just a technical checkbox in Google Play Console.

It’s a practical tool that turns meaningless crash data into actionable insights.

If you’re building serious Android apps, especially with native components, treat symbol management as part of your release process. 

Automate it. Store it. Upload it.

BoxWithConstraints in Jetpack Compose

Mastering BoxWithConstraints in Jetpack Compose: Build Truly Responsive UIs

Modern Android apps run on phones, tablets, foldables, Chromebooks, and even desktop environments. If your layout only looks good on one screen size, users will notice.

That’s where BoxWithConstraints in Jetpack Compose becomes powerful.

In this guide, you’ll learn what it is, when to use it, how it works internally, and how to build truly responsive layouts with practical examples. 

What Is BoxWithConstraints in Jetpack Compose?

BoxWithConstraints in Jetpack Compose is a layout composable that gives you access to the size constraints of its parent during composition.

It lets you know how much space is available so you can change your UI dynamically.

Instead of guessing screen size or using hardcoded breakpoints, you can read:

  • maxWidth
  • maxHeight
  • minWidth
  • minHeight

And build your layout accordingly.

Why It Matters for Responsive UI

Responsive design is no longer optional.

Your app may run on:

  • Compact phones
  • Large tablets
  • Foldables in multi-window mode
  • Desktop mode

If you rely only on Modifier.fillMaxWidth() or fixed sizes, your UI may stretch or break.

BoxWithConstraints in Jetpack Compose helps you:

  • Adapt layout based on width
  • Switch between column and row layouts
  • Show or hide content conditionally
  • Change typography and spacing dynamically

This is real responsiveness, not just resizing.

How BoxWithConstraints Works

Here’s the basic structure:

Kotlin
@Composable
fun ResponsiveExample() {
    BoxWithConstraints {
        if (maxWidth < 600.dp) {
            Text("Compact Screen")
        } else {
            Text("Large Screen")
        }
    }
}

Inside BoxWithConstraints, you can directly access maxWidth.

The important thing to understand:

  • The values are Dp
  • They represent the constraints passed from the parent
  • The composable recomposes if constraints change

So your UI reacts automatically.

Example 1: Switching Between Column and Row Layout

This is a common real-world case.

On small screens → stack items vertically
On large screens → place items side by side

Kotlin
@Composable
fun ProfileSection() {
    BoxWithConstraints(
        modifier = Modifier.fillMaxSize()
    ) {
        val isCompact = maxWidth < 600.dp

    if (isCompact) {
            Column(
                modifier = Modifier.padding(16.dp)
            ) {
                Avatar()
                Spacer(modifier = Modifier.height(16.dp))
                UserInfo()
            }
        } else {
            Row(
                modifier = Modifier.padding(24.dp)
            ) {
                Avatar()
                Spacer(modifier = Modifier.width(24.dp))
                UserInfo()
            }
        }
    }
}
  • We check if width is less than 600dp.
  • If true → vertical layout.
  • If false → horizontal layout.
  • Spacing is adjusted accordingly.

This approach is clean and easy to scale.

Example 2: Dynamic Card Grid

Let’s build a responsive grid without using a predefined grid layout.

Kotlin
@Composable
fun ResponsiveGrid() {
    BoxWithConstraints(
        modifier = Modifier.fillMaxWidth()
    ) {
        val columns = when {
            maxWidth < 600.dp -> 1
            maxWidth < 840.dp -> 2
            else -> 3
        }

    LazyVerticalGrid(
            columns = GridCells.Fixed(columns),
            contentPadding = PaddingValues(16.dp)
        ) {
            items(20) { index ->
                Card(
                    modifier = Modifier
                        .padding(8.dp)
                        .fillMaxWidth()
                ) {
                    Text(
                        text = "Item $index",
                        modifier = Modifier.padding(16.dp)
                    )
                }
            }
        }
    }
}
  • We calculate column count dynamically.
  • Grid adapts to available width.
  • No device-specific logic.
  • No hardcoded “tablet mode.”

This is flexible and future-proof.

Accessing Constraints in Pixels

Sometimes you need pixel-level calculations.

Inside BoxWithConstraints in Jetpack Compose, you can convert Dp to pixels:

Kotlin
@Composable
fun WidthInPixelsExample() {
    BoxWithConstraints {
        val widthInPx = with(LocalDensity.current) {
            maxWidth.toPx()
        }

        Text("Width in pixels: $widthInPx")
    }
}

Use this carefully. Most of the time, Dp is enough.

When Should You Use BoxWithConstraints?

Use it when:

  • Layout changes based on available width
  • You need responsive breakpoints
  • Parent constraints matter for child composition
  • You’re building adaptive layouts

Avoid using it:

  • For simple static UI
  • When Modifier constraints are enough
  • When you only need screen size (use LocalConfiguration instead)

Think of BoxWithConstraints in Jetpack Compose as a precision tool, not a default choice.

Common Mistakes Developers Make

1. Confusing Screen Size with Available Space

BoxWithConstraints gives you parent constraints, not the entire screen size.

In split-screen mode, constraints may be smaller than the device width.

This is good. It makes your UI adaptive.

2. Overusing Nested BoxWithConstraints

Nesting multiple constraint readers increases complexity and recomposition cost.

Keep it simple.

3. Hardcoding Too Many Breakpoints

Instead of:

Kotlin
maxWidth < 400.dp
maxWidth < 500.dp
maxWidth < 600.dp

Stick to meaningful layout breakpoints like:

  • Compact
  • Medium
  • Expanded

This keeps logic maintainable.

Performance Considerations

Is BoxWithConstraints in Jetpack Compose expensive?

Not really. But:

  • It introduces recomposition when constraints change.
  • Complex logic inside it can slow composition.

Best practice:

Keep heavy calculations outside or memoize using remember.

Example:

Kotlin
val isCompact = remember(maxWidth) {
    maxWidth < 600.dp
}

This ensures efficient re-composition.

Real-World Pattern: Adaptive Master-Detail Layout

Classic example:

Phone → single column
Tablet → list + details side by side

Kotlin
@Composable
fun MasterDetailLayout() {
    BoxWithConstraints(
        modifier = Modifier.fillMaxSize()
    ) {
        val isTablet = maxWidth >= 840.dp

         if (isTablet) {
            Row {
                Box(modifier = Modifier.weight(1f)) {
                    MasterList()
                }
                Box(modifier = Modifier.weight(2f)) {
                    DetailPane()
                }
            }
        } else {
            MasterList()
        }
    }
}

This pattern is widely used in email apps, dashboards, and productivity tools.

Box vs BoxWithConstraints

You might wonder:

Why not just use Box?

Here’s the difference:

If you don’t need constraint info, stick with Box.

How It Aligns with Modern Android Best Practices

Google encourages:

  • Adaptive layouts
  • Multi-device support
  • Foldable readiness

BoxWithConstraints in Jetpack Compose supports all of this naturally.

It works well alongside:

  • Window size classes
  • Material 3 adaptive design
  • Large screen guidelines

You’re building future-ready UI when you use it correctly.

Quick FAQ

What is BoxWithConstraints in Jetpack Compose?

It is a layout composable that exposes parent layout constraints like maxWidth and maxHeight, allowing dynamic and responsive UI decisions during composition.

When should I use BoxWithConstraints?

Use it when your layout must change depending on available space, such as switching from column to row or adjusting grid columns.

Does BoxWithConstraints affect performance?

It can trigger recomposition when constraints change, but it is generally efficient when used correctly.

Is BoxWithConstraints better than LocalConfiguration?

They serve different purposes.

  • LocalConfiguration gives device configuration.
  • BoxWithConstraints gives parent layout constraints.

Conclusion

Mastering BoxWithConstraints in Jetpack Compose changes how you think about UI design.

Instead of designing for fixed screens, you design for available space.

That shift makes your apps:

  • More adaptive
  • More professional
  • More future-proof

Start simple. Add breakpoints thoughtfully. Test in multi-window mode. Resize your emulator. Observe how your UI behaves.

Responsive design is not about bigger screens. It’s about flexible thinking.

And BoxWithConstraints in Jetpack Compose is one of your best tools to make that happen.

Design Tokens

Design Tokens in Material Design 3 with Jetpack Compose

If you’ve been diving into modern Android development, you’ve probably heard the buzz about Material Design 3 (also known as Material You) and Jetpack Compose. Today, we’re going to explore one of the most powerful yet underappreciated features that ties them together: Design Tokens.

Understanding Design Tokens in Material 3 and Jetpack Compose will transform how you build consistent, beautiful, and maintainable Android apps. 

Let’s dive in..!

What Are Design Tokens?

Before we jump into the Material 3 specifics, let’s get on the same page about what design tokens actually are.

Think of design tokens as the DNA of your app’s design system. They’re named values that store design decisions like colors, typography, spacing, and shapes. Instead of hardcoding Color(0xFF6200EE) everywhere in your app, you’d use a token like MaterialTheme.colorScheme.primary.

Btw why this matters..?

Actually, when you decide to rebrand your app or support dark mode, you only need to change the token values in one place, not hunt down hundreds of hardcoded values scattered across your codebase.

Why Material Design 3 Changed Everything

Material Design 3 represents a massive evolution in how we think about design systems. Unlike Material Design 2, which had a more rigid structure, Material 3 introduces a flexible, personalized approach that adapts to user preferences.

Design Tokens in Material 3 and Jetpack Compose work together to make this personalization possible. Material 3 includes over 40 color tokens, dynamic color generation from wallpapers, and a comprehensive token system for typography, shapes, and elevation.

Understanding the Material Design 3 Token Structure

Material Design 3 organizes tokens into structured layers:

1. Reference Tokens

Raw values like colors or sizes.

Example:

  • Blue 500
  • 16sp
  • 8dp

2. System Tokens

Semantic values used by the UI system.

Example:

  • primary
  • onPrimary
  • surface

3. Component Tokens

Values applied to specific UI components.

Example:

  • Button container color
  • TextField label color

Jetpack Compose primarily exposes system tokens through MaterialTheme, which internally map to component behavior.

Material Design 3 in Jetpack Compose

Jetpack Compose provides the MaterialTheme composable (from the material3 library) that exposes design tokens:

  • colorScheme
  • typography
  • shapes

Let’s explore each with Kotlin examples.

The Core Components of Design Tokens in Material 3

Let’s break down the main categories of design tokens you’ll work with:

1. Color Tokens

Material 3’s color system is brilliant. Instead of just “primary” and “secondary,” you get a full palette that automatically handles light and dark modes, accessibility, and color harmonies.

Kotlin
@Composable
fun ColorTokenExample() {
    // Access color tokens through MaterialTheme
    val primaryColor = MaterialTheme.colorScheme.primary
    val onPrimary = MaterialTheme.colorScheme.onPrimary
    val surface = MaterialTheme.colorScheme.surface
    val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant
    
    Card(
        colors = CardDefaults.cardColors(
            containerColor = surface,
            contentColor = MaterialTheme.colorScheme.onSurface
        )
    ) {
        Text(
            text = "This uses design tokens!",
            color = MaterialTheme.colorScheme.onSurface
        )
    }
}

Here, we’re accessing color tokens through MaterialTheme.colorScheme. The MaterialTheme composable provides access to Material 3’s design tokens. These tokens automatically adjust based on whether the user is in light or dark mode. The onPrimary token ensures text on your primary color is always readable.

2. Typography Tokens

Typography tokens define your text styles consistently across your app. Material Design 3 provides a complete type scale with tokens for everything from large display text to tiny labels.

Kotlin
@Composable
fun TypographyTokenExample() {
    Column(
        modifier = Modifier.padding(16.dp)
    ) {
        // Display large - for prominent text
        Text(
            text = "Welcome Back!",
            style = MaterialTheme.typography.displayLarge
        )
        
        // Headline medium - for section headers
        Text(
            text = "Your Dashboard",
            style = MaterialTheme.typography.headlineMedium
        )
        
        // Body large - for main content
        Text(
            text = "Here's a summary of your activity today.",
            style = MaterialTheme.typography.bodyLarge
        )
        
        // Label small - for captions or metadata
        Text(
            text = "Last updated: 2 hours ago",
            style = MaterialTheme.typography.labelSmall
        )
    }
}

Each typography token (displayLarge, headlineMedium, bodyLarge, labelSmall) defines font size, weight, line height, and letter spacing. By using these Material 3 tokens instead of hardcoding text styles, your app maintains perfect typographic hierarchy.

3. Shape Tokens

Shapes define the corner radii and other geometric properties of your components. Material Design 3 uses different shape tokens for different component types.

Kotlin
@Composable
fun ShapeTokenExample() {
    Row(
        modifier = Modifier.padding(16.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        // Extra small - for chips and small elements
        Surface(
            shape = MaterialTheme.shapes.extraSmall,
            color = MaterialTheme.colorScheme.primaryContainer,
            modifier = Modifier.size(60.dp)
        ) {
            Box(contentAlignment = Alignment.Center) {
                Text("XS")
            }
        }
        
        // Medium - for cards
        Surface(
            shape = MaterialTheme.shapes.medium,
            color = MaterialTheme.colorScheme.secondaryContainer,
            modifier = Modifier.size(60.dp)
        ) {
            Box(contentAlignment = Alignment.Center) {
                Text("M")
            }
        }
        
        // Large - for dialogs and sheets
        Surface(
            shape = MaterialTheme.shapes.large,
            color = MaterialTheme.colorScheme.tertiaryContainer,
            modifier = Modifier.size(60.dp)
        ) {
            Box(contentAlignment = Alignment.Center) {
                Text("L")
            }
        }
    }
}

Shape tokens (extraSmall, medium, large) ensure consistent corner radii throughout your app. Material 3 uses different shapes for different component types, creating visual cohesion and helping users understand component hierarchy.

Setting Up Design Tokens in Your Jetpack Compose Project

Now let’s get practical. Here’s how to implement Design Tokens in Material 3 and Jetpack Compose in your project.

Add Material 3 Dependency

First, ensure you have the Material 3 library in your build.gradle.kts file:

Kotlin
dependencies {
    implementation("androidx.compose.material3:material3:1.2.0")
    implementation("androidx.compose.ui:ui:1.6.0")
}

Create Your Color Scheme

Material Design 3 makes it easy to generate a complete color scheme. You can use the Material Theme Builder tool or define colors manually.

Kotlin
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.ui.graphics.Color

// Define your seed colors
private val md_theme_light_primary = Color(0xFF6750A4)
private val md_theme_light_onPrimary = Color(0xFFFFFFFF)
private val md_theme_light_primaryContainer = Color(0xFFEADDFF)
private val md_theme_light_onPrimaryContainer = Color(0xFF21005D)
private val md_theme_light_secondary = Color(0xFF625B71)
private val md_theme_light_onSecondary = Color(0xFFFFFFFF)
private val md_theme_light_secondaryContainer = Color(0xFFE8DEF8)
private val md_theme_light_onSecondaryContainer = Color(0xFF1D192B)
private val md_theme_light_surface = Color(0xFFFFFBFE)
private val md_theme_light_onSurface = Color(0xFF1C1B1F)
private val md_theme_dark_primary = Color(0xFFD0BCFF)
private val md_theme_dark_onPrimary = Color(0xFF381E72)
private val md_theme_dark_primaryContainer = Color(0xFF4F378B)
private val md_theme_dark_onPrimaryContainer = Color(0xFFEADDFF)
private val md_theme_dark_secondary = Color(0xFFCCC2DC)
private val md_theme_dark_onSecondary = Color(0xFF332D41)
private val md_theme_dark_secondaryContainer = Color(0xFF4A4458)
private val md_theme_dark_onSecondaryContainer = Color(0xFFE8DEF8)
private val md_theme_dark_surface = Color(0xFF1C1B1F)
private val md_theme_dark_onSurface = Color(0xFFE6E1E5)

val LightColorScheme = lightColorScheme(
    primary = md_theme_light_primary,
    onPrimary = md_theme_light_onPrimary,
    primaryContainer = md_theme_light_primaryContainer,
    onPrimaryContainer = md_theme_light_onPrimaryContainer,
    secondary = md_theme_light_secondary,
    onSecondary = md_theme_light_onSecondary,
    secondaryContainer = md_theme_light_secondaryContainer,
    onSecondaryContainer = md_theme_light_onSecondaryContainer,
    surface = md_theme_light_surface,
    onSurface = md_theme_light_onSurface
)

val DarkColorScheme = darkColorScheme(
    primary = md_theme_dark_primary,
    onPrimary = md_theme_dark_onPrimary,
    primaryContainer = md_theme_dark_primaryContainer,
    onPrimaryContainer = md_theme_dark_onPrimaryContainer,
    secondary = md_theme_dark_secondary,
    onSecondary = md_theme_dark_onSecondary,
    secondaryContainer = md_theme_dark_secondaryContainer,
    onSecondaryContainer = md_theme_dark_onSecondaryContainer,
    surface = md_theme_dark_surface,
    onSurface = md_theme_dark_onSurface
)

We’re defining two color schemes — one for light mode and one for dark mode. This follows the Material Design 3 color system specification. Each color has a specific purpose. 

Notice the “on” prefix..? Those ensure text and icons are readable on their corresponding background colors.

Create Your Custom Theme

Now let’s wrap everything in a theme composable. This is where we configure the MaterialTheme composable with our Material 3 design tokens:

Kotlin
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.Shapes
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// Custom typography following Material Design 3 type scale
val AppTypography = Typography(
    displayLarge = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Bold,
        fontSize = 57.sp,
        lineHeight = 64.sp,
        letterSpacing = (-0.25).sp
    ),
    headlineMedium = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.SemiBold,
        fontSize = 28.sp,
        lineHeight = 36.sp,
        letterSpacing = 0.sp
    ),
    bodyLarge = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp,
        lineHeight = 24.sp,
        letterSpacing = 0.5.sp
    ),
    labelSmall = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Medium,
        fontSize = 11.sp,
        lineHeight = 16.sp,
        letterSpacing = 0.5.sp
    )
)

@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = if (darkTheme) {
        DarkColorScheme
    } else {
        LightColorScheme
    }
    
    MaterialTheme(
        colorScheme = colorScheme,
        typography = AppTypography,
        shapes = Shapes(), // Uses default Material 3 shapes
        content = content
    )
}

The MyAppTheme composable automatically detects if the system is in dark mode and switches between your light and dark color schemes. We pass our design tokens to the MaterialTheme composable, which makes them available throughout your app. We’re defining custom typography based on Material Design 3’s type scale while using Material 3’s default shapes.

Apply Your Theme

Wrap your app’s root composable with your theme:

Kotlin
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyAppTheme {
                // Surface provides a background using the surface color token
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    // Your app content goes here
                    AppContent()
                }
            }
        }
    }
}

By wrapping everything in MyAppTheme, all composables inside can access your Material 3 design tokens through MaterialTheme. The Surface composable uses the background color token automatically.

Advanced: Dynamic Color and Material You

One of the coolest features of Design Tokens in Material 3 and Jetpack Compose is dynamic color. On Android 12+, your app can generate its color scheme from the user’s wallpaper..!

This is the signature feature of Material You (Material Design 3’s brand name), creating truly personalized user experiences.

Kotlin
import android.os.Build
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext

@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true, // Enable dynamic color
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        // Use dynamic colors on Android 12+
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context)
            else dynamicLightColorScheme(context)
        }
        // Fall back to custom colors
        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }
    
    MaterialTheme(
        colorScheme = colorScheme,
        typography = AppTypography,
        content = content
    )
}

On devices running Android 12 or higher, dynamicLightColorScheme() and dynamicDarkColorScheme() generate a complete Material 3 color scheme based on the user’s wallpaper. This creates a truly personalized experience without any extra work on your part! Your design tokens automatically adapt to the generated colors.

Creating Custom Design Tokens

Sometimes you need tokens beyond what Material 3 provides. Here’s how to extend the system while maintaining consistency with Material Design 3 principles:

Kotlin
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

// Define custom spacing tokens
data class Spacing(
    val none: Dp = 0.dp,
    val extraSmall: Dp = 4.dp,
    val small: Dp = 8.dp,
    val medium: Dp = 16.dp,
    val large: Dp = 24.dp,
    val extraLarge: Dp = 32.dp,
    val huge: Dp = 48.dp
)

// Create a CompositionLocal
val LocalSpacing = staticCompositionLocalOf { Spacing() }

// Extension property for easy access
val MaterialTheme.spacing: Spacing
    @Composable
    get() = LocalSpacing.current

// Usage in your theme
@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
    
    MaterialTheme(
        colorScheme = colorScheme,
        typography = AppTypography
    ) {
        // Provide custom spacing tokens
        CompositionLocalProvider(LocalSpacing provides Spacing()) {
            content()
        }
    }
}

// Now you can use custom spacing tokens!
@Composable
fun CustomSpacingExample() {
    Column(
        modifier = Modifier.padding(MaterialTheme.spacing.medium)
    ) {
        Text(
            text = "First item",
            modifier = Modifier.padding(bottom = MaterialTheme.spacing.small)
        )
        Text(
            text = "Second item",
            modifier = Modifier.padding(bottom = MaterialTheme.spacing.large)
        )
    }
}

We created custom spacing tokens using CompositionLocal, which allows us to provide values that can be accessed by any composable in the tree. The extension property makes accessing these tokens feel natural, just like accessing built-in Material 3 tokens. This approach maintains consistency with how Material Design 3 organizes its design system.

Best Practices for Design Tokens

Working with Design Tokens in Material 3 and Jetpack Compose effectively requires following some key principles:

Always Use Tokens, Never Hardcode

Bad:

Kotlin
Text(
    text = "Hello",
    color = Color(0xFF6750A4), // Hardcoded color
    fontSize = 16.sp // Hardcoded size
)

Good:

Kotlin
Text(
    text = "Hello",
    color = MaterialTheme.colorScheme.primary,
    style = MaterialTheme.typography.bodyLarge
)

Use Semantic Token Names

When creating custom tokens, use names that describe the purpose, not the appearance. This follows Material Design 3’s semantic naming philosophy:

Bad: val blueButton = Color(0xFF0000FF)

Good: val buttonPrimary = MaterialTheme.colorScheme.primary

Leverage “On” Color Tokens

Material 3 provides “on” tokens that ensure proper contrast:

Kotlin
@Composable
fun AccessibleButton() {
    Button(
        onClick = { },
        colors = ButtonDefaults.buttonColors(
            containerColor = MaterialTheme.colorScheme.primary,
            contentColor = MaterialTheme.colorScheme.onPrimary // Always readable!
        )
    ) {
        Text("Click Me")
    }
}

The onPrimary token adjusts automatically to maintain proper contrast ratio for accessibility, whether you’re in light mode, dark mode, or using dynamic colors. This is a core principle of Material Design 3’s accessibility-first approach.

Real-World Example: Building a Themed Card Component

Let’s put everything together with a practical example that showcases Design Tokens in Material 3 and Jetpack Compose:

Kotlin
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun ProductCard(
    title: String,
    description: String,
    price: String,
    onFavoriteClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Card(
        modifier = modifier.fillMaxWidth(),
        // Using Material 3 shape token
        shape = MaterialTheme.shapes.medium,
        // Using Material 3 color tokens
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.surfaceVariant,
            contentColor = MaterialTheme.colorScheme.onSurfaceVariant
        ),
        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                // Using Material 3 typography token
                Text(
                    text = title,
                    style = MaterialTheme.typography.headlineSmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
                
                IconButton(onClick = onFavoriteClick) {
                    Icon(
                        imageVector = Icons.Default.Favorite,
                        contentDescription = "Add to favorites",
                        tint = MaterialTheme.colorScheme.primary
                    )
                }
            }
            
            Spacer(modifier = Modifier.height(8.dp))
            
            // Using Material 3 typography token for body text
            Text(
                text = description,
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
            )
            
            Spacer(modifier = Modifier.height(12.dp))
            
            // Using Material 3 typography token for price
            Text(
                text = price,
                style = MaterialTheme.typography.titleLarge,
                color = MaterialTheme.colorScheme.primary
            )
        }
    }
}

// Using the component
@Composable
fun ProductScreen() {
    MyAppTheme {
        Surface(
            modifier = Modifier.fillMaxSize(),
            color = MaterialTheme.colorScheme.background
        ) {
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(16.dp)
            ) {
                ProductCard(
                    title = "Wireless Headphones",
                    description = "Premium noise-cancelling headphones with 30-hour battery life.",
                    price = "$299.99",
                    onFavoriteClick = { /* Handle favorite */ }
                )
            }
        }
    }
}

This ProductCard component uses Material 3 design tokens exclusively. It automatically adapts to light/dark mode, respects dynamic colors from Material You, maintains proper typography hierarchy, and ensures all text is readable against its background. That’s the power of Material Design 3’s token-based system!

Testing Your Design Tokens

Want to make sure your Material 3 tokens work in all scenarios? Create a preview showcase:

Kotlin
import androidx.compose.ui.tooling.preview.Preview

@Preview(name = "Light Mode", showBackground = true)
@Composable
fun ProductCardLightPreview() {
    MyAppTheme(darkTheme = false) {
        ProductCard(
            title = "Wireless Headphones",
            description = "Premium noise-cancelling headphones with 30-hour battery life.",
            price = "$299.99",
            onFavoriteClick = { }
        )
    }
}

@Preview(name = "Dark Mode", showBackground = true)
@Composable
fun ProductCardDarkPreview() {
    MyAppTheme(darkTheme = true) {
        ProductCard(
            title = "Wireless Headphones",
            description = "Premium noise-cancelling headphones with 30-hour battery life.",
            price = "$299.99",
            onFavoriteClick = { }
        )
    }
}

Pro tip: Android Studio shows these previews side-by-side, letting you verify that your Material 3 design tokens create a cohesive experience in both light and dark modes.

Common Mistakes to Avoid

Mistake 1: Mixing Hardcoded and Token Values

Don’t do this:

Kotlin
Text(
    text = "Title",
    fontSize = 24.sp, // Hardcoded
    color = MaterialTheme.colorScheme.primary // Token
)

Instead:

Kotlin
Text(
    text = "Title",
    style = MaterialTheme.typography.headlineMedium,
    color = MaterialTheme.colorScheme.primary
)

Mistake 2: Forgetting About Accessibility

Always use “on” color tokens for text on colored backgrounds. Material Design 3 emphasizes accessibility:

Kotlin
// This might have poor contrast
Button(
    colors = ButtonDefaults.buttonColors(
        containerColor = MaterialTheme.colorScheme.tertiary,
        contentColor = Color.Gray // Bad!
    )
) { Text("Submit") }

// This ensures proper contrast following Material 3 guidelines
Button(
    colors = ButtonDefaults.buttonColors(
        containerColor = MaterialTheme.colorScheme.tertiary,
        contentColor = MaterialTheme.colorScheme.onTertiary // Good!
    )
) { Text("Submit") }

Mistake 3: Not Testing in Both Modes

Always preview your composables in both light and dark modes to ensure your Material 3 token usage works correctly.

Why Design Tokens Improve Long-Term Maintainability

Design tokens:

  • Reduce UI bugs
  • Speed up redesigns
  • Improve accessibility
  • Keep your codebase cleaner
  • Align perfectly with Material Design 3 principles

This is why using design tokens with Material Design 3 in Jetpack Compose is strongly recommended.

Conclusion

Understanding and implementing Design Tokens in Material 3 and Jetpack Compose transforms your development workflow. You get:

  • Consistency: Every component uses the same Material Design 3 language
  • Maintainability: Change your entire theme by updating token values
  • Accessibility: Automatic contrast ratios and readability
  • Personalization: Dynamic colors that adapt to user preferences through Material You
  • Scalability: Easy to extend with custom tokens while maintaining Material 3 principles

The examples we’ve covered today give you a solid foundation to build beautiful, consistent Android apps following Material Design 3 guidelines. Start by implementing basic color and typography tokens, then gradually expand to custom tokens as your needs grow.

Remember, the key to mastering Design Tokens in Material 3 and Jetpack Compose is practice. Start refactoring your existing projects to use Material 3 tokens, and you’ll quickly see the benefits of this systematic approach.

Custom App Theme in Jetpack Compose

How to Build a Consistent Custom App Theme in Jetpack Compose (Material 3)

Creating a polished Android app starts with one crucial decision: your app’s visual identity. If you’ve been wondering how to make your Jetpack Compose app look consistent and professional across every screen, you’re in the right place.

In this guide, I’ll walk you through building a Custom App Theme in Jetpack Compose using Material 3. Whether you’re building your first app or refining an existing one, you’ll learn how to create a theming system that’s both flexible and maintainable.

Why Material 3 Makes Custom Theming Easier

Material 3 (also called Material You) isn’t just another design system update. It’s Google’s most flexible theming framework yet, and it plays beautifully with Jetpack Compose.

Here’s what makes it special:

Dynamic color support — Your app can adapt to the user’s wallpaper colors (on Android 12+) 

Improved design tokens — More granular control over colors, typography, and shapes Better accessibility — Built-in contrast and readability improvements

The best part..? 

Once you set up your Custom App Theme in Jetpack Compose, Material 3 handles the heavy lifting of maintaining consistency throughout your app.

Understanding the Theme Building Blocks

Before we dive into code, let’s understand what makes up a theme in Jetpack Compose. Think of it like building a house — you need a solid foundation.

Your theme consists of three main pillars:

  1. Color Scheme — All the colors your app uses
  2. Typography — Font families, sizes, and weights
  3. Shapes — Corner radiuses and component shapes

When these three work together harmoniously, your app feels intentional and polished.

Setting Up Your Project Dependencies

First things first — make sure you have the right dependencies in your build.gradle.kts file:

Kotlin
dependencies {
    implementation("androidx.compose.material3:material3:1.2.0")
    implementation("androidx.compose.ui:ui:1.6.0")
    implementation("androidx.compose.ui:ui-tooling-preview:1.6.0")
}

These dependencies give you access to Material 3 components and theming capabilities. 

Always check for the latest stable versions on the official documentation.

Creating Your Custom Color Scheme

Colors are the personality of your app. Let’s create a custom color palette that reflects your brand.

Define Your Color Palette

Create a new Kotlin file called Color.kt in your UI theme package:

Kotlin
package com.yourapp.ui.theme

import androidx.compose.ui.graphics.Color

// Light Theme Colors
val PrimaryLight = Color(0xFF6750A4)
val OnPrimaryLight = Color(0xFFFFFFFF)
val PrimaryContainerLight = Color(0xFFEADDFF)
val OnPrimaryContainerLight = Color(0xFF21005D)
val SecondaryLight = Color(0xFF625B71)
val OnSecondaryLight = Color(0xFFFFFFFF)
val SecondaryContainerLight = Color(0xFFE8DEF8)
val OnSecondaryContainerLight = Color(0xFF1D192B)
val TertiaryLight = Color(0xFF7D5260)
val OnTertiaryLight = Color(0xFFFFFFFF)
val TertiaryContainerLight = Color(0xFFFFD8E4)
val OnTertiaryContainerLight = Color(0xFF31111D)
val ErrorLight = Color(0xFFB3261E)
val OnErrorLight = Color(0xFFFFFFFF)
val ErrorContainerLight = Color(0xFFF9DEDC)
val OnErrorContainerLight = Color(0xFF410E0B)
val BackgroundLight = Color(0xFFFFFBFE)
val OnBackgroundLight = Color(0xFF1C1B1F)
val SurfaceLight = Color(0xFFFFFBFE)
val OnSurfaceLight = Color(0xFF1C1B1F)

// Dark Theme Colors
val PrimaryDark = Color(0xFFD0BCFF)
val OnPrimaryDark = Color(0xFF381E72)
val PrimaryContainerDark = Color(0xFF4F378B)
val OnPrimaryContainerDark = Color(0xFFEADDFF)
val SecondaryDark = Color(0xFFCCC2DC)
val OnSecondaryDark = Color(0xFF332D41)
val SecondaryContainerDark = Color(0xFF4A4458)
val OnSecondaryContainerDark = Color(0xFFE8DEF8)
val TertiaryDark = Color(0xFFEFB8C8)
val OnTertiaryDark = Color(0xFF492532)
val TertiaryContainerDark = Color(0xFF633B48)
val OnTertiaryContainerDark = Color(0xFFFFD8E4)
val ErrorDark = Color(0xFFF2B8B5)
val OnErrorDark = Color(0xFF601410)
val ErrorContainerDark = Color(0xFF8C1D18)
val OnErrorContainerDark = Color(0xFFF9DEDC)
val BackgroundDark = Color(0xFF1C1B1F)
val OnBackgroundDark = Color(0xFFE6E1E5)
val SurfaceDark = Color(0xFF1C1B1F)
val OnSurfaceDark = Color(0xFFE6E1E5)

Here,

Each color has a specific role in Material 3. The naming convention follows a pattern:

  • Primary – Your brand’s main color
  • OnPrimary – Text/icon color that appears on top of primary
  • PrimaryContainer – A lighter shade for containers
  • OnPrimaryContainer – Text/icons on primary containers

This naming system ensures your Custom App Theme in Jetpack Compose maintains proper contrast and readability automatically.

Build Your Color Schemes

Now let’s create the actual color scheme objects. Add this to a new file called Theme.kt:

Kotlin
package com.yourapp.ui.theme

import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme

private val LightColorScheme = lightColorScheme(
    primary = PrimaryLight,
    onPrimary = OnPrimaryLight,
    primaryContainer = PrimaryContainerLight,
    onPrimaryContainer = OnPrimaryContainerLight,
    
    secondary = SecondaryLight,
    onSecondary = OnSecondaryLight,
    secondaryContainer = SecondaryContainerLight,
    onSecondaryContainer = OnSecondaryContainerLight,
    
    tertiary = TertiaryLight,
    onTertiary = OnTertiaryLight,
    tertiaryContainer = TertiaryContainerLight,
    onTertiaryContainer = OnTertiaryContainerLight,
    
    error = ErrorLight,
    onError = OnErrorLight,
    errorContainer = ErrorContainerLight,
    onErrorContainer = OnErrorContainerLight,
    
    background = BackgroundLight,
    onBackground = OnBackgroundLight,
    surface = SurfaceLight,
    onSurface = OnSurfaceLight,
)

private val DarkColorScheme = darkColorScheme(
    primary = PrimaryDark,
    onPrimary = OnPrimaryDark,
    primaryContainer = PrimaryContainerDark,
    onPrimaryContainer = OnPrimaryContainerDark,
    
    secondary = SecondaryDark,
    onSecondary = OnSecondaryDark,
    secondaryContainer = SecondaryContainerDark,
    onSecondaryContainer = OnSecondaryContainerDark,
    
    tertiary = TertiaryDark,
    onTertiary = OnTertiaryDark,
    tertiaryContainer = TertiaryContainerDark,
    onTertiaryContainer = OnTertiaryContainerDark,
    
    error = ErrorDark,
    onError = OnErrorDark,
    errorContainer = ErrorContainerDark,
    onErrorContainer = OnErrorContainerDark,
    
    background = BackgroundDark,
    onBackground = OnBackgroundDark,
    surface = SurfaceDark,
    onSurface = OnSurfaceDark,
)

These functions (lightColorScheme and darkColorScheme) are provided by Material 3 to ensure your colors meet accessibility standards.

Customizing Typography

Typography shapes how users read and understand your content. Let’s create a type scale that’s both beautiful and functional.

Defining Custom Fonts

Create a Type.kt file:

Kotlin
package com.yourapp.ui.theme

import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// Define your custom font families
// Make sure to add your font files to res/font/
val Montserrat = FontFamily(
    Font(R.font.montserrat_regular, FontWeight.Normal),
    Font(R.font.montserrat_medium, FontWeight.Medium),
    Font(R.font.montserrat_semibold, FontWeight.SemiBold),
    Font(R.font.montserrat_bold, FontWeight.Bold)
)

val Roboto = FontFamily(
    Font(R.font.roboto_regular, FontWeight.Normal),
    Font(R.font.roboto_medium, FontWeight.Medium),
    Font(R.font.roboto_bold, FontWeight.Bold)
)


// Custom Typography
val AppTypography = Typography(
    // Display styles - for large, impactful text
    displayLarge = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.Bold,
        fontSize = 57.sp,
        lineHeight = 64.sp,
        letterSpacing = (-0.25).sp
    ),
    displayMedium = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.Bold,
        fontSize = 45.sp,
        lineHeight = 52.sp,
        letterSpacing = 0.sp
    ),
    displaySmall = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.Bold,
        fontSize = 36.sp,
        lineHeight = 44.sp,
        letterSpacing = 0.sp
    ),
    
    // Headline styles - for section headers
    headlineLarge = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.SemiBold,
        fontSize = 32.sp,
        lineHeight = 40.sp,
        letterSpacing = 0.sp
    ),
    headlineMedium = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.SemiBold,
        fontSize = 28.sp,
        lineHeight = 36.sp,
        letterSpacing = 0.sp
    ),
    headlineSmall = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.SemiBold,
        fontSize = 24.sp,
        lineHeight = 32.sp,
        letterSpacing = 0.sp
    ),
    
    // Title styles - for card titles and important text
    titleLarge = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.Medium,
        fontSize = 22.sp,
        lineHeight = 28.sp,
        letterSpacing = 0.sp
    ),
    titleMedium = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.Medium,
        fontSize = 16.sp,
        lineHeight = 24.sp,
        letterSpacing = 0.15.sp
    ),
    titleSmall = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.Medium,
        fontSize = 14.sp,
        lineHeight = 20.sp,
        letterSpacing = 0.1.sp
    ),
    
    // Body styles - for main content
    bodyLarge = TextStyle(
        fontFamily = Roboto,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp,
        lineHeight = 24.sp,
        letterSpacing = 0.5.sp
    ),
    bodyMedium = TextStyle(
        fontFamily = Roboto,
        fontWeight = FontWeight.Normal,
        fontSize = 14.sp,
        lineHeight = 20.sp,
        letterSpacing = 0.25.sp
    ),
    bodySmall = TextStyle(
        fontFamily = Roboto,
        fontWeight = FontWeight.Normal,
        fontSize = 12.sp,
        lineHeight = 16.sp,
        letterSpacing = 0.4.sp
    ),
    
    // Label styles - for buttons and small UI elements
    labelLarge = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.Medium,
        fontSize = 14.sp,
        lineHeight = 20.sp,
        letterSpacing = 0.1.sp
    ),
    labelMedium = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.Medium,
        fontSize = 12.sp,
        lineHeight = 16.sp,
        letterSpacing = 0.5.sp
    ),
    labelSmall = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.Medium,
        fontSize = 11.sp,
        lineHeight = 16.sp,
        letterSpacing = 0.5.sp
    )
)

Understanding the type scale:

Material 3 provides 15 different text styles organized into five categories. This gives you flexibility while maintaining consistency in your Custom App Theme in Jetpack Compose:

  • Display — Hero text, splash screens
  • Headline — Page titles, section headers
  • Title — Card titles, dialog headers
  • Body — Paragraphs, main content
  • Label — Buttons, tabs, small UI elements

Defining Custom Shapes

Shapes add personality to your UI. From rounded corners to sharp edges, shapes influence how modern or traditional your app feels.

Create a Shape.kt file:

Kotlin
package com.yourapp.ui.theme

import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp

val AppShapes = Shapes(
    // Extra small - chips, small buttons
    extraSmall = RoundedCornerShape(4.dp),
    
    // Small - buttons, text fields
    small = RoundedCornerShape(8.dp),
    
    // Medium - cards, dialogs
    medium = RoundedCornerShape(12.dp),
    
    // Large - bottom sheets, large cards
    large = RoundedCornerShape(16.dp),
    
    // Extra large - special components
    extraLarge = RoundedCornerShape(28.dp)
)

Shape usage tips:

  • Use extraSmall for chips and toggles
  • Use small for buttons and input fields
  • Use medium for cards and elevated surfaces
  • Use large for bottom sheets and modals
  • Use extraLarge for floating action buttons

You can also create asymmetric shapes or custom shapes using GenericShape for more creative designs.

Bringing It All Together: Your Theme Composable

Now comes the exciting part — assembling everything into your main theme composable. Update your Theme.kt file:

Kotlin
package com.yourapp.ui.theme

import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat

@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true, // Dynamic color available on Android 12+
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        // Dynamic color (Material You) - uses user's wallpaper colors
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) 
            else dynamicLightColorScheme(context)
        }
        // Dark theme
        darkTheme -> DarkColorScheme
        // Light theme
        else -> LightColorScheme
    }
    
    val view = LocalView.current
    if (!view.isInEditMode) {
        SideEffect {
            val window = (view.context as Activity).window
            window.statusBarColor = colorScheme.primary.toArgb()
            WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
        }
    }
    MaterialTheme(
        colorScheme = colorScheme,
        typography = AppTypography,
        shapes = AppShapes,
        content = content
    )
}
  1. Dark theme detection — Automatically detects if the user prefers dark mode
  2. Dynamic color support — On Android 12+, colors adapt to the user’s wallpaper
  3. Status bar styling — Ensures the status bar matches your theme
  4. Fallback colors — Uses your custom colors on older Android versions

This is the heart of your Custom App Theme in Jetpack Compose. Every screen that uses this theme will automatically have consistent colors, typography, and shapes.

Using Your Theme in the App

Now let’s see how to apply your theme to your app. In your MainActivity.kt:

Kotlin
package com.yourapp

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.yourapp.ui.theme.AppTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AppTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    HomeScreen()
                }
            }
        }
    }
}

@Composable
fun HomeScreen() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        // Using themed text styles
        Text(
            text = "Welcome to My App",
            style = MaterialTheme.typography.headlineLarge,
            color = MaterialTheme.colorScheme.primary
        )
        
        Text(
            text = "This is a subtitle showing our custom typography",
            style = MaterialTheme.typography.titleMedium,
            color = MaterialTheme.colorScheme.onSurface
        )
        
        Text(
            text = "Body text looks great with our custom font family. " +
                  "Notice how everything feels cohesive and professional.",
            style = MaterialTheme.typography.bodyLarge,
            color = MaterialTheme.colorScheme.onSurfaceVariant
        )
        
        // Using themed button with custom shapes
        Button(
            onClick = { /* Handle click */ },
            shape = MaterialTheme.shapes.medium
        ) {
            Text("Primary Button")
        }
        
        // Using themed card
        Card(
            modifier = Modifier.fillMaxWidth(),
            shape = MaterialTheme.shapes.large,
            colors = CardDefaults.cardColors(
                containerColor = MaterialTheme.colorScheme.primaryContainer
            )
        ) {
            Column(modifier = Modifier.padding(16.dp)) {
                Text(
                    text = "Card Title",
                    style = MaterialTheme.typography.titleLarge,
                    color = MaterialTheme.colorScheme.onPrimaryContainer
                )
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = "This card uses our theme colors and shapes automatically.",
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onPrimaryContainer
                )
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun HomeScreenPreview() {
    AppTheme {
        HomeScreen()
    }
}

Key takeaways from this example:

  • Wrap your content in AppTheme { } to apply your custom theme
  • Access colors via MaterialTheme.colorScheme.primary (not hardcoded values!)
  • Access typography via MaterialTheme.typography.headlineLarge
  • Access shapes via MaterialTheme.shapes.medium

This approach ensures your Custom App Theme in Jetpack Compose is applied consistently throughout your app.

Creating Theme-Aware Components

Let’s build a custom component that respects your theme. This is where the real power of theming shines:

Kotlin
@Composable
fun ThemedInfoCard(
    title: String,
    description: String,
    modifier: Modifier = Modifier
) {
    Card(
        modifier = modifier.fillMaxWidth(),
        shape = MaterialTheme.shapes.large,
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.secondaryContainer
        ),
        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Text(
                text = title,
                style = MaterialTheme.typography.titleLarge,
                color = MaterialTheme.colorScheme.onSecondaryContainer
            )
            
            Divider(
                color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
                thickness = 1.dp
            )
            
            Text(
                text = description,
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSecondaryContainer
            )
        }
    }
}

// Usage
@Composable
fun ExampleScreen() {
    Column(modifier = Modifier.padding(16.dp)) {
        ThemedInfoCard(
            title = "Themed Component",
            description = "This card automatically adapts to light/dark theme changes!"
        )
    }
}

This component will automatically look perfect in both light and dark modes because it uses theme values instead of hardcoded colors.

Advanced: Adding Custom Theme Values

Sometimes you need values beyond what Material 3 provides. Here’s how to extend your theme:

Kotlin
// Create custom theme values
data class CustomColors(
    val success: Color,
    val onSuccess: Color,
    val warning: Color,
    val onWarning: Color,
    val info: Color,
    val onInfo: Color
)

// Light theme custom colors
val LightCustomColors = CustomColors(
    success = Color(0xFF4CAF50),
    onSuccess = Color(0xFFFFFFFF),
    warning = Color(0xFFFF9800),
    onWarning = Color(0xFF000000),
    info = Color(0xFF2196F3),
    onInfo = Color(0xFFFFFFFF)
)

// Dark theme custom colors
val DarkCustomColors = CustomColors(
    success = Color(0xFF81C784),
    onSuccess = Color(0xFF000000),
    warning = Color(0xFFFFB74D),
    onWarning = Color(0xFF000000),
    info = Color(0xFF64B5F6),
    onInfo = Color(0xFF000000)
)

// Create a CompositionLocal
val LocalCustomColors = staticCompositionLocalOf { LightCustomColors }

// Extend your theme
@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val customColors = if (darkTheme) DarkCustomColors else LightCustomColors
    
    // ... existing color scheme code ...
    
    CompositionLocalProvider(LocalCustomColors provides customColors) {
        MaterialTheme(
            colorScheme = colorScheme,
            typography = AppTypography,
            shapes = AppShapes,
            content = content
        )
    }
}

// Access custom colors
@Composable
fun CustomThemedComponent() {
    val customColors = LocalCustomColors.current
    
    Button(
        onClick = { },
        colors = ButtonDefaults.buttonColors(
            containerColor = customColors.success
        )
    ) {
        Text("Success Action", color = customColors.onSuccess)
    }
}

This technique lets you add semantic colors like success, warning, and info to your Custom App Theme in Jetpack Compose.

Best Practices for Theme Consistency

Here are proven strategies to keep your theme consistent as your app grows:

1. Never Hardcode Colors

Bad:

Kotlin
Text(
    text = "Hello",
    color = Color(0xFF6750A4) // Hardcoded!
)

Good:

Kotlin
Text(
    text = "Hello",
    color = MaterialTheme.colorScheme.primary
)

2. Create Reusable Components

Instead of repeating styling code, create themed components:

Kotlin
@Composable
fun AppButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    isPrimary: Boolean = true
) {
    Button(
        onClick = onClick,
        modifier = modifier,
        colors = ButtonDefaults.buttonColors(
            containerColor = if (isPrimary) 
                MaterialTheme.colorScheme.primary 
            else 
                MaterialTheme.colorScheme.secondary
        ),
        shape = MaterialTheme.shapes.medium
    ) {
        Text(
            text = text,
            style = MaterialTheme.typography.labelLarge
        )
    }
}

3. Use Semantic Naming

Choose color roles based on meaning, not appearance:

Kotlin
// Bad naming
val BlueColor = Color(0xFF2196F3)

// Good naming
val LinkColor = MaterialTheme.colorScheme.primary
val SuccessColor = customColors.success

4. Test Both Themes

Always preview your screens in both light and dark modes:

Kotlin
@Preview(name = "Light Mode", showBackground = true)
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun ScreenPreview() {
    AppTheme {
        YourScreen()
    }
}

5. Keep Typography Consistent

Use the defined typography scale instead of creating custom text styles on the fly:

Kotlin
// Avoid this
Text(
    text = "Title",
    fontSize = 24.sp,
    fontWeight = FontWeight.Bold
)

// Do this
Text(
    text = "Title",
    style = MaterialTheme.typography.headlineSmall
)

Debugging Theme Issues

Sometimes things don’t look right. Here’s how to troubleshoot:

Preview Your Theme Values

Create a debug screen to visualize your theme:

Kotlin
@Composable
fun ThemeDebugScreen() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
            .verticalScroll(rememberScrollState())
    ) {
        Text("Color Scheme", style = MaterialTheme.typography.headlineMedium)
        Spacer(modifier = Modifier.height(8.dp))
        
        ColorBox("Primary", MaterialTheme.colorScheme.primary)
        ColorBox("Secondary", MaterialTheme.colorScheme.secondary)
        ColorBox("Tertiary", MaterialTheme.colorScheme.tertiary)
        // ... add more colors
        
        Spacer(modifier = Modifier.height(16.dp))
        Text("Typography", style = MaterialTheme.typography.headlineMedium)
        
        Text("Display Large", style = MaterialTheme.typography.displayLarge)
        Text("Headline Medium", style = MaterialTheme.typography.headlineMedium)
        Text("Body Large", style = MaterialTheme.typography.bodyLarge)
        // ... add more styles
    }
}

@Composable
fun ColorBox(name: String, color: Color) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 4.dp),
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Text(name)
        Box(
            modifier = Modifier
                .size(40.dp)
                .background(color, MaterialTheme.shapes.small)
        )
    }
}

This screen helps you visually verify all your theme values at a glance.

Supporting Dynamic Color (Material You)

Material You allows users to personalize their experience. Here’s how to give users control:

Kotlin
// In your ViewModel or state management
class ThemeViewModel : ViewModel() {
    private val _useDynamicColor = MutableStateFlow(true)
    val useDynamicColor: StateFlow<Boolean> = _useDynamicColor.asStateFlow()
    
    fun toggleDynamicColor() {
        _useDynamicColor.value = !_useDynamicColor.value
    }
}

// In your settings screen
@Composable
fun SettingsScreen(viewModel: ThemeViewModel = viewModel()) {
    val useDynamicColor by viewModel.useDynamicColor.collectAsState()
    
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text("Use Material You colors")
        Switch(
            checked = useDynamicColor,
            onCheckedChange = { viewModel.toggleDynamicColor() }
        )
    }
}

Users on Android 12+ can then choose between your custom colors and colors that match their wallpaper.

Common Mistakes to Avoid

Mistake 1: Mixing Hardcoded and Theme Values

Don’t mix approaches in the same project. Pick theme values and stick with them:

Kotlin
// Inconsistent - Don't do this
Column {
    Text(text = "Title", color = MaterialTheme.colorScheme.primary)
    Text(text = "Subtitle", color = Color.Gray) // Hardcoded!
}

// Consistent - Do this
Column {
    Text(text = "Title", color = MaterialTheme.colorScheme.primary)
    Text(text = "Subtitle", color = MaterialTheme.colorScheme.onSurfaceVariant)
}

Mistake 2: Ignoring Accessibility

Always ensure sufficient contrast between text and backgrounds:

Kotlin
// Check contrast ratios
val backgroundColor = MaterialTheme.colorScheme.primary
val textColor = MaterialTheme.colorScheme.onPrimary // Guaranteed good contrast

Material 3 handles this automatically if you use the correct color pairs (primary/onPrimary, surface/onSurface, etc.).

Mistake 3: Over-Customizing

Not every component needs custom styling. Sometimes the default Material 3 styling is perfect:

Kotlin
// Often unnecessary
Button(
    onClick = { },
    colors = ButtonDefaults.buttonColors(/* custom colors */),
    shape = RoundedCornerShape(/* custom shape */),
    elevation = ButtonDefaults.elevatedButtonElevation(/* custom elevation */)
) { }

// Usually sufficient
Button(onClick = { }) {
    Text("Click Me")
}

Migration from Material 2 to Material 3

If you’re upgrading an existing app, here’s a quick migration guide:

Color Migration

Material 2 → Material 3:

  • primaryprimary (similar)
  • primaryVariantprimaryContainer
  • secondarysecondary (similar)
  • secondaryVariantsecondaryContainer
  • backgroundbackground (same)
  • surfacesurface (same)

Typography Migration

Material 3 has more granular typography styles. Map your old styles:

  • h1displayLarge
  • h2displayMedium
  • h3displaySmall
  • h4headlineLarge
  • h5headlineMedium
  • h6headlineSmall
  • subtitle1titleLarge
  • subtitle2titleMedium
  • body1bodyLarge
  • body2bodyMedium

Conclusion

Building a Custom App Theme in Jetpack Compose with Material 3 might seem complex at first, but it’s an investment that pays dividends. You get:

  • Consistency — Every screen automatically follows your design system
  • Maintainability — Change one value, update the entire app
  • Flexibility — Support light/dark themes and Material You effortlessly
  • Professionalism — Your app looks polished and well-crafted
  • Accessibility — Built-in contrast and readability standards

The key is starting with a solid foundation: well-defined colors, typography, and shapes. Once your theme is set up, building new screens becomes faster because you’re working with a consistent design language.

Remember, your Custom App Theme in Jetpack Compose isn’t set in stone. As your app evolves and your brand matures, you can refine your theme values. The beauty of this system is that those updates propagate throughout your entire app automatically.

CompositionLocal in Jetpack Compose

CompositionLocal in Jetpack Compose: How to Avoid Prop Drilling

When building apps with Jetpack Compose, you’ll often pass data down through multiple layers of composables. At first, this feels fine. But as your UI grows, you may find yourself passing the same parameter through five or six functions just to reach a deeply nested child.

That pattern is called prop drilling.

It works, but it clutters your APIs and makes your code harder to maintain.

This is where CompositionLocal in Jetpack Compose becomes incredibly useful. In this guide, we’ll learn what it is, when to use it, how it works under the hood, and how to avoid common mistakes.

What Is Prop Drilling in Jetpack Compose?

Prop drilling happens when you pass data through multiple composables, even though intermediate composables don’t use that data.

For example:

Kotlin
@Composable
fun ParentScreen() {
    val userName = "Amol"
    LevelOne(userName)
}

@Composable
fun LevelOne(userName: String) {
    LevelTwo(userName)
}

@Composable
fun LevelTwo(userName: String) {
    Greeting(userName)
}

@Composable
fun Greeting(userName: String) {
    Text(text = "Hello, $userName")
}

Only Greeting actually needs userName. But we pass it through LevelOne and LevelTwo anyway.

In small apps, this is fine. In large apps, it becomes noisy and harder to refactor.

What Is CompositionLocal in Jetpack Compose?

CompositionLocal in Jetpack Compose is a way to implicitly pass data down the composable tree without manually threading it through every function.

It allows you to define a value once and access it anywhere inside a specific part of the composition.

Think of it as a scoped global value. It’s not truly global, but it’s available to any composable inside its scope.

Jetpack Compose already uses it internally. For example:

  • MaterialTheme
  • LocalContext
  • LocalDensity
  • LocalLayoutDirection

These are all built using CompositionLocal.

When Should You Use CompositionLocal?

Use CompositionLocal in Jetpack Compose when:

  • The data is cross-cutting (theme, configuration, permissions, localization).
  • Many composables need access to it.
  • Passing it as a parameter would create unnecessary noise.

Avoid using it for:

  • Screen-specific business logic
  • Frequently changing state
  • ViewModel data that belongs to a specific screen

In short, use it for shared environmental values, not regular state.

How to Create a CompositionLocal

There are two main ways to create one:

  1. compositionLocalOf
  2. staticCompositionLocalOf

Let’s start with the common one.

Example: Creating a CompositionLocal

Suppose we want to provide a custom app theme color.

Define the CompositionLocal

Kotlin
val LocalAppPrimaryColor = compositionLocalOf { Color.Blue }

What this does:

  • Creates a CompositionLocal.
  • Provides a default value (Color.Blue).
  • If no value is provided, the default will be used.

Provide a Value

We use CompositionLocalProvider to supply a value.

Kotlin
@Composable
fun MyApp() {
    CompositionLocalProvider(
        LocalAppPrimaryColor provides Color.Green
    ) {
        HomeScreen()
    }
}

Here,

  • Inside MyApp, we provide Color.Green.
  • Any composable inside HomeScreen() can now access it.
  • Outside this block, the default value applies.

So basically, 

What’s the provides keyword?

It’s an infix function that creates a ProvidedValue pairing your CompositionLocal with an actual value. Think of it as saying: “For this scope, LocalAppPrimaryColor provides Color.Green.”

You can even provide multiple values at once:

Kotlin
@Composable
fun MyApp() {
    val theme = AppTheme(/* ... */)
    val user = User(id = "123", name = "Anaya")
    
    CompositionLocalProvider(
        LocalAppTheme provides theme,
        LocalUser provides user
    ) {
        MainScreen()
    }
}

Consume the Value

Now we access it using .current.

Kotlin
@Composable
fun HomeScreen() {

    val primaryColor = LocalAppPrimaryColor.current
    
    Text(
        text = "Welcome",
        color = primaryColor
    )
}

That’s it.

No parameter passing. 

No prop drilling.

How CompositionLocal in Jetpack Compose Works Internally

Understanding this improves your architectural decisions.

When you use CompositionLocal in Jetpack Compose, the value becomes part of the composition tree. Compose tracks reads of .current. If the value changes, only the composables that read it will recompose.

This makes it efficient.

It’s not like a global variable. It’s scoped and lifecycle-aware.

Using staticCompositionLocalOf

Use this when the value rarely or never changes. It’s more optimized but less flexible:

Kotlin
val LocalAppConfiguration = staticCompositionLocalOf {
    AppConfiguration(apiUrl = "https://api.softaai.com")
}

When to use static? Only when the value is truly static for the entire composition, like build configuration or app constants.

compositionLocalOf Vs staticCompositionLocalOf

This is important.

compositionLocalOf

  • Tracks reads.
  • Causes recomposition when value changes.
  • Best for values that may change.

Example: dynamic theme.

staticCompositionLocalOf

  • Does NOT track reads.
  • Better performance.
  • Use when value will never change.

Example: app configuration object that stays constant.

Example:

Kotlin
val LocalAppConfig = staticCompositionLocalOf<AppConfig> {
    error("No AppConfig provided")
}

Use this only when you are sure the value won’t change.

Real-World Example: Building a Theme System

Let’s build a complete example that shows the power of CompositionLocal in Jetpack Compose. We’ll create a theme system with light and dark modes:

Define a data class

Kotlin
// Step 1: Define our theme data
data class AppTheme(
    val colors: AppColors,
    val typography: AppTypography,
    val isDark: Boolean
)

data class AppColors(
    val primary: Color,
    val background: Color,
    val surface: Color,
    val text: Color
)

data class AppTypography(
    val heading: TextStyle,
    val body: TextStyle
)

Create CompositionLocal

Kotlin
// Step 2: Create CompositionLocal
val LocalAppTheme = compositionLocalOf {
    AppTheme(
        colors = AppColors(
            primary = Color.Blue,
            background = Color.White,
            surface = Color.LightGray,
            text = Color.Black
        ),
        typography = AppTypography(
            heading = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold),
            body = TextStyle(fontSize = 16.sp)
        ),
        isDark = false
    )
}

Provide the value

Kotlin
// Step 3: Create theme instances
object AppThemes {
    val Light = AppTheme(
        colors = AppColors(
            primary = Color(0xFF2196F3),
            background = Color.White,
            surface = Color(0xFFF5F5F5),
            text = Color.Black
        ),
        typography = AppTypography(
            heading = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold),
            body = TextStyle(fontSize = 16.sp)
        ),
        isDark = false
    )
    
    val Dark = AppTheme(
        colors = AppColors(
            primary = Color(0xFF90CAF9),
            background = Color(0xFF121212),
            surface = Color(0xFF1E1E1E),
            text = Color.White
        ),
        typography = AppTypography(
            heading = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold),
            body = TextStyle(fontSize = 16.sp)
        ),
        isDark = true
    )
}

// Step 4: Provide theme at app level
@Composable
fun MyApp() {
    var isDarkMode by remember { mutableStateOf(false) }
    val currentTheme = if (isDarkMode) AppThemes.Dark else AppThemes.Light
    
    CompositionLocalProvider(LocalAppTheme provides currentTheme) {
        Scaffold(
            topBar = {
                TopAppBar(
                    title = { Text("My App") },
                    actions = {
                        IconButton(onClick = { isDarkMode = !isDarkMode }) {
                            Icon(
                                imageVector = if (isDarkMode) 
                                    Icons.Default.LightMode 
                                else 
                                    Icons.Default.DarkMode,
                                contentDescription = "Toggle theme"
                            )
                        }
                    }
                )
            }
        ) { padding ->
            MainContent(modifier = Modifier.padding(padding))
        }
    }
}

Consume it anywhere

Kotlin
// Step 5: Use theme throughout the app
@Composable
fun MainContent(modifier: Modifier = Modifier) {
    val theme = LocalAppTheme.current
    
    Column(
        modifier = modifier
            .fillMaxSize()
            .background(theme.colors.background)
            .padding(16.dp)
    ) {
        Text(
            text = "Welcome!",
            style = theme.typography.heading,
            color = theme.colors.text
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        // This composable also has access to the theme
        ProfileCard()
    }
}

@Composable
fun ProfileCard() {
    val theme = LocalAppTheme.current
    
    Card(
        modifier = Modifier.fillMaxWidth(),
        colors = CardDefaults.cardColors(
            containerColor = theme.colors.surface
        )
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(
                text = "User Profile",
                style = theme.typography.heading,
                color = theme.colors.primary
            )
            Text(
                text = "This card automatically updates with the theme!",
                style = theme.typography.body,
                color = theme.colors.text
            )
        }
    }
}

Notice how ProfileCard doesn’t need to receive the theme as a parameter. It simply accesses LocalAppTheme.current and gets the value. When you toggle between light and dark mode, all composables that read from LocalAppTheme automatically recompose with the new values.

That’s the power of CompositionLocal in Jetpack Compose.

Best Practices for Using CompositionLocal

1. Use It Sparingly

CompositionLocal is powerful, but don’t overuse it. It’s perfect for:

  • Application-wide themes
  • User authentication state
  • Locale/language settings
  • Navigation controllers
  • Dependency injection

It’s NOT ideal for:

  • Component-specific state
  • Data that changes frequently at the component level
  • Communication between sibling composables

2. Always Provide Default Values

Always include a sensible default in your compositionLocalOf lambda:

Kotlin
val LocalUser = compositionLocalOf {
    User(id = "123", name = "Anaya", isAuthenticated = false)
}

This prevents crashes if someone forgets to provide a value and makes your code more robust.

3. Make CompositionLocals Top-Level Properties

Define them at the file level, not inside composables:

Kotlin
// Good - Top level
val LocalAnalytics = compositionLocalOf { AnalyticsTracker() }

@Composable
fun MyScreen() {
    // Bad - Inside composable
    val LocalSomething = compositionLocalOf { /* ... */ }
}

4. Use Descriptive Names with “Local” Prefix

This convention makes it immediately clear that you’re dealing with a CompositionLocal:

Kotlin
val LocalAppTheme = compositionLocalOf { /* ... */ }  // Clear
val theme = compositionLocalOf { /* ... */ }           // Confusing

5. Document Your CompositionLocals

Add KDoc comments to explain what the CompositionLocal provides and when to use it:

Kotlin
/**
 * Provides the current app theme (colors, typography, spacing).
 * This value updates when the user switches between light and dark mode.
 */
val LocalAppTheme = compositionLocalOf {
    AppTheme(/* ... */)
}

Common Pitfalls and How to Avoid Them

Even sometimes experienced developers make mistakes with CompositionLocal in Jetpack Compose. Here are the most common issues:

Pitfall 1: Reading CompositionLocal in Non-Composable Context

Kotlin
// Wrong - Can't use .current outside a composable
class MyViewModel {
    val theme = LocalAppTheme.current  // Compilation error!
}

// Correct - Pass it as a parameter if needed
@Composable
fun MyScreen(viewModel: MyViewModel) {
    val theme = LocalAppTheme.current
    viewModel.updateTheme(theme)
}

Pitfall 2: Creating New Instances on Every Recomposition

Kotlin
@Composable
fun MyApp() {
    // Bad - Creates new theme on every recomposition
    CompositionLocalProvider(
        LocalAppTheme provides AppTheme(/* ... */)
    ) {
        Content()
    }
}

@Composable
fun MyApp() {
    // Good - Remember the theme
    val theme = remember {
        AppTheme(/* ... */)
    }
    
    CompositionLocalProvider(LocalAppTheme provides theme) {
        Content()
    }
}

Pitfall 3: Using CompositionLocal for Frequent Updates

Kotlin
// Not ideal - Mouse position changes too frequently
val LocalMousePosition = compositionLocalOf { Offset.Zero }

// Better - Use State or pass as parameter
@Composable
fun TrackingCanvas() {
    var mousePosition by remember { mutableStateOf(Offset.Zero) }
    // Use mousePosition directly
}

Pitfall 4: Forgetting to Provide a Value

If you forget to provide a value, you’ll get the default. This might be okay, or it might be a bug:

Kotlin
val LocalUser = compositionLocalOf<User?> { null }

@Composable
fun MyApp() {
    // Forgot to provide a user!
    MainScreen()
}

@Composable
fun MainScreen() {
    val user = LocalUser.current  // Will be null
    Text("Hello, ${user?.name}")  // Displays "Hello, null"
}

Testing Composables with CompositionLocal

When testing composables that rely on a CompositionLocal, you should provide a value using CompositionLocalProvider if the composable depends on that value and no suitable default exists. This allows you to override environment values and test different scenarios.

Kotlin
@Test
fun testThemedButtonUsesCorrectColor() {
    composeTestRule.setContent {
        // Provide a test theme
        val testTheme = AppTheme(
            colors = AppColors(
                primary = Color.Red,
                background = Color.White,
                surface = Color.Gray,
                text = Color.Black
            ),
            typography = AppTypography(/* ... */),
            isDark = false
        )
        
        CompositionLocalProvider(LocalAppTheme provides testTheme) {
            ThemedButton(text = "Click me", onClick = {})
        }
    }
    
    composeTestRule.onNodeWithText("Click me")
        .assertExists()
        .assertHasColor(Color.Red) // Verify theme is actually applied
}

This approach lets you test your composables with different CompositionLocal values, ensuring they work correctly in all scenarios.

CompositionLocal vs. Other State Management Solutions

You might wonder when to use CompositionLocal in Jetpack Compose versus other state management approaches. Here’s a quick guide:

Use CompositionLocal when:

  • Data is needed by many composables across the tree
  • The data represents ambient context (theme, locale, user)
  • You want to avoid prop drilling
  • The data changes infrequently

Use State/ViewModel when:

  • Data is specific to a screen or feature
  • You need business logic tied to the data
  • The data changes frequently
  • You need to survive configuration changes

Use Passed Parameters when:

  • Only a few composables need the data
  • The relationship is direct parent-child
  • You want explicit data flow

Often, the best solution combines these approaches. For example, you might use CompositionLocal for the theme, ViewModels for business logic, and parameters for component-specific props.

FAQ’s

What is CompositionLocal in Jetpack Compose?

CompositionLocal in Jetpack Compose is a mechanism to implicitly pass data down the composable tree without manually passing parameters through every function.

How does CompositionLocal avoid prop drilling?

It provides scoped values that child composables can access directly, eliminating the need to pass the same parameter through multiple intermediate composables.

When should you use CompositionLocal?

Use it for shared, cross-cutting concerns such as themes, configuration, context, or localization. Avoid using it for regular screen state.

Conclusion

Prop drilling isn’t always wrong. But when your composable tree gets deep, it becomes frustrating.

CompositionLocal in Jetpack Compose gives you a clean, structured way to share data across your UI without cluttering every function signature.

Use it thoughtfully.

Keep your dependencies clear.

And treat it as a tool for environmental data, not a replacement for proper state management.

When applied correctly, it makes your Compose code cleaner, more scalable, and easier to reason about.

If you’re building modern Android apps with Kotlin and Jetpack Compose, mastering CompositionLocal is not optional. It’s part of writing professional-level Compose code.

error: Content is protected !!