Skip to main content

Presenting cards in a bottom sheet

Sometimes you might like to use Atomic containers in ways that don't fit within the native container variants that Atomic provides. Atomic containers can be used flexibly in your application in many ways - each one is a view you can embed wherever your design calls for cards. A common example is a bottom sheet: a surface that slides up from the bottom of the screen when there is a card worth showing (an offer, a required action, a timely reminder) and takes no space in your layout when there is not.

This pattern presents a horizontal stream container in a bottom sheet that:

  • presents itself when the container has one or more cards,
  • sizes itself to its content, so the sheet hugs a short card and never clips a tall one, and
  • after the customer dismisses it, stays closed until the card count next changes.
A card presented in a bottom sheet over the accounts screen of the Atomic Explore demo app

A card presented in a bottom sheet, as implemented in the Atomic Explore app.

You can try the pattern for yourself in the Atomic Explore app, available for iOS and Android.

If you want a sheet the customer opens themselves (from a button, for example), use the same recipe and drive the sheet's visibility from your button instead of the card count; Step 1 becomes unnecessary. And if you want to embed cards within a screen and hide the region when it is empty, you do not need a sheet at all: see Always-on cards.

The pattern at a glance

  1. Observe the container's card count.
  2. Present a bottom sheet when the count is greater than zero and the customer is on the screen that owns the sheet.
  3. Host a horizontal container inside the sheet.
  4. Size the sheet to the card's height.
  5. When the customer dismisses the sheet, keep it closed until the count next changes.

Atomic SDKs push card-count changes to your app over a WebSocket by default, so the sheet reacts in near real time.

Step 1: Observe the card count

A small shared observer object owns the subscription and exposes the count as observable state. Screens read the count; they do not subscribe themselves. Create the observer once (at app start or when the customer logs in) and stop it on logout.

import SwiftUI
import AtomicSwiftUISDK

@Observable
class CardCountObserver {
var cardCount = 0

private var token: NSObjectProtocol?

init() {
token = AACSession.observeCardCountForStreamContainer(
withIdentifier: "<stream container id>",
interval: 15
) { [weak self] count in
guard let self, let count else { return }
DispatchQueue.main.async {
self.cardCount = Int(truncating: count)
}
}
}

func stopObserving() {
if let token { AACSession.stopObservingCardCount(token) }
token = nil
cardCount = 0
}
}

The count updates over the WebSocket immediately after the published card number changes; the interval applies only when the SDK is using HTTP (see the note below). Inject the observer into your view hierarchy with .environment(...) so any screen can read it.

The snippets on this page use the iOS 17 @Observable macro and zero-parameter onChange(of:). On iOS 16, an ObservableObject with a @Published property works the same way.

Also works over HTTP

WebSockets are the default, but Atomic SDKs can be switched to HTTP (iOS, Android), and fall back to HTTP polling automatically when a WebSocket connection is unavailable. The pattern still works over HTTP - count changes just arrive at the polling interval rather than immediately: the interval you pass to the observer on iOS, and the container's cardListRefreshInterval on Android (15 seconds by default).

Filters apply to counts too

If the sheet's container is scoped with card filters (iOS, Android), pass the same filters to the observer so the count matches what the sheet will show.

Step 2: Present the sheet when cards arrive

The presentation rule: show the sheet when the count is greater than zero, the customer is on the screen that owns the sheet, and no higher-priority sheet is showing.

struct HomeScreen: View {
@Environment(CardCountObserver.self) private var observer
@State private var showingSheet = false

var body: some View {
NavigationStack {
// Your screen content
}
.sheet(isPresented: $showingSheet) {
// Sheet content (Steps 3 and 4)
}
.onAppear {
showingSheet = observer.cardCount > 0
}
.onChange(of: observer.cardCount) {
showingSheet = observer.cardCount > 0
}
}
}

.sheet(isPresented:) supplies the slide-up transition, the dimmed backdrop and swipe-to-dismiss for free - there is no custom animation code in this pattern. Evaluate the condition in both places: .onAppear covers cards that were already present when the screen loaded, and .onChange covers cards arriving while the customer is on the screen. If the view lives inside a TabView, extend the condition so the sheet only presents on the tab that owns it.

Swiping down sets showingSheet back to false through the binding, and nothing sets it to true again until the card count changes, so the "stay closed until the count changes" behavior falls out of the binding, with no extra state.

Yield to more important sheets

If another sheet can appear on the same screen, i.e. a detail view the customer opened, add that to the condition so the card sheet yields to it.

Step 3: Put a horizontal container in the sheet

A horizontal container suits a sheet well: cards have a fixed width, sit side by side at a uniform height, and the container is designed to be embedded in a region of your own UI. See the SDK references for the full set of options: iOS, Android.

The sheet content from Step 2 is a horizontal container in a VStack:

VStack {
HorizontalContainer(
containerId: "<stream container id>",
cardWidth: 350
)
}

Step 4: Size the sheet to its content

A bottom sheet looks right when it hugs the card: no dead space below a short card, no clipping on a tall one. Each platform needs one technique to achieve this.

Package the sizing logic as a reusable ViewModifier:

struct ContentHeightSheetModifier: ViewModifier {
@State private var detents: Set<PresentationDetent> = [.medium]
@State private var selectedDetent: PresentationDetent = .medium

func body(content: Content) -> some View {
ScrollView {
content
.presentationDetents(detents, selection: $selectedDetent)
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { size in
detents.insert(.height(size.height))
selectedDetent = .height(size.height)
}
.transaction { transaction in
transaction.addAnimationCompletion(criteria: .removed) {
detents = [selectedDetent]
}
}
}
.scrollDisabled(true)
.frame(maxWidth: .infinity, alignment: .center)
}
}

Apply it to the sheet content from Step 3:

VStack {
HorizontalContainer(
containerId: "<stream container id>",
cardWidth: 350
)
}
.modifier(ContentHeightSheetModifier())

Four pieces work together:

  • presentationDetents starts at .medium, a sensible resting height before the content has been measured.
  • onGeometryChange measures the content and inserts a .height(...) detent, then selects it, so the sheet snaps to exactly the content's height, and re-snaps if the card changes size.
  • The transaction block prunes the detent set to just the selected height after the resize animation finishes, so no stale detents are left for the customer to drag to.
  • The ScrollView with .scrollDisabled(true) is a measuring wrapper, not scrolling behavior; it gives onGeometryChange a stable parent to measure in.

Because it is a ViewModifier, any sheet in your app can opt in.

Behavior notes

  • Dismissing the sheet does not dismiss the card. The card stays in the container's feed; the latch keeps the sheet closed until the count changes. If the customer dismisses or completes the card inside the sheet, the count drops, and at zero the presentation rule closes the sheet by itself.
  • The entrance animation differs by platform. iOS's .sheet slides up from the bottom. Android's Dialog uses the platform's default dialog transition rather than a slide; add your own enter animation if the slide matters to your design.
  • The sheet is one consumer of the count. The same observer can drive a badge, a tab dot, or anything else keyed to the container: one subscription, many consumers.