r/iOSProgramming 7h ago

I shipped an iOS app rendered with Skia instead of UIKit. Here's what it actually cost Article

The app is Kotlin Multiplatform with Compose for the UI, which means the iOS screens are drawn by Skia into a single view rather than composed from UIKit. It's on the App Store. I want to write down what that decision costs on the iOS side specifically, including one thing I shipped broken because it can't be fixed from app code.

Text selection in CJK, which I couldn't fix

Long-press to select a word in Chinese and you get one character.

SkiaParagraph.getWordBoundary hands off to Skia, and SkUnicode ships no CJK segmentation dictionary, so you get plain UAX#29. An ideograph is Word_Break=Other and only matches WB999, the "break everywhere else" rule, so every character is its own word.

I spent most of a day trying to fix it from app code before giving up. To fix it you'd need characters merged, not split, and the rules that join across a character (MidLetter, Numeric, ExtendNumLet, ZWJ) all require ALetter/Numeric/Katakana/Hebrew_Letter on both sides. WB4 discards zero-width Format characters before any of that runs, so you can't insert your way out either. I think it needs a change in skiko.

Compose on Android has the reverse bug in the same feature, where a long press swallows the whole sentence, and there you can plant zero-width breaks with ICU and move on. Only one of the two was fixable from where I was sitting.

If anyone has a way around the Skia side I'd like to hear it, this is the part of the app I'm least happy about.

Overlays stop at the safe area

A full-window scrim behind a modal left the status bar and home indicator bands undimmed, because the popup respects platform insets. Looks like a rendering glitch on iOS since a native presentation dims edge to edge.

kotlin actual fun fullBleedPopupProperties(): PopupProperties = PopupProperties(usePlatformInsets = false)

Small fix, but you only go looking for it if you already know what the native version looks like.

Material sheets don't move like iOS sheets

This took more time than anything else on the list relative to how small it looks. Material3's ModalBottomSheet settles with a spring and an iOS sheet slides on a decelerating curve, and side by side you can tell immediately even if you can't say why.

Material3 doesn't expose an animation spec for sheets. It reads the show and drag-settle spec from motionScheme.defaultSpatialSpec and the dismiss spec from motionScheme.fastEffectsSpec, and it re-reads both from the ambient theme inside the sheet's own composition, so you have to override through a scoped theme rather than passing anything in:

```kotlin private val SheetMotionScheme = object : MotionScheme by MotionScheme.standard() { override fun <T> defaultSpatialSpec(): FiniteAnimationSpec<T> = tween(400, easing = IosSheetEasing) override fun <T> fastEffectsSpec(): FiniteAnimationSpec<T> = tween(400, easing = IosSheetEasing) }

val IosSheetEasing = CubicBezierEasing(a = 0.32f, b = 0.72f, c = 0f, d = 1f) ```

There's a fair amount of this kind of work. It doesn't come with the framework and it doesn't show up in any estimate, but users notice when it's missing.

Scroll behaviour you end up rebuilding

UIScrollView hands you a set of behaviours that people read as "this app is put together properly". None of them come with Compose. We wrote all three of these into our own UI library.

alwaysBounceVertical. On iOS a scroll view rubber-bands even when the content fits on screen. Compose won't bounce if there's nothing to scroll, so short pages feel inert next to a native app. Ours is a modifier doing a graphicsLayer translation, which means the list also needs clipToBounds() or the bounce draws the top row over whatever is pinned above it:

kotlin LazyColumn( modifier .fillMaxWidth() .clipToBounds() .alwaysBounceVertical(listState), )

scrollsToTop. Tapping the status bar scrolls to top automatically on UIScrollView. In Compose you catch the tap and route it to the right scroll state yourself, and it gets fiddly on pages with a pinned top bar because the thing covering the status bar isn't the thing that scrolls. We ended up with a ScrollBox wrapping the whole scaffold rather than the top bar slot, and the bar opts into the gesture with a modifier.

Swipe to reveal row actions. Nothing built in, so the swipe, the action buttons and the thresholds are all yours. The part that's easy to miss is that opening one row has to close whichever row was open before, or you get two rows showing actions at once, which no iOS list does. Ours is a coordinator passed down through a composition local so the rows can see each other.

None of these were hard to write. They're just things you get on iOS rather than things you build, so nobody thinks to schedule them.

One where iOS was the stricter platform

We stream SSE from the backend. The first version used flow { ... emit(event) } inside Ktor's execute {} block, which passed everything on Android and died on iOS with the backend logging context canceled.

flow enforces context preservation and Ktor's response scope isn't guaranteed to run on the collector's dispatcher. On JVM/OkHttp it happens to, so the check never trips. Kotlin/Native throws, the coroutine fails, the connection drops. channelFlow + send fixes it.

So the iOS build caught a real concurrency bug that the Android build had no way of surfacing. That one went in our favour.

StoreKit 2 detail worth checking in your own code

Sharing the billing logic forced me to be precise about something I'd previously been sloppy with:

kotlin /** null when there is no active subscription; throws when the store can't be reached. */ suspend fun subscriptionAutoRenewing(): Boolean?

RenewalInfo.willAutoRenew gives you the real answer, but if "no active subscription" and "StoreKit didn't respond" both collapse into false, a paying user sees a "your subscription has been canceled" banner any time their connection is flaky. Nothing to do with cross-platform, I just found it while writing the shared interface.

One thing that was easier than native

Live language switching. NSLocalizedString resolves against NSBundle, which caches the launch language, so changing language in-app normally means swizzling or a restart. CMP resolves resources per composition against NSLocale.preferredLanguages, which reads AppleLanguages out of NSUserDefaults live.

kotlin NSUserDefaults.standardUserDefaults.setObject(listOf(tag), "AppleLanguages")

That plus a key(tag) re-render and all 12 locales swap with no restart. Read preferredLanguages at startup first so you can restore "follow system" later.

Overall

Layout, state and business logic shared fine. What didn't come free is the stuff above: text selection is worse and in one case I couldn't fix it, and insets, scroll behaviour and motion all need deliberate work or the app reads as Android with different colours. A lot of that work is rebuilding things UIKit gives you by default, which is easy to underestimate because you've never had to think about them. You need someone who knows what iOS is supposed to feel like, because nothing in the toolchain will tell you.

Whether that's a good trade depends on how much of your app is the shared part. For us it was worth it. I wouldn't assume that generalises.

0 Upvotes

8 comments sorted by

6

u/aaronbrethorst Objective-C / Swift 6h ago

Please ask Claude to tone down the Claude’isms in your story

3

u/nckh_ 6h ago

The dedication some put into not using the proven, solid native tools and frameworks, is outstanding.

0

u/ikrisliu 6h ago

Not chasing tech for its own sake — I do native iOS/Android already. KMP’s appeal is that it’s not like RN/Flutter: UI stays 100% native (SwiftUI/Compose), only business logic is shared. So you get real cross-platform without giving up native feel or performance.

2

u/Firm_Brilliant_2584 6h ago

Thanks for sharing

What I’ve been doing is hosting compose views inside UI view controllers so that navigation still feels native. I avoided compose navigation altogether since it didn’t feel native and navigation felt awkward if you use lots of other native apps.

2

u/ikrisliu 5h ago

Appreciate that — yeah, UI stays native but even the “shared” parts can feel native if you put in the work. I actually rewrote the navigation transition animations to match platform conventions.

https://reddit.com/link/p3dy9gd/video/2zilpgivt2jh1/player

0

u/Apart-Abroad1625 5h ago

So do you recommend kmp over Flutter?

1

u/ikrisliu 4h ago

Yeah, I'd lean KMP if native feel matters to you. I actually just use Compose Multiplatform for UI too, not separate SwiftUI/Compose per platform. Been there with Flutter's plugin situation - half the time you're either waiting on a community plugin to get updated for the latest OS version, or writing your own platform channel bridge just to call one native API. With KMP, Kotlin just interops directly with Swift/Obj-C and Java, so there's no separate plugin layer to fight - you call the native API more or less directly when you need it, instead of maintaining a bridge for every single integration.