Sitemap
MateeDevs

Prague software company

Part 1: Liquid Glass Components in Compose Multiplatform

9 min readOct 14, 2025

--

Press enter or click to view image in full size

When building Compose Multiplatform apps, we often want native effects that make the UI feel “right” on each platform.

One example is the liquid glass effect on iOS — the translucent blur used across Apple’s UI.

In this article, we’ll look at how I tried to integrate these native materials in a shared Compose setup — what worked, what didn’t, and where the platform boundaries stop us.

Update:
Compose Multiplatform 1.10.0-beta01 introduced a new placedAsOverlay flag that changes how UIKit and Compose layers can interact on iOS. I explored what it enables — and what still isn’t possible — in Part 2: Liquid Glass Components in Compose Multiplatform 1.10.0-beta01

Goal

I wanted to use:

  • Liquid glass (blurred) components on iOS 26+,
  • Native fallbacks on older iOS versions,
  • And pure Compose implementations for Android.

Tabs — The Hardest Part: Getting Blur and Content to Coexist

Tabs turned out to be much harder to get right than expected — not because of logic, but because of how Compose and UIKit layers are composited together.

Press enter or click to view image in full size
SwiftUI TabView with ScrollView inside

First Attempt — Native Tabs on Top of Compose

At first, I thought I could simply draw the native iOS tab bar (via CInterop or a small UIKit wrapper) and leave the main screen content in Compose.

That failed almost immediately.

Compose is implemented using Skia, which on iOS is rendered over the UIKit layer.

When you embed a UIKit or SwiftUI view in Compose (UIKitView), Compose effectively punches a hole in its own Skia surface to show the underlying UIKit content.

That means the UIKit view is literally behind Compose — it can’t see or blur anything that Compose drew “above” it. Even if you set backgroundColor to clear, there’s simply nothing to see through.

As a result, I couldn’t have both:

  • a native blurred tab bar, and
  • composable content rendered behind it.

The blur effect simply didn’t interact with the Compose layer at all — colors behind were flat or missing.

Press enter or click to view image in full size
Compose screen with Compose content and SwiftUI Tabs via UIKitViewController

Second Attempt — SwiftUI Tabs Hosting Composable Content

The solution was to flip the rendering hierarchy.

Instead of embedding SwiftUI inside Compose, I embedded Compose inside SwiftUI.

Now, SwiftUI became the host layer, and my Compose UI was drawn inside it using ComposeUIViewController.

In this direction, SwiftUI controls the top layer — meaning its materials (like .ultraThinMaterial or .regularMaterial) can now blur and blend correctly with everything underneath, including Compose content.

In short, in the first attempt UIKit lived under Compose — invisible behind an opaque Skia layer. In the second attempt, Compose lives inside SwiftUI — visible behind the glass material, just like any native view.

This is what I imagined the view could look like to be easily used on both Android and iOS:

@Composable
expect fun NativeScaffold(
modifier: Modifier = Modifier,
tabs: List<TabItem> = emptyList(),
selectedTabPosition: Int = 0,
onTabSelected: (Int) -> Unit = {},
content: @Composable (currentTabPosition: Int?, contentPadding: PaddingValues) -> Unit,
)

In Kotlin, my actual implementation maps each composable screen into a UIViewController:

fun contentMapper(index: Int): UIViewController =
ComposeUIViewController {
Theme {
content(index, padding)
}
}

Then, on the Swift side, I use a full TabView that hosts those UIViewControllers:

struct NativeScaffoldView: View {
@ObservedObject var observable: NativeScaffoldObservable
var body: some View {
TabView(selection: $observable.selectedTab) {
ForEach(observable.tabs, id: \.position) { tab in
Group {
if #available(iOS 26.0, *) {
ComposeViewController { observable.content(tab.position) }
.ignoresSafeArea()
} else {
ComposeViewController { observable.content(tab.position) }
}
}
.tabItem {
if let uiImage = tab.icon.toUIImage() {
Image(uiImage: uiImage)
}
Text(tab.title)
}
.tag(tab.position)
}
}
}
}

This way, the entire screen — including the tab bar — is drawn by SwiftUI.

Each tab’s content is still my composable UI, just wrapped in a ComposeUIViewController.

Now the blur works perfectly, because the Compose content is physically behind the SwiftUI glass material.

The tab bar bends colors and shapes just like native iOS.

Press enter or click to view image in full size
SwiftUI TabView with Compose content

Remaining Problem — Scroll Behavior and Gradient Overlay

This setup solved the blur issue but introduced a new one: scrolling.

When scrolling happens inside Compose, SwiftUI doesn’t know about it — meaning:

  • The tab bar can’t collapse or hide during scroll.
  • The native gradient overlay that appears at the bottom of tab bars on iOS never shows.

I tried solving this by using a native SwiftUI ScrollView around the composable content, but it caused:

  • Incorrect height measurements (Compose couldn’t size itself properly inside ScrollView), and
  • Significant performance degradation (I couldn’t use lazy composition).

So in the end, I accepted a compromise:

  • Scrolling happens entirely in Compose.
  • The tab bar stays fixed (no collapse).
  • I manually added a gradient overlay at the bottom in Compose to simulate the native shadow.

Final Result:
Tabs rendered natively with full glass blur,
Composable content hosted inside SwiftUI,
Manual gradient overlay to match native appearance.

Press enter or click to view image in full size
SwiftUI TabView with Compose content and Compose color overlay on the bottom

Toolbar — Bridging TopAppBar and SwiftUI Toolbar

Toolbars were more complex. I wanted the same API in shared code, but different rendering on each platform.

Press enter or click to view image in full size
SwiftUI Toolbar on SwiftUI content inside ScrollView

SwiftUI Toolbar + Compose Content

I added a custom Toolbar data class as a parameter in my NativeScaffold:

@Composable
expect fun NativeScaffold(
modifier: Modifier = Modifier,
toolbar: Toolbar? = null,
tabs: List<TabItem> = emptyList(),
selectedTabPosition: Int = 0,
onTabSelected: (Int) -> Unit = {},
content: @Composable (currentTabPosition: Int?, contentPadding: PaddingValues) -> Unit,
)
data class Toolbar(
val title: String? = null,
val buttons: List<ToolbarButtonData> = emptyList(),
)
data class ToolbarButtonData(
val icon: ImageResource,
val description: String? = null,
val position: ToolbarButtonPosition = ToolbarButtonPosition.Trailing,
val tint: NativeColor? = null,
val onClick: () -> Unit,
)

And on iOS, I implemented it with a SwiftUI extension:

extension View {
@available(iOS 16.0, *)
func toolbar(_ toolbar: Toolbar) -> some View {
NavigationStack {
self
.navigationTitle(toolbar.title ?? "")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
let leading = toolbar.buttons.filter { $0.position == .leading }
let trailing = toolbar.buttons.filter { $0.position == .trailing }
if !leading.isEmpty {
ToolbarItemGroup(placement: .topBarLeading) {
ForEach(Array(leading.enumerated()), id: \.offset) { _, button in
toolbarButtonView(for: button)
}
}
}
if !trailing.isEmpty {
ToolbarItemGroup(placement: .topBarTrailing) {
ForEach(Array(trailing.enumerated()), id: \.offset) { _, button in
toolbarButtonView(for: button)
}
}
}
}
}
}
@ViewBuilder
private func toolbarButtonView(for button: ToolbarButtonData) -> some View {
Button {
button.onClick()
} label: {
if let uiImage = button.icon.toUIImage() {
Image(uiImage: uiImage)
.renderingMode(.template)
}
}
.tint(button.tint.map { Color(kmpColor: $0) } ?? .primary)
}
}

This achieved the best of both worlds:

  • Native iOS toolbar layout, spacing, and blur.
  • Shared Compose content below it.

But there were still limitations:

  • Just like with the tab bar, scroll events in Compose aren’t propagated to SwiftUI — so the toolbar can’t react with its native blur transition. I had to recreate that effect manually in Compose.
  • ToolbarItems can’t be created dynamically inside a ForEach, so I had to use ToolbarItemGroups and pre-filter items by position (leading/trailing).

Final Result:
A hybrid scaffold — SwiftUI view with native toolbar and Compose content below.

Press enter or click to view image in full size
SwiftUI Toolbar on Compose content with Compose blur + color overlay on the top

Card / Button — The Unsolvable Blur Problem

This one’s tricky. I wanted to render a “liquid glass” card or button with composable content inside via expect/actual, reusable like any other composable.

Attempted Approaches

I tried both UIKitView and UIKitViewController, implemented through SwiftUI and CInterop.

No matter what I did, the result was the same: a solid white rectangle behind my view.

The reason lies in Compose Multiplatform’s rendering architecture. As mentioned above:

Compose is implemented using Skia, which on iOS is rendered over the UIKit layer.

When you embed a UIKit or SwiftUI view into Compose, you’re effectively punching a hole in the Skia canvas — that area is replaced by UIKit, not blended with it.

So even if my UIKit view is transparent, there’s no Compose content “below” it to see through.

That’s when I realized I’d reached a hard limitation:

it’s currently impossible to implement true translucent cards or buttons as composables with native glass blur effects.

Press enter or click to view image in full size
SwiftUI Button with glass effect in white punched-in hole in Compose view

Conclusion

Liquid glass components bring a lot of polish to iOS, but Compose Multiplatform’s current rendering stack limits how much native blending we can achieve.

Still, depending on the project’s needs, it’s possible to build a convincing hybrid UI:

  • Native materials for system-level elements (tabs, toolbars)
  • Shared Composable logic for layout and content

It’s not perfect — but it feels right on both platforms, and keeps the codebase unified.

Press enter or click to view image in full size
TabView and Toolbar entirely in SwiftUI
Press enter or click to view image in full size
PlatformScaffold in action

Bonus chapter — Navigation

In this case, PlatformScaffold is a Composable function that wraps SwiftUI or UIKit elements under the hood. That means it’s still called inside a Composable screen, even on iOS.

How Navigation Works Here

It doesn’t matter whether we use iOS native navigation (SwiftUI’s NavigationStack or UIKit navigation) or Compose Multiplatform navigation — the entire Compose screen containing the PlatformScaffold becomes a single navigation node.

From SwiftUI’s or UIKit’s perspective, this is just one self-contained view. Inside it, all Compose content — including tabs, toolbar, and their composable pages — are rendered inside a single ComposeUIViewController.

I briefly considered adding smaller navigation inside the NativeScaffold, so each tab could switch or push composable routes independently. Technically, that would work — Compose navigation can operate normally within the Compose portion of the screen.

But practically, it would feel wrong for iOS navigation patterns, since the tabs and toolbar would stay on top without change.

Why We Keep It as a Single Node

The goal of NativeScaffold is to represent a complete “shell” — with toolbar, tab bar, and blur layers.

If we started navigating inside it (e.g., changing only the content of one tab), the outer UI elements would stay visible with the same data (titles, buttons, selected tab).

That’s rarely what users expect on iOS, where navigation usually pushes or slides an entirely new screen.

So I decided to treat the whole scaffold as one native screen:

  • The toolbar and tab bar belong to that screen only.
  • Any navigation beyond it (details, modals, new flows) happens at the higher level.
  • Inside Compose, the content of each tab stays static.

In short:
The NativeScaffold is a full-screen unit. Regardless of whether navigation is handled by UIKit, SwiftUI, or Compose, it’s treated as a single node. Internal navigation is intentionally avoided to preserve native behavior and visual consistency.

Navigation (UIKit / SwiftUI / Compose)
├─ NativeScaffold (full-screen shell)
│ ├─ Toolbar (SwiftUI)
│ ├─ TabBar (SwiftUI)
│ └─ Compose Content (per tab)
└─ OtherScreen / Modal / Details

Wrapping up

This project started with a clear goal — to bring liquid glass components into a Compose Multiplatform app and make them feel truly native on iOS.

Along the way, I learned more about how Compose and SwiftUI actually work together — and where their boundaries are.

The key takeaway is this:
It is possible to use truly translucent UIKit/SwiftUI components in Compose, but only when Compose content is hosted inside SwiftUI, not the other way around.

When UIKit or SwiftUI views are embedded inside Compose, they sit below the Skia layer and can’t blend with it.

But when we flip the hierarchy — rendering Compose inside SwiftUI — the blur and glass effects behave just like in native iOS apps.

By treating each NativeScaffold as a full-screen unit (a single navigation node), we preserve the expected native navigation flow while still keeping the flexibility of Compose for the actual UI content.

It’s not a perfect solution, but it’s a practical one:
SwiftUI provides the native polish and effects, Compose provides the shared layout and logic — and together, they make hybrid UIs that still feel at home on both platforms.

--

--

Responses (2)