Skip to content

JNI: closureMethodName names the method of closure interfaces - #29

Open
piercifani wants to merge 2 commits into
jni/24-main-actor-async-closure-resultsfrom
jni/25-closure-method-name
Open

piercifani wants to merge 2 commits into
jni/24-main-actor-async-closure-resultsfrom
jni/25-closure-method-name

Conversation

@piercifani

Copy link
Copy Markdown

Stack 25/25 · base: jni/24-main-actor-async-closure-results · previous: #28

Problem

A closure crosses to Java as a functional interface whose one method is apply. Passing a Kotlin lambda into Swift reads naturally, since Kotlin converts { … } to the interface. Calling a closure read back from Swift, such as a view model's callback (#21, #28), doesn't:

screen.onTap.apply()
screen.onSelect.apply("BTC")

Skip exposes the same properties as Kotlin function types, called as screen.onTap().

Changes

  • "closureMethodName": "invoke". A new option names the interfaces' method. Kotlin treats a Java method named invoke as the invoke operator, so the calls become screen.onTap() and screen.onSelect("BTC"), and Kotlin lambdas still convert to the interface. The default stays apply.
  • Where the name is used: the Java interface, the @JavaInterface jextract prints for escaping closures (JNI: escaping closures taking jextracted types; @Sendable/@MainActor closure types #2), and the calls into Java, synchronous and async (JNI: async closures implemented in Java return a CompletableFuture #22).
  • No java.util.function interfaces with a custom name. Closures such as (Int64) -> Int64, which map to LongUnaryOperator and its applyAsLong, get their own interface instead, so every closure is called the same way.

Kotlin in this PR

The sample sets "closureMethodName": "invoke", and its tests now call closures as functions. ScreenCallbacksTest.kt:

screen.onTap()
screen.onSelect("BTC")
assertEquals("Markets", screen.title())
screen.onDismiss?.invoke()
assertEquals(3L, screen.count("abc").get())

ClosuresTest.kt calls a returned closure as format(Item.init(it, 1)), and still passes { it * 2 } to (Int) -> Int, which now has its own interface.

Why Skip needs it

Kotlin code written against Skip calls these callbacks as functions. With invoke, the same Kotlin code compiles against jextract's output.

Testing

  • Kotlin sample: ./gradlew :Samples:SwiftJavaExtractJNIKotlinSampleApp:test passes (56 tests). That covers escaping, non-escaping, async and returned closures, and primitive ones that used to be LongUnaryOperator.
  • New JNIClosureMethodNameTests (2 tests): the Java interfaces and the Swift wrapper use invoke, and no java.util.function interface is used.
  • swift test passes; config docs regenerated.

🤖 Generated with Claude Code

piercifani and others added 2 commits November 4, 2026 15:13
Closure interfaces declare one method, `apply`, so Kotlin calls a closure
read from Swift as `onTap.apply()`, where Skip and Kotlin's own function types
give `onTap()`. Kotlin treats a Java method named `invoke` as the invoke
operator, so with `"closureMethodName": "invoke"` the same closure is called
as `onTap()`, and Kotlin lambdas still convert to the interface.

The name is used for the Java interface, the `@JavaInterface` jextract prints
for escaping closures, and the calls into Java, synchronous and async. With a
name other than `apply`, closures that a `java.util.function` interface would
cover, such as `(Int64) -> Int64` with `LongUnaryOperator.applyAsLong`, get
their own interface too, so every closure is called the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant