Fullscreen cover (cover) & the system-gesture shield
A cover presents a Day subtree over the whole window (edge-to-edge, above every other
surface): the SwiftUI fullScreenCover(item:) shape. Like nav and nav_stack
(docs/navigation.md), it is a projection of an app-owned signal, not an imperative controller:
let open = Signal::new(None::<Section>); // Section: any Route type
zstack((
home_page(open),
cover(open, |section| game_page(*section))
.background(|section| section.surface_color()),
))
// present: open.set(Some(Section::Breakout));
// dismiss: open.set(None); // e.g. from an in-content close button
Some(r)buildsbuild(&r)under the cover and presents it (slide-up where the platform animates modals).Nonedismisses it. Switching directly fromSome(a)toSome(b)swaps the content and re-presents..background(f)paints the surface color edge-to-edge (under the status bar and home indicator) while the content itself is laid out inside the safe area. Without it the platform’s default surface color shows in the unsafe regions.- The builder runs inside the presented content’s scope: state it restores, signals it creates, and cleanups it registers (e.g. a save-on-exit) live exactly as long as the presentation.
- The content is disposed only after the backend reports the hide transition finished
(
Event::CoverHiddenon the cover node), so the surface never blanks mid-animation. Scope cleanups (the natural save-on-exit hook) run at that moment.
The CoverHidden delivery contract
App teardown hangs off this one event, so its delivery is a hard guarantee, not a best-effort animation callback:
- Backends must deliver it after every dismissal, even when the platform loses the
animation completion (UIKit drops transition completions under scripted bursts; Android
cancels
withEndActionwhen an animator is superseded). Both backends pair the normal completion with a delayed backstop that reports once the surface has verifiably left the screen. - The piece treats it as idempotent and orderable: duplicates are no-ops, and a belated report from a previous dismissal cannot dispose content presented since (the closing gate). Backends may therefore over-report freely rather than risk under-reporting.
- The mock-toolkit e2e (
cover_cycle_keeps_siblings_alive_and_represents) pins present → dismiss → re-present across double and late reports.
The shield modifiers
Two Decorate modifiers protect a fullscreen interaction (a game, a drawing canvas) from
accidental exits. Both are registration-scoped: they apply while their subtree is mounted
and lift when it unmounts.
game_page()
.defers_system_gestures(Edges::ALL) // SwiftUI defersSystemGestures(on:)
.interactive_dismiss_disabled() // SwiftUI interactiveDismissDisabled()
defers_system_gestures(edges)asks the OS to require a second swipe for its edge gestures on the givenEdges(TOP/BOTTOM/LEADING/TRAILING/ALL). iOS defers the chosen screen edges (preferredScreenEdgesDeferringSystemGestureson the root and cover view controllers); Android enters swipe-to-reveal immersive mode while any request is live; desktop backends no-op. day-core keeps the union of all mounted requests and re-sends it through theToolkit::defer_system_gesturesduty on every change.interactive_dismiss_disabled()blocks the user-initiated dismissal of the enclosing cover: Android’s system back stops closing it (iOS setsisModalInPresentation, inert under the fullscreen style). Programmatic writes (open.set(None), dayscriptnav_back) still close it; ship an explicit close control. The state is queryable viaday_core::shield::dismiss_disabled()(reactive: reads track a change counter).
Routes, dayscript, deep links
A mounted cover registers a string-route adapter (docs/navigation.md): navigate("<key>")
presents the parsed route, nav_back() dismisses, and the presented key is the cover’s
contribution to current_route(). Day-Games’ walkthrough drives games with plain
- navigate: { route: breakout } / - nav_back: steps.
.unrouted(): a cover that belongs to a control
cover(open, build).unrouted() skips that registration: navigate("<key>") does not reach it, it
contributes nothing to current_route(), and nav_back() walks past it. Interactive dismissal is
unaffected; Android’s system back arrives as Event::NavBack on the cover’s own node, which
never went through the adapter.
Reach for it whenever the cover belongs to a control rather than to the app: a color picker’s chooser (docs/colorpicker.md), a scrubber’s fullscreen mode. Two things go wrong otherwise.
The sharper problem is that a routed cover claims route segments through R::from_key, and the
untyped route is String, whose from_key accepts anything. So a routed cover(Signal<Option<String>>, …)
claims every segment: mount one, and the app’s next navigate("settings") presents that cover
keyed "settings" instead of going to settings. A piece that mounts a cover would be silently
rewriting its host app’s navigation, which is what happened to the showcase’s walkthrough
the first time the color picker’s panel went in. A typed Route enum does not have this problem,
because its from_key rejects what is not its own; .unrouted() is the fix when the key is a
string, and the right answer regardless when the surface is not a destination.
The quieter problem is that current_route() naming a transient chooser makes a restored session
reopen it, and a “link me to this screen” URL point at a modal.
How it works
kinds::COVERis realized detached from the visible hierarchy (itsset_frameis a toolkit no-op: the frame is native-owned).CoverPatch::Present { background, dismiss_disabled }shows it;CoverPatch::Dismisshides it;CoverPatch:: DismissDisabledtracks the shield while presented.- Layout follows the nav-page contract: the backend reports the content container’s
safe-area size via
Event::FrameChanged, andCoverLayout(day-core) lays the children out at that size. In the tree the cover node measures 0×0, so it never disturbs the layout it sits in. - A native dismissal request (Android back) arrives as
Event::NavBackon the cover node; the piece writesNoneinto the signal unless dismissal is disabled, the same origin-tagged write-back discipline as every control. - A presented cover is a touch boundary, including blank content and safe-area margins.
Android’s
DayCover.onTouchEventconsumes events that descendants do not handle, after normal child dispatch. Background opacity alone does not stop taps reaching the page below; apps do not need dummy gesture handlers on headers or spacers. Hiding the cover restores interaction with the underlying page.
Per-toolkit realization
| Toolkit | Present | Dismiss request | Hidden report |
|---|---|---|---|
| uikit | DayCoverVC (fullscreen modal) over a DayNavPageView, through the dialog FIFO | none (fullscreen has no sheet gesture) | dismiss completion block |
| android | DayCover shell re-homed onto the activity content root, slide-up ValueAnimator | OnBackPressedCallback → NavBack | slide-out completion, shell becomes GONE |
| arkui | Stack re-homed onto the window root at full bounds (no transition) | none | posted immediately on dismiss |
| mock | patch recorded (flag = presented) | tests emit it | tests emit it |
| appkit / gtk / qt / xaml / dom | topmost full-window child of the window content, opaque theme background by default (Cap::Cover = Emulated, no transition) | none | posted immediately on dismiss |