From 15a38106793f8d71fb93d38241303b195081ae24 Mon Sep 17 00:00:00 2001 From: Max Rozdobudko Date: Wed, 12 Aug 2026 05:12:24 +0300 Subject: [PATCH 1/7] feat: add Android support via Skip Fuse native compilation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 28 ++++- Package.resolved | 114 ++++++++++++++++++ Package.swift | 51 +++++++- README.md | 30 ++++- Sources/FormsKit/FormController.swift | 7 ++ Sources/FormsKit/Skip/skip.yml | 13 ++ .../ViewModifiers/FocusedOnViewModifier.swift | 5 +- .../FormBindFocusViewModifier.swift | 1 + .../FormToolbarViewModifier.swift | 7 +- .../FormValidationErrorModifier.swift | 1 + Tests/FormsKitTests/Skip/skip.yml | 3 + Tests/FormsKitTests/ViewModifierTests.swift | 18 ++- 12 files changed, 261 insertions(+), 17 deletions(-) create mode 100644 Package.resolved create mode 100644 Sources/FormsKit/Skip/skip.yml create mode 100644 Tests/FormsKitTests/Skip/skip.yml diff --git a/CLAUDE.md b/CLAUDE.md index dac600e..c6e271f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,21 +6,26 @@ Guidance for Claude Code when working in this repository. `FormsKit` — a small, opinionated SwiftUI form-validation library. Ships a `@Validated` property wrapper, composable typed `ValidationRule`s, a `FormController` with a submission state machine, and four SwiftUI modifiers (`.formValidationError(for:)`, `.formToolbar(controller:onSubmit:)`, `.formBindFocus(_:on:)`, and `.focused(on:equals:)`). -Target audience: SwiftUI apps on iOS 17+ that use `@Observable` (not `ObservableObject`/Combine). Intentionally no Combine, no third-party deps. +Target audience: SwiftUI apps on iOS 17+ that use `@Observable` (not `ObservableObject`/Combine). Intentionally no Combine, no third-party runtime deps. Also compiles for Android as a [Skip](https://skip.dev) Fuse native framework (see "Skip / Android support" below). ## Build / test ```bash -swift build -swift test +swift build # Apple build; with Skip installed, also cross-compiles for Android via the skipstone plugin +swift test # Apple tests; with Skip installed, also builds+runs the Android side via Gradle/Robolectric +SKIP_ZERO=1 swift test # the pure-Apple, zero-dependency path (Skip plugin + deps stripped from the manifest) ``` -Package is `swift-tools-version: 6.3`, Swift 6 language mode, platforms iOS 17 / macOS 14 / tvOS 17 / watchOS 10 / visionOS 1. No dependencies. Don't add any. +Package is `swift-tools-version: 6.3`, Swift 6 language mode, platforms iOS 17 / macOS 14 / tvOS 17 / watchOS 10 / visionOS 1. The only dependencies are the Skip build-time packages (`skip`, `skip-fuse`, `skip-fuse-ui`), which the `SKIP_ZERO=1` manifest block removes entirely; don't add any others, and don't remove that block. + +Android test runs need a Gradle JVM ≥ 21 (Robolectric / Android SDK 36 requirement). `~/.gradle/gradle.properties` on this machine pins `org.gradle.java.home` to a Java 17 JBR; override per-run with `GRADLE_OPTS="-Dorg.gradle.java.home="` rather than editing the global file. ## Source layout ``` Sources/FormsKit/ +├── Skip/ +│ └── skip.yml # Skip config: native (Fuse) mode — see "Skip / Android support" ├── Validated.swift # @Validated property wrapper + State/Mode ├── ValidatedField.swift # type-erased schema entry for a Validated field ├── ValidationRule.swift # protocol ValidationRule @@ -89,7 +94,7 @@ The library has a deliberate isolation shape; deviating from it will produce con - **Public surface, narrow.** Default to `internal`; mark `public` only what consumers must touch. The `name` field on `Validated` and the closures on `ValidatedField` intentionally stay non-public — consumers don't need them. - **No Combine.** Ever. `@Observable` only. -- **No third-party dependencies.** Foundation + SwiftUI + Observation. If a feature seems to need a dep, find another way or push back. +- **No third-party runtime dependencies.** Foundation + SwiftUI + Observation. The Skip packages are the single sanctioned exception: build-time only, inert on Apple platforms, and strippable via `SKIP_ZERO=1`. If any other feature seems to need a dep, find another way or push back. - **Rules are value types.** A `ValidationRule` impl is a plain struct with a `validate(value:) -> String?` method. Add a static factory on `ValidationRule where Self == YourRule` for call-site sugar (`.minLength(3)` style). Mirror the existing `MinStringLengthValidationRule` pattern. - **Rule error messages are passed in.** Don't hardcode user-facing strings inside rules beyond English defaults; consumers localize at call site by passing `message:`. (Localizing the package's own defaults via `String(localized:bundle: .module)` is a future improvement — track it as such, not as a quiet refactor.) - **`@Validated` mode default is `.onChange`.** Means "stay quiet until the field becomes `.invalid`, then re-validate on each keystroke." Don't change the default; it's the UX consumers expect. @@ -184,6 +189,19 @@ The `bind` in `.formBindFocus` reflects the bidirectional sync: writes to `$focu What's intentionally **not** in this slice: next/previous chevron buttons above the keyboard. That likely needs a `FocusableForm` protocol with an explicit `focusableFields: [PartialKeyPath]` so non-validated fields participate in ordered traversal. Defer until there's a concrete consumer need. +## Skip / Android support + +FormsKit ships as a Skip **Fuse (native) framework**: `Sources/FormsKit/Skip/skip.yml` declares `mode: 'native'`, so the Swift compiles as-is for Android with the Swift SDK for Android, and the SwiftUI layer resolves to SkipFuseUI → Compose. This is the only viable mode — Skip's *transpiled* mode supports neither custom property wrappers (`@Validated`) nor key paths (the `ValidatedField` schema and the whole focus system), so never attempt a transpiled port. + +Rules that keep the Android build green: + +- **`// SKIP @nobridge` on every public `View`/`ViewModifier` type.** Skip's test harness force-bridges the module's public API toward Kotlin, and skipstone 1.9.5's generated `ViewModifier` bridges don't compile (missing `SkipUI` imports, unlabeled `body` call). FormsKit is consumed from Swift only, so the Kotlin-facing bridge is unnecessary — keep it off. New public SwiftUI types get the same annotation. +- **Property-wrapper storage in public SwiftUI types must be `internal`, not `private`.** Skip's bridge diagnostics reject private `@State`/`@Environment`/`@FocusState` storage inside bridged-adjacent types ("Private state property cannot be bridged"). This is why `dismiss`, `showsDiscardWarning`, and `isFocused` are internal. +- **`ViewModifierTests.swift` is wrapped in `#if !os(Android)`.** It hosts views via `ImageRenderer`/`NS-`/`UIHostingController`, which don't exist on Android. Logic tests (rules, `Validated`, controller, focus) run on both platforms — keep new UI-hosting tests inside that guard and new logic tests outside it. +- **`FormController.swift` imports `SkipFuse` behind `#if canImport(SkipFuse)`.** On Android this wires `@Observable` change tracking into Compose; under `SKIP_ZERO` the module doesn't exist, hence the guard. Give any future `@Observable` type the same import. +- **The `SKIP_ZERO` block in `Package.swift` is the no-dependency escape hatch** for Apple-only consumers. Preserve it when touching the manifest, and keep the Skip dependencies out of any code path it can't strip. +- **`.formBindFocus(_:on:)` is degraded on Android** (SkipUI doesn't fully support optional-valued `@FocusState`); `.focused(on:equals:)` is the cross-platform-safe variant. Don't build new features on optional `@FocusState`. + ## Things to leave alone - The `Validated.State.editing` case. It's recorded on value changes but not (yet) read anywhere. Reserved for "field has been touched but not yet validated" UX. Don't remove it without a replacement. diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..dedfd13 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,114 @@ +{ + "originHash" : "947412299fb6945475472ef57117328d6c1bdd1441ebca85758500ba8072838a", + "pins" : [ + { + "identity" : "skip", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip.git", + "state" : { + "revision" : "885f0c520e1ebdbec1f0e296d713293dadc5a2f4", + "version" : "1.9.5" + } + }, + { + "identity" : "skip-android-bridge", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-android-bridge.git", + "state" : { + "revision" : "545ea1b2d7ba4abc82daa2be00f81f4ea8e64e5b", + "version" : "0.6.4" + } + }, + { + "identity" : "skip-bridge", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-bridge.git", + "state" : { + "revision" : "72b7b1d4734332cfdc4b519539b5beec0fb3ac00", + "version" : "0.17.2" + } + }, + { + "identity" : "skip-foundation", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-foundation.git", + "state" : { + "revision" : "94d47aeed3bb8027ef3ad8e07a8771b52529c238", + "version" : "1.4.2" + } + }, + { + "identity" : "skip-fuse", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-fuse.git", + "state" : { + "revision" : "8f3295094ad29075730284c5197c7f1d94c0f2d9", + "version" : "1.0.2" + } + }, + { + "identity" : "skip-fuse-ui", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-fuse-ui.git", + "state" : { + "revision" : "d27fc109268b21feb98a48bc1a3f4558e162d9ca", + "version" : "1.18.1" + } + }, + { + "identity" : "skip-lib", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-lib.git", + "state" : { + "revision" : "76e7da8a870b5b66ea0c3264f648b58b73bcdc0d", + "version" : "1.4.0" + } + }, + { + "identity" : "skip-model", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-model.git", + "state" : { + "revision" : "54c7914e985e5ae07b1a8fe29e7aac7156b88874", + "version" : "1.7.6" + } + }, + { + "identity" : "skip-ui", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-ui.git", + "state" : { + "revision" : "ef7bbdd541cdf2efd3ce6ecde72337e1beb92366", + "version" : "1.59.1" + } + }, + { + "identity" : "skip-unit", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-unit.git", + "state" : { + "revision" : "c89af47fd645e04db863e938ade39f91e1bb62b8", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-android-native", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/swift-android-native.git", + "state" : { + "revision" : "7e6e833e6f163a2b75340f75c70b1d96ea6b8135", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-jni", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/swift-jni.git", + "state" : { + "revision" : "fe76ac21aca639976833b5ea3e875dc072519ac4", + "version" : "0.5.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 6b7b798..b962cc2 100644 --- a/Package.swift +++ b/Package.swift @@ -19,16 +19,63 @@ let package = Package( targets: ["FormsKit"] ), ], + dependencies: [ + .package(url: "https://source.skip.tools/skip.git", from: "1.9.5"), + .package(url: "https://source.skip.tools/skip-fuse.git", from: "1.0.0"), + .package(url: "https://source.skip.tools/skip-fuse-ui.git", from: "1.0.0"), + ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( - name: "FormsKit" + name: "FormsKit", + dependencies: [ + .product(name: "SkipFuse", package: "skip-fuse"), + .product(name: "SkipFuseUI", package: "skip-fuse-ui"), + ], + plugins: [.plugin(name: "skipstone", package: "skip")] ), .testTarget( name: "FormsKitTests", - dependencies: ["FormsKit"] + dependencies: [ + "FormsKit", + .product(name: "SkipTest", package: "skip"), + ], + plugins: [.plugin(name: "skipstone", package: "skip")] ), ], swiftLanguageModes: [.v6] ) + +// 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" { + package.targets.forEach { target in + // remove the Skip plugin + target.plugins?.removeAll(where: { + if case .plugin(let name, _) = $0 { + return name == "skipstone" + } else { + return false + } + }) + + // remove the Skip target dependencies + target.dependencies.removeAll(where: { dependency in + if case .productItem(_, let package, _, _) = dependency { + return package == "skip" || package?.hasPrefix("skip-") == true + } else { + return false + } + }) + } + + // remove the Skip package dependencies + package.dependencies.removeAll(where: { dependency in + if case .sourceControl(_, let url, _) = dependency.kind { + return url.hasPrefix("https://source.skip.dev/") || url.hasPrefix("https://source.skip.tools/") + } else { + return false + } + }) +} diff --git a/README.md b/README.md index 66c3929..9067725 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # FormsKit -[![Platforms](https://img.shields.io/badge/Platforms-iOS_|_macOS_|_watchOS_|_tvOS_|_visionOS-blue.svg)](https://developer.apple.com/xcode/) +[![Platforms](https://img.shields.io/badge/Platforms-iOS_|_macOS_|_watchOS_|_tvOS_|_visionOS_|_Android_(Skip)-blue.svg)](https://developer.apple.com/xcode/) [![Swift 6.3](https://img.shields.io/badge/Swift-6.3-orange.svg)](https://swift.org) [![Release](https://img.shields.io/github/v/release/rozd/forms-kit)](https://github.com/rozd/forms-kit/releases) [![codecov](https://codecov.io/gh/rozd/forms-kit/branch/main/graph/badge.svg)](https://codecov.io/gh/rozd/forms-kit) @@ -35,7 +35,8 @@ struct CreatePlanForm: ValidatableForm, SubmittableForm { - 🧰 **Built-in string rules** — `isNotEmpty`, `minLength`, `maxLength`, `pattern`, `email`. - 🎨 **SwiftUI modifiers** — `.formValidationError(for:)` for inline field errors, `.formToolbar(...)` for a Cancel/Submit toolbar, `.focused(on:equals:)` and `.formBindFocus(_:on:)` for focus traversal. - 🛡️ **Dirty-state-aware dismiss** — discard confirmation dialog + `interactiveDismissDisabled` when the form has unsaved changes. -- 🪶 **Zero dependencies** — Foundation + SwiftUI + Observation. No Combine, no third-party packages. +- 🪶 **Zero runtime dependencies** — Foundation + SwiftUI + Observation. No Combine. The only external packages are [Skip](https://skip.dev)'s build-time integration for Android, and resolving with `SKIP_ZERO=1` strips even those (see [Android (Skip)](#android-skip)). +- 🤖 **Android via [Skip](https://skip.dev)** — compiles natively for Android as a Skip Fuse module; the same `@Validated` forms and modifiers drive Jetpack Compose through SkipFuseUI. - ⚡ **`@Observable` native** — built for iOS 17+ / Swift 5.9+ macros, not `ObservableObject`. - 🔒 **Swift 6 concurrency** — explicit `@MainActor` isolation on the form lifecycle, no `Sendable` headaches for consumers. @@ -46,6 +47,7 @@ struct CreatePlanForm: ValidatableForm, SubmittableForm { - Swift 6.0+ (built with tools 6.3, language mode v6) - iOS 17 / macOS 14 / tvOS 17 / watchOS 10 / visionOS 1 - Xcode 16+ +- Android: via [Skip](https://skip.dev) 1.9.5+ (optional — see [Android (Skip)](#android-skip)) ## Installation @@ -64,6 +66,30 @@ targets: [ Or in Xcode: **File → Add Package Dependencies…** and paste the repository URL. +## Android (Skip) + +FormsKit is a [Skip](https://skip.dev) **Fuse (native) framework**: the Swift source — including the `@Validated` property wrapper and the `KeyPath`-driven focus system — is compiled natively for Android with the Swift SDK for Android, and the SwiftUI modifiers render through [SkipFuseUI](https://github.com/skiptools/skip-fuse-ui) → Jetpack Compose. (Skip's *transpiled* mode is not supported: its Swift-to-Kotlin transpiler handles neither custom property wrappers nor key paths, both of which are the heart of this library.) + +**In a Skip app**, add FormsKit as an ordinary dependency of your Fuse module — no extra configuration; Skip detects the module's `Skip/skip.yml` and wires the Gradle side automatically: + +```swift +.target(name: "MyApp", dependencies: [ + .product(name: "SkipFuseUI", package: "skip-fuse-ui"), + .product(name: "FormsKit", package: "forms-kit"), +], plugins: [.plugin(name: "skipstone", package: "skip")]) +``` + +**In an Apple-only project**, nothing changes at the call site, and the Skip packages are build-time-only (on Apple platforms `SkipFuseUI` simply re-exports SwiftUI and compiles away). If you don't want them in your dependency graph at all, resolve with the `SKIP_ZERO` environment variable set — the manifest then strips the Skip plugin and every Skip dependency, restoring a zero-dependency package: + +```bash +SKIP_ZERO=1 swift build +``` + +Platform notes: + +- `.formBindFocus(_:on:)` relies on an optional-valued `@FocusState`, which SkipUI does not fully support yet — prefer `.focused(on:equals:)` (internally `Bool`-based) in cross-platform forms. +- The view-modifier test suite runs on Apple platforms only (it hosts views via `ImageRenderer`/`HostingController`, which don't exist on Android); all validation, controller, and focus-logic tests run on both platforms. + --- ## Quick start diff --git a/Sources/FormsKit/FormController.swift b/Sources/FormsKit/FormController.swift index 4f76a15..345c9de 100644 --- a/Sources/FormsKit/FormController.swift +++ b/Sources/FormsKit/FormController.swift @@ -1,4 +1,11 @@ import Observation +#if canImport(SkipFuse) +// On Android (Skip Fuse), SkipFuse wires @Observable state tracking into +// Compose so views re-render when the controller changes. On Apple +// platforms the import is inert, and it disappears entirely when the +// package is resolved with SKIP_ZERO=1. +import SkipFuse +#endif @MainActor @Observable diff --git a/Sources/FormsKit/Skip/skip.yml b/Sources/FormsKit/Skip/skip.yml new file mode 100644 index 0000000..a40cb12 --- /dev/null +++ b/Sources/FormsKit/Skip/skip.yml @@ -0,0 +1,13 @@ +# Skip (https://skip.dev) configuration for the FormsKit module. +# +# FormsKit is a natively-compiled Skip Fuse module: the Swift source is +# compiled as-is for Android with the Swift SDK for Android, so the +# @Validated property wrapper and the KeyPath-driven focus system work +# unchanged (neither is expressible in Skip's transpiled mode). +# +# Bridging is intentionally not enabled: FormsKit is consumed from Swift +# (SwiftUI) code only, and its generic, key-path-based API is not +# representable in Kotlin anyway. +skip: + mode: 'native' + bridging: false diff --git a/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift b/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift index 1e54322..c7b7a27 100644 --- a/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift +++ b/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift @@ -1,12 +1,15 @@ import SwiftUI +// SKIP @nobridge public struct FocusedOnViewModifier: ViewModifier { let controller: Binding> let keyPath: KeyPath - @FocusState private var isFocused: Bool + // internal (not private): Skip's Android bridge for SwiftUI types + // cannot reach private property-wrapper storage. + @FocusState var isFocused: Bool public func body(content: Content) -> some View { let myKeyPath: PartialKeyPath = keyPath diff --git a/Sources/FormsKit/ViewModifiers/FormBindFocusViewModifier.swift b/Sources/FormsKit/ViewModifiers/FormBindFocusViewModifier.swift index 36b9229..d7ba4c5 100644 --- a/Sources/FormsKit/ViewModifiers/FormBindFocusViewModifier.swift +++ b/Sources/FormsKit/ViewModifiers/FormBindFocusViewModifier.swift @@ -1,5 +1,6 @@ import SwiftUI +// SKIP @nobridge public struct FormBindFocusViewModifier: ViewModifier { let focus: FocusState?>.Binding diff --git a/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift b/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift index feba1c7..dd983f9 100644 --- a/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift +++ b/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift @@ -1,9 +1,12 @@ import SwiftUI +// SKIP @nobridge public struct FormToolbarViewModifier: ViewModifier { - @Environment(\.dismiss) private var dismiss + // internal (not private): Skip's Android bridge for SwiftUI types + // cannot reach private property-wrapper storage. + @Environment(\.dismiss) var dismiss - @State private var showsDiscardWarning: Bool = false + @State var showsDiscardWarning: Bool = false let controller: FormController diff --git a/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift b/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift index 88431c7..b69e16c 100644 --- a/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift +++ b/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift @@ -1,5 +1,6 @@ import SwiftUI +// SKIP @nobridge public struct FormValidationErrorModifier: ViewModifier { let state: Validated.State diff --git a/Tests/FormsKitTests/Skip/skip.yml b/Tests/FormsKitTests/Skip/skip.yml new file mode 100644 index 0000000..1ef653f --- /dev/null +++ b/Tests/FormsKitTests/Skip/skip.yml @@ -0,0 +1,3 @@ +# Skip (https://skip.dev) configuration for the FormsKitTests module. +skip: + mode: 'native' diff --git a/Tests/FormsKitTests/ViewModifierTests.swift b/Tests/FormsKitTests/ViewModifierTests.swift index a409884..19ced80 100644 --- a/Tests/FormsKitTests/ViewModifierTests.swift +++ b/Tests/FormsKitTests/ViewModifierTests.swift @@ -1,3 +1,9 @@ +// These tests drive the modifiers through real SwiftUI hosts +// (ImageRenderer / NS-/UIHostingController), which don't exist on Android. +// On Android (Skip Fuse) the modifiers are exercised by consumers' own UI +// tests instead; the library's logic tests all run on both platforms. +#if !os(Android) + import Testing import SwiftUI @testable import FormsKit @@ -10,7 +16,7 @@ import UIKit // MARK: - Fixtures -private struct VMForm: ValidatableForm, SubmittableForm { +struct VMForm: ValidatableForm, SubmittableForm { @Validated(name: "name", .isNotEmpty(message: "Required")) var name: String = "" @@ -218,7 +224,7 @@ struct FormToolbarViewModifierTests { // MARK: - FormBindFocusViewModifier /// Hosts the modifier under a real SwiftUI runtime so its `onChange` handlers fire. -private struct FormBindFocusHostView: View { +struct FormBindFocusHostView: View { let controller: FormController @FocusState var focus: PartialKeyPath? @@ -232,7 +238,7 @@ private struct FormBindFocusHostView: View { /// `onChange(of: focus.wrappedValue)` handler (focus → controller direction) /// gets exercised. The fields are real `TextField`s bound to the same /// `@FocusState` so SwiftUI can actually grant focus on the write. -private struct FormBindFocusAppearHost: View { +struct FormBindFocusAppearHost: View { let controller: FormController @FocusState var focus: PartialKeyPath? let appearAction: (FocusState?>.Binding) -> Void @@ -339,7 +345,7 @@ struct FormBindFocusViewModifierTests { /// `.focused(on:equals:)` owns its `@FocusState` internally and takes a /// `Binding>`; the host materialises that binding via `@State`. -private struct FocusedOnHostView: View { +struct FocusedOnHostView: View { @State var controller: FormController var body: some View { @@ -355,7 +361,7 @@ private struct FocusedOnHostView: View { /// inside `onAppear` causes SwiftUI to grant focus to the chosen field, /// which flips the modifier's internal `@FocusState` and exercises the /// `onChange(of: isFocused)` handler. -private struct FocusedOnFocusableHost: View { +struct FocusedOnFocusableHost: View { enum Scenario { case setInitial(PartialKeyPath) case setThenClear(PartialKeyPath) @@ -535,3 +541,5 @@ private func _spin() async { await Task.yield() } } + +#endif From 904269dcae1796d1ce32f8aa4ec630eebfa3aaf5 Mon Sep 17 00:00:00 2001 From: Max Rozdobudko Date: Thu, 13 Aug 2026 21:53:27 +0300 Subject: [PATCH 2/7] fix(android): restructure view modifiers as bridged wrapper views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 20 ++++-- Package.resolved | 2 +- Package.swift | 18 +++++ README.md | 5 +- Skills/formskit-expert.skill | Bin 9229 -> 9718 bytes .../references/api-cheatsheet.md | 3 +- Sources/FormsKit/FormController.swift | 3 + Sources/FormsKit/Forms/PopulatableForm.swift | 1 + Sources/FormsKit/Forms/SubmittableForm.swift | 1 + Sources/FormsKit/Forms/ValidatableForm.swift | 1 + Sources/FormsKit/Skip/skip.yml | 10 +-- Sources/FormsKit/Validated.swift | 1 + Sources/FormsKit/ValidatedField.swift | 1 + Sources/FormsKit/ValidationError.swift | 1 + Sources/FormsKit/ValidationRule.swift | 1 + .../StringValidationRule.swift | 1 + .../EmailValidationRule.swift | 4 ++ .../MaxStringLengthValidationRule.swift | 4 ++ .../MinStringLengthValidationRule.swift | 4 ++ .../NotEmptyStringRule.swift | 4 ++ .../RegularExpressionValidationRule.swift | 4 ++ .../ViewModifiers/FocusedOnView.swift | 56 +++++++++++++++ .../ViewModifiers/FocusedOnViewModifier.swift | 54 --------------- ...ViewModifier.swift => FormBindFocus.swift} | 46 ++++++------- ...ewModifier.swift => FormToolbarView.swift} | 50 +++++++------- .../ViewModifiers/FormValidationError.swift | 27 ++++++++ .../FormValidationErrorModifier.swift | 52 -------------- Sources/FormsKitSwiftUI/FormsKitSwiftUI.swift | 16 +++++ Sources/FormsKitSwiftUI/Skip/skip.yml | 7 ++ Tests/FormsKitTests/ViewModifierTests.swift | 64 +++++++++++------- 30 files changed, 266 insertions(+), 195 deletions(-) create mode 100644 Sources/FormsKit/ViewModifiers/FocusedOnView.swift delete mode 100644 Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift rename Sources/FormsKit/ViewModifiers/{FormBindFocusViewModifier.swift => FormBindFocus.swift} (58%) rename Sources/FormsKit/ViewModifiers/{FormToolbarViewModifier.swift => FormToolbarView.swift} (59%) create mode 100644 Sources/FormsKit/ViewModifiers/FormValidationError.swift delete mode 100644 Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift create mode 100644 Sources/FormsKitSwiftUI/FormsKitSwiftUI.swift create mode 100644 Sources/FormsKitSwiftUI/Skip/skip.yml diff --git a/CLAUDE.md b/CLAUDE.md index c6e271f..620693f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,9 @@ Android test runs need a Gradle JVM ≥ 21 (Robolectric / Android SDK 36 require ## Source layout ``` +Sources/FormsKitSwiftUI/ +├── Skip/skip.yml # native mode, no bridging +└── FormsKitSwiftUI.swift # shim: re-exports SwiftUI, or SkipSwiftUI in bridge builds Sources/FormsKit/ ├── Skip/ │ └── skip.yml # Skip config: native (Fuse) mode — see "Skip / Android support" @@ -39,10 +42,10 @@ Sources/FormsKit/ │ ├── StringValidationRule.swift # protocol StringValidationRule │ └── StringValidationRules/ # concrete rules (NotEmpty, MinLength, …) └── ViewModifiers/ - ├── FormValidationErrorModifier.swift # .formValidationError(for:) - ├── FormToolbarViewModifier.swift # .formToolbar(controller:onSubmit:) - ├── FormBindFocusViewModifier.swift # .formBindFocus(_:on:) - └── FocusedOnViewModifier.swift # .focused(on:equals:) + ├── FormValidationError.swift # .formValidationError(for:) — direct composition, no struct + ├── FormToolbarView.swift # .formToolbar(controller:onSubmit:) — bridged wrapper view + ├── FormBindFocus.swift # .formBindFocus(_:on:) — direct composition + FormBindFocusSupport + └── FocusedOnView.swift # .focused(on:equals:) — bridged wrapper view ``` Keep one type per file. Group concrete rules under `ValidationRules/ValidationRules/` (currently only `String`; add `Number`, `Date`, etc. the same way if needed). The three form-conformance protocols live in `Forms/`; everything else is a high-visibility public type and stays at root. @@ -98,7 +101,7 @@ The library has a deliberate isolation shape; deviating from it will produce con - **Rules are value types.** A `ValidationRule` impl is a plain struct with a `validate(value:) -> String?` method. Add a static factory on `ValidationRule where Self == YourRule` for call-site sugar (`.minLength(3)` style). Mirror the existing `MinStringLengthValidationRule` pattern. - **Rule error messages are passed in.** Don't hardcode user-facing strings inside rules beyond English defaults; consumers localize at call site by passing `message:`. (Localizing the package's own defaults via `String(localized:bundle: .module)` is a future improvement — track it as such, not as a quiet refactor.) - **`@Validated` mode default is `.onChange`.** Means "stay quiet until the field becomes `.invalid`, then re-validate on each keystroke." Don't change the default; it's the UX consumers expect. -- **View modifier UI is intentionally minimal.** `FormValidationErrorModifier` hardcodes `.red` and `.caption`; `FormToolbarViewModifier` hardcodes English button titles + a discard dialog. Making these themeable / localizable is on the roadmap but hasn't shipped — don't sneak it in piecemeal; do it as one deliberate change with a public API. +- **View modifier UI is intentionally minimal.** `formValidationError` hardcodes `.red` and `.caption`; `FormToolbarView` hardcodes English button titles + a discard dialog. Making these themeable / localizable is on the roadmap but hasn't shipped — don't sneak it in piecemeal; do it as one deliberate change with a public API. - **View modifiers prefixed `form*` are package-original concepts; unprefixed ones (e.g. `.focused(on:equals:)`) deliberately overload existing SwiftUI vocabulary.** Don't prefix the overloads (it breaks discovery via SwiftUI muscle memory); do prefix new concepts (it groups the package's surface in autocomplete). ## Focus support @@ -195,8 +198,11 @@ FormsKit ships as a Skip **Fuse (native) framework**: `Sources/FormsKit/Skip/ski Rules that keep the Android build green: -- **`// SKIP @nobridge` on every public `View`/`ViewModifier` type.** Skip's test harness force-bridges the module's public API toward Kotlin, and skipstone 1.9.5's generated `ViewModifier` bridges don't compile (missing `SkipUI` imports, unlabeled `body` call). FormsKit is consumed from Swift only, so the Kotlin-facing bridge is unnecessary — keep it off. New public SwiftUI types get the same annotation. -- **Property-wrapper storage in public SwiftUI types must be `internal`, not `private`.** Skip's bridge diagnostics reject private `@State`/`@Environment`/`@FocusState` storage inside bridged-adjacent types ("Private state property cannot be bridged"). This is why `dismiss`, `showsDiscardWarning`, and `isFocused` are internal. +- **Never implement UI as a custom `ViewModifier`.** On Android, SkipSwiftUI's `View.modifier(_:)` ignores `body(content:)` entirely — it applies the modifier's `Java_modifier`, which defaults to `SkipUI.EmptyModifier()`. A custom `ViewModifier` therefore renders its content unchanged and silently drops everything else (this is how the toolbar/validation/focus modifiers shipped as no-ops before being caught on the emulator). The only escape — skipstone's generated `ViewModifier` bridge — doesn't compile in 1.9.5 (missing `SkipUI` imports, unlabeled `body` call). Express UI either as **direct composition in the `View` extension function** (stateless: `formValidationError`, `formBindFocus`) or as a **non-generic bridged wrapper `View`** (needs `@State`/`@FocusState`/`@Environment`: `FormToolbarView`, `FocusedOnView`). +- **Wrapper views must be non-generic and bridged.** skip-bridge does not support generic types, so wrapper views erase their type parameters (`AnyView` content + closures over the controller, `AnyKeyPath` for focus identity) and must NOT carry `// SKIP @nobridge` — the generated Kotlin peer is exactly what makes them render on Android. `skip.yml` sets `bridging: true` for the same reason. +- **View files import `FormsKitSwiftUI`, never `SwiftUI` directly.** The `FormsKitSwiftUI` shim target re-exports real SwiftUI, except in Skip bridge builds (`-DSKIP_BRIDGE`: the Android cross-compile and the Robolectric host build) where it re-exports SkipSwiftUI, whose `SkipUIBridging`/`SkipUI` machinery the generated bridges reference. The indirection is load-bearing: the bridge generator mirrors source-file imports verbatim into the generated `*_Bridge.swift` files and cannot evaluate `#if` conditions, so the conditional must live at module level in the shim, and the view files' import must stay a plain unconditional `import FormsKitSwiftUI`. `ViewModifierTests.swift` is guarded with `!SKIP_BRIDGE` in addition to `!os(Android)` (in bridge builds FormsKit's views are SkipSwiftUI-typed, so real-SwiftUI hosting doesn't apply). One sharp edge: switching between `SKIP_ZERO` and Skip-active builds in the same checkout can leave stale incremental state (`missing required module 'CJNI'`) — run `swift package clean` when that appears. +- **Everything else public carries `// SKIP @nobridge`.** With `bridging: true`, skipstone tries to bridge the whole public API, and FormsKit's is unbridgeable by design: key paths (`ValidatedField`), generic types with constructors (`FormController`, `Validated`), and statics added via constrained extensions (the `.minLength(3)`-style rule factories) all hard-error in the generator. FormsKit is consumed from Swift only, so the Kotlin-facing surface is deliberately empty except the two wrapper views. A new public declaration gets `// SKIP @nobridge` unless it is a non-generic wrapper `View`. +- **Property-wrapper storage in public SwiftUI types must be `internal`, not `private`.** Skip's bridge diagnostics reject private `@State`/`@Environment`/`@FocusState` storage inside bridged types ("Private state property cannot be bridged"). This is why `dismiss`, `showsDiscardWarning`, and `isFocused` are internal. - **`ViewModifierTests.swift` is wrapped in `#if !os(Android)`.** It hosts views via `ImageRenderer`/`NS-`/`UIHostingController`, which don't exist on Android. Logic tests (rules, `Validated`, controller, focus) run on both platforms — keep new UI-hosting tests inside that guard and new logic tests outside it. - **`FormController.swift` imports `SkipFuse` behind `#if canImport(SkipFuse)`.** On Android this wires `@Observable` change tracking into Compose; under `SKIP_ZERO` the module doesn't exist, hence the guard. Give any future `@Observable` type the same import. - **The `SKIP_ZERO` block in `Package.swift` is the no-dependency escape hatch** for Apple-only consumers. Preserve it when touching the manifest, and keep the Skip dependencies out of any code path it can't strip. diff --git a/Package.resolved b/Package.resolved index dedfd13..ead7491 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "947412299fb6945475472ef57117328d6c1bdd1441ebca85758500ba8072838a", + "originHash" : "2843369d144d777ff7a6f96efeda9552766cb2467dfe859b3cab3f22307cde94", "pins" : [ { "identity" : "skip", diff --git a/Package.swift b/Package.swift index b962cc2..efcd011 100644 --- a/Package.swift +++ b/Package.swift @@ -27,9 +27,27 @@ let package = Package( targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. + + // Internal shim: re-exports real SwiftUI, except in Skip bridge builds + // (-DSKIP_BRIDGE), where it re-exports SkipSwiftUI. FormsKit's view files + // import this module unconditionally so the skipstone bridge generator — + // which mirrors source-file imports verbatim and cannot evaluate `#if` — + // produces *_Bridge.swift files that compile in every build flavor. + .target( + name: "FormsKitSwiftUI", + dependencies: [ + // SkipFuseUI (not the SkipSwiftUI product): depending on the + // dynamic SkipSwiftUI product alongside SkipFuseUI's static use + // of the same target is a SwiftPM linkage conflict; the + // SkipSwiftUI *module* is importable transitively. + .product(name: "SkipFuseUI", package: "skip-fuse-ui"), + ], + plugins: [.plugin(name: "skipstone", package: "skip")] + ), .target( name: "FormsKit", dependencies: [ + "FormsKitSwiftUI", .product(name: "SkipFuse", package: "skip-fuse"), .product(name: "SkipFuseUI", package: "skip-fuse-ui"), ], diff --git a/README.md b/README.md index 9067725..6e46e22 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ SKIP_ZERO=1 swift build Platform notes: +- The view modifiers are deliberately *not* implemented as custom `ViewModifier`s — on Android, SkipSwiftUI renders a custom `ViewModifier` as a silent no-op. They are built from wrapper views and direct composition instead, so `.formToolbar`, `.formValidationError`, and `.focused(on:equals:)` render identically on both platforms. - `.formBindFocus(_:on:)` relies on an optional-valued `@FocusState`, which SkipUI does not fully support yet — prefer `.focused(on:equals:)` (internally `Bool`-based) in cross-platform forms. - The view-modifier test suite runs on Apple platforms only (it hosts views via `ImageRenderer`/`HostingController`, which don't exist on Android); all validation, controller, and focus-logic tests run on both platforms. @@ -594,8 +595,8 @@ FormsKit ships an **agent skill** at [`Skills/formskit-expert/`](Skills/formskit ## Roadmap - Localized default error messages via `String(localized:bundle: .module)`. -- Themeable error color on `FormValidationErrorModifier` (currently hardcoded `.red`). -- Localizable strings in `FormToolbarViewModifier` ("Discard Changes?", etc.). +- Themeable error color on `formValidationError` (currently hardcoded `.red`). +- Localizable strings in `FormToolbarView` ("Discard Changes?", etc.). - Additional rule families (`Number`, `Date`, `Collection`). ## License diff --git a/Skills/formskit-expert.skill b/Skills/formskit-expert.skill index 4eb617361407a688b93f01c09c7fc92ebc591334..904eb8528c81e98f491fe260c33c621568ada5c7 100644 GIT binary patch literal 9718 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 V1ms_V0>J-z!u|?egY56O{{<3KLURBB literal 9229 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 { @@ -32,6 +33,7 @@ public final class FormController { // MARK: Controller Extension for Validatable Forms +// SKIP @nobridge extension FormController where T: ValidatableForm { var isDirty: Bool { @@ -64,6 +66,7 @@ extension FormController where T: ValidatableForm { // MARK: Controller Extension for Submittable and Validatable Forms +// SKIP @nobridge public extension FormController where T: SubmittableForm, T: ValidatableForm { var isLoading: Bool { diff --git a/Sources/FormsKit/Forms/PopulatableForm.swift b/Sources/FormsKit/Forms/PopulatableForm.swift index d65355f..5f306da 100644 --- a/Sources/FormsKit/Forms/PopulatableForm.swift +++ b/Sources/FormsKit/Forms/PopulatableForm.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge public protocol PopulatableForm { associatedtype Data diff --git a/Sources/FormsKit/Forms/SubmittableForm.swift b/Sources/FormsKit/Forms/SubmittableForm.swift index 9a95978..21b9ccc 100644 --- a/Sources/FormsKit/Forms/SubmittableForm.swift +++ b/Sources/FormsKit/Forms/SubmittableForm.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge public protocol SubmittableForm { associatedtype Output diff --git a/Sources/FormsKit/Forms/ValidatableForm.swift b/Sources/FormsKit/Forms/ValidatableForm.swift index 46aa4a4..10041fc 100644 --- a/Sources/FormsKit/Forms/ValidatableForm.swift +++ b/Sources/FormsKit/Forms/ValidatableForm.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge public protocol ValidatableForm { var validatedFields: [ValidatedField] { get } } diff --git a/Sources/FormsKit/Skip/skip.yml b/Sources/FormsKit/Skip/skip.yml index a40cb12..08f60b5 100644 --- a/Sources/FormsKit/Skip/skip.yml +++ b/Sources/FormsKit/Skip/skip.yml @@ -5,9 +5,11 @@ # @Validated property wrapper and the KeyPath-driven focus system work # unchanged (neither is expressible in Skip's transpiled mode). # -# Bridging is intentionally not enabled: FormsKit is consumed from Swift -# (SwiftUI) code only, and its generic, key-path-based API is not -# representable in Kotlin anyway. +# Bridging is required: FormsKit's wrapper Views (the four form-control +# modifiers) need Kotlin peers so they render on Android. Custom Views bridge +# fine; module-wide bridging is safe. If the generator produces problematic +# Kotlin for other API, scope it down with `// SKIP @nobridge` on those +# declarations (never on the wrapper Views themselves). skip: mode: 'native' - bridging: false + bridging: true diff --git a/Sources/FormsKit/Validated.swift b/Sources/FormsKit/Validated.swift index 062b310..74ce59c 100644 --- a/Sources/FormsKit/Validated.swift +++ b/Sources/FormsKit/Validated.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge @propertyWrapper public struct Validated { diff --git a/Sources/FormsKit/ValidatedField.swift b/Sources/FormsKit/ValidatedField.swift index c41baa5..18f7bd6 100644 --- a/Sources/FormsKit/ValidatedField.swift +++ b/Sources/FormsKit/ValidatedField.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge public struct ValidatedField
{ public let keyPath: PartialKeyPath diff --git a/Sources/FormsKit/ValidationError.swift b/Sources/FormsKit/ValidationError.swift index 038effb..a5191ea 100644 --- a/Sources/FormsKit/ValidationError.swift +++ b/Sources/FormsKit/ValidationError.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge public enum ValidationError: Error { case misconfigured(message: String) case invalid(errors: [String: [String]]) diff --git a/Sources/FormsKit/ValidationRule.swift b/Sources/FormsKit/ValidationRule.swift index 26a6d95..75ee76a 100644 --- a/Sources/FormsKit/ValidationRule.swift +++ b/Sources/FormsKit/ValidationRule.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge public protocol ValidationRule { associatedtype Value func validate(value: Value) -> String? diff --git a/Sources/FormsKit/ValidationRules/StringValidationRule.swift b/Sources/FormsKit/ValidationRules/StringValidationRule.swift index 8afe674..155580d 100644 --- a/Sources/FormsKit/ValidationRules/StringValidationRule.swift +++ b/Sources/FormsKit/ValidationRules/StringValidationRule.swift @@ -1 +1,2 @@ +// SKIP @nobridge public protocol StringValidationRule: ValidationRule where Value == String {} diff --git a/Sources/FormsKit/ValidationRules/StringValidationRules/EmailValidationRule.swift b/Sources/FormsKit/ValidationRules/StringValidationRules/EmailValidationRule.swift index 61779a9..a5d790b 100644 --- a/Sources/FormsKit/ValidationRules/StringValidationRules/EmailValidationRule.swift +++ b/Sources/FormsKit/ValidationRules/StringValidationRules/EmailValidationRule.swift @@ -1,5 +1,6 @@ import Foundation +// SKIP @nobridge public struct EmailValidator: StringValidationRule { let message: String @@ -16,6 +17,9 @@ public struct EmailValidator: StringValidationRule { } } +// Kotlin companion objects cannot express static members added via +// generically-constrained extensions; these factories are Swift-only sugar. +// SKIP @nobridge public extension ValidationRule where Self == EmailValidator { static func email(message: String = "Invalid email address") -> EmailValidator { EmailValidator(message) diff --git a/Sources/FormsKit/ValidationRules/StringValidationRules/MaxStringLengthValidationRule.swift b/Sources/FormsKit/ValidationRules/StringValidationRules/MaxStringLengthValidationRule.swift index a29cb13..fd1008b 100644 --- a/Sources/FormsKit/ValidationRules/StringValidationRules/MaxStringLengthValidationRule.swift +++ b/Sources/FormsKit/ValidationRules/StringValidationRules/MaxStringLengthValidationRule.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge public struct MaxStringLengthValidationRule: StringValidationRule { let maxLength: Int let message: String @@ -12,6 +13,9 @@ public struct MaxStringLengthValidationRule: StringValidationRule { } } +// Kotlin companion objects cannot express static members added via +// generically-constrained extensions; these factories are Swift-only sugar. +// SKIP @nobridge public extension ValidationRule where Self == MaxStringLengthValidationRule { static func maxLength(_ length: Int, message: String? = nil) -> MaxStringLengthValidationRule { MaxStringLengthValidationRule(length, message) diff --git a/Sources/FormsKit/ValidationRules/StringValidationRules/MinStringLengthValidationRule.swift b/Sources/FormsKit/ValidationRules/StringValidationRules/MinStringLengthValidationRule.swift index 30bc46b..3d02374 100644 --- a/Sources/FormsKit/ValidationRules/StringValidationRules/MinStringLengthValidationRule.swift +++ b/Sources/FormsKit/ValidationRules/StringValidationRules/MinStringLengthValidationRule.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge public struct MinStringLengthValidationRule: StringValidationRule { let minLength: Int let message: String @@ -12,6 +13,9 @@ public struct MinStringLengthValidationRule: StringValidationRule { } } +// Kotlin companion objects cannot express static members added via +// generically-constrained extensions; these factories are Swift-only sugar. +// SKIP @nobridge public extension ValidationRule where Self == MinStringLengthValidationRule { static func minLength(_ length: Int, message: String? = nil) -> MinStringLengthValidationRule { MinStringLengthValidationRule(length, message) diff --git a/Sources/FormsKit/ValidationRules/StringValidationRules/NotEmptyStringRule.swift b/Sources/FormsKit/ValidationRules/StringValidationRules/NotEmptyStringRule.swift index b52a4e4..138e997 100644 --- a/Sources/FormsKit/ValidationRules/StringValidationRules/NotEmptyStringRule.swift +++ b/Sources/FormsKit/ValidationRules/StringValidationRules/NotEmptyStringRule.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge public struct NotEmptyStringRule: StringValidationRule { let message: String @@ -9,6 +10,9 @@ public struct NotEmptyStringRule: StringValidationRule { } } +// Kotlin companion objects cannot express static members added via +// generically-constrained extensions; these factories are Swift-only sugar. +// SKIP @nobridge public extension ValidationRule where Self == NotEmptyStringRule { static func isNotEmpty(message: String) -> NotEmptyStringRule { NotEmptyStringRule(message: message) diff --git a/Sources/FormsKit/ValidationRules/StringValidationRules/RegularExpressionValidationRule.swift b/Sources/FormsKit/ValidationRules/StringValidationRules/RegularExpressionValidationRule.swift index 0b1e8e3..6dccb46 100644 --- a/Sources/FormsKit/ValidationRules/StringValidationRules/RegularExpressionValidationRule.swift +++ b/Sources/FormsKit/ValidationRules/StringValidationRules/RegularExpressionValidationRule.swift @@ -1,5 +1,6 @@ import Foundation +// SKIP @nobridge public struct RegularExpressionValidationRule: StringValidationRule { let pattern: String let message: String @@ -11,6 +12,9 @@ public struct RegularExpressionValidationRule: StringValidationRule { } } +// Kotlin companion objects cannot express static members added via +// generically-constrained extensions; these factories are Swift-only sugar. +// SKIP @nobridge public extension ValidationRule where Self == RegularExpressionValidationRule { static func pattern(_ pattern: String, message: String) -> RegularExpressionValidationRule { RegularExpressionValidationRule(pattern: pattern, message: message) diff --git a/Sources/FormsKit/ViewModifiers/FocusedOnView.swift b/Sources/FormsKit/ViewModifiers/FocusedOnView.swift new file mode 100644 index 0000000..ad1a24b --- /dev/null +++ b/Sources/FormsKit/ViewModifiers/FocusedOnView.swift @@ -0,0 +1,56 @@ +// FormsKitSwiftUI re-exports real SwiftUI, or SkipSwiftUI in Skip bridge +// builds. The import must stay unconditional: the bridge generator mirrors it +// into the generated *_Bridge.swift files. See FormsKitSwiftUI.swift. +import FormsKitSwiftUI + +public struct FocusedOnView: View { + let content: AnyView + let fieldKeyPath: AnyKeyPath + let currentFocus: () -> AnyKeyPath? + let setFocus: (AnyKeyPath?) -> Void + + // internal (not private): Skip's Android bridge for SwiftUI types + // cannot reach private property-wrapper storage. + @FocusState var isFocused: Bool + + public var body: some View { + content + .focused($isFocused) + .onChange(of: currentFocus()) { _, new in + let shouldBeFocused = (new == fieldKeyPath) + guard isFocused != shouldBeFocused else { + return + } + Task { @MainActor in + isFocused = shouldBeFocused + } + } + .onChange(of: isFocused) { _, new in + if new { + if currentFocus() != fieldKeyPath { + setFocus(fieldKeyPath) + } + } else if currentFocus() == fieldKeyPath { + setFocus(nil) + } + } + } +} + +// MARK: - View Extension + +// SKIP @nobridge +public extension View { + + func focused( + on controller: Binding>, + equals keyPath: KeyPath + ) -> some View { + FocusedOnView( + content: AnyView(self), + fieldKeyPath: keyPath, + currentFocus: { controller.wrappedValue.focus }, + setFocus: { controller.wrappedValue.focus = $0.flatMap { $0 as? PartialKeyPath } } + ) + } +} diff --git a/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift b/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift deleted file mode 100644 index c7b7a27..0000000 --- a/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift +++ /dev/null @@ -1,54 +0,0 @@ -import SwiftUI - -// SKIP @nobridge -public struct FocusedOnViewModifier: ViewModifier { - - let controller: Binding> - - let keyPath: KeyPath - - // internal (not private): Skip's Android bridge for SwiftUI types - // cannot reach private property-wrapper storage. - @FocusState var isFocused: Bool - - public func body(content: Content) -> some View { - let myKeyPath: PartialKeyPath = keyPath - return content - .focused($isFocused) - .onChange(of: controller.wrappedValue.focus) { _, new in - let shouldBeFocused = (new == myKeyPath) - guard isFocused != shouldBeFocused else { - return - } - Task { @MainActor in - isFocused = shouldBeFocused - } - } - .onChange(of: isFocused) { _, new in - if new { - if controller.wrappedValue.focus != myKeyPath { - controller.wrappedValue.focus = myKeyPath - } - } else if controller.wrappedValue.focus == myKeyPath { - controller.wrappedValue.focus = nil - } - } - } -} - -// MARK: - View Extension - -public extension View { - - func focused( - on controller: Binding>, - equals keyPath: KeyPath - ) -> some View { - modifier( - FocusedOnViewModifier( - controller: controller, - keyPath: keyPath - ) - ) - } -} diff --git a/Sources/FormsKit/ViewModifiers/FormBindFocusViewModifier.swift b/Sources/FormsKit/ViewModifiers/FormBindFocus.swift similarity index 58% rename from Sources/FormsKit/ViewModifiers/FormBindFocusViewModifier.swift rename to Sources/FormsKit/ViewModifiers/FormBindFocus.swift index d7ba4c5..cd4b4ad 100644 --- a/Sources/FormsKit/ViewModifiers/FormBindFocusViewModifier.swift +++ b/Sources/FormsKit/ViewModifiers/FormBindFocus.swift @@ -1,26 +1,13 @@ -import SwiftUI +// FormsKitSwiftUI re-exports real SwiftUI, or SkipSwiftUI in Skip bridge +// builds. The import must stay unconditional: the bridge generator mirrors it +// into the generated *_Bridge.swift files. See FormsKitSwiftUI.swift. +import FormsKitSwiftUI -// SKIP @nobridge -public struct FormBindFocusViewModifier: ViewModifier { - - let focus: FocusState?>.Binding - - let controller: FormController - - public func body(content: Content) -> some View { - content - .onChange(of: focus.wrappedValue) { _, new in - Self.syncControllerFocus(controller, to: new) - } - .onChange(of: controller.focus) { _, new in - guard focus.wrappedValue != new else { return } - Task { @MainActor in - focus.wrappedValue = new - } - } - } +// MARK: - FormBindFocusSupport - static func syncControllerFocus( +internal enum FormBindFocusSupport { + @MainActor + static func syncControllerFocus( _ controller: FormController, to new: PartialKeyPath? ) { @@ -32,17 +19,22 @@ public struct FormBindFocusViewModifier: ViewModifier { // MARK: - View Extension +// SKIP @nobridge public extension View { func formBindFocus( _ focus: FocusState?>.Binding, on controller: FormController ) -> some View { - modifier( - FormBindFocusViewModifier( - focus: focus, - controller: controller - ) - ) + self + .onChange(of: focus.wrappedValue) { _, new in + FormBindFocusSupport.syncControllerFocus(controller, to: new) + } + .onChange(of: controller.focus) { _, new in + guard focus.wrappedValue != new else { return } + Task { @MainActor in + focus.wrappedValue = new + } + } } } diff --git a/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift b/Sources/FormsKit/ViewModifiers/FormToolbarView.swift similarity index 59% rename from Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift rename to Sources/FormsKit/ViewModifiers/FormToolbarView.swift index dd983f9..7ab5205 100644 --- a/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift +++ b/Sources/FormsKit/ViewModifiers/FormToolbarView.swift @@ -1,24 +1,25 @@ -import SwiftUI +// FormsKitSwiftUI re-exports real SwiftUI, or SkipSwiftUI in Skip bridge +// builds. The import must stay unconditional: the bridge generator mirrors it +// into the generated *_Bridge.swift files. See FormsKitSwiftUI.swift. +import FormsKitSwiftUI -// SKIP @nobridge -public struct FormToolbarViewModifier: ViewModifier { +public struct FormToolbarView: View { // internal (not private): Skip's Android bridge for SwiftUI types // cannot reach private property-wrapper storage. @Environment(\.dismiss) var dismiss @State var showsDiscardWarning: Bool = false - let controller: FormController - + let content: AnyView let cancelTitle: String - let submitTitle: String - let preventsAccidentalDismiss: Bool + let isDirty: () -> Bool + let isLoading: () -> Bool + let validateReturningIsValid: () -> Bool + let onSubmit: () -> Void - let onSubmit: (() -> Void) - - public func body(content: Content) -> some View { + public var body: some View { content .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -27,10 +28,10 @@ public struct FormToolbarViewModifier: Vie ToolbarItem(placement: .confirmationAction) { Button(submitTitle, action: submitTapped) .bold() - .disabled(!controller.isDirty || controller.isLoading) + .disabled(!isDirty() || isLoading()) } } - .interactiveDismissDisabled(preventsAccidentalDismiss && controller.isDirty) + .interactiveDismissDisabled(preventsAccidentalDismiss && isDirty()) .confirmationDialog("Discard Changes?", isPresented: $showsDiscardWarning) { Button("Discard Changes", role: .destructive) { dismiss() } Button("Keep Editing", role: .cancel) { } @@ -40,7 +41,7 @@ public struct FormToolbarViewModifier: Vie } func cancelTapped() { - if preventsAccidentalDismiss && controller.isDirty { + if preventsAccidentalDismiss && isDirty() { showsDiscardWarning = true } else { dismiss() @@ -48,8 +49,7 @@ public struct FormToolbarViewModifier: Vie } func submitTapped() { - controller.validate() - if controller.form.isValid { + if validateReturningIsValid() { onSubmit() } } @@ -57,6 +57,7 @@ public struct FormToolbarViewModifier: Vie // MARK: - View Extension +// SKIP @nobridge public extension View { func formToolbar( @@ -64,16 +65,17 @@ public extension View { 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 - ) + FormToolbarView( + content: AnyView(self), + cancelTitle: cancelTitle, + submitTitle: submitTitle, + preventsAccidentalDismiss: preventsAccidentalDismiss, + isDirty: { controller.isDirty }, + isLoading: { controller.isLoading }, + validateReturningIsValid: { controller.validate(); return controller.form.isValid }, + onSubmit: onSubmit ) } } diff --git a/Sources/FormsKit/ViewModifiers/FormValidationError.swift b/Sources/FormsKit/ViewModifiers/FormValidationError.swift new file mode 100644 index 0000000..38918d7 --- /dev/null +++ b/Sources/FormsKit/ViewModifiers/FormValidationError.swift @@ -0,0 +1,27 @@ +// FormsKitSwiftUI re-exports real SwiftUI, or SkipSwiftUI in Skip bridge +// builds. The import must stay unconditional: the bridge generator mirrors it +// into the generated *_Bridge.swift files. See FormsKitSwiftUI.swift. +import FormsKitSwiftUI + +// MARK: - View Extension + +// SKIP @nobridge +public extension View { + + func formValidationError( + for state: Validated.State, + alignment: HorizontalAlignment = .leading, + spacing: CGFloat? = 4 + ) -> some View { + VStack(alignment: alignment, spacing: spacing) { + self + if case let .invalid(messages) = state { + ForEach(messages, id: \.self) { message in + Text(message) + .foregroundStyle(.red) + .font(.caption) + } + } + } + } +} diff --git a/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift b/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift deleted file mode 100644 index b69e16c..0000000 --- a/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift +++ /dev/null @@ -1,52 +0,0 @@ -import SwiftUI - -// SKIP @nobridge -public struct FormValidationErrorModifier: ViewModifier { - - let state: Validated.State - - let alignment: HorizontalAlignment - let spacing: CGFloat? - - init( - state: Validated.State, - alignment: HorizontalAlignment = .leading, - spacing: CGFloat? = 4 - ) { - self.state = state - self.alignment = alignment - self.spacing = spacing - } - - public func body(content: Content) -> some View { - VStack(alignment: alignment, spacing: spacing) { - content - if case let .invalid(messages) = state { - ForEach(messages, id: \.self) { message in - Text(message) - .foregroundStyle(.red) - .font(.caption) - } - } - } - } -} - -// MARK: - View Extension - -public extension View { - - func formValidationError( - for state: Validated.State, - alignment: HorizontalAlignment = .leading, - spacing: CGFloat? = 4 - ) -> some View { - modifier( - FormValidationErrorModifier( - state: state, - alignment: alignment, - spacing: spacing - ) - ) - } -} diff --git a/Sources/FormsKitSwiftUI/FormsKitSwiftUI.swift b/Sources/FormsKitSwiftUI/FormsKitSwiftUI.swift new file mode 100644 index 0000000..be88d06 --- /dev/null +++ b/Sources/FormsKitSwiftUI/FormsKitSwiftUI.swift @@ -0,0 +1,16 @@ +// Internal shim consumed by FormsKit's view files via a plain, unconditional +// `import FormsKitSwiftUI` — the form the skipstone bridge generator can +// mirror into the generated *_Bridge.swift files (it cannot evaluate `#if` +// conditions, so a conditional import in the view files themselves would not +// survive into the bridges). The conditional lives here instead, where the +// compiler's real flags decide it: +// +// - Skip bridge builds (the Android cross-compile and the Robolectric host +// build, both compiled with -DSKIP_BRIDGE): re-export SkipSwiftUI, whose +// SkipUIBridging / SkipUI machinery the generated bridges reference. +// - Every other build (Apple platforms, SKIP_ZERO): re-export real SwiftUI. +#if SKIP_BRIDGE +@_exported import SkipSwiftUI +#else +@_exported import SwiftUI +#endif diff --git a/Sources/FormsKitSwiftUI/Skip/skip.yml b/Sources/FormsKitSwiftUI/Skip/skip.yml new file mode 100644 index 0000000..a637a1a --- /dev/null +++ b/Sources/FormsKitSwiftUI/Skip/skip.yml @@ -0,0 +1,7 @@ +# Skip (https://skip.dev) configuration for the FormsKitSwiftUI shim module. +# +# Natively-compiled Skip Fuse module, like FormsKit itself. Pure re-export +# shim (see FormsKitSwiftUI.swift); nothing here needs a Kotlin-facing API. +skip: + mode: 'native' + bridging: false diff --git a/Tests/FormsKitTests/ViewModifierTests.swift b/Tests/FormsKitTests/ViewModifierTests.swift index 19ced80..7e590c6 100644 --- a/Tests/FormsKitTests/ViewModifierTests.swift +++ b/Tests/FormsKitTests/ViewModifierTests.swift @@ -1,8 +1,11 @@ // These tests drive the modifiers through real SwiftUI hosts -// (ImageRenderer / NS-/UIHostingController), which don't exist on Android. -// On Android (Skip Fuse) the modifiers are exercised by consumers' own UI -// tests instead; the library's logic tests all run on both platforms. -#if !os(Android) +// (ImageRenderer / NS-/UIHostingController), which don't exist on Android — +// and in bridge builds (SKIP_BRIDGE: the Android cross-compile and the +// Robolectric host build) FormsKit's views are SkipSwiftUI-typed, so real +// SwiftUI hosting doesn't apply there either. On Android (Skip Fuse) the +// modifiers are exercised by consumers' own UI tests instead; the library's +// logic tests all run on both platforms. +#if !os(Android) && !SKIP_BRIDGE import Testing import SwiftUI @@ -32,10 +35,10 @@ struct VMForm: ValidatableForm, SubmittableForm { func submit() async throws -> String { name } } -// MARK: - FormValidationErrorModifier +// MARK: - formValidationError @MainActor -@Suite("FormValidationErrorModifier") +@Suite("formValidationError") struct FormValidationErrorModifierTests { @Test("View extension `.formValidationError(for:)` builds a modified view — .idle branch") @@ -81,10 +84,10 @@ struct FormValidationErrorModifierTests { } } -// MARK: - FormToolbarViewModifier +// MARK: - FormToolbarView @MainActor -@Suite("FormToolbarViewModifier") +@Suite("FormToolbarView") struct FormToolbarViewModifierTests { @Test("View extension `.formToolbar(...)` builds a modified view for a dirty form") @@ -138,11 +141,14 @@ struct FormToolbarViewModifierTests { @Test("cancelTapped on a clean form invokes dismiss (no warning shown)") func cancelTappedCleanDismisses() { let controller = FormController(form: VMForm()) - let modifier = FormToolbarViewModifier( - controller: controller, + let modifier = FormToolbarView( + content: AnyView(Text("body")), cancelTitle: "Cancel", submitTitle: "Submit", preventsAccidentalDismiss: true, + isDirty: { controller.isDirty }, + isLoading: { controller.isLoading }, + validateReturningIsValid: { controller.validate(); return controller.form.isValid }, onSubmit: { Issue.record("onSubmit should not fire for cancel") } ) // Form is clean → guard is false → falls through to dismiss(). @@ -154,11 +160,14 @@ struct FormToolbarViewModifierTests { func cancelTappedDirtyShowsWarning() { let controller = FormController(form: VMForm()) controller.form.name = "edited" - let modifier = FormToolbarViewModifier( - controller: controller, + let modifier = FormToolbarView( + content: AnyView(Text("body")), cancelTitle: "Cancel", submitTitle: "Submit", preventsAccidentalDismiss: true, + isDirty: { controller.isDirty }, + isLoading: { controller.isLoading }, + validateReturningIsValid: { controller.validate(); return controller.form.isValid }, onSubmit: { Issue.record("onSubmit should not fire for cancel") } ) modifier.cancelTapped() @@ -171,11 +180,14 @@ struct FormToolbarViewModifierTests { func cancelTappedNoPreventDismissesWhenDirty() { let controller = FormController(form: VMForm()) controller.form.name = "edited" - let modifier = FormToolbarViewModifier( - controller: controller, + let modifier = FormToolbarView( + content: AnyView(Text("body")), cancelTitle: "Cancel", submitTitle: "Submit", preventsAccidentalDismiss: false, + isDirty: { controller.isDirty }, + isLoading: { controller.isLoading }, + validateReturningIsValid: { controller.validate(); return controller.form.isValid }, onSubmit: { Issue.record("onSubmit should not fire for cancel") } ) // preventsAccidentalDismiss=false short-circuits the &&; falls to dismiss(). @@ -190,11 +202,14 @@ struct FormToolbarViewModifierTests { controller.form.name = "Alice" controller.form.email = "alice@example.com" var didSubmit = false - let modifier = FormToolbarViewModifier( - controller: controller, + let modifier = FormToolbarView( + content: AnyView(Text("body")), cancelTitle: "Cancel", submitTitle: "Submit", preventsAccidentalDismiss: true, + isDirty: { controller.isDirty }, + isLoading: { controller.isLoading }, + validateReturningIsValid: { controller.validate(); return controller.form.isValid }, onSubmit: { didSubmit = true } ) modifier.submitTapped() @@ -207,11 +222,14 @@ struct FormToolbarViewModifierTests { let controller = FormController(form: VMForm()) // Both fields empty → invalid after validate(). var didSubmit = false - let modifier = FormToolbarViewModifier( - controller: controller, + let modifier = FormToolbarView( + content: AnyView(Text("body")), cancelTitle: "Cancel", submitTitle: "Submit", preventsAccidentalDismiss: true, + isDirty: { controller.isDirty }, + isLoading: { controller.isLoading }, + validateReturningIsValid: { controller.validate(); return controller.form.isValid }, onSubmit: { didSubmit = true } ) modifier.submitTapped() @@ -261,7 +279,7 @@ struct FormBindFocusAppearHost: View { } @MainActor -@Suite("FormBindFocusViewModifier", .serialized) +@Suite("formBindFocus", .serialized) struct FormBindFocusViewModifierTests { @Test("View extension `.formBindFocus(_:on:)` builds a modified view without crashing") @@ -320,7 +338,7 @@ struct FormBindFocusViewModifierTests { @Test("syncControllerFocus writes a new value into controller.focus") func syncControllerFocusWritesNewValue() { let controller = FormController(form: VMForm()) - FormBindFocusViewModifier.syncControllerFocus(controller, to: \VMForm.name) + FormBindFocusSupport.syncControllerFocus(controller, to: \VMForm.name) #expect(controller.focus == \VMForm.name) } @@ -328,7 +346,7 @@ struct FormBindFocusViewModifierTests { func syncControllerFocusNoOpWhenEqual() { let controller = FormController(form: VMForm()) controller.focus = \VMForm.email - FormBindFocusViewModifier.syncControllerFocus(controller, to: \VMForm.email) + FormBindFocusSupport.syncControllerFocus(controller, to: \VMForm.email) #expect(controller.focus == \VMForm.email) } @@ -336,7 +354,7 @@ struct FormBindFocusViewModifierTests { func syncControllerFocusClears() { let controller = FormController(form: VMForm()) controller.focus = \VMForm.email - FormBindFocusViewModifier.syncControllerFocus(controller, to: nil) + FormBindFocusSupport.syncControllerFocus(controller, to: nil) #expect(controller.focus == nil) } } @@ -406,7 +424,7 @@ struct FocusedOnFocusableHost: View { } @MainActor -@Suite("FocusedOnViewModifier", .serialized) +@Suite("focused(on:equals:)", .serialized) struct FocusedOnViewModifierTests { @Test("View extension `.focused(on:equals:)` builds a modified view without crashing") From d9ce37c75c7179eb7793ec96445196cb48197c04 Mon Sep 17 00:00:00 2001 From: Max Rozdobudko Date: Fri, 14 Aug 2026 11:52:25 +0300 Subject: [PATCH 3/7] refactor(android): restore custom ViewModifiers via dual generic/erased structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 for the bridged twins; AnyKeyPath carries focus identity. - FormToolbarViewModifier + ErasedFormToolbarModifier (was FormToolbarView). - FocusedOnViewModifier + 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 --- .gitignore | 1 + CLAUDE.md | 17 +- README.md | 4 +- Skills/formskit-expert.skill | Bin 9718 -> 9755 bytes .../references/api-cheatsheet.md | 3 +- Sources/FormsKit/AnyFormController.swift | 31 ++++ .../ViewModifiers/FocusedOnView.swift | 56 ------- .../ViewModifiers/FocusedOnViewModifier.swift | 101 ++++++++++++ .../ViewModifiers/FormBindFocus.swift | 18 +-- .../ViewModifiers/FormToolbarView.swift | 81 ---------- .../FormToolbarViewModifier.swift | 146 ++++++++++++++++++ .../ViewModifiers/FormValidationError.swift | 27 ---- .../FormValidationErrorModifier.swift | 51 ++++++ Tests/FormsKitTests/ViewModifierTests.swift | 141 +++++++++++------ 14 files changed, 436 insertions(+), 241 deletions(-) create mode 100644 Sources/FormsKit/AnyFormController.swift delete mode 100644 Sources/FormsKit/ViewModifiers/FocusedOnView.swift create mode 100644 Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift delete mode 100644 Sources/FormsKit/ViewModifiers/FormToolbarView.swift create mode 100644 Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift delete mode 100644 Sources/FormsKit/ViewModifiers/FormValidationError.swift create mode 100644 Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift diff --git a/.gitignore b/.gitignore index 0023a53..f7b276a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store /.build +/.build-zero /Packages xcuserdata/ DerivedData/ diff --git a/CLAUDE.md b/CLAUDE.md index 620693f..9a2ff07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,11 +41,12 @@ Sources/FormsKit/ ├── ValidationRules/ │ ├── StringValidationRule.swift # protocol StringValidationRule │ └── StringValidationRules/ # concrete rules (NotEmpty, MinLength, …) +├── AnyFormController.swift # internal closure-erased facade over FormController for the bridged modifiers └── ViewModifiers/ - ├── FormValidationError.swift # .formValidationError(for:) — direct composition, no struct - ├── FormToolbarView.swift # .formToolbar(controller:onSubmit:) — bridged wrapper view - ├── FormBindFocus.swift # .formBindFocus(_:on:) — direct composition + FormBindFocusSupport - └── FocusedOnView.swift # .focused(on:equals:) — bridged wrapper view + ├── FormValidationErrorModifier.swift # .formValidationError(for:) — single non-generic bridged modifier + ├── FormToolbarViewModifier.swift # .formToolbar(controller:onSubmit:) — generic modifier + bridged erased twin + ├── FormBindFocus.swift # .formBindFocus(_:on:) — direct composition, no struct + └── FocusedOnViewModifier.swift # .focused(on:equals:) — generic modifier + bridged erased twin ``` Keep one type per file. Group concrete rules under `ValidationRules/ValidationRules/` (currently only `String`; add `Number`, `Date`, etc. the same way if needed). The three form-conformance protocols live in `Forms/`; everything else is a high-visibility public type and stays at root. @@ -101,7 +102,7 @@ The library has a deliberate isolation shape; deviating from it will produce con - **Rules are value types.** A `ValidationRule` impl is a plain struct with a `validate(value:) -> String?` method. Add a static factory on `ValidationRule where Self == YourRule` for call-site sugar (`.minLength(3)` style). Mirror the existing `MinStringLengthValidationRule` pattern. - **Rule error messages are passed in.** Don't hardcode user-facing strings inside rules beyond English defaults; consumers localize at call site by passing `message:`. (Localizing the package's own defaults via `String(localized:bundle: .module)` is a future improvement — track it as such, not as a quiet refactor.) - **`@Validated` mode default is `.onChange`.** Means "stay quiet until the field becomes `.invalid`, then re-validate on each keystroke." Don't change the default; it's the UX consumers expect. -- **View modifier UI is intentionally minimal.** `formValidationError` hardcodes `.red` and `.caption`; `FormToolbarView` hardcodes English button titles + a discard dialog. Making these themeable / localizable is on the roadmap but hasn't shipped — don't sneak it in piecemeal; do it as one deliberate change with a public API. +- **View modifier UI is intentionally minimal.** `formValidationError` hardcodes `.red` and `.caption`; `FormToolbarViewModifier` hardcodes English button titles + a discard dialog. Making these themeable / localizable is on the roadmap but hasn't shipped — don't sneak it in piecemeal; do it as one deliberate change with a public API. - **View modifiers prefixed `form*` are package-original concepts; unprefixed ones (e.g. `.focused(on:equals:)`) deliberately overload existing SwiftUI vocabulary.** Don't prefix the overloads (it breaks discovery via SwiftUI muscle memory); do prefix new concepts (it groups the package's surface in autocomplete). ## Focus support @@ -198,10 +199,10 @@ FormsKit ships as a Skip **Fuse (native) framework**: `Sources/FormsKit/Skip/ski Rules that keep the Android build green: -- **Never implement UI as a custom `ViewModifier`.** On Android, SkipSwiftUI's `View.modifier(_:)` ignores `body(content:)` entirely — it applies the modifier's `Java_modifier`, which defaults to `SkipUI.EmptyModifier()`. A custom `ViewModifier` therefore renders its content unchanged and silently drops everything else (this is how the toolbar/validation/focus modifiers shipped as no-ops before being caught on the emulator). The only escape — skipstone's generated `ViewModifier` bridge — doesn't compile in 1.9.5 (missing `SkipUI` imports, unlabeled `body` call). Express UI either as **direct composition in the `View` extension function** (stateless: `formValidationError`, `formBindFocus`) or as a **non-generic bridged wrapper `View`** (needs `@State`/`@FocusState`/`@Environment`: `FormToolbarView`, `FocusedOnView`). -- **Wrapper views must be non-generic and bridged.** skip-bridge does not support generic types, so wrapper views erase their type parameters (`AnyView` content + closures over the controller, `AnyKeyPath` for focus identity) and must NOT carry `// SKIP @nobridge` — the generated Kotlin peer is exactly what makes them render on Android. `skip.yml` sets `bridging: true` for the same reason. +- **Custom `ViewModifier`s work on Android only when bridged — and only non-generic types bridge.** On Android, SkipSwiftUI's `View.modifier(_:)` never calls `body(content:)` itself; it applies the modifier's `Java_modifier`, whose protocol-default implementation is `SkipUI.EmptyModifier()`. For a *bridged* modifier, skipstone generates the override (`Java_modifier { return self }` plus a Kotlin peer whose `body()` calls back into Swift), and the modifier renders. For an *unbridged* one — generic, `@nobridge`d, or hidden from the generator — the default fires and the modifier silently renders its content unchanged, dropping everything else (this is how the toolbar/validation/focus modifiers originally shipped as no-ops: they were generic, hence unbridgeable). Genericity is the trap, not the `ViewModifier` protocol. +- **Generic modifiers therefore come in dual form: a typed variant for non-bridge builds plus an erased bridged twin.** The generic variant lives in `#if !SKIP_BRIDGE && !SKIP` (Apple + SKIP_ZERO builds; the `!SKIP` half hides it from the skipstone generator, which parses with `SKIP` defined but `SKIP_BRIDGE` undefined). The erased twin (`ErasedFormToolbarModifier`, `ErasedFocusedOnModifier`) is declared **unconditionally** — the generator must see it to emit its Kotlin peer, and the Robolectric host build compiles the generated `*_Bridge.swift` against it — and erases `FormController` behind the internal `AnyFormController` closure facade (`AnyKeyPath` for focus identity). Erased twins must NOT carry `// SKIP @nobridge`, keep their memberwise inits internal (no constructor bridging), and their two bodies must be kept in sync by hand — the mirrored unit tests in `ViewModifierTests.swift` cover both variants. `skip.yml` sets `bridging: true` for the peers. - **View files import `FormsKitSwiftUI`, never `SwiftUI` directly.** The `FormsKitSwiftUI` shim target re-exports real SwiftUI, except in Skip bridge builds (`-DSKIP_BRIDGE`: the Android cross-compile and the Robolectric host build) where it re-exports SkipSwiftUI, whose `SkipUIBridging`/`SkipUI` machinery the generated bridges reference. The indirection is load-bearing: the bridge generator mirrors source-file imports verbatim into the generated `*_Bridge.swift` files and cannot evaluate `#if` conditions, so the conditional must live at module level in the shim, and the view files' import must stay a plain unconditional `import FormsKitSwiftUI`. `ViewModifierTests.swift` is guarded with `!SKIP_BRIDGE` in addition to `!os(Android)` (in bridge builds FormsKit's views are SkipSwiftUI-typed, so real-SwiftUI hosting doesn't apply). One sharp edge: switching between `SKIP_ZERO` and Skip-active builds in the same checkout can leave stale incremental state (`missing required module 'CJNI'`) — run `swift package clean` when that appears. -- **Everything else public carries `// SKIP @nobridge`.** With `bridging: true`, skipstone tries to bridge the whole public API, and FormsKit's is unbridgeable by design: key paths (`ValidatedField`), generic types with constructors (`FormController`, `Validated`), and statics added via constrained extensions (the `.minLength(3)`-style rule factories) all hard-error in the generator. FormsKit is consumed from Swift only, so the Kotlin-facing surface is deliberately empty except the two wrapper views. A new public declaration gets `// SKIP @nobridge` unless it is a non-generic wrapper `View`. +- **Everything else public carries `// SKIP @nobridge`.** With `bridging: true`, skipstone tries to bridge the whole public API, and FormsKit's is unbridgeable by design: key paths (`ValidatedField`), generic types with constructors (`FormController`, `Validated`), and statics added via constrained extensions (the `.minLength(3)`-style rule factories) all hard-error in the generator. FormsKit is consumed from Swift only, so the Kotlin-facing surface is deliberately empty except the bridged modifier structs. A new public declaration gets `// SKIP @nobridge` unless it is a non-generic `View` or `ViewModifier` that must render on Android. - **Property-wrapper storage in public SwiftUI types must be `internal`, not `private`.** Skip's bridge diagnostics reject private `@State`/`@Environment`/`@FocusState` storage inside bridged types ("Private state property cannot be bridged"). This is why `dismiss`, `showsDiscardWarning`, and `isFocused` are internal. - **`ViewModifierTests.swift` is wrapped in `#if !os(Android)`.** It hosts views via `ImageRenderer`/`NS-`/`UIHostingController`, which don't exist on Android. Logic tests (rules, `Validated`, controller, focus) run on both platforms — keep new UI-hosting tests inside that guard and new logic tests outside it. - **`FormController.swift` imports `SkipFuse` behind `#if canImport(SkipFuse)`.** On Android this wires `@Observable` change tracking into Compose; under `SKIP_ZERO` the module doesn't exist, hence the guard. Give any future `@Observable` type the same import. diff --git a/README.md b/README.md index 6e46e22..0ff2e73 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ SKIP_ZERO=1 swift build Platform notes: -- The view modifiers are deliberately *not* implemented as custom `ViewModifier`s — on Android, SkipSwiftUI renders a custom `ViewModifier` as a silent no-op. They are built from wrapper views and direct composition instead, so `.formToolbar`, `.formValidationError`, and `.focused(on:equals:)` render identically on both platforms. +- The view modifiers are implemented as custom `ViewModifier`s with a twist: skip-bridge cannot represent generic types, so on Android the generic modifiers are swapped for non-generic, type-erased twins that skipstone bridges into Kotlin peers (an *unbridged* custom `ViewModifier` renders as a silent no-op on Android). `.formToolbar`, `.formValidationError`, and `.focused(on:equals:)` render identically on both platforms. - `.formBindFocus(_:on:)` relies on an optional-valued `@FocusState`, which SkipUI does not fully support yet — prefer `.focused(on:equals:)` (internally `Bool`-based) in cross-platform forms. - The view-modifier test suite runs on Apple platforms only (it hosts views via `ImageRenderer`/`HostingController`, which don't exist on Android); all validation, controller, and focus-logic tests run on both platforms. @@ -596,7 +596,7 @@ FormsKit ships an **agent skill** at [`Skills/formskit-expert/`](Skills/formskit - Localized default error messages via `String(localized:bundle: .module)`. - Themeable error color on `formValidationError` (currently hardcoded `.red`). -- Localizable strings in `FormToolbarView` ("Discard Changes?", etc.). +- Localizable strings in `FormToolbarViewModifier` ("Discard Changes?", etc.). - Additional rule families (`Number`, `Date`, `Collection`). ## License diff --git a/Skills/formskit-expert.skill b/Skills/formskit-expert.skill index 904eb8528c81e98f491fe260c33c621568ada5c7..5fa019086af6395933f72c3e3301fa8366d147de 100644 GIT binary patch delta 1788 zcmV=5S*M4de*OBy7e;^4ga)$H6Z@w8mfvGm7eJ&ha-QPmK;mm>E zaPFe$T9*~e9T>jihO3+>@IteK7oe5g!NZ0t*y$P+$0eqKe;{cq){42>@Fa?!Jb~K60NQ`Os)A4-hrtlw<)^0gXRpO)oWb5 zm`{=FS(>0>4z!Yl-JMPGAIJJ@X-&uRE@}GkeV`po+lf(i`0dJEK z12%uZi0)a~SK8f_mD{xmOkH$0h?EOXr(m<^;BS|D0IGA- zbawLQT4M&al-yu1z`SM$NV&XQT5h%!Kz1g_E1|Ga<`OXnCr34SY(%NdFnJ}mEoO6zsyu#s9*Hq%e)zw*F2l3BZhhO$NrRNU~a#%nS!XMZJ!}0H6 zyQ33ika^N@NT}v^D}$BP4{7w91RIa)Y88FYlIXVy+^(Q$+3@w>{{dYQ2${sOF%-ax z0_sHkpy35QbsaWyU;179=Yf9+$jI*!m?58LmgWzd_GAbNx4uu{8f`&FchyCM8^2HB z4qI%U=hzy&y}f#~ic){@Qjh!K4=_k06=2bhLJ(os^O2wBvf;Uihp6X6O`<<~Yc!Yo z+G@swW8YTTx^SWnrsM2DyEpoHPqWin-9?Swjv;M^aSTdNYuIEBAyBmRX&z~oiC=wZb@tiGXd@rC3gzy9kK0qBT! zz7Mr|&G5-2$Ds~!+|_?_i3Id(iX>;I80CH>x3I493kLLxvha#+#o9-MUgz(D&ag

eIrZ9sR_H`fxj4xXHfwBrOxk@LpHmMH)@K`Rjm6=)ur#7r-4sKo!U7}_7+FAzy e0D2OWDkVh%5Z9A&B|8CMldUB~27V*}0001GhJ5P) delta 1776 zcmV08f#EA|xXO6~FElH70b0o&JZ!jvovuM~Tw)6N2a>j8t(dC~Pon6_6UZ(XOp1az zUR=ymxcRYWjx8mhWH6|VuDEe{lwlP<#Zi=HnSBr|7mdg8Mz{?KC7fV?61Gg%JlY>N zP^{!B%$yNw{TKK%C?SVX>H-&E;(uI8w7T9fwdO;32d0+Xrs(Dlnlpq}uW|8WK1q@! zj>ym;I8rRgSPJtYcq`4h`(w~i^ba!!Hyi}UkJwyx;x;>IvKX^#V3xR zInq1ujZI3n;XnyIZ;%py7eyn;DE2IcEMfA2?Q900@VQL{=Dgr5R!bK*>$7@U3YVeg zz0v>VIXW0p4TJ0#E5T)v#UL!W4Troo2m=S%!HrImnZu_njR4SkA_|FCzW($DQL*I) zc!B;E%_hBII>NrwHKMJ+6l(`N?#OI7wUPGkS$nSXuY(d}XKT8X!~#nJcasnUHh*74 z_bluy?QY7-?b-yUu}4O`9BhQd1(i~$7hJ8~X7DaO)_EyZ5D)wPyf^CX!?)DghsV_` z<`CBEMENye*OHmG2(&t{&Pyg_FVq`aYs9r7NrDZrUk3>LI97{XvW>JA8E; zqaQ4xvC%nW@?KY%$wy2+LxH}*vc`fZhm^fost$cgR`G-2p8n#G=Qv(0|?qxj?PQ zBKMRGkj~65KD~D0;)}&$yBhdByd!h>%xzM59v=LOLs(3Pu>Zl@UtGQT;RX=L8_#yN zMJHEuUplWXV9?45xATWKP3LLX2cNe#-WcJn_iT0ayO>P8u{W=;?(c72@68oPY>_|q z8I0nFXEaKXm<5In#qb|%VSg}VFh_!yVrdwXNzN3Ja>3~oZ1x=d?NSdwb#9u@PTpK= z%%GN%8|(#`*X#f(mv>9c&6Wbl&g6I{6gJ9SBIe-as0NRXD3uu|uf&#ox{Ucu;hq^9 z6WT=0C(TIE7q=T-%VJ7OQ(>&T4SmIf>n< zTZ6Z^S8rBP>JMJ(aUc8v25F=MEZR{BBJ6rT^0QnvJQwj0^?ayF^ha-v=2Bl<&3JI^ z+X`D3PSnA4oE>QQMj!8Kc3Nv()g|=AklnngP-!`{97 zelp2%s6!lgwSQb90sWdH$(boexgW_btZRIO0ewYTc*VA2?V~}j^Y=h!SR~Up1UnC> z4EDvEo^of;)!h3r7fyCpiB5hO-21L49E#;cI0Vrb#vyErS+ZAo?i(7D&xAz`*&_z+ zDd~=7FF7WnA`5|P7FrY`b${nZk8-58Qmd9RmO>R?RyHUa4Y74>M1fY}(zCh0WS^y+ zdhI|fbRwu+oMZ@_H8wsb?Y@oAsfP#avkkV!;_zHp8d0qAlXM3(e?P<>5cv0>>!IgH zLBx*R5B+iU^~-<1e)$Q159%^h-I*QVZoakM%-uh;9k{cBFa2mjRr<%6|;IzT?Z*NAc zKiu};gEZph33T;g5jUdrGw1BRs9Fdw@k6t)9vHJ-2!k90=d2Br<0L;VQ4fA` for the bridged (non-generic) +// view modifiers: skip-bridge cannot represent generic types, so the erased +// modifier variants store this instead of `FormController` and reach the +// controller through closures. Internal: consumers only ever see the generic +// `FormController` through the public `View` extension functions. +@MainActor +struct AnyFormController { + let isDirty: () -> Bool + let isLoading: () -> Bool + let validateReturningIsValid: () -> Bool + let getFocus: () -> AnyKeyPath? + let setFocus: (AnyKeyPath?) -> Void + + init(_ controller: FormController) { + self.isDirty = { controller.isDirty } + self.isLoading = { controller.isLoading } + self.validateReturningIsValid = { controller.validate(); return controller.form.isValid } + self.getFocus = { controller.focus } + self.setFocus = { controller.focus = $0.flatMap { $0 as? PartialKeyPath } } + } + + /// Focus-only erasure for `focused(on:equals:)`, whose form type carries no + /// validation constraints. The validation closures are inert stubs. + init(focusing controller: FormController) { + self.isDirty = { false } + self.isLoading = { false } + self.validateReturningIsValid = { false } + self.getFocus = { controller.focus } + self.setFocus = { controller.focus = $0.flatMap { $0 as? PartialKeyPath } } + } +} diff --git a/Sources/FormsKit/ViewModifiers/FocusedOnView.swift b/Sources/FormsKit/ViewModifiers/FocusedOnView.swift deleted file mode 100644 index ad1a24b..0000000 --- a/Sources/FormsKit/ViewModifiers/FocusedOnView.swift +++ /dev/null @@ -1,56 +0,0 @@ -// FormsKitSwiftUI re-exports real SwiftUI, or SkipSwiftUI in Skip bridge -// builds. The import must stay unconditional: the bridge generator mirrors it -// into the generated *_Bridge.swift files. See FormsKitSwiftUI.swift. -import FormsKitSwiftUI - -public struct FocusedOnView: View { - let content: AnyView - let fieldKeyPath: AnyKeyPath - let currentFocus: () -> AnyKeyPath? - let setFocus: (AnyKeyPath?) -> Void - - // internal (not private): Skip's Android bridge for SwiftUI types - // cannot reach private property-wrapper storage. - @FocusState var isFocused: Bool - - public var body: some View { - content - .focused($isFocused) - .onChange(of: currentFocus()) { _, new in - let shouldBeFocused = (new == fieldKeyPath) - guard isFocused != shouldBeFocused else { - return - } - Task { @MainActor in - isFocused = shouldBeFocused - } - } - .onChange(of: isFocused) { _, new in - if new { - if currentFocus() != fieldKeyPath { - setFocus(fieldKeyPath) - } - } else if currentFocus() == fieldKeyPath { - setFocus(nil) - } - } - } -} - -// MARK: - View Extension - -// SKIP @nobridge -public extension View { - - func focused( - on controller: Binding>, - equals keyPath: KeyPath - ) -> some View { - FocusedOnView( - content: AnyView(self), - fieldKeyPath: keyPath, - currentFocus: { controller.wrappedValue.focus }, - setFocus: { controller.wrappedValue.focus = $0.flatMap { $0 as? PartialKeyPath } } - ) - } -} diff --git a/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift b/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift new file mode 100644 index 0000000..c2bd776 --- /dev/null +++ b/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift @@ -0,0 +1,101 @@ +// FormsKitSwiftUI re-exports real SwiftUI, or SkipSwiftUI in Skip bridge +// builds. The import must stay unconditional: the bridge generator mirrors it +// into the generated *_Bridge.swift files. See FormsKitSwiftUI.swift. +import FormsKitSwiftUI + +// Dual structure: the fully-typed generic modifier serves non-bridge builds; +// the erased twin below serves bridge builds (skip-bridge cannot represent +// generic types). `!SKIP` additionally hides the generic variant from the +// skipstone generator, which parses with SKIP defined but SKIP_BRIDGE +// undefined and would otherwise try to bridge it. +#if !SKIP_BRIDGE && !SKIP +public struct FocusedOnViewModifier: ViewModifier { + let controller: FormController + let fieldKeyPath: KeyPath + + @FocusState var isFocused: Bool + + public func body(content: Content) -> some View { + content + .focused($isFocused) + .onChange(of: controller.focus) { _, new in + let shouldBeFocused = (new == fieldKeyPath) + guard isFocused != shouldBeFocused else { + return + } + Task { @MainActor in + isFocused = shouldBeFocused + } + } + .onChange(of: isFocused) { _, new in + if new { + if controller.focus != fieldKeyPath { + controller.focus = fieldKeyPath + } + } else if controller.focus == fieldKeyPath { + controller.focus = nil + } + } + } +} +#endif + +// Bridged twin for Android: non-generic (skip-bridge requirement), reaching +// the controller through `AnyFormController`'s closures and comparing focus +// identity as `AnyKeyPath`. Must NOT carry `// SKIP @nobridge` — the +// generated Kotlin peer is what makes the modifier apply on Android. Keep +// this body in sync with the generic variant above. +public struct ErasedFocusedOnModifier: ViewModifier { + let controller: AnyFormController + let fieldKeyPath: AnyKeyPath + + // internal (not private): Skip's Android bridge for SwiftUI types + // cannot reach private property-wrapper storage. + @FocusState var isFocused: Bool + + public func body(content: Content) -> some View { + content + .focused($isFocused) + .onChange(of: controller.getFocus()) { _, new in + let shouldBeFocused = (new == fieldKeyPath) + guard isFocused != shouldBeFocused else { + return + } + Task { @MainActor in + isFocused = shouldBeFocused + } + } + .onChange(of: isFocused) { _, new in + if new { + if controller.getFocus() != fieldKeyPath { + controller.setFocus(fieldKeyPath) + } + } else if controller.getFocus() == fieldKeyPath { + controller.setFocus(nil) + } + } + } +} + +// MARK: - View Extension + +// SKIP @nobridge +public extension View { + + func focused( + on controller: Binding>, + equals keyPath: KeyPath + ) -> some View { + #if SKIP_BRIDGE + return modifier(ErasedFocusedOnModifier( + controller: AnyFormController(focusing: controller.wrappedValue), + fieldKeyPath: keyPath + )) + #else + return modifier(FocusedOnViewModifier( + controller: controller.wrappedValue, + fieldKeyPath: keyPath + )) + #endif + } +} diff --git a/Sources/FormsKit/ViewModifiers/FormBindFocus.swift b/Sources/FormsKit/ViewModifiers/FormBindFocus.swift index cd4b4ad..87e1e39 100644 --- a/Sources/FormsKit/ViewModifiers/FormBindFocus.swift +++ b/Sources/FormsKit/ViewModifiers/FormBindFocus.swift @@ -3,20 +3,6 @@ // into the generated *_Bridge.swift files. See FormsKitSwiftUI.swift. import FormsKitSwiftUI -// MARK: - FormBindFocusSupport - -internal enum FormBindFocusSupport { - @MainActor - static func syncControllerFocus( - _ controller: FormController, - to new: PartialKeyPath? - ) { - if controller.focus != new { - controller.focus = new - } - } -} - // MARK: - View Extension // SKIP @nobridge @@ -28,7 +14,9 @@ public extension View { ) -> some View { self .onChange(of: focus.wrappedValue) { _, new in - FormBindFocusSupport.syncControllerFocus(controller, to: new) + if controller.focus != new { + controller.focus = new + } } .onChange(of: controller.focus) { _, new in guard focus.wrappedValue != new else { return } diff --git a/Sources/FormsKit/ViewModifiers/FormToolbarView.swift b/Sources/FormsKit/ViewModifiers/FormToolbarView.swift deleted file mode 100644 index 7ab5205..0000000 --- a/Sources/FormsKit/ViewModifiers/FormToolbarView.swift +++ /dev/null @@ -1,81 +0,0 @@ -// FormsKitSwiftUI re-exports real SwiftUI, or SkipSwiftUI in Skip bridge -// builds. The import must stay unconditional: the bridge generator mirrors it -// into the generated *_Bridge.swift files. See FormsKitSwiftUI.swift. -import FormsKitSwiftUI - -public struct FormToolbarView: View { - // internal (not private): Skip's Android bridge for SwiftUI types - // cannot reach private property-wrapper storage. - @Environment(\.dismiss) var dismiss - - @State var showsDiscardWarning: Bool = false - - let content: AnyView - let cancelTitle: String - let submitTitle: String - let preventsAccidentalDismiss: Bool - let isDirty: () -> Bool - let isLoading: () -> Bool - let validateReturningIsValid: () -> Bool - let onSubmit: () -> Void - - public var body: some View { - content - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(cancelTitle, action: cancelTapped) - } - ToolbarItem(placement: .confirmationAction) { - Button(submitTitle, action: submitTapped) - .bold() - .disabled(!isDirty() || isLoading()) - } - } - .interactiveDismissDisabled(preventsAccidentalDismiss && isDirty()) - .confirmationDialog("Discard Changes?", isPresented: $showsDiscardWarning) { - Button("Discard Changes", role: .destructive) { dismiss() } - Button("Keep Editing", role: .cancel) { } - } message: { - Text("You have unsaved changes. Are you sure you want to discard them?") - } - } - - func cancelTapped() { - if preventsAccidentalDismiss && isDirty() { - showsDiscardWarning = true - } else { - dismiss() - } - } - - func submitTapped() { - if validateReturningIsValid() { - onSubmit() - } - } -} - -// MARK: - View Extension - -// SKIP @nobridge -public extension View { - - func formToolbar( - controller: FormController, - cancelTitle: String = "Cancel", - submitTitle: String = "Submit", - preventsAccidentalDismiss: Bool = true, - onSubmit: @escaping () -> Void - ) -> some View { - FormToolbarView( - content: AnyView(self), - cancelTitle: cancelTitle, - submitTitle: submitTitle, - preventsAccidentalDismiss: preventsAccidentalDismiss, - isDirty: { controller.isDirty }, - isLoading: { controller.isLoading }, - validateReturningIsValid: { controller.validate(); return controller.form.isValid }, - onSubmit: onSubmit - ) - } -} diff --git a/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift b/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift new file mode 100644 index 0000000..cbb1ae9 --- /dev/null +++ b/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift @@ -0,0 +1,146 @@ +// FormsKitSwiftUI re-exports real SwiftUI, or SkipSwiftUI in Skip bridge +// builds. The import must stay unconditional: the bridge generator mirrors it +// into the generated *_Bridge.swift files. See FormsKitSwiftUI.swift. +import FormsKitSwiftUI + +// Dual structure: the fully-typed generic modifier serves non-bridge builds; +// the erased twin below serves bridge builds (skip-bridge cannot represent +// generic types). `!SKIP` additionally hides the generic variant from the +// skipstone generator, which parses with SKIP defined but SKIP_BRIDGE +// undefined and would otherwise try to bridge it. +#if !SKIP_BRIDGE && !SKIP +public struct FormToolbarViewModifier: ViewModifier { + @Environment(\.dismiss) var dismiss + + @State var showsDiscardWarning: Bool = false + + let controller: FormController + let cancelTitle: String + let submitTitle: String + let preventsAccidentalDismiss: Bool + let onSubmit: () -> Void + + public func body(content: Content) -> some View { + content + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(cancelTitle, action: cancelTapped) + } + ToolbarItem(placement: .confirmationAction) { + Button(submitTitle, action: submitTapped) + .bold() + .disabled(!controller.isDirty || controller.isLoading) + } + } + .interactiveDismissDisabled(preventsAccidentalDismiss && controller.isDirty) + .confirmationDialog("Discard Changes?", isPresented: $showsDiscardWarning) { + Button("Discard Changes", role: .destructive) { dismiss() } + Button("Keep Editing", role: .cancel) { } + } message: { + Text("You have unsaved changes. Are you sure you want to discard them?") + } + } + + func cancelTapped() { + if preventsAccidentalDismiss && controller.isDirty { + showsDiscardWarning = true + } else { + dismiss() + } + } + + func submitTapped() { + controller.validate() + if controller.form.isValid { + onSubmit() + } + } +} +#endif + +// Bridged twin for Android: non-generic (skip-bridge requirement), reaching +// the controller through `AnyFormController`'s closures. Must NOT carry +// `// SKIP @nobridge` — the generated Kotlin peer is exactly what makes the +// modifier apply on Android (an unbridged custom ViewModifier falls back to +// SkipSwiftUI's default `Java_modifier`, an EmptyModifier, and silently +// renders nothing). Keep this body in sync with the generic variant above. +public struct ErasedFormToolbarModifier: ViewModifier { + // internal (not private): Skip's Android bridge for SwiftUI types + // cannot reach private property-wrapper storage. + @Environment(\.dismiss) var dismiss + + @State var showsDiscardWarning: Bool = false + + let controller: AnyFormController + let cancelTitle: String + let submitTitle: String + let preventsAccidentalDismiss: Bool + let onSubmit: () -> Void + + public func body(content: Content) -> some View { + content + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(cancelTitle, action: cancelTapped) + } + ToolbarItem(placement: .confirmationAction) { + Button(submitTitle, action: submitTapped) + .bold() + .disabled(!controller.isDirty() || controller.isLoading()) + } + } + .interactiveDismissDisabled(preventsAccidentalDismiss && controller.isDirty()) + .confirmationDialog("Discard Changes?", isPresented: $showsDiscardWarning) { + Button("Discard Changes", role: .destructive) { dismiss() } + Button("Keep Editing", role: .cancel) { } + } message: { + Text("You have unsaved changes. Are you sure you want to discard them?") + } + } + + func cancelTapped() { + if preventsAccidentalDismiss && controller.isDirty() { + showsDiscardWarning = true + } else { + dismiss() + } + } + + func submitTapped() { + if controller.validateReturningIsValid() { + onSubmit() + } + } +} + +// MARK: - View Extension + +// SKIP @nobridge +public extension View { + + func formToolbar( + controller: FormController, + cancelTitle: String = "Cancel", + submitTitle: String = "Submit", + preventsAccidentalDismiss: Bool = true, + onSubmit: @escaping () -> Void + ) -> some View { + #if SKIP_BRIDGE + return modifier(ErasedFormToolbarModifier( + controller: AnyFormController(controller), + cancelTitle: cancelTitle, + submitTitle: submitTitle, + preventsAccidentalDismiss: preventsAccidentalDismiss, + onSubmit: onSubmit + )) + #else + return modifier(FormToolbarViewModifier( + controller: controller, + cancelTitle: cancelTitle, + submitTitle: submitTitle, + preventsAccidentalDismiss: preventsAccidentalDismiss, + onSubmit: onSubmit + )) + #endif + } +} diff --git a/Sources/FormsKit/ViewModifiers/FormValidationError.swift b/Sources/FormsKit/ViewModifiers/FormValidationError.swift deleted file mode 100644 index 38918d7..0000000 --- a/Sources/FormsKit/ViewModifiers/FormValidationError.swift +++ /dev/null @@ -1,27 +0,0 @@ -// FormsKitSwiftUI re-exports real SwiftUI, or SkipSwiftUI in Skip bridge -// builds. The import must stay unconditional: the bridge generator mirrors it -// into the generated *_Bridge.swift files. See FormsKitSwiftUI.swift. -import FormsKitSwiftUI - -// MARK: - View Extension - -// SKIP @nobridge -public extension View { - - func formValidationError( - for state: Validated.State, - alignment: HorizontalAlignment = .leading, - spacing: CGFloat? = 4 - ) -> some View { - VStack(alignment: alignment, spacing: spacing) { - self - if case let .invalid(messages) = state { - ForEach(messages, id: \.self) { message in - Text(message) - .foregroundStyle(.red) - .font(.caption) - } - } - } - } -} diff --git a/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift b/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift new file mode 100644 index 0000000..2e06671 --- /dev/null +++ b/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift @@ -0,0 +1,51 @@ +// FormsKitSwiftUI re-exports real SwiftUI, or SkipSwiftUI in Skip bridge +// builds. The import must stay unconditional: the bridge generator mirrors it +// into the generated *_Bridge.swift files. See FormsKitSwiftUI.swift. +import FormsKitSwiftUI + +// Non-generic on every platform (the generic `Validated.State` is unpacked +// into plain `[String]?` at the call site), so a single bridged modifier +// serves both. Must NOT carry `// SKIP @nobridge` — the generated Kotlin peer +// is what makes the modifier apply on Android. +public struct FormValidationErrorModifier: ViewModifier { + let errorMessages: [String]? + let alignment: HorizontalAlignment + let spacing: CGFloat? + + public func body(content: Content) -> some View { + VStack(alignment: alignment, spacing: spacing) { + content + if let errorMessages { + ForEach(errorMessages, id: \.self) { message in + Text(message) + .foregroundStyle(.red) + .font(.caption) + } + } + } + } +} + +// MARK: - View Extension + +// SKIP @nobridge +public extension View { + + func formValidationError( + for state: Validated.State, + alignment: HorizontalAlignment = .leading, + spacing: CGFloat? = 4 + ) -> some View { + let errorMessages: [String]? + if case let .invalid(messages) = state { + errorMessages = messages + } else { + errorMessages = nil + } + return modifier(FormValidationErrorModifier( + errorMessages: errorMessages, + alignment: alignment, + spacing: spacing + )) + } +} diff --git a/Tests/FormsKitTests/ViewModifierTests.swift b/Tests/FormsKitTests/ViewModifierTests.swift index 7e590c6..0e10a3f 100644 --- a/Tests/FormsKitTests/ViewModifierTests.swift +++ b/Tests/FormsKitTests/ViewModifierTests.swift @@ -84,10 +84,10 @@ struct FormValidationErrorModifierTests { } } -// MARK: - FormToolbarView +// MARK: - FormToolbarViewModifier @MainActor -@Suite("FormToolbarView") +@Suite("FormToolbarViewModifier") struct FormToolbarViewModifierTests { @Test("View extension `.formToolbar(...)` builds a modified view for a dirty form") @@ -141,14 +141,11 @@ struct FormToolbarViewModifierTests { @Test("cancelTapped on a clean form invokes dismiss (no warning shown)") func cancelTappedCleanDismisses() { let controller = FormController(form: VMForm()) - let modifier = FormToolbarView( - content: AnyView(Text("body")), + let modifier = FormToolbarViewModifier( + controller: controller, cancelTitle: "Cancel", submitTitle: "Submit", preventsAccidentalDismiss: true, - isDirty: { controller.isDirty }, - isLoading: { controller.isLoading }, - validateReturningIsValid: { controller.validate(); return controller.form.isValid }, onSubmit: { Issue.record("onSubmit should not fire for cancel") } ) // Form is clean → guard is false → falls through to dismiss(). @@ -160,14 +157,11 @@ struct FormToolbarViewModifierTests { func cancelTappedDirtyShowsWarning() { let controller = FormController(form: VMForm()) controller.form.name = "edited" - let modifier = FormToolbarView( - content: AnyView(Text("body")), + let modifier = FormToolbarViewModifier( + controller: controller, cancelTitle: "Cancel", submitTitle: "Submit", preventsAccidentalDismiss: true, - isDirty: { controller.isDirty }, - isLoading: { controller.isLoading }, - validateReturningIsValid: { controller.validate(); return controller.form.isValid }, onSubmit: { Issue.record("onSubmit should not fire for cancel") } ) modifier.cancelTapped() @@ -180,14 +174,11 @@ struct FormToolbarViewModifierTests { func cancelTappedNoPreventDismissesWhenDirty() { let controller = FormController(form: VMForm()) controller.form.name = "edited" - let modifier = FormToolbarView( - content: AnyView(Text("body")), + let modifier = FormToolbarViewModifier( + controller: controller, cancelTitle: "Cancel", submitTitle: "Submit", preventsAccidentalDismiss: false, - isDirty: { controller.isDirty }, - isLoading: { controller.isLoading }, - validateReturningIsValid: { controller.validate(); return controller.form.isValid }, onSubmit: { Issue.record("onSubmit should not fire for cancel") } ) // preventsAccidentalDismiss=false short-circuits the &&; falls to dismiss(). @@ -202,14 +193,11 @@ struct FormToolbarViewModifierTests { controller.form.name = "Alice" controller.form.email = "alice@example.com" var didSubmit = false - let modifier = FormToolbarView( - content: AnyView(Text("body")), + let modifier = FormToolbarViewModifier( + controller: controller, cancelTitle: "Cancel", submitTitle: "Submit", preventsAccidentalDismiss: true, - isDirty: { controller.isDirty }, - isLoading: { controller.isLoading }, - validateReturningIsValid: { controller.validate(); return controller.form.isValid }, onSubmit: { didSubmit = true } ) modifier.submitTapped() @@ -222,14 +210,11 @@ struct FormToolbarViewModifierTests { let controller = FormController(form: VMForm()) // Both fields empty → invalid after validate(). var didSubmit = false - let modifier = FormToolbarView( - content: AnyView(Text("body")), + let modifier = FormToolbarViewModifier( + controller: controller, cancelTitle: "Cancel", submitTitle: "Submit", preventsAccidentalDismiss: true, - isDirty: { controller.isDirty }, - isLoading: { controller.isLoading }, - validateReturningIsValid: { controller.validate(); return controller.form.isValid }, onSubmit: { didSubmit = true } ) modifier.submitTapped() @@ -237,6 +222,84 @@ struct FormToolbarViewModifierTests { // validate() ran — both fields are now in .invalid state. #expect(controller.form.isValid == false) } + + // MARK: ErasedFormToolbarModifier (the variant Android actually runs) + + @Test("Erased twin: cancelTapped on a clean form invokes dismiss") + func erasedCancelTappedCleanDismisses() { + let controller = FormController(form: VMForm()) + let modifier = ErasedFormToolbarModifier( + controller: AnyFormController(controller), + cancelTitle: "Cancel", + submitTitle: "Submit", + preventsAccidentalDismiss: true, + onSubmit: { Issue.record("onSubmit should not fire for cancel") } + ) + modifier.cancelTapped() + } + + @Test("Erased twin: cancelTapped on a dirty form takes the warning branch") + func erasedCancelTappedDirtyShowsWarning() { + let controller = FormController(form: VMForm()) + controller.form.name = "edited" + let modifier = ErasedFormToolbarModifier( + controller: AnyFormController(controller), + cancelTitle: "Cancel", + submitTitle: "Submit", + preventsAccidentalDismiss: true, + onSubmit: { Issue.record("onSubmit should not fire for cancel") } + ) + modifier.cancelTapped() + } + + @Test("Erased twin: submitTapped with valid form runs validate() then onSubmit") + func erasedSubmitTappedValidCallsOnSubmit() { + let controller = FormController(form: VMForm()) + controller.form.name = "Alice" + controller.form.email = "alice@example.com" + var didSubmit = false + let modifier = ErasedFormToolbarModifier( + controller: AnyFormController(controller), + cancelTitle: "Cancel", + submitTitle: "Submit", + preventsAccidentalDismiss: true, + onSubmit: { didSubmit = true } + ) + modifier.submitTapped() + #expect(didSubmit == true) + #expect(controller.form.isValid == true) + } + + @Test("Erased twin: submitTapped with invalid form runs validate() but skips onSubmit") + func erasedSubmitTappedInvalidSkipsOnSubmit() { + let controller = FormController(form: VMForm()) + var didSubmit = false + let modifier = ErasedFormToolbarModifier( + controller: AnyFormController(controller), + cancelTitle: "Cancel", + submitTitle: "Submit", + preventsAccidentalDismiss: true, + onSubmit: { didSubmit = true } + ) + modifier.submitTapped() + #expect(didSubmit == false) + #expect(controller.form.isValid == false) + } + + @Test("AnyFormController(focusing:) reads and writes controller.focus; validation stubs are inert") + func anyFormControllerFocusingErasure() { + let controller = FormController(form: VMForm()) + let erased = AnyFormController(focusing: controller) + #expect(erased.getFocus() == nil) + erased.setFocus(\VMForm.name) + #expect(controller.focus == \VMForm.name) + #expect(erased.getFocus() == \VMForm.name) + erased.setFocus(nil) + #expect(controller.focus == nil) + #expect(erased.validateReturningIsValid() == false) + #expect(erased.isDirty() == false) + #expect(erased.isLoading() == false) + } } // MARK: - FormBindFocusViewModifier @@ -333,30 +396,6 @@ struct FormBindFocusViewModifierTests { // SwiftUI focus-system assertion, not a FormsKit one. } - // MARK: syncControllerFocus (deterministic) - - @Test("syncControllerFocus writes a new value into controller.focus") - func syncControllerFocusWritesNewValue() { - let controller = FormController(form: VMForm()) - FormBindFocusSupport.syncControllerFocus(controller, to: \VMForm.name) - #expect(controller.focus == \VMForm.name) - } - - @Test("syncControllerFocus is a no-op when the controller is already there") - func syncControllerFocusNoOpWhenEqual() { - let controller = FormController(form: VMForm()) - controller.focus = \VMForm.email - FormBindFocusSupport.syncControllerFocus(controller, to: \VMForm.email) - #expect(controller.focus == \VMForm.email) - } - - @Test("syncControllerFocus clears controller.focus when handed nil") - func syncControllerFocusClears() { - let controller = FormController(form: VMForm()) - controller.focus = \VMForm.email - FormBindFocusSupport.syncControllerFocus(controller, to: nil) - #expect(controller.focus == nil) - } } // MARK: - FocusedOnViewModifier From 5b2f898433092abe882184a475ee00e8751e18d5 Mon Sep 17 00:00:00 2001 From: Max Rozdobudko Date: Fri, 14 Aug 2026 13:54:52 +0300 Subject: [PATCH 4/7] refactor(android): drop FormsKitSwiftUI shim for a conditional import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Package.resolved | 2 +- Package.swift | 17 ++--------------- .../ViewModifiers/FocusedOnViewModifier.swift | 12 ++++++++---- .../FormsKit/ViewModifiers/FormBindFocus.swift | 12 ++++++++---- .../ViewModifiers/FormToolbarViewModifier.swift | 12 ++++++++---- .../FormValidationErrorModifier.swift | 12 ++++++++---- Sources/FormsKitSwiftUI/FormsKitSwiftUI.swift | 16 ---------------- Sources/FormsKitSwiftUI/Skip/skip.yml | 7 ------- 8 files changed, 35 insertions(+), 55 deletions(-) delete mode 100644 Sources/FormsKitSwiftUI/FormsKitSwiftUI.swift delete mode 100644 Sources/FormsKitSwiftUI/Skip/skip.yml diff --git a/Package.resolved b/Package.resolved index ead7491..1aa8523 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2843369d144d777ff7a6f96efeda9552766cb2467dfe859b3cab3f22307cde94", + "originHash" : "00be0c89a5ce14089419b4292da34c97d8d1d84a9a76842a61fe1fb7ee29165a", "pins" : [ { "identity" : "skip", diff --git a/Package.swift b/Package.swift index efcd011..5269450 100644 --- a/Package.swift +++ b/Package.swift @@ -28,14 +28,10 @@ let package = Package( // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. - // Internal shim: re-exports real SwiftUI, except in Skip bridge builds - // (-DSKIP_BRIDGE), where it re-exports SkipSwiftUI. FormsKit's view files - // import this module unconditionally so the skipstone bridge generator — - // which mirrors source-file imports verbatim and cannot evaluate `#if` — - // produces *_Bridge.swift files that compile in every build flavor. .target( - name: "FormsKitSwiftUI", + name: "FormsKit", dependencies: [ + .product(name: "SkipFuse", package: "skip-fuse"), // SkipFuseUI (not the SkipSwiftUI product): depending on the // dynamic SkipSwiftUI product alongside SkipFuseUI's static use // of the same target is a SwiftPM linkage conflict; the @@ -44,15 +40,6 @@ let package = Package( ], plugins: [.plugin(name: "skipstone", package: "skip")] ), - .target( - name: "FormsKit", - dependencies: [ - "FormsKitSwiftUI", - .product(name: "SkipFuse", package: "skip-fuse"), - .product(name: "SkipFuseUI", package: "skip-fuse-ui"), - ], - plugins: [.plugin(name: "skipstone", package: "skip")] - ), .testTarget( name: "FormsKitTests", dependencies: [ diff --git a/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift b/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift index c2bd776..13d0570 100644 --- a/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift +++ b/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift @@ -1,7 +1,11 @@ -// FormsKitSwiftUI re-exports real SwiftUI, or SkipSwiftUI in Skip bridge -// builds. The import must stay unconditional: the bridge generator mirrors it -// into the generated *_Bridge.swift files. See FormsKitSwiftUI.swift. -import FormsKitSwiftUI +// `SKIP` covers the skipstone bridge generator (which parses with SKIP defined +// and SKIP_BRIDGE undefined); `SKIP_BRIDGE` covers the two real bridge compiles +// (Android cross-compile, Robolectric host). Apple builds take the else branch. +#if SKIP || SKIP_BRIDGE +import SkipSwiftUI +#else +import SwiftUI +#endif // Dual structure: the fully-typed generic modifier serves non-bridge builds; // the erased twin below serves bridge builds (skip-bridge cannot represent diff --git a/Sources/FormsKit/ViewModifiers/FormBindFocus.swift b/Sources/FormsKit/ViewModifiers/FormBindFocus.swift index 87e1e39..91b35c3 100644 --- a/Sources/FormsKit/ViewModifiers/FormBindFocus.swift +++ b/Sources/FormsKit/ViewModifiers/FormBindFocus.swift @@ -1,7 +1,11 @@ -// FormsKitSwiftUI re-exports real SwiftUI, or SkipSwiftUI in Skip bridge -// builds. The import must stay unconditional: the bridge generator mirrors it -// into the generated *_Bridge.swift files. See FormsKitSwiftUI.swift. -import FormsKitSwiftUI +// `SKIP` covers the skipstone bridge generator (which parses with SKIP defined +// and SKIP_BRIDGE undefined); `SKIP_BRIDGE` covers the two real bridge compiles +// (Android cross-compile, Robolectric host). Apple builds take the else branch. +#if SKIP || SKIP_BRIDGE +import SkipSwiftUI +#else +import SwiftUI +#endif // MARK: - View Extension diff --git a/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift b/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift index cbb1ae9..096152c 100644 --- a/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift +++ b/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift @@ -1,7 +1,11 @@ -// FormsKitSwiftUI re-exports real SwiftUI, or SkipSwiftUI in Skip bridge -// builds. The import must stay unconditional: the bridge generator mirrors it -// into the generated *_Bridge.swift files. See FormsKitSwiftUI.swift. -import FormsKitSwiftUI +// `SKIP` covers the skipstone bridge generator (which parses with SKIP defined +// and SKIP_BRIDGE undefined); `SKIP_BRIDGE` covers the two real bridge compiles +// (Android cross-compile, Robolectric host). Apple builds take the else branch. +#if SKIP || SKIP_BRIDGE +import SkipSwiftUI +#else +import SwiftUI +#endif // Dual structure: the fully-typed generic modifier serves non-bridge builds; // the erased twin below serves bridge builds (skip-bridge cannot represent diff --git a/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift b/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift index 2e06671..7b2ac3d 100644 --- a/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift +++ b/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift @@ -1,7 +1,11 @@ -// FormsKitSwiftUI re-exports real SwiftUI, or SkipSwiftUI in Skip bridge -// builds. The import must stay unconditional: the bridge generator mirrors it -// into the generated *_Bridge.swift files. See FormsKitSwiftUI.swift. -import FormsKitSwiftUI +// `SKIP` covers the skipstone bridge generator (which parses with SKIP defined +// and SKIP_BRIDGE undefined); `SKIP_BRIDGE` covers the two real bridge compiles +// (Android cross-compile, Robolectric host). Apple builds take the else branch. +#if SKIP || SKIP_BRIDGE +import SkipSwiftUI +#else +import SwiftUI +#endif // Non-generic on every platform (the generic `Validated.State` is unpacked // into plain `[String]?` at the call site), so a single bridged modifier diff --git a/Sources/FormsKitSwiftUI/FormsKitSwiftUI.swift b/Sources/FormsKitSwiftUI/FormsKitSwiftUI.swift deleted file mode 100644 index be88d06..0000000 --- a/Sources/FormsKitSwiftUI/FormsKitSwiftUI.swift +++ /dev/null @@ -1,16 +0,0 @@ -// Internal shim consumed by FormsKit's view files via a plain, unconditional -// `import FormsKitSwiftUI` — the form the skipstone bridge generator can -// mirror into the generated *_Bridge.swift files (it cannot evaluate `#if` -// conditions, so a conditional import in the view files themselves would not -// survive into the bridges). The conditional lives here instead, where the -// compiler's real flags decide it: -// -// - Skip bridge builds (the Android cross-compile and the Robolectric host -// build, both compiled with -DSKIP_BRIDGE): re-export SkipSwiftUI, whose -// SkipUIBridging / SkipUI machinery the generated bridges reference. -// - Every other build (Apple platforms, SKIP_ZERO): re-export real SwiftUI. -#if SKIP_BRIDGE -@_exported import SkipSwiftUI -#else -@_exported import SwiftUI -#endif diff --git a/Sources/FormsKitSwiftUI/Skip/skip.yml b/Sources/FormsKitSwiftUI/Skip/skip.yml deleted file mode 100644 index a637a1a..0000000 --- a/Sources/FormsKitSwiftUI/Skip/skip.yml +++ /dev/null @@ -1,7 +0,0 @@ -# Skip (https://skip.dev) configuration for the FormsKitSwiftUI shim module. -# -# Natively-compiled Skip Fuse module, like FormsKit itself. Pure re-export -# shim (see FormsKitSwiftUI.swift); nothing here needs a Kotlin-facing API. -skip: - mode: 'native' - bridging: false From adc2a4efad28ab2418ba7e72d2d89e4fd2390932 Mon Sep 17 00:00:00 2001 From: Max Rozdobudko Date: Fri, 14 Aug 2026 14:11:33 +0300 Subject: [PATCH 5/7] ci: install the Skip toolchain so the Android test leg can run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/ci.yml | 29 ++++++++++++++++++- CLAUDE.md | 15 +++++++--- .../references/api-cheatsheet.md | 4 ++- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6deaa17..1e0b0d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,9 @@ on: jobs: test: runs-on: macos-26 + # The Android half (Swift SDK install + cross-compile + Gradle/Robolectric) + # dominates the wall clock; the Apple half alone runs in ~2 minutes. + timeout-minutes: 90 steps: - uses: actions/checkout@v4 @@ -16,19 +19,43 @@ jobs: # uses: swift-actions/setup-swift@v2 # with: # swift-version: 6.2 + # (setup-skip below can also pin the host toolchain via its + # `swift-version` input, which routes through swiftly.) + # Robolectric against Android SDK 36 refuses to create a sandbox on + # anything below Java 21. The runner defaults to 17. + - name: Setup Java 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + # Installs Homebrew, Gradle, the `skip` CLI, and the Swift SDK for + # Android. FormsKit builds in Skip's Fuse (native) mode, so the Android + # SDK is required: without it the skipstone plugin still generates the + # Kotlin peers and Gradle project, but `:FormsKit:buildLocalSwiftTestLibs` + # shells out to `skip` and dies with "command not found". + - name: Setup Skip + uses: skiptools/actions/setup-skip@v1 + with: + install-swift-android-sdk: 'true' + + # Apple build plus the Android cross-compile via the skipstone plugin. - name: Build run: swift build + # Runs both halves: the Apple test bundle and, through the SkipTest + # harness, the Android side under Gradle/Robolectric. - name: Test with coverage run: swift test --enable-code-coverage + # Coverage is Apple-side only; the Android run has no llvm profile. - name: Export coverage to lcov run: | BIN=$(swift build --show-bin-path) xcrun llvm-cov export \ "$BIN/FormsKitPackageTests.xctest/Contents/MacOS/FormsKitPackageTests" \ - -instr-profile "$(find .build -name default.profdata -type f)" \ + -instr-profile "$(find .build -name default.profdata -type f | head -n1)" \ -format lcov > coverage.lcov - name: Upload coverage report artifact diff --git a/CLAUDE.md b/CLAUDE.md index 9a2ff07..2888771 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,9 +23,6 @@ Android test runs need a Gradle JVM ≥ 21 (Robolectric / Android SDK 36 require ## Source layout ``` -Sources/FormsKitSwiftUI/ -├── Skip/skip.yml # native mode, no bridging -└── FormsKitSwiftUI.swift # shim: re-exports SwiftUI, or SkipSwiftUI in bridge builds Sources/FormsKit/ ├── Skip/ │ └── skip.yml # Skip config: native (Fuse) mode — see "Skip / Android support" @@ -201,7 +198,17 @@ Rules that keep the Android build green: - **Custom `ViewModifier`s work on Android only when bridged — and only non-generic types bridge.** On Android, SkipSwiftUI's `View.modifier(_:)` never calls `body(content:)` itself; it applies the modifier's `Java_modifier`, whose protocol-default implementation is `SkipUI.EmptyModifier()`. For a *bridged* modifier, skipstone generates the override (`Java_modifier { return self }` plus a Kotlin peer whose `body()` calls back into Swift), and the modifier renders. For an *unbridged* one — generic, `@nobridge`d, or hidden from the generator — the default fires and the modifier silently renders its content unchanged, dropping everything else (this is how the toolbar/validation/focus modifiers originally shipped as no-ops: they were generic, hence unbridgeable). Genericity is the trap, not the `ViewModifier` protocol. - **Generic modifiers therefore come in dual form: a typed variant for non-bridge builds plus an erased bridged twin.** The generic variant lives in `#if !SKIP_BRIDGE && !SKIP` (Apple + SKIP_ZERO builds; the `!SKIP` half hides it from the skipstone generator, which parses with `SKIP` defined but `SKIP_BRIDGE` undefined). The erased twin (`ErasedFormToolbarModifier`, `ErasedFocusedOnModifier`) is declared **unconditionally** — the generator must see it to emit its Kotlin peer, and the Robolectric host build compiles the generated `*_Bridge.swift` against it — and erases `FormController` behind the internal `AnyFormController` closure facade (`AnyKeyPath` for focus identity). Erased twins must NOT carry `// SKIP @nobridge`, keep their memberwise inits internal (no constructor bridging), and their two bodies must be kept in sync by hand — the mirrored unit tests in `ViewModifierTests.swift` cover both variants. `skip.yml` sets `bridging: true` for the peers. -- **View files import `FormsKitSwiftUI`, never `SwiftUI` directly.** The `FormsKitSwiftUI` shim target re-exports real SwiftUI, except in Skip bridge builds (`-DSKIP_BRIDGE`: the Android cross-compile and the Robolectric host build) where it re-exports SkipSwiftUI, whose `SkipUIBridging`/`SkipUI` machinery the generated bridges reference. The indirection is load-bearing: the bridge generator mirrors source-file imports verbatim into the generated `*_Bridge.swift` files and cannot evaluate `#if` conditions, so the conditional must live at module level in the shim, and the view files' import must stay a plain unconditional `import FormsKitSwiftUI`. `ViewModifierTests.swift` is guarded with `!SKIP_BRIDGE` in addition to `!os(Android)` (in bridge builds FormsKit's views are SkipSwiftUI-typed, so real-SwiftUI hosting doesn't apply). One sharp edge: switching between `SKIP_ZERO` and Skip-active builds in the same checkout can leave stale incremental state (`missing required module 'CJNI'`) — run `swift package clean` when that appears. +- **View files import SwiftUI behind `#if SKIP || SKIP_BRIDGE`, never plain `import SwiftUI`.** Every file declaring a `View` or `ViewModifier` opens with: + + ```swift + #if SKIP || SKIP_BRIDGE + import SkipSwiftUI + #else + import SwiftUI + #endif + ``` + + The generated `*_Bridge.swift` files mirror their source file's imports, and that module has to resolve to SkipSwiftUI — whose `SkipUIBridging`/`SkipUI` machinery the bridges reference — in bridge builds, and to real SwiftUI everywhere else. **Both disjuncts are load-bearing.** `SKIP` covers the skipstone generator, which parses with `SKIP` defined and `SKIP_BRIDGE` undefined (the same evaluation that hides the generic modifier variants behind `!SKIP`); `SKIP_BRIDGE` covers the two real bridge compiles (Android cross-compile, Robolectric host). `SKIP_BRIDGE` alone makes the generator emit `import SwiftUI` into the bridges, which then can't find `SkipUI.ViewModifier` on the macOS Robolectric host; `SKIP` alone breaks both real compiles. Note the generator *does* evaluate `#if` — an earlier `FormsKitSwiftUI` shim target existed on the belief that it couldn't, and was removed in `5b2f898`; don't reintroduce it. `ViewModifierTests.swift` is guarded with `!SKIP_BRIDGE` in addition to `!os(Android)` (in bridge builds FormsKit's views are SkipSwiftUI-typed, so real-SwiftUI hosting doesn't apply). One sharp edge: switching between `SKIP_ZERO` and Skip-active builds in the same checkout can leave stale incremental state (`missing required module 'CJNI'`) — run `swift package clean` when that appears. - **Everything else public carries `// SKIP @nobridge`.** With `bridging: true`, skipstone tries to bridge the whole public API, and FormsKit's is unbridgeable by design: key paths (`ValidatedField`), generic types with constructors (`FormController`, `Validated`), and statics added via constrained extensions (the `.minLength(3)`-style rule factories) all hard-error in the generator. FormsKit is consumed from Swift only, so the Kotlin-facing surface is deliberately empty except the bridged modifier structs. A new public declaration gets `// SKIP @nobridge` unless it is a non-generic `View` or `ViewModifier` that must render on Android. - **Property-wrapper storage in public SwiftUI types must be `internal`, not `private`.** Skip's bridge diagnostics reject private `@State`/`@Environment`/`@FocusState` storage inside bridged types ("Private state property cannot be bridged"). This is why `dismiss`, `showsDiscardWarning`, and `isFocused` are internal. - **`ViewModifierTests.swift` is wrapped in `#if !os(Android)`.** It hosts views via `ImageRenderer`/`NS-`/`UIHostingController`, which don't exist on Android. Logic tests (rules, `Validated`, controller, focus) run on both platforms — keep new UI-hosting tests inside that guard and new logic tests outside it. diff --git a/Skills/formskit-expert/references/api-cheatsheet.md b/Skills/formskit-expert/references/api-cheatsheet.md index 9b424f4..4454945 100644 --- a/Skills/formskit-expert/references/api-cheatsheet.md +++ b/Skills/formskit-expert/references/api-cheatsheet.md @@ -149,5 +149,7 @@ Sources/FormsKit/ ├── ValidationRules/StringValidationRules/*.swift ├── AnyFormController.swift # internal erased facade for the bridged (Android) modifier twins └── ViewModifiers/{FormValidationErrorModifier,FormToolbarViewModifier,FocusedOnViewModifier,FormBindFocus}.swift -Sources/FormsKitSwiftUI/FormsKitSwiftUI.swift # internal shim for Skip bridge builds ``` + +Single target. View files select their SwiftUI module inline — +`#if SKIP || SKIP_BRIDGE` → `import SkipSwiftUI`, else `import SwiftUI`. From 366b3ca358465584b3dc2095e20a872f899b6730 Mon Sep 17 00:00:00 2001 From: Max Rozdobudko Date: Fri, 14 Aug 2026 14:43:27 +0300 Subject: [PATCH 6/7] test(android): cover the erased bridged view modifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 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) --- Tests/FormsKitTests/ViewModifierTests.swift | 227 ++++++++++++++++++++ 1 file changed, 227 insertions(+) diff --git a/Tests/FormsKitTests/ViewModifierTests.swift b/Tests/FormsKitTests/ViewModifierTests.swift index 0e10a3f..b544fc8 100644 --- a/Tests/FormsKitTests/ViewModifierTests.swift +++ b/Tests/FormsKitTests/ViewModifierTests.swift @@ -286,6 +286,88 @@ struct FormToolbarViewModifierTests { #expect(controller.form.isValid == false) } + @Test("Erased twin: cancelTapped with preventsAccidentalDismiss=false dismisses even when dirty") + func erasedCancelTappedNoPreventDismissesWhenDirty() { + let controller = FormController(form: VMForm()) + controller.form.name = "edited" + let modifier = ErasedFormToolbarModifier( + controller: AnyFormController(controller), + cancelTitle: "Cancel", + submitTitle: "Submit", + preventsAccidentalDismiss: false, + onSubmit: { Issue.record("onSubmit should not fire for cancel") } + ) + modifier.cancelTapped() + } + + @Test("Erased twin: body renders for a dirty form (submit button disabled path)") + func erasedBodyRendersDirty() { + let controller = FormController(form: VMForm()) + controller.form.name = "edited" + let view = NavigationStack { + Text("body").modifier(ErasedFormToolbarModifier( + controller: AnyFormController(controller), + cancelTitle: "Cancel", + submitTitle: "Submit", + preventsAccidentalDismiss: true, + onSubmit: { } + )) + } + _renderOnce(view) + } + + @Test("Erased twin: body renders for a clean form") + func erasedBodyRendersClean() { + let controller = FormController(form: VMForm()) + let view = NavigationStack { + Text("body").modifier(ErasedFormToolbarModifier( + controller: AnyFormController(controller), + cancelTitle: "Close", + submitTitle: "Create", + preventsAccidentalDismiss: false, + onSubmit: { } + )) + } + _renderOnce(view) + } + + @Test("Erased twin: hosting the toolbar drives the @State property initializer") + func erasedBodyHosted() async { + // As with the generic variant: `ImageRenderer` doesn't always install + // `@State` containers, an `NSHostingController` does — so this is what + // fires `showsDiscardWarning`'s default initializer on the erased twin. + let controller = FormController(form: VMForm()) + controller.form.name = "edited" + let view = NavigationStack { + Text("body").modifier(ErasedFormToolbarModifier( + controller: AnyFormController(controller), + cancelTitle: "Cancel", + submitTitle: "Submit", + preventsAccidentalDismiss: true, + onSubmit: { } + )) + } + await _withHostedView(view) { /* nothing to mutate */ } + } + + @Test("AnyFormController(_:) forwards isDirty / isLoading / validate to the controller") + func anyFormControllerFullErasure() { + let controller = FormController(form: VMForm()) + let erased = AnyFormController(controller) + #expect(erased.isDirty() == false) + #expect(erased.isLoading() == false) + controller.form.name = "edited" + #expect(erased.isDirty() == true) + // Still invalid — `email` is empty. + #expect(erased.validateReturningIsValid() == false) + controller.form.email = "alice@example.com" + #expect(erased.validateReturningIsValid() == true) + // Focus round-trips through the same facade the toolbar erasure builds. + erased.setFocus(\VMForm.email) + #expect(controller.focus == \VMForm.email) + #expect(erased.getFocus() == \VMForm.email) + } + @Test("AnyFormController(focusing:) reads and writes controller.focus; validation stubs are inert") func anyFormControllerFocusingErasure() { let controller = FormController(form: VMForm()) @@ -462,6 +544,79 @@ struct FocusedOnFocusableHost: View { } } +/// Mirror of `FocusedOnHostView` for the erased twin. The public +/// `.focused(on:equals:)` extension only routes to `ErasedFocusedOnModifier` +/// under `SKIP_BRIDGE`, so on Apple builds the twin has to be applied by hand +/// via `.modifier(_:)` — which is also exactly what skipstone's generated +/// Kotlin peer ends up doing on Android. +struct ErasedFocusedOnHostView: View { + let controller: FormController + + var body: some View { + VStack { + Text("name field").modifier(ErasedFocusedOnModifier( + controller: AnyFormController(focusing: controller), + fieldKeyPath: \VMForm.name + )) + Text("email field").modifier(ErasedFocusedOnModifier( + controller: AnyFormController(focusing: controller), + fieldKeyPath: \VMForm.email + )) + } + } +} + +/// Erased mirror of `FocusedOnFocusableHost`: real `TextField`s plus a parent +/// `@FocusState` driver, so SwiftUI actually grants focus and the twin's +/// `onChange(of: isFocused)` handler fires. +struct ErasedFocusedOnFocusableHost: View { + enum Scenario { + case setInitial(PartialKeyPath) + case setThenClear(PartialKeyPath) + case setThenSwitch(PartialKeyPath, PartialKeyPath) + } + + @FocusState var parentFocus: PartialKeyPath? + @State var controller: FormController + let scenario: Scenario + + var body: some View { + VStack { + TextField("name", text: $controller.form.name) + .focused($parentFocus, equals: \VMForm.name) + .modifier(ErasedFocusedOnModifier( + controller: AnyFormController(focusing: controller), + fieldKeyPath: \VMForm.name + )) + TextField("email", text: $controller.form.email) + .focused($parentFocus, equals: \VMForm.email) + .modifier(ErasedFocusedOnModifier( + controller: AnyFormController(focusing: controller), + fieldKeyPath: \VMForm.email + )) + } + .onAppear { + // Same deferral rationale as `FocusedOnFocusableHost`: writes made + // synchronously in `onAppear` race the focus system's wire-up. + Task { @MainActor in + try? await Task.sleep(nanoseconds: 20_000_000) + switch scenario { + case .setInitial(let kp): + parentFocus = kp + case .setThenClear(let kp): + parentFocus = kp + try? await Task.sleep(nanoseconds: 30_000_000) + parentFocus = nil + case .setThenSwitch(let a, let b): + parentFocus = a + try? await Task.sleep(nanoseconds: 30_000_000) + parentFocus = b + } + } + } + } +} + @MainActor @Suite("focused(on:equals:)", .serialized) struct FocusedOnViewModifierTests { @@ -530,6 +685,78 @@ struct FocusedOnViewModifierTests { await _withHostedView(host) { /* mutations happen via deferred Task */ } #expect(controller.focus == nil) } + + // MARK: ErasedFocusedOnModifier (the variant Android actually runs) + // + // These live in the `focused(on:equals:)` suite rather than a suite of + // their own on purpose: SwiftUI's focus system is process-global (one key + // window), and `.serialized` only orders tests *within* a suite — sibling + // top-level suites still run concurrently, so a second window-hosting + // focus suite steals first responder from this one and both flake. + + @Test("Erased twin builds a modified view without crashing") + func erasedFocusedOnBuilds() { + _renderOnce(ErasedFocusedOnHostView(controller: FormController(form: VMForm()))) + } + + @Test("Erased twin: setting controller.focus to this field's key path drives the sync handler") + func erasedControllerFocusTriggersHandler() async { + let controller = FormController(form: VMForm()) + let host = ErasedFocusedOnHostView(controller: controller) + await _withHostedView(host) { + controller.focus = \VMForm.name + } + } + + @Test("Erased twin: setting controller.focus elsewhere triggers the not-mine branch") + func erasedControllerFocusOtherKeyPath() async { + let controller = FormController(form: VMForm()) + controller.focus = \VMForm.name + let host = ErasedFocusedOnHostView(controller: controller) + await _withHostedView(host) { + controller.focus = \VMForm.email + controller.focus = nil + } + } + + @Test("Erased twin: SwiftUI focus on a TextField exercises the focus → controller handler") + func erasedSwiftUIFocusPropagatesToController() async { + let controller = FormController(form: VMForm()) + let host = ErasedFocusedOnFocusableHost( + controller: controller, + scenario: .setInitial(\VMForm.name) + ) + await _withHostedView(host) { /* mutation happens via deferred Task */ } + // No strict assertion — same reasoning as the generic variant's + // `swiftUIFocusPropagatesToController`: whether the first programmatic + // focus write lands in a non-key window varies; the setThenSwitch and + // setThenClear scenarios below carry the assertions. + } + + @Test("Erased twin: switching SwiftUI focus writes the new key path through setFocus") + func erasedSwiftUIFocusSwitchUpdatesController() async { + let controller = FormController(form: VMForm()) + let host = ErasedFocusedOnFocusableHost( + controller: controller, + scenario: .setThenSwitch(\VMForm.name, \VMForm.email) + ) + await _withHostedView(host) { /* mutations happen via deferred Task */ } + // Proves the AnyKeyPath → PartialKeyPath downcast inside + // `AnyFormController.setFocus` round-trips to a value that still + // compares equal to the literal `\VMForm.email` a consumer writes. + #expect(controller.focus == \VMForm.email) + } + + @Test("Erased twin: clearing SwiftUI focus clears controller.focus (else-if branch)") + func erasedSwiftUIUnfocusClearsController() async { + let controller = FormController(form: VMForm()) + let host = ErasedFocusedOnFocusableHost( + controller: controller, + scenario: .setThenClear(\VMForm.name) + ) + await _withHostedView(host) { /* mutations happen via deferred Task */ } + #expect(controller.focus == nil) + } } // MARK: - Helpers From 2671592f1121940ac757ac428e5e21d062e93586 Mon Sep 17 00:00:00 2001 From: Max Rozdobudko Date: Fri, 14 Aug 2026 17:20:34 +0300 Subject: [PATCH 7/7] feat(android): ensure Skip generator uses erased view modifiers 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. --- Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift | 2 +- Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift b/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift index 13d0570..eb7abc4 100644 --- a/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift +++ b/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift @@ -90,7 +90,7 @@ public extension View { on controller: Binding>, equals keyPath: KeyPath ) -> some View { - #if SKIP_BRIDGE + #if SKIP || SKIP_BRIDGE return modifier(ErasedFocusedOnModifier( controller: AnyFormController(focusing: controller.wrappedValue), fieldKeyPath: keyPath diff --git a/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift b/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift index 096152c..539adcf 100644 --- a/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift +++ b/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift @@ -129,7 +129,7 @@ public extension View { preventsAccidentalDismiss: Bool = true, onSubmit: @escaping () -> Void ) -> some View { - #if SKIP_BRIDGE + #if SKIP || SKIP_BRIDGE return modifier(ErasedFormToolbarModifier( controller: AnyFormController(controller), cancelTitle: cancelTitle,