57 lines
7.1 KiB
Markdown
57 lines
7.1 KiB
Markdown
|
|
# Engineering Knowledge
|
|||
|
|
|
|||
|
|
Project-specific lessons learned while building the idp.global Swift app. Intentionally small and opinionated — append only when a finding will save time on a future task.
|
|||
|
|
|
|||
|
|
## iOS 26 Liquid Glass and progressive blur at screen edges
|
|||
|
|
|
|||
|
|
Context: the pairing welcome screen needed a floating bottom action bar with "native Apple chrome" look — a rounded Liquid Glass pill containing the Scan/NFC buttons, with content gracefully fading as it approaches the top and bottom edges of the screen.
|
|||
|
|
|
|||
|
|
### What we shipped
|
|||
|
|
|
|||
|
|
Simple and reliable: linear-gradient edge fades + a `glassEffect`-backed container for the buttons. See `swift/Sources/Features/Auth/LoginRootView.swift` (`PairingWelcomeView`, `EdgeFade`, `PairingActionDock`).
|
|||
|
|
|
|||
|
|
- Scroll content at the back, `.ignoresSafeArea(.container, edges: .vertical)` so content can scroll under both edges.
|
|||
|
|
- A `GeometryReader` exposes `proxy.safeAreaInsets.top`; a `Color.clear.frame(height: topInset + 8)` spacer at the top of the scroll pushes the hero below the status bar at rest.
|
|||
|
|
- Top and bottom `EdgeFade` views layered above the scroll: each is a `LinearGradient` from `Color.idpGroupedBackground` (opaque at the edge) to clear (away from the edge), `.allowsHitTesting(false)`, ignoring safe area. The top fade height is `topInset` (status bar only) so it doesn't creep into the hero.
|
|||
|
|
- `PairingActionDock` is two buttons using the project-wide `PrimaryActionStyle` / `SecondaryActionStyle` (shadcn near-black primary + outlined secondary), wrapped in `.padding(16).glassEffect(.regular, in: RoundedRectangle(cornerRadius: 20, style: .continuous))`, then `.padding(.horizontal, 16).padding(.bottom, 12)` for outer spacing. Native floating glass pill without tint-colored button chrome.
|
|||
|
|
|
|||
|
|
### What we tried that didn't match what we wanted
|
|||
|
|
|
|||
|
|
Keep these in mind before reaching for them again:
|
|||
|
|
|
|||
|
|
- `scrollEdgeEffectStyle(.soft, for: .all)` on a `ScrollView`. Too subtle to read as blur; mostly invisible at rest. It is a style hint for the scroll edge effect, not a blur renderer.
|
|||
|
|
- `safeAreaBar(edge: .bottom)` (the iOS 26 replacement for `safeAreaInset` that auto-adds progressive blur). Apple's default blur intensity is very light — "zero control over radius/tint/fade length" — and it doesn't look like the heavy Notes/Safari chrome effect people expect.
|
|||
|
|
- `safeAreaInset(edge: .bottom) { dock.glassEffect(...) }`. Works visually but the glass only covers the inset's intrinsic size, so there's a gap in the home-indicator strip unless you add a hack `.background { Rectangle().glassEffect().ignoresSafeArea(.bottom) }`.
|
|||
|
|
- `.toolbar { ToolbarItem(placement: .bottomBar) { dock } }`. The toolbar placement flattens a custom VStack of full-width stacked buttons into a single icon-only toolbar item. Unusable for two stacked full-width action buttons.
|
|||
|
|
- `.backgroundExtensionEffect()`. Mirrors (reflects) the content into the safe area. Content literally appears upside-down at the edge — wrong tool for this.
|
|||
|
|
- Gradient-masked `UIVisualEffectView` (`.regularMaterial` etc.) stacked layers. Reads as "fade to white" in light mode because `systemThickMaterial` is basically opaque; the actual blur is hidden under the material tint.
|
|||
|
|
- Duplicating the content inside `.overlay` with `.blur(radius:)` + gradient mask. Produces real blur but duplicating a `ScrollView` gives two independent scroll states, and blurring content that extends into the status bar area leaks colored halos (e.g., the hero glyph became a purple smear at the top).
|
|||
|
|
- `.toolbar(.hidden, for: .navigationBar)`. Kills the top chrome host. Any automatic top scroll-edge effect relies on there being a nav/toolbar/safe-area-bar host at that edge — if you hide it, you lose the top blur even when the rest of the pipeline is correct.
|
|||
|
|
|
|||
|
|
### If you actually need a real progressive Gaussian blur
|
|||
|
|
|
|||
|
|
`swift/Sources/Core/Design/GlassChrome.swift` keeps a `VariableBlurView` helper (adapted from `nikstar/VariableBlur`, MIT). It wraps `UIVisualEffectView` and installs the private `CAFilter` `variableBlur` filter with a linear-gradient mask image, producing a real variable-radius Gaussian blur. Strings (`"CAFilter"`, `"filterWithType:"`) are obfuscated via `.reversed()` so trivial static scanners don't flag the symbol — same pattern the upstream packages use.
|
|||
|
|
|
|||
|
|
Tuning notes from the round of iteration:
|
|||
|
|
|
|||
|
|
- Ramp speed is controlled by **layer height**, not `maxBlurRadius`. To make the ramp slower, make the `VariableBlurView` taller (e.g., 380pt). Cranking `maxBlurRadius` just makes the final-pixel blur heavier, not more gradual.
|
|||
|
|
- For the top edge, height should be approximately `safeAreaInsets.top + ~20–30pt`. Bigger than that and the blur bleeds into content (the hero glyph shows up as a colored halo above itself).
|
|||
|
|
- Place the bottom `VariableBlurView` between the scroll and the dock in a `ZStack(alignment: .bottom)`. The scroll needs `.ignoresSafeArea(.container, edges: .vertical)` so content passes behind both the blur layer and the chrome.
|
|||
|
|
|
|||
|
|
### Project conventions for this pattern
|
|||
|
|
|
|||
|
|
- Use `Color.idpGroupedBackground` as the fade target, not `.white` — it adapts to dark mode via the design tokens in `swift/Sources/Core/Design/IdPTokens.swift`.
|
|||
|
|
- Primary/secondary actions use `PrimaryActionStyle` (near-black) / `SecondaryActionStyle` (outlined) from `swift/Sources/Core/Design/ButtonStyles.swift`. Don't use `.buttonStyle(.glassProminent).tint(IdP.tint)` — the resulting purple pill doesn't match the rest of the app's shadcn-style chrome.
|
|||
|
|
- Shadcn card scale: `IdP.cardRadius` (= 12), `IdP.controlRadius` (= 8). Icon/number chips in steps are 24pt rounded-rects at cornerRadius 6. Hero glyph is 40pt at `IdP.controlRadius`. Avoid oversized radii (22+) and chunky circles — `AppSectionCard` + `ShadcnBadge` + the `MonogramAvatar`-style glyph are the canonical building blocks (see `HomeCards.swift`, `AppComponents.swift`).
|
|||
|
|
- `AppScrollScreen` (in `swift/Sources/App/AppTheme.swift`) is the standard scroll container. It does not own safe-area handling; apply `.ignoresSafeArea(.container, edges: .vertical)` at the call site if you need content to extend behind edges. Don't bake it into `AppScrollScreen` — other screens rely on it respecting the safe area.
|
|||
|
|
|
|||
|
|
## tsswift remote-builder workflow for UI screenshots
|
|||
|
|
|
|||
|
|
`tsswift screenshots` and `tsswift review` are not supported over a remote builder, so the automated review pipelines won't work. For UI verification during a task:
|
|||
|
|
|
|||
|
|
- `pnpm exec tsswift run --path swift/IDPGlobal.xcodeproj --platform ios` (or `ipad`) builds on the remote, installs, and launches.
|
|||
|
|
- To grab a screenshot: `ssh <builder> "xcrun simctl io <udid> screenshot /tmp/x.png"` then `scp` it back. Booted iPhone/iPad UDIDs are listed via `xcrun simctl list devices booted` on the builder.
|
|||
|
|
- To drive the app into a specific screen without a real camera or tap automation, add a launch argument (see `--show-pair-scanner` in `AppViewModel.bootstrap`): the harness has no tap primitive, so launch-arg state toggles are the cheapest way to put the app into a specific UI state for screenshots.
|
|||
|
|
|
|||
|
|
`xcrun simctl ui` does not have a tap/touch primitive. `idb` is not installed on the remote. The remote also does not allow AppleScript keystroke sending. Don't waste time trying those paths.
|