Skip to content

feat(wallet): expand token cards into the currency info screen - #587

Open
bmc08gt wants to merge 1 commit into
mainfrom
feat-wallet-zoom-transition
Open

feat(wallet): expand token cards into the currency info screen#587
bmc08gt wants to merge 1 commit into
mainfrom
feat-wallet-zoom-transition

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Supersedes #586, which GitHub closed rather than retargeted when #585 merged and its base branch was deleted. Same commit, rebased onto main.

Tapping a bill card pulls it out of the deck and into the currency info screen, Apple Wallet style. Closing puts it back in its slot, sliding under the cards stacked on top of it.

How it works

The deck is driven by one expansionProgress scalar that the card's flight animates in lockstep with — the cards above gather onto the slot it rises to and fade underneath it, the ones below run off the bottom, and the balance header fades with them. The opened card keeps its natural place in the deck's z-order the whole way, which is what makes closing read as reinserting rather than dropping it on top.

Things worth knowing before changing it

Most of these were bugs first, and are easy to reintroduce:

  • The reorganisation is a value, not a branch. visualEffect interpolates a changing offset, but swapping which formula applies (by toggling the opened mint) resolves in a single frame and reads as a snap.
  • Blocking work goes before the commit, not after. Hiding the tab bar relayouts the whole TabView, and building the page runs a synchronous metadata read. Both happen in the un-animated seed step. SwiftUI's springs are driven on the main thread, so work deferred until after withAnimation interrupts the motion rather than running alongside it — measured as worse, not better. The tab bar is restored the same way at the top of the close, or it arrives visibly late.
  • There is no hand-off on open. The card that travels is the detail screen's own hero card, displaced down to the deck slot and animated back to zero while the rest of the screen fades in behind it. It belongs to the scroll view from the first frame, so it scrolls with the content immediately. An overlaid copy swapped in on completion left a window where the page was scrollable but the card wasn't part of it yet — and gating interaction to cover that just traded a stranded card for a dead gesture. Closing is the mirror: the deck takes the card back before anything moves, so it returns inside the stack with its neighbours drawn over it.
  • Callbacks are tagged. Both transitions finish in callbacks that outlive them. Untagged, a close settling in the background clears state out from under the open that interrupted it, leaving the wallet with its header faded out and the tab bar stuck hidden.
  • The screen arrives in one pass. Nothing below the tiles is gated on loading — the activity read is off the main thread, and the market cap section builds its view model up front so it holds full height around a placeholder plot. Only the chart's points wait, since a populated Swift Charts draw is the genuinely expensive part.
  • Overlay hosting draws its own top fade. The system's soft scroll edge is rendered by the navigation bar's background, and an overlay has none.

Testing

Driven with Maestro and verified from frame-by-frame analysis of screen recordings rather than by eye:

  • Open/close on a top card, a mid card, and one deep in the deck
  • Card's top edge tracked per frame — lands with no snap-back, eases to rest in 2px steps
  • Dropped frames during the opening: 2–4 of 26 (Debug, simulator)
  • 30 back-to-back open/close cycles with no settle time — header and tab bar intact after each
  • Scroll immediately after opening, with the transition slowed to widen the window: reproduced the stranded card, then confirmed the fix removes it
  • Full flow: open → scroll → back → assert dismissed

Tapping a bill card in the wallet now pulls it out of the deck and into the
currency info screen, Apple Wallet style, and closing puts it back where it
came from.

The deck is driven by a single `expansionProgress` scalar that the card's
travel animates in lockstep with: the cards above gather onto the slot it
rises to and fade underneath it, the ones below run off the bottom, and the
balance header fades with them. The opened card keeps its natural place in
the deck's z-order throughout, which is what lets it slide back *under* its
neighbours on the way home rather than landing on top of them.

Opening does not hand the card between two views. The card that travels is
the detail screen's own hero card, displaced down to the deck slot it came
from and animated back to zero while the rest of the screen fades in behind
it. It therefore belongs to the screen's scroll view from the first frame and
scrolls with the content immediately — an overlaid copy handed over on
completion left a window where the page was scrollable but the card was not
yet part of it, and the content slid out from underneath it. Closing is the
mirror: the deck takes the card back before anything moves, so it returns
inside the stack with its neighbours drawn over it.

Notes on the parts that are less obvious than they look:

- The reorganisation has to be a value, not a branch. `visualEffect`
  interpolates a changing offset, but swapping which formula applies (by
  toggling the opened mint) resolves in one frame and reads as a snap.
- Work that blocks the main thread — hiding the tab bar, which relayouts the
  whole `TabView`, and building the page — happens in an un-animated step
  before the animation is committed. SwiftUI's springs are driven on the main
  thread, so anything deferred until after the commit interrupts the motion
  instead of running alongside it. The tab bar is restored the same way, at
  the top of the close, so it does not arrive late.
- Both transitions finish their work in callbacks, so each is tagged and
  every callback checks it is still current — otherwise a close settling in
  the background tears down the open that interrupted it.

The screen itself arrives in one pass. Nothing below the action tiles is
gated on loading: the activity read runs off the main thread, and the market
cap section builds its view model up front so it holds its full height around
a placeholder plot. Only the chart's points, and so the expensive populated
Swift Charts draw, wait for the transition to finish.

Hosted as an overlay the screen has no navigation bar, so it draws its own
chrome and its own top fade — the system's soft scroll edge is rendered by
the bar's background, and without it the page scrolled up to meet the status
bar at full strength.
@bmc08gt
bmc08gt force-pushed the feat-wallet-zoom-transition branch from 5be8d79 to 9104e78 Compare August 17, 2026 03:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant