Skip to content

Investigate a more Swift-native value and Binding projection API #15

Description

@sonmbol

Problem

The current SwiftUI surface deliberately separates the real imported Kotlin ViewModel from its observed native-value projection:

@KMPStateObject private var example = ExampleViewModel()

Text($example.messageState)
TextField("Search", text: $example.searchText)

Button("Increment") {
    example.increment()
}

$example.messageState is technically correct, but the syntax is not immediately obvious to every Swift developer. $example.searchText may also be interpreted as a Binding, while a read-only StateFlow projection produces a native value.

Investigate a clearer Swift-native projection API without sacrificing the architecture.

Why example.messageState cannot currently return String

example is the property wrapper's wrappedValue, which intentionally remains the real Kotlin ViewModel so consumers can call Kotlin methods naturally:

example.increment()
example.retry()

The imported model already declares:

var messageState: SkieSwiftStateFlow<String> { get }

A macro or extension cannot redeclare messageState with a different return type. Swift does not overload properties by return type.

Changing wrappedValue to a proxy could make dynamic-member value reads possible, but it would break transparent Kotlin method calls because methods are not key-path dynamic members. Consumers would need syntax such as:

example.rawModel.increment()

That makes the common action path less native and weakens the core concept that the wrapped value is the Kotlin ViewModel.

Why example.$messageState is not available

That syntax exists only when messageState itself is a Swift property wrapper, such as @Published. Imported Kotlin/SKIE properties are not Swift property-wrapper declarations. Swift also reserves $-prefixed projected identifiers for compiler-synthesized wrapper projections, so the bridge cannot add an equivalent member retroactively.

Approaches to evaluate

Keep the current compact projection

$example.messageState
$example.searchText

Pros: minimal syntax, field-level dependency tracking, no extra proxy at the call site.

Cons: read-only native values and writable bindings can look ambiguous.

Separate value and binding namespaces

$example.state.messageState
$example.binding.searchText

Pros: explicit semantics and excellent discoverability.

Cons: more typing and potentially additional lightweight projection values. The implementation must prove these are allocation-free or amortized and preserve dynamic-member type inference.

Keep values compact and make bindings explicit

$example.messageState
$example.binding.searchText

Pros: preserves the current read syntax while removing binding ambiguity.

Cons: the leading $ remains unfamiliar for a non-Binding value.

Local named projection

var body: some View {
    let state = $example

    Text(state.messageState)
    TextField("Search", text: state.binding.searchText)
}

Pros: no new public machinery and clean rendering expressions.

Cons: small per-body declaration and documentation burden.

Macro-generated sibling projection

@KMPStateObject
@KMPProjection(named: "state")
private var example = ExampleViewModel()

Text(state.messageState)

Pros: removes $ at reads.

Cons: hidden identifier generation, naming collisions, access-control complexity, poorer code search, and additional macro surface. It still cannot make example.messageState override the imported property.

Change wrappedValue to a proxy

Text(example.messageState)
example.rawModel.increment()

This is technically possible but not recommended. It breaks the existing concept and source compatibility: the wrapped value would stop being the Kotlin ViewModel. It may also create proxy identity/lifetime confusion and complicate interoperability with APIs expecting the original Kotlin type.

Pure SwiftUI container/presentation boundary

ExampleContent(
    message: $example.messageState,
    searchText: $example.searchText,
    increment: example.increment
)

The presentation view receives ordinary Swift values, bindings, and closures. This remains the recommended large-scale architecture because bridge syntax stays inside a thin integration container.

Architectural invariants

Any redesign must preserve:

  • the wrapped value is the original Kotlin ViewModel;
  • Kotlin methods remain callable through example.method();
  • Kotlin remains the authoritative state store;
  • no copied shadow state;
  • field-level Observation tracking on supported platforms;
  • correct iOS 15/16 fallback;
  • no new per-body collector or long-lived wrapper object;
  • no unexpected heap allocation on state reads;
  • writable bindings retain the correct store lifetime;
  • existing 1.1 syntax remains source-compatible or has a carefully justified major-version migration.

Acceptance criteria

  • Read-only native values and writable bindings are visually unambiguous.
  • The API feels natural to SwiftUI developers and has strong autocomplete.
  • Kotlin actions remain example.action().
  • Rendering and allocation benchmarks match or improve upon the existing projection.
  • Observation tests prove unrelated fields do not invalidate each other.
  • Documentation demonstrates both direct container usage and the recommended pure SwiftUI presentation boundary.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions