DEV Community

Cover image for My Journey Making Compose Multiplatform Work on tvOS, and What I Learned
Sajid Ali
Sajid Ali

Posted on AI-assisted

My Journey Making Compose Multiplatform Work on tvOS, and What I Learned

Part 1 of a series. This post is the high-level overview. The next posts go into each module in detail.

I am an Android and Android TV developer with about ten years of experience. For the last few years I have been leading a streaming app at Devolic that has to run on Android mobile, Android TV, desktop, iOS and tvOS. This series is about how that requirement ended with me maintaining a fork of Compose Multiplatform with tvOS support, and what I learned along the way.

How we got here

The app did not start out multiplatform. It went through the same three phases as a lot of Android codebases.

Android Views. The first version was classic Views and XML, one codebase for phone and TV with a lot of if (isTv) checks.

Jetpack Compose. When Compose became stable we rewrote the UI in it. The TV side got a lot better because focus handling in Compose is explicit, and androidx.tv:tv-material gave us proper TV components.

Compose Multiplatform. Then desktop, iOS and tvOS were added to the roadmap. We are a small team. Building and maintaining four or five separate UI layers was never realistic, and even with more people, keeping feature parity between them is where small teams lose the most time. Compose Multiplatform was the obvious choice: same UI code, same design system, same navigation on every platform.

Except for one platform.

Why not Kotlin Multiplatform with SwiftUI on the TV?

The first plan for Apple TV was the usual one: share the business logic with Kotlin Multiplatform and write the tvOS UI natively in SwiftUI.

We tried it, and SwiftUI's focus management was the deal breaker. On a TV, focus is the whole user interface. You need to decide exactly where focus goes on every D-pad press, restore it when the user comes back to a screen, keep it inside a dialog, move it into a row and remember which item was focused last time. Compose gives you FocusRequester, focusProperties, focusRestorer and the ability to intercept key events anywhere in the tree. SwiftUI gives you a handful of modifiers and a focus engine that mostly does what it wants. We spent a lot of time fighting it and still could not match the behaviour we already had on Android TV.

So the question became: how hard would it be to make Compose Multiplatform itself run on tvOS?

What tvOS actually is

Once I started researching, the picture was more encouraging than I expected.

tvOS is iOS with a different input model and some frameworks removed. Same kernel, same Objective-C runtime, same UIKit at the core, same Metal. The differences that matter for a UI toolkit are:

  • No touch screen. Input comes from the Siri Remote as UIPress events plus indirect touches from the trackpad.
  • Focus is driven by UIKit's focus engine, and every view is expected to take part in it.
  • A long list of iOS APIs simply do not exist: drag and drop, hover gestures, the text loupe, edit menus, screen edge pan gestures, window scene orientation APIs, and so on.
  • A different SDK name (appletvos / appletvsimulator) and different Kotlin/Native targets (tvosArm64, tvosSimulatorArm64).

That last point is the real cost. In Kotlin Multiplatform, a target is a target. It does not matter how similar tvOS is to iOS: every library in the dependency graph has to publish a tvosArm64 variant or the build fails during dependency resolution. Compose Multiplatform is a deep stack of modules, so that meant going through the entire stack.

The one thing that could have stopped the idea before it started was Skiko, the Skia binding that Compose Multiplatform uses for rendering. If Skiko had no tvOS build, I would have had to build Skia for tvOS myself. It turned out that Skiko already published tvOS targets. That was a major relief, and the moment the project went from "maybe" to "let's do it".

Deciding how to do it

There were two repositories to change.

The Gradle plugin (compose-multiplatform repository). This is where the Compose Gradle plugin decides which Kotlin targets it knows how to configure, mostly for resources. Kotlin Multiplatform already supported tvOS, so this was a matter of telling the plugin that a tvOS target is an Apple target that needs the same resource handling as iOS:

private fun KotlinNativeTarget.isTvosSimulatorTarget(): Boolean =
    konanTarget === KonanTarget.TVOS_X64 || konanTarget === KonanTarget.TVOS_SIMULATOR_ARM64

private fun KotlinNativeTarget.isTvosDeviceTarget(): Boolean =
    konanTarget === KonanTarget.TVOS_ARM64

private fun KotlinNativeTarget.isTvosTarget(): Boolean =
    isTvosSimulatorTarget() || isTvosDeviceTarget()

private fun KotlinNativeTarget.isIosOrMacTarget(): Boolean =
    isIosTarget() || isMacTarget() || isTvosTarget()
Enter fullscreen mode Exit fullscreen mode

The runtime (compose-multiplatform-core repository). This is the actual androidx.compose.* code with JetBrains' iOS, desktop and web implementations. Every module with a UIKit-specific implementation needed a tvOS one. The build setup already had a TV_OS platform enum that was not used anywhere. Enabling it was a one-line change in ComposePlatforms.kt:

val SKIKO_SUPPORT = EnumSet.of(KotlinMultiplatform) + JVM_BASED + IOS + TV_OS + MACOS_NATIVE + WEB
Enter fullscreen mode Exit fullscreen mode

Plus registering the two targets in the shared target configuration with the same UIKit linker flags that iOS uses:

val uikitFlags = listOf("-linker-option", "-framework", "-linker-option", "UIKit")

project.multiplatformExtension!!.run {
    iosArm64 { configureFreeCompilerArgs() }
    iosSimulatorArm64 { configureFreeCompilerArgs() }
    tvosArm64 { configureFreeCompilerArgs() }
    tvosSimulatorArm64 { configureFreeCompilerArgs() }
}
Enter fullscreen mode Exit fullscreen mode

With the targets enabled, the build immediately showed what was missing: dozens of expect declarations without a tvOS actual, and a handful of Objective-C files that did not compile against the tvOS SDK. That is where the real work started, and I needed an order to do it in.

Going through the modules in dependency order

The approach I settled on was simple. Start with the module that does not depend on any other Compose module, make it build for tvOS, then move to the modules that only depend on modules that are already done. Never touch a module before its dependencies compile.

The order ended up like this:

compose:ui:ui-uikit          <- Objective-C / cinterop layer, no Compose dependencies. Start here.
   └─ compose:ui:ui-util
       └─ compose:ui:ui-text
           └─ compose:ui:ui       <- ComposeUIViewController, rendering, input, focus, density
               ├─ compose:ui:ui-test
               └─ compose:foundation:foundation
                   ├─ compose:material3 (+ adaptive, ripple)
                   ├─ navigation-compose / navigation3-ui / window-core
                   └─ tv:tv-material   <- ported from the Android-only androidx.tv library
Enter fullscreen mode Exit fullscreen mode

One decision made this much easier. Instead of a separate tvosMain copy of everything, I added a shared uiKitMain source set that both iosMain and tvosMain depend on. Anything that is plain UIKit code lives there once. Only the parts that are actually different get a tvOS-specific file.

uiKitMain {
    dependsOn(nativeMain)
    dependencies { implementation(project(":compose:ui:ui-uikit")) }
}
iosMain  { dependsOn(uiKitMain) }
tvosMain { dependsOn(uiKitMain) }
Enter fullscreen mode Exit fullscreen mode

The first module: ui-uikit

ui-uikit is the thin Objective-C and Swift layer that Compose's Kotlin code calls through cinterop. It is an Xcode project (CMPUIKitUtils) that Gradle builds with xcodebuild and exposes to Kotlin through a generated .def file. Because it has no Kotlin dependencies on the rest of Compose, it was the natural first step. It was also the smallest step in the whole port: around 110 lines across 20 files, in a single commit.

Three kinds of changes were needed.

1. Build the Xcode project for the tvOS SDKs. The Gradle code that runs xcodebuild assumed iPhone SDK names, so it had to learn about appletvos and appletvsimulator and the matching build destinations. The Xcode project itself needed the tvOS platforms and the Apple TV device family added. I will cover the whole build setup, including a cinterop quirk around linker options, in the next post.

2. Exclude UIKit APIs that do not exist on tvOS. This was most of the diff. Every file that used an iOS-only class got a TARGET_OS_TV check. For example, the accessibility element uses UIFocusHaloEffect, which tvOS does not have:

#if !TARGET_OS_TV
- (UIFocusEffect *)focusEffect {
    return [UIFocusHaloEffect effectWithRect:[self focusEffectRect]];
}
#endif
Enter fullscreen mode Exit fullscreen mode

Whole classes were excluded the same way: CMPDragInteractionProxy and CMPDropInteractionProxy (no drag and drop on tvOS), CMPHoverGestureRecognizer (no pointer hover), CMPScreenEdgePanGestureRecognizer, CMPTextLoupeSession and the UIWindowScene orientation extensions.

3. Add empty implementations for classes that cinterop still references. Excluding a header is not always enough. CMPEditMenuView is declared in a header that the shared cinterop definition still includes, so the Kotlin side references the class and the linker needs the symbol. On tvOS it gets an empty implementation:

#if TARGET_OS_TV
// tvOS stub: CMPEditMenuView is iOS-only. This empty implementation satisfies
// the linker when the interface is included via the shared cinterop headers.
@implementation CMPEditMenuView
@end
#else // !TARGET_OS_TV
... real implementation ...
#endif
Enter fullscreen mode Exit fullscreen mode

Once ui-uikit produced a framework for both tvOS targets, I could move on to the rest of the modules.

Which module needed the most effort

By a wide margin, compose:ui:ui. Roughly 4,400 lines changed across 25 commits, compared to about 110 lines in ui-uikit and under 300 in foundation. It is the module where tvOS stops being "iOS with some APIs missing" and becomes a genuinely different platform:

  • Rendering and hosting. ComposeSceneMediator, ComposeContainer, the hosting view and the view controller all have tvOS versions. These files own the UIKit view hierarchy, rendering through Skiko, and the interop with native views. They are also the files JetBrains refactors most often, so keeping them in sync with upstream is the main ongoing maintenance cost of this fork.
  • Siri Remote input. UIPress and UIPressesEvent have to be converted into Compose KeyEvents. D-pad navigation, swipe-to-focus on the trackpad, key repeat, treating the Menu button as Key.Back, and telling a trackpad click apart from a swipe (which needed hardware timestamps, because the trackpad is itself a button).
  • Focus. UIKit's focus engine and Compose's focus system have to agree on which one owns focus, which happens through didUpdateFocusInContext.
  • Screen density. UIKit reports an Apple TV at the same scale as a phone, which gives a 1080p screen a 1920x1080 dp canvas. That is far too dense for a TV UI viewed from the couch. Android TV treats 1080p as density 2.0, a 960x540 dp canvas, and squaring the UIKit scale reproduces exactly that. The original scale is kept for insets and accessibility.
  • Text input. tvOS has a full-screen keyboard instead of the inline one on iOS, so it has its own text input service.
  • No-op implementations. Drag and drop, haptics and clipboard get empty implementations.

Foundation mostly needed the move to the shared uiKitMain source set plus tvOS implementations for things like text selection handles and the magnifier that do not exist on a TV. Material3 was almost entirely build configuration. And tv-material, the component library that makes a TV app look like a TV app, had to be moved from an Android-only AndroidX module into commonMain with expect/actual pairs for the Android and tvOS specifics.

Lessons learned

  • Check the critical dependency first. Skiko having tvOS support was the deciding factor. Confirm that kind of thing before writing any code.
  • Port modules in dependency order. Every failed build then points at exactly one module, and you never chase errors caused by a dependency you have not ported yet.
  • Share as much as possible between iOS and tvOS. The uiKitMain source set kept the tvOS-specific code small enough for one person to maintain.
  • The hard part is not the missing APIs, it is input. Excluding UIHoverGestureRecognizer takes a minute. Making the Siri Remote feel right takes weeks.
  • Expect constant upstream changes. A fork of a fast-moving toolkit needs regular rebasing. The rendering and hosting files had to be adapted to upstream refactors many times.

Try it

Everything is published and usable today as a community project. It is not official JetBrains work.

Adding it to an existing Compose Multiplatform project is one plugin:

plugins {
    id("dev.sajidali.compose-tvos") version "1.4.2"
}

kotlin {
    tvosArm64()
    tvosSimulatorArm64()
}
Enter fullscreen mode Exit fullscreen mode

Keep your normal org.jetbrains.compose.*, androidx.tv:tv-material, Koin and Coil dependencies. The plugin redirects them to tvOS-capable builds of the exact same versions. Kotlin 2.3.20 or newer is required.

What is next in this series

This post stayed deliberately high level. Over the coming weeks I will go through each part in detail:

  1. Overview. This post.
  2. Build setup. Kotlin targets, the shared uiKitMain source set, the fork's separate build files, the ui-uikit Xcode and cinterop build, and tvOS resources in the Gradle plugin.
  3. Rendering. How Compose draws inside a UIViewController on tvOS: ComposeSceneMediator, the hosting view, layers, Skiko and Metal, frame scheduling, and why these files change the most on every upstream rebase.
  4. Siri Remote input. Converting UIPress to KeyEvent, D-pad navigation, connecting UIKit focus with Compose focus, and the Menu button as Back.
  5. Siri Remote trackpad. Swipe-to-focus, telling a click from a swipe with hardware timestamps, phantom swipes, key repeat and long press.
  6. Screen density and text input. Why the density is the square of the screen scale, and building a text input service around the full-screen tvOS keyboard.
  7. Porting tv-material. Surface, Carousel, SurfaceGlow, accessibility, and a null Skia pointer that took a while to find.
  8. The Gradle plugin and third-party libraries. Why one plugin instead of new Maven coordinates, Koin and Coil, libraries that publish no tvOS variant, and the Kotlin version requirement.
  9. Maintaining the fork. Rebasing onto upstream, the release process, compile-verified versus test-verified, and what I would do differently.

If you have a Compose app and a TV target on your roadmap, I would love to hear what breaks when you try it.

Top comments (0)