I gave my Compose Multiplatform app a Liquid Glass UI
The Need
When I started bonbox, I used Compose Multiplatform as my first choice to get out a working app quickly and Multiplatform. It works pretty well and I tried to optimise the experience as I went. More and more I noticed things that just were rather difficult on iOS. The first one was a date field. The Material Date Picker (as cool as it is) just feels extremely out of place on iOS. Back then I went through the first couple of struggles but eventually managed to display a native Swift UI date field within Compose.
The approach uses Compose’s expect/actual pattern with a factory that bridges SwiftUI into Compose:
// Common: expect declaration
@Composable
expect fun DateField(
value: Long,
onValueChange: (Long) -> Unit,
modifier: Modifier = Modifier
)
// iOS: actual implementation using UIKitViewController
@Composable
actual fun DateField(value: Long, onValueChange: (Long) -> Unit, modifier: Modifier) {
val factory = LocalNativeViewFactory.current
key(value) {
UIKitViewController(
modifier = modifier.heightIn(min = 40.dp),
factory = { factory.createDateField(value, onValueChange) }
)
}
}
The factory interface lives in Kotlin but is implemented in Swift:
// Kotlin interface
interface NativeViewFactory {
fun createDateField(value: Long, onValueChanged: (Long) -> Unit): UIViewController
}
// Swift implementation — wraps a SwiftUI DateField in UIHostingController
class IOSNativeViewFactory: NativeViewFactory {
func createDateField(value: Int64, onValueChanged: @escaping (KotlinLong) -> Void) -> UIViewController {
let view = DateField(date: value, action: { onValueChanged(KotlinLong(value: $0)) })
return UIHostingController(rootView: view)
}
}
And the SwiftUI DateField itself is a native date picker with a popover:
struct DateField: View {
var date: Int64
var action: (Int64) -> Void
@State private var currentDate: Date
@State private var showDatePicker = false
var body: some View {
Button { showDatePicker.toggle() } label: {
Text(currentDate, style: .date)
}
.popover(isPresented: $showDatePicker) {
DatePicker("Select Date", selection: $currentDate, displayedComponents: .date)
.onChange(of: currentDate) { _, newValue in
action(newValue.millisecondsSince1970)
}
.datePickerStyle(WheelDatePickerStyle())
.presentationCompactAdaptation(.popover)
}
}
}
It works, but it’s a lot of glue code for a single component. And this pattern needs to be repeated for every native element you want to embed. The bummer was that in this special case I was not able to just trigger the native iOS “date picker” in a form that I liked, so I had to bring over a whole SwiftUI field which then calls the date picker. While integrating that date field there were other caveats; f.ex. iOS components tend to stretch to the size you give them within Compose, so you will likely need to hardcode that with Modifiers.
Further down the road, I tried to experiment with including native Liquid Glass Components such as Bottom Navigation and Toolbar and managed to do it in some way or the other. But I realised that the code suffers majorly from choices like that. Eventually I came to the moment that it’s probably best to give iOS its own UI in Swift and reuse my Kotlin Multiplatform logic for everything up to the ViewModels. Thankfully that’s quite easy to do and in the area of Agentic Coding even easier than ever.
The Solution

A few weeks of bringing over features to Liquid Glass, struggles with toolbars, rearranging UIs later and we have the PR ready to be merged. It took quite a lot of attempts to get every feature in and I’m convinced that still not 100% is working, but hopefully 99%. And even now I find things that don’t look good in dark mode / light mode or both that I simply hadn’t tested thoroughly yet (can’t borrow my gf’s iPhone for testing all the time and simulator sometimes gives different results).
Overall I’m glad that I went this path of creating a UI in Compose first and then converting it to Liquid Glass when I saw fit. The new UI now is Native but still uses most of the existing Kotlin Multiplatform Stack. Shipping new features will be a bit more complicated but not by much.
Takeaway
Starting in Compose Multiplatform was the right decision — it let me ship on both platforms fast with a single UI codebase. But when the platform demands native UI (and Liquid Glass really does), the migration is surprisingly smooth if your ViewModels live in KMP. I’d take this path again: shared logic in Kotlin, native UI where it matters. If you start to want to include tiny bits of Liquid Glass into your Compose Multiplatform app it might be fine, but it gets messy rather quick.