feat: add Android support via Skip Fuse native compilation - #1
Merged
Conversation
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>
There was a problem hiding this comment.
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 +
skipstoneplugin wiring inPackage.swift, along with an env-gatedSKIP_ZEROpath 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
ViewModifiertypes to avoid Skip bridging, and adds a conditionalSkipFuseimport 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
| File | Description |
|---|---|
| Tests/FormsKitTests/ViewModifierTests.swift | Wraps SwiftUI-hosted modifier tests in #if !os(Android) to avoid unavailable hosting APIs on Android. |
| Tests/FormsKitTests/Skip/skip.yml | Adds Skip config for the test module to compile in native mode. |
| Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift | Adds // SKIP @nobridge to prevent Skip bridge generation. |
| Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift | Adds // SKIP @nobridge and adjusts property-wrapper storage visibility for Skip compatibility. |
| Sources/FormsKit/ViewModifiers/FormBindFocusViewModifier.swift | Adds // SKIP @nobridge to prevent Skip bridge generation. |
| Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift | Adds // SKIP @nobridge and adjusts @FocusState storage visibility for Skip compatibility. |
| Sources/FormsKit/Skip/skip.yml | Declares FormsKit as a native Skip Fuse module with bridging disabled. |
| Sources/FormsKit/FormController.swift | Conditionally imports SkipFuse when available to integrate with Compose change tracking on Android. |
| README.md | Documents Android (Skip) support and clarifies “zero runtime dependencies” positioning. |
| Package.swift | Adds Skip dependencies/plugins and implements SKIP_ZERO stripping logic. |
| Package.resolved | Pins Skip dependency graph for reproducible Skip builds. |
| CLAUDE.md | Documents 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 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' |
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>
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>
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.
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
@Validatedproperty wrapper and theKeyPath-driven focus system — compiles natively for Android with the Swift SDK for Android, and the SwiftUI layer renders through SkipFuseUI → Jetpack Compose.SKIP_ZERO=1manifest block strips the plugin and every Skip dependency for Apple-only consumers.d9ce37c): the modifiers are customViewModifiers 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'sJava_modifier, whose protocol default isSkipUI.EmptyModifier()— an unbridged customViewModifieris a silent no-op. But for a bridged modifier, skipstone generates the override (a Kotlin peer whosebody()calls back into Swiftbody(content:)), and it renders. Genericity — which skip-bridge can't represent — was the actual blocker, not theViewModifierprotocol. The public API is unchanged throughout.Changes
skip,skip-fuse,skip-fuse-uidependencies +skipstoneplugin, theSKIP_ZEROescape-hatch block, and theFormsKitSwiftUIshim 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)#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 facadeAnyFormController(AnyKeyPathfor focus identity).FormValidationErrorModifieris non-generic by construction (takeserrorMessages: [String]?) and needs no twin;formBindFocusstays a plain extension function. NoAnyViewcontent erasure anywhere.Sources/FormsKit/Skip/skip.yml—mode: '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 onlyinternal, notprivate(dismiss,showsDiscardWarning,isFocused) — Skip's bridge diagnostics reject private storage; public API unchangedFormControllerimportsSkipFusebehind#if canImport— wires@Observablechange tracking into Compose on AndroidViewModifierTests.swiftguarded 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 platformsViewModifiers silently no-op on Android; bridged non-generic ones render; dual-form pattern; shim import indirection;@nobridgepolicy)Verification (at
d9ce37c)SKIP_ZERO=1 swift test(pure Apple, zero deps)swift test(Skip active, Apple side)swift test(Skip active, Android side via Gradle/Robolectric)isDirty); red per-field validation errors render; tap-to-focus and keyboard-submitfocusFirstInvalidField()jump both move focus correctlyAndroid 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.propertiespins an older JDK, override per-run withJAVA_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