VerticalCardListModel
State model and interaction coordinator for a vertical card list.
A Vertical Card List (often referred to as a "wallet card stack" or "pass deck" UI) displays a collection of cards, credentials, or passes in a vertically overlapping, cascading presentation reminiscent of a physical wallet or cardholder.
Core Display Modes
The vertical card list operates in two distinct visual and operational modes:
1. List Mode (Unfocused / Overview)
Cascading Cards: All cards in the list are rendered in an overlapping cascade, peeking out from beneath each other by a configurable percentage (CardListLayoutParameters.unfocusedVisiblePercent, typically 25%–50%).
Scrollable Viewport: When the total height of the cascaded cards exceeds the available viewport, the entire card list scrolls smoothly with standard physics.
Dynamic Top Content: Optional header content (such as search fields, wallet balance, or action buttons) can be placed above the card cascade and scrolls naturally with the cards.
Interactive Reordering: Users can long-press and drag cards to reorder them in real-time, with smooth displacement animations for affected cards.
Empty State Placeholder: If the list contains no cards, an optional dashed placeholder card can be rendered to guide the user.
2. Focused Mode (Card Detail View)
Promoted Card: When a card is selected, it transitions to the top of the viewport (CardListLayoutParameters.paddingTop), scaled up slightly (1.025) with prominent elevation shadow and high
zIndex.Inline Detail Content: Card-specific information, attributes, verification status, or action buttons are rendered directly underneath the focused card in the remaining viewport space.
Top Content Collapse: Any top content header smoothly fades and collapses out of view.
Bottom Card Stack (CardListLayoutParameters.showStackWhileFocused): The remaining non-focused cards collapse into a compact, 3D overlapping deck pinned to the bottom of the viewport. This preserves spatial continuity, visually communicating that the other cards remain accessible. Tapping the bottom stack or the focused card initiates a return transition back to List Mode.
Architecture & Responsibilities
CardListLayoutCalculator: Pure mathematical engine that calculates card dimensions, header offsets, and the visual state (
y,scale,elevation,alpha,zIndex) for every card in both list and focused modes.VerticalCardListModel: UI-agnostic coordinator managing card display order, drag-and-drop state machines, cooldown timers, and tap action resolution (handleTap).
Platform UI Layer (Compose / SwiftUI): Binds the visual tree, renders card graphics/badges, captures gesture events, and executes smooth interpolations between computed layout states.
UI Toolkit Implementation & Navigation Guidelines
When integrating this model into navigation frameworks (e.g. Jetpack Navigation, SwiftUI NavigationStack), follow these design patterns to ensure smooth, glitch-free transitions:
1. Forward Navigation & Card Focusing
When a card is tapped in list mode, handleTap returns CardTapAction.Focus. The UI should navigate to a focused destination carrying the document identifier and
animateListTransitions = true.On initial render of the focused screen, card visual properties should start at their list positions (or the position of
lastFocusedCardIdentifier) and animate to the focused layout over ~400ms.When navigating to a focused card directly from an external screen (e.g., Document Viewer), set
animateListTransitions = false. The UI MUST initialize its visual properties directly to the focused state on Frame 0 synchronously to prevent unintended transition animations.
2. Back Navigation & In-Place Unfocusing
In-Place Reverse Animation: Do not pop immediately and attempt to animate on the previous screen. Instead, trigger the unfocus animation in-place on the active (top) screen over ~400ms, and pop the screen silently (using
ExitTransition.None/popWithoutAnimation()) once the animation finishes. This reveals the previous list screen already resting at its exact layout.Unified Back Handling: Route all back triggers (hardware back button, back gestures/swipes, toolbar back arrow, tapping the focused card, and tapping the background bottom card stack) to the same unified back handler.
Context-Aware Dismissal: If the previous destination on the navigation stack is an instance of the card list, perform the 400ms in-place unfocus animation. If returning to an external destination (e.g., Document Viewer or Home), navigate back immediately.
3. Concurrency & State Scoping (Compose vs. SwiftUI)
In SwiftUI, views naturally evaluate only when top-of-stack.
In Compose,
NavHostmay compose both outgoing and incoming destinations during navigation transitions. To avoid race conditions where an underlying list destination overwrites or clears the active screen's focus state, keep per-screen focus properties locally scoped (e.g. viaremember) and trigger unfocusing via distinct instance triggers rather than mutating shared model fields directly during composition passes.
4. Synchronous Header Measurement (Preventing Frame-0 Jumps)
Any dynamic top content (such as search bars or header titles) MUST be measured synchronously during the initial layout pass (e.g., using
SubcomposeLayoutin Compose) before card Y positions are resolved. Deferring measurement to asynchronous effects causes a 1-frame layout shift (a visible "slide-down" jump) on cold app launches.
Constructors
Secondary no-argument constructor for platform convenience.
Properties
Whether to animate spatial transitions when entering this screen.
The current display order of the cards, tracked by identifier.
The current vertical position of the dragged card in coordinate space.
The identifier of the card currently being dragged, if any.
Whether a drag gesture just ended (used to ignore accidental tap events).
The identifier of the last focused card, used to preserve animations across navigation.
Current vertical scroll offset in dp/points.
Whether to show a dashed placeholder card when the card list is empty.
Whether the top content should be shown when no card is focused.
The measured height of the top content in dp/points.
Functions
Cancels the active drag gesture.
Clears the dragJustEnded flag after the cooldown period expires.
Completes the active drag gesture.
Resolves the list of cards in current display order.
Initiates a drag gesture for the card with cardIdentifier.
Synchronizes displayOrderIdentifiers with the provided list of card identifiers.
Updates the active drag gesture by deltaY.
Updates the active drag gesture to an absolute Y position dragY.