Skip to content

feat: add Android support via Skip Fuse native compilation - #1

Merged
rozd merged 7 commits into
mainfrom
feat/skip-android-support
Aug 14, 2026
Merged

feat: add Android support via Skip Fuse native compilation#1
rozd merged 7 commits into
mainfrom
feat/skip-android-support

Conversation

@rozd

@rozdrozd commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Summary

  • FormsKit builds as a SkipFuse (native) framework: the Swift — including the @Validated property wrapper and the KeyPath-driven focus system — compiles natively for Android with the Swift SDK for Android, and the SwiftUI layer renders through SkipFuseUI → Jetpack Compose.
  • Skip's transpiled mode is not viable for this library (its Swift→Kotlin transpiler supports neither custom property wrappers nor key paths), so native mode is the only supported shape — recorded in CLAUDE.md.
  • The repo's no-dependencies rule is preserved in spirit: the Skip packages are build-time only and inert on Apple platforms, and the official SKIP_ZERO=1 manifest block strips the plugin and every Skip dependency for Apple-only consumers.
  • Third commit (d9ce37c): the modifiers are custom ViewModifiers again, in dual generic/erased form. The second commit's wrapper-View workaround rested on a stale diagnosis. The real rule: on Android, View.modifier(_:) applies the modifier's Java_modifier, whose protocol default is SkipUI.EmptyModifier() — an unbridged custom ViewModifier is a silent no-op. But for a bridged modifier, skipstone generates the override (a Kotlin peer whose body() calls back into Swift body(content:)), and it renders. Genericity — which skip-bridge can't represent — was the actual blocker, not the ViewModifier protocol. The public API is unchanged throughout.

Changes

  • Package.swiftskip, skip-fuse, skip-fuse-ui dependencies + skipstone plugin, the SKIP_ZERO escape-hatch block, and the FormsKitSwiftUI shim target (re-exports SwiftUI, or SkipSwiftUI under -DSKIP_BRIDGE; the bridge generator mirrors source-file imports verbatim and cannot evaluate #if, so the conditional lives at module level)
  • Dual-form view modifiers — each generic modifier has a fully-typed variant for non-bridge builds (hidden from the skipstone generator behind #if !SKIP_BRIDGE && !SKIP) plus an unconditional non-generic erased twin that skipstone bridges for Android: FormToolbarViewModifier<T> + ErasedFormToolbarModifier, FocusedOnViewModifier<T, V> + ErasedFocusedOnModifier. The twins reach the controller through the internal closure facade AnyFormController (AnyKeyPath for focus identity). FormValidationErrorModifier is non-generic by construction (takes errorMessages: [String]?) and needs no twin; formBindFocus stays a plain extension function. No AnyView content erasure anywhere.
  • Sources/FormsKit/Skip/skip.ymlmode: 'native', bridging: true (the erased twins need Kotlin peers to render); every other public declaration carries // SKIP @nobridge — key paths, generic constructors, and constrained-extension statics all hard-error in the bridge generator, and FormsKit is consumed from Swift only
  • Property-wrapper storage in bridged modifier types is internal, not private (dismiss, showsDiscardWarning, isFocused) — Skip's bridge diagnostics reject private storage; public API unchanged
  • FormController imports SkipFuse behind #if canImport — wires @Observable change tracking into Compose on Android
  • ViewModifierTests.swift guarded by #if !os(Android) && !SKIP_BRIDGE; the toolbar tap logic is unit-tested on both variants (typed and erased), so the body Android runs is covered on Apple. All validation/controller/focus logic tests run on both platforms
  • README / CLAUDE.md — "Android (Skip)" consumer docs; CLAUDE.md records the corrected rule set (unbridged custom ViewModifiers silently no-op on Android; bridged non-generic ones render; dual-form pattern; shim import indirection; @nobridge policy)

Verification (at d9ce37c)

PathResult
SKIP_ZERO=1 swift test (pure Apple, zero deps)111/111 pass
swift test (Skip active, Apple side)111/111 pass
swift test (Skip active, Android side via Gradle/Robolectric)BUILD SUCCESSFUL, 82/82 pass
Demo app on Android emulator (Pixel-class AVD, API 32)Toolbar renders (Cancel + Sign In, disabled state tracks isDirty); red per-field validation errors render; tap-to-focus and keyboard-submit focusFirstInvalidField() jump both move focus correctly
Demo app on iOS simulator (iPhone 17, iOS 26.5)Renders and behaves as before

Android verification ran locally with Skip 1.9.5, Swift 6.3.3 + matching Swift Android SDK, Gradle 9.7. Note: Robolectric (Android SDK 36) needs a Gradle JVM ≥ 21; on machines where ~/.gradle/gradle.properties pins an older JDK, override per-run with JAVA_OPTS="-Dorg.gradle.java.home=<jdk21+ home>".

Known limitation

.formBindFocus(_:on:) relies on an optional-valued @FocusState, which SkipUI doesn't fully support yet — .focused(on:equals:) is the cross-platform-safe variant. Documented in README and CLAUDE.md.

🤖 Generated with Claude Code

FormsKit now builds as a Skip (https://skip.dev) Fuse native framework:
the Swift — including the @validated property wrapper and the
KeyPath-driven focus system — compiles natively for Android with the
Swift SDK for Android, and the SwiftUI modifiers render through
SkipFuseUI → Jetpack Compose. Skip's transpiled mode is not viable for
this library (no custom property wrappers, no key paths), so native
mode is the only supported shape.
- Package.swift: add skip, skip-fuse, skip-fuse-ui deps + skipstone
plugin, mirroring the Skip 1.9.5 templates, plus the official
SKIP_ZERO block so Apple-only consumers can resolve with zero Skip
dependencies (they are build-time only and inert on Apple platforms)
- Skip/skip.yml (source + tests): mode 'native'
- // SKIP @NoBridge on the four public ViewModifier types — the
Kotlin-facing bridge is unnecessary (consumed from Swift only) and
skipstone 1.9.5's generated ViewModifier bridges don't compile
- property-wrapper storage in public SwiftUI types is internal, not
private (Skip bridge diagnostics reject private storage); public API
is unchanged
- FormController imports SkipFuse behind canImport to wire @observable
tracking into Compose on Android
- ViewModifierTests are Apple-only (#if !os(Android)): they host views
via ImageRenderer/HostingController; all logic tests run on both
platforms
Verified: SKIP_ZERO path 109/109 tests pass; Skip-active Apple side
109/109 pass; Android side compiles and passes under Gradle/Robolectric.
Known limitation: .formBindFocus(_:on:) relies on optional-valued
@focusstate, which SkipUI doesn't fully support yet; .focused(on:equals:)
is the cross-platform-safe variant. Documented in README and CLAUDE.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates FormsKit’s packaging and documentation to support Android builds via Skip Fuse native compilation, while preserving an Apple-only “zero-dependency” escape hatch via SKIP_ZERO.

Changes:

  • Adds Skip dependencies + skipstone plugin wiring in Package.swift, along with an env-gated SKIP_ZERO path to strip Skip integration.
  • Introduces Skip native configuration (Skip/skip.yml) for the library and test target, and gates SwiftUI-hosted modifier tests off Android.
  • Annotates public SwiftUI ViewModifier types to avoid Skip bridging, and adds a conditional SkipFuse import for Android Compose re-render support.

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 2 comments.

Show a summary per file
FileDescription
Tests/FormsKitTests/ViewModifierTests.swiftWraps SwiftUI-hosted modifier tests in #if !os(Android) to avoid unavailable hosting APIs on Android.
Tests/FormsKitTests/Skip/skip.ymlAdds Skip config for the test module to compile in native mode.
Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swiftAdds // SKIP @nobridge to prevent Skip bridge generation.
Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swiftAdds // SKIP @nobridge and adjusts property-wrapper storage visibility for Skip compatibility.
Sources/FormsKit/ViewModifiers/FormBindFocusViewModifier.swiftAdds // SKIP @nobridge to prevent Skip bridge generation.
Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swiftAdds // SKIP @nobridge and adjusts @FocusState storage visibility for Skip compatibility.
Sources/FormsKit/Skip/skip.ymlDeclares FormsKit as a native Skip Fuse module with bridging disabled.
Sources/FormsKit/FormController.swiftConditionally imports SkipFuse when available to integrate with Compose change tracking on Android.
README.mdDocuments Android (Skip) support and clarifies “zero runtime dependencies” positioning.
Package.swiftAdds Skip dependencies/plugins and implements SKIP_ZERO stripping logic.
Package.resolvedPins Skip dependency graph for reproducible Skip builds.
CLAUDE.mdDocuments Skip/Android constraints and repository rules for keeping Android builds green.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadPackage.swift
Comment on lines +50 to +52
// Setting the SKIP_ZERO=1 environment strips out the Skip plugin and all Skip dependencies,
// restoring FormsKit to a zero-dependency package for consumers that don't target Android.
if Context.environment["SKIP_ZERO"] ?? "0" != "0" {
Comment on lines +1 to +3
# Skip (https://skip.dev) configuration for the FormsKitTests module.
skip:
mode: 'native'
rozdand others added 4 commits August 13, 2026 21:53
All four view modifiers were silent no-ops on Android: SkipSwiftUI's
View.modifier(_:) never calls a custom ViewModifier's body(content:) —
it applies the modifier's Java_modifier, which defaults to
SkipUI.EmptyModifier() — and the only escape (skipstone's generated
ViewModifier bridge) does not compile in 1.9.5. Content rendered;
toolbars, error text, and focus sync were dropped. Caught on the
emulator; Robolectric could not see it because the UI-hosting tests are
excluded from Android builds.
Same public API, new mechanics:
- formValidationError / formBindFocus: direct composition in the View
extension functions (stateless; no bridge involvement at all).
- formToolbar / focused(on:equals:): non-generic bridged wrapper views
(FormToolbarView, FocusedOnView) — skip-bridge cannot bridge generic
types, so type parameters are erased via AnyView content + closures
over the controller, with AnyKeyPath as the focus identity.
- New FormsKitSwiftUI shim target: re-exports SwiftUI, or SkipSwiftUI
under -DSKIP_BRIDGE. The generated *_Bridge.swift files mirror
source-file imports verbatim and cannot evaluate #if, so the view
files import the shim unconditionally and the conditional lives at
module level. Depends on the SkipFuseUI product (the dynamic
SkipSwiftUI product conflicts with SkipFuseUI's static use).
- skip.yml: bridging: true (the wrapper views need Kotlin peers); every
other public declaration is now `// SKIP @nobridge` — key paths,
generic constructors, and constrained-extension statics all hard-error
in the bridge generator, and FormsKit is consumed from Swift only.
- ViewModifierTests: direct constructions updated to the wrapper views;
guard extended to !SKIP_BRIDGE (bridge builds are SkipSwiftUI-typed).
Verified: SKIP_ZERO tests 109/109; Skip-active Apple tests 109/109;
Android Gradle/Robolectric BUILD SUCCESSFUL, 82/82; on-emulator proof
in the demo app — toolbar renders (Cancel + Sign In, disabled tracking
isDirty), red per-field errors render, tap-to-focus and
focusFirstInvalidField() both move focus correctly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed structure
The wrapper-View workaround (904269d) rested on a stale diagnosis: skipstone's
ViewModifier bridge works — the generated Kotlin peer calls back into Swift
body(content:) and overrides the EmptyModifier default — provided the modifier
is non-generic, bridged, and its file imports the FormsKitSwiftUI shim.
Genericity, not the ViewModifier protocol, was the real Android blocker.
Each UI modifier is now a ViewModifier again, in dual form where generics are
involved: a fully-typed generic variant for non-bridge builds (hidden from the
generator behind `#if !SKIP_BRIDGE && !SKIP`) plus an unconditional non-generic
erased twin that skipstone bridges for Android. The AnyView content erasure is
gone on every platform.
- AnyFormController (internal): closure-erased facade over FormController<T>
for the bridged twins; AnyKeyPath carries focus identity.
- FormToolbarViewModifier<T> + ErasedFormToolbarModifier (was FormToolbarView).
- FocusedOnViewModifier<T, V> + ErasedFocusedOnModifier (was FocusedOnView).
- FormValidationErrorModifier: single non-generic modifier taking
errorMessages: [String]? (was direct composition).
- formBindFocus: still extension-only; FormBindFocusSupport helper inlined.
- Tests cover both variants of the toolbar tap logic; suites renamed.
- CLAUDE.md/README/skill: corrected rule — unbridged custom ViewModifiers
no-op on Android; bridged non-generic ones render.
Public API unchanged. Verified: SKIP_ZERO 111/111, Skip-active Apple 111/111,
Android Robolectric 82/82 (BUILD SUCCESSFUL), emulator walkthrough (toolbar
render + isDirty enable/disable, red field errors, tap-to-focus, keyboard-
submit focus jump to first invalid field), iOS simulator render.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The FormsKitSwiftUI target existed because the view files' import is
mirrored into the generated *_Bridge.swift files, and that module name
has to resolve to SkipSwiftUI in bridge builds and to real SwiftUI
everywhere else. The stated rationale was that the skipstone generator
cannot evaluate `#if` conditions, so the conditional had to live at
module level in a separate target.
That premise was wrong. The generator does evaluate `#if` — with SKIP
defined and SKIP_BRIDGE undefined, the same evaluation that already
hides the generic modifier variants behind `!SKIP` — and emits the
resolved import into the bridge. So the conditional can live in the view
files after all:
#if SKIP || SKIP_BRIDGE
import SkipSwiftUI
#else
import SwiftUI
#endif
Both disjuncts are load-bearing. `SKIP` covers the generator;
`SKIP_BRIDGE` covers the two real bridge compiles (Android
cross-compile, Robolectric host). `SKIP_BRIDGE` alone would make the
generator emit `import SwiftUI` into the bridges, which then fail to
find SkipUI.ViewModifier on the macOS Robolectric host; `SKIP` alone
would break both real compiles.
Verified green across all four build flavors, both Skip-active runs
after `swift package clean`:
- Apple `swift test` — 111 tests, 0 errors
- Robolectric host — BUILD SUCCESSFUL, XCSkipTests passed, 83
Android-runtime test events (coverage unchanged)
- `SKIP_ZERO=1 swift test` — 111 tests, 0 errors, no Skip deps
- Android cross-compile — BUILD SUCCESSFUL, FormsKit.swiftmodule for
aarch64-unknown-linux-android28
Note that `swift test` exits 0 even when the Robolectric leg fails, so
the Gradle output has to be read directly to confirm that row.
CLAUDE.md and the formskit-expert skill still describe the removed shim;
updating those is a follow-up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI ran bare `swift build` / `swift test` on a runner with no Skip
toolchain. Since Package.swift declares the skipstone plugin
unconditionally, the plugin ran on every build: it generated the Kotlin
peers, compiled all 1184 bridge files, and started Gradle — then
`:FormsKit:buildLocalSwiftTestLibs` shelled out to the `skip` CLI, which
is a Homebrew install rather than a SwiftPM product and was absent:
+ skip android test --build-test-libs ... --robolectric
sh: skip: command not found
> Process 'command 'sh'' finished with non-zero exit value 127
All 111 Apple tests passed; the job still exited 1 on that, surfacing as
two XCSkipTests.testSkipModule errors about a missing test-output folder.
Adds skiptools/actions/setup-skip@v1 (Homebrew, Gradle, the `skip` CLI,
and the Swift SDK for Android, which Fuse/native mode requires) and
pins JDK 21 — Robolectric against Android SDK 36 refuses to create a
sandbox below 21 and the runner defaults to 17.
No Android emulator: ARM macOS runners lack nested virtualization, which
is why Skip's own Fuse packages set run-android-tests: false. `swift
test` exercises the Robolectric path on the host JVM, which is the leg
that broke here. Borrowing only the setup-skip step, rather than the
skip-framework reusable workflow, keeps the existing job shape and its
Codecov upload.
Also hardens the lcov export: the Skip build tree can hold more than one
default.profdata, and the unquoted find would have passed several paths
to a single -instr-profile argument.
Follows up 5b2f898, which noted the docs as pending: CLAUDE.md and the
formskit-expert cheatsheet still described the removed FormsKitSwiftUI
shim. The layout blocks drop it, and the import bullet is rewritten —
its stated rationale ("the generator cannot evaluate #if") is the
premise that commit disproved, so it now documents the actual
`#if SKIP || SKIP_BRIDGE` pattern, why each disjunct is load-bearing,
and that the shim must not be reintroduced.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecovBot commented Aug 14, 2026

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

`ErasedFocusedOnModifier` had no tests at all, and
`ErasedFormToolbarModifier` only had its two plain methods exercised —
neither twin's `body` was covered, which is the part that silently
degrades to a no-op on Android when the bridge regresses.
The erased twins are unreachable through the public `.focused(on:equals:)`
/ `.formToolbar(...)` extensions on Apple builds (those take the non-bridge
branch), so the new tests apply them by hand via `.modifier(_:)` — the same
thing skipstone's generated Kotlin peer does on Android.
Added for the toolbar twin: the third `cancelTapped` branch the generic
variant already had, `body` rendering for dirty and clean forms, a hosted
run so the `@State` initializer fires, and direct coverage of the
`AnyFormController(_:)` full erasure. For the focus twin: build, both
controller -> focus branches, and focus -> controller for set / switch /
clear, asserting that the AnyKeyPath -> PartialKeyPath<T> downcast still
compares equal to the literal key path a consumer writes.
The focus tests live in the existing `focused(on:equals:)` suite rather
than one of their own: `.serialized` only orders tests within a suite,
sibling suites still run in parallel, and SwiftUI's focus system is
process-global — a second window-hosting focus suite steals first
responder and flakes both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 28 out of 31 changed files in this pull request and generated 2 comments.

Comment on lines +125 to +132
func formToolbar<T: ValidatableForm & SubmittableForm>(
controller: FormController<T>,
cancelTitle: String = "Cancel",
submitTitle: String = "Submit",
preventsAccidentalDismiss: Bool = true,
onSubmit: @escaping () -> Void,
onSubmit: @escaping () -> Void
) -> some View {
self.modifier(
FormToolbarViewModifier(
controller: controller,
cancelTitle: cancelTitle,
submitTitle: submitTitle,
preventsAccidentalDismiss: preventsAccidentalDismiss,
onSubmit: onSubmit
)
)
#if SKIP_BRIDGE
Comment on lines 89 to +94
func focused<T, V>(
on controller: Binding<FormController<T>>,
equals keyPath: KeyPath<T, V>
) -> some View {
modifier(
FocusedOnViewModifier(
controller: controller,
keyPath: keyPath
)
)
#if SKIP_BRIDGE
return modifier(ErasedFocusedOnModifier(
Extends the conditional compilation for `ErasedFocusedOnModifier` and
`ErasedFormToolbarModifier` to include the `SKIP` flag.
This `SKIP || SKIP_BRIDGE` pattern ensures that the skipstone code
generator (when `SKIP` is defined) correctly selects the non-generic
erased modifier variants. This prevents the generator from attempting
to process the generic modifier variants, which are not bridgeable.
`SKIP_BRIDGE` continues to cover the actual Android cross-compiles.
@rozd
rozd merged commit 0b6c098 into mainAug 14, 2026
3 checks passed
@rozd
rozd deleted the feat/skip-android-support branch August 14, 2026 14:51
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@rozd