Tracking from SwiftUI¶
HeraldSwiftUI is an optional module for tracking from SwiftUI views: screen views each time a
screen becomes visible, impressions when something is really on screen, and taps in small views
that have no view model. It's the SwiftUI counterpart of the Android SDK's
Compose helpers and Flutter's widgets.
It's in the herald-ios package, so there's nothing new to install: add the HeraldSwiftUI module
to your app target. It needs only SwiftUI.
Do you need it?¶
Not to use Herald. A view model can track its own events, screen views included, through
EventTrackerService, as on every other page of this site:
// Plain Herald, no extra module: the view model tracks its screen view, once each time it's made.
@MainActor
final class SettingsViewModel: ObservableObject {
init(analytics: any EventTrackerService) {
analytics.track(SettingsScreenViewed())
}
}
That counts a screen once each time it opens. Use HeraldSwiftUI when tracking from the view is
the simpler fit, such as counting a screen each time the user comes back to it.
Set it up once¶
.eventTracker(_:) gives the tracker to every view inside it, through SwiftUI's environment:
struct ShopApp: App {
let herald = makeHerald()
var body: some Scene {
WindowGroup {
NavigationStack {
ProductScreen(productId: "day_pass")
}
.eventTracker(herald) // or your DI's EventTrackerService
}
}
}
The helpers¶
They follow the same rule as the Compose helpers: track when a screen is shown or hidden, or when something is really on screen, never when a view is drawn. SwiftUI redraws views all the time, and the user did nothing to cause it.
| Helper | Tracks | Answers |
|---|---|---|
.trackScreenView(_:) |
each time the screen becomes visible: first show, coming back to it, the app returning from the background | how often is this screen looked at? |
.track(_:on:) |
each time the screen is .visible or .hidden |
the same, for any event: .hidden for "left the screen" |
@Environment(\.eventTracker) |
when you call track on it |
a tap in a small view with no view model |
.trackImpression(_:threshold:minVisibleDuration:) |
once per appearance, when that share of the view has been visible that long | was this actually seen? |
Sheets and alerts over a screen don't hide it, and the app briefly losing focus, as under Control Center, doesn't either. A tab counts each time it's selected.
Screen views¶
Put it on the screen's root view:
struct ProductScreen: View {
let productId: String
var body: some View {
Text(productId)
.navigationTitle(productId)
.trackScreenView(ProductScreenViewed(productId: productId))
}
}
To track when the user leaves a screen:
Text("Checkout")
// pushed over, closed, or the app went to the background
.track(CheckoutLeft(itemsInCart: itemsInCart), on: .hidden)
Taps¶
struct PromoBanner: View {
let promoId: String
@Environment(\.eventTracker) private var analytics
var body: some View {
Button("See the offer") {
analytics.track(PromoBannerTapped(promoId: promoId))
}
}
}
Events that come from your app's logic belong in its view model. This is for small views that have none.
Impressions¶
List(offerIds, id: \.self) { offerId in
Text(offerId)
.trackImpression(
OfferShown(offerId: offerId),
threshold: 0.5, // half of it on screen
minVisibleDuration: 1 // for a second, not a fast scroll
)
}
- Once per appearance. An item that scrolls out of the list and back in counts again, which is what an impression is. "Each offer once per session" is your app's rule, so it belongs in your code.
- Events must be
Equatable. A different event starts the count again, and the compiler checks that events can be compared. For a struct, addingEquatableis enough. - "On screen" means inside the window. On iOS 18 and newer, it also means inside the view's scroll view, so a card cut off by a small carousel counts only for its visible part, and the part of a view under a navigation or tab bar doesn't count.
UIKit¶
UIKit needs no extra module. viewDidAppear runs each time a screen becomes visible, so track the
screen view there:
// UIKit needs no extra module: viewDidAppear runs each time the screen becomes visible.
final class ProductViewController: UIViewController {
private let analytics: any EventTrackerService
private let productId: String
init(analytics: any EventTrackerService, productId: String) {
self.analytics = analytics
self.productId = productId
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
analytics.track(ProductScreenViewed(productId: productId))
}
}
Limits¶
- A full-screen cover doesn't hide the screen under it. SwiftUI doesn't tell the screen below,
so it isn't counted again when the cover closes. The cover's own
.trackScreenViewstill counts. - Going back shows the screen first. SwiftUI shows the screen you return to just before it
hides the one you leave, so its screen view comes a moment before the other's
.hiddenevent. - Notification Center counts as leaving. It covers the whole screen and sends the app to the background, so the screen counts again when it closes. Control Center doesn't.
Tests and previews¶
In Xcode Previews, the helpers track nothing, so a preview needs no setup:
To check what a view tracks, host it in a test with .eventTracker(analytics) and a
FakeAnalyticsProvider from HeraldTesting, as in Testing. Events from
your app's logic are simpler to check in its view model's tests.
A view without a tracker stops a debug build at its first event, with a message saying to add
.eventTracker(_:). A release build drops the event.