How to add analytics event tracking in SwiftUI (the elegant way)
Mixpanel
Posted on October 22, 2021
This article was written by Joseph Pacheco and originally appeared on Mixpanel's product analytics blog, The Signal.
SwiftUI is groundbreaking in its capacity for enabling well-architected, concisely defined, and highly readable UI code.
So the last thing we want is to step on all of that by needlessly littering our views with event tracking calls to analytics platforms. The solution: Update the elegance of your event tracking approach to match the elegance of SwiftUI.
Here’s how to harness the incredible product insights of an analytics framework like Mixpanel, while keeping your SwiftUI as clean and simple as it was meant to be.
Analytics code can balloon SwiftUI views
Never before in iOS have fewer lines of code been needed to create something like a task list. Rather than a UITableViewController with accompanying storyboard and custom UITableViewCell subclasses, all we need now is a binding to a list of tasks and 10-ish lines of actual code:
struct TaskListView: View {
@Binding var list: TaskList
var body: some View {
NavigationView {
List(list.tasks) { task in
TaskRow(task: task)
}
}
.toolbar {
Button("Add") {
...
}
}
}
...
}
But what happens if we want to add event tracking to this view? Perhaps we want to track when the Add button is tapped, when the view appears, or even when each task is presented. Suddenly our crisp structure is flooded with verbose tracking code, like this:
struct TaskListView: View {
@Binding var list: TaskList
var body: some View {
NavigationView {
List(list.tasks) { task in
TaskRow(task: task)
.onAppear {
Mixpanel.mainInstance().track(event: "Task List Item Viewed", properties: [
"Task Type": task.type,
"List Title": list.title,
"List Type": list.type,
...
])
}
}
.toolbar {
Button("Add") {
Mixpanel.mainInstance().track(event: "Button Tapped", properties: [
"Button Name": "Add",
"Button Context": "Task List",
...
])
...
}
}
.onAppear {
Mixpanel.mainInstance().track(event: "Task List Viewed", properties: [
"List Title": list.title,
"List Type": list.type,
...
])
}
}
}
}
Yuck! All we did was add tracking for three events with 2-3 properties for each event, and our view code is completely visually dominated by those additions.
Sure, we could move the row tracking to the TaskRow view itself, but we’d still be left with an ugly mess. And views often have more than three events!
To clean this up, we’re going to have to take a multi-pronged approach.
Decouple events from analytics platforms
Step one in our battle is making the code we do end up writing in our views less ugly and more elegant.
Analytics platforms, including Mixpanel, often provide a singleton where you can pass an event name as a string, along with properties to track as a dictionary. That’s how we ended up with our event tracking code above.
Mixpanel.mainInstance().track(event: "Button Tapped", properties: [
"Button Name": "Add",
"Button Context": "Task List",
...
])
But as I explain in this article, there’s a lot of problems with inserting this directly into your views. Aside from being unattractive in context, there is no type safety, and your view code becomes dependent upon third party API that you have zero control over.
So the best thing to do is to decouple your tracked events from all platforms (including Mixpanel), and take full advantage of the power of native Swift structs.
If you follow my guide, the above code could end up looking something like this:
let event = ButtonTapped(name: .add, context: .taskList)
analyticsService.track(event)
Swift
And this looks a heck of a lot better in context:
struct TaskListView: View, AnalyticsTracking {
@Binding var list: TaskList
let analyticsService: AnalyticsService
var body: some View {
NavigationView {
List(list.tasks) { task in
TaskRow(task: task)
.onAppear {
let event = TaskRowViewed(taskType: task.type, listTitle: list.title, listType: list.type)
analyticsService.track(event)
}
}
.toolbar {
Button("Add") {
let event = ButtonTapped(name: .add, context: .taskList)
analyticsService.track(event)
...
}
}
.onAppear {
let event = TaskListViewed(title: list.title, type: list.type)
analyticsService.track(event)
}
}
}
}
But it’s still not great. Yes, we’re down to two lines of code per event (easily combinable into one). Yes, each of the events is fully native, independent of third-party code, and strongly typed. But the event code still somehow dominates the view more than the view code itself!
And can you imagine if we had to write additional code to derive any of the event properties? The majority of the view would be analytics code!
In an ideal world, each of these calls would be as concise as possible so as to appear hierarchically secondary to the view code (while of course still maintaining readability).
Use custom event initializers to reduce footprint
Right now, our native events are initialized with exactly the data they need to track.
While our above example TaskRowTapped has only three parameters, plenty of events in the real world can end up having a number of parameters that are uncomfortably long in length.
So let’s imagine it with a few more params:
let event = TaskRowTapped(taskCreatedDate: task.created, taskTitle: task.title, taskType: task.type, listTitle: list.title, listType: list.type, ...)
Swift
That’s pretty long. And it would be even worse if we turned it into a single line by wrapping it in the call to the analytics service:
analyticsService.track(TaskRowViewed(taskType: task.type, listTitle: list.title, listType: list.type))
We’d basically have created textual line breaks all over our code.
But, as it turns out, all the information we need for this event is derived from the Task and TaskList objects associated with the view. So we can create a custom init for the TaskRowedViewed event which takes just these two parameters, and derives the others for us, as so:
struct TaskRowTapped: AnalyticsEvent {
init(task: Task, list: TaskList) {
self.taskTitle = task.title
self.taskType = task.type
self.taskCreatedDate = task.created
self.listType = list.type
self.listTitle = list.title
...
}
...
}
And just like that, our formerly massive line is a lot more digestible:
analyticsService.track(TaskRowViewed(task: task, list: list))
The goal here is to create custom initializers that take as few parameters as possible (within reason) from which the requisite event properties can be derived.
If the initializer is obviously reusable, add it to the original event definition like we did above. If the initializer is somehow unique to a given view (perhaps because it takes the view model for the view as a parameter) add it in an extension of the event in question and group it with your view.
/// Filename: TaskRowViewed+TaskListViewModel
extension TaskRowViewed {
init(_ tasklistViewModel: TaskListViewModel) {
...
}
}
Whatever the case, all logic that derives event parameters from models or view models as they are should be done in one of these initializers—not in the view itself.
Use custom view modifiers for more logical grouping
While each line of event tracking code is now vastly more concise, we still haven’t accounted for the fact that we have these lifecycle modifiers all over our code that are used solely to track events for analytics purposes.
I’m talking about all the onAppear {} and onDisappear {} closures that wouldn’t otherwise be there if events weren’t being tracked. They add clutter. And worse, doing this obfuscates the handful of instances where you might be using a lifecycle modifier for logic specific to your app, especially if multiple events were tracked in each closure:
struct TaskListView: View, AnalyticsTracking {
@Binding var list: TaskList
let analyticsService: AnalyticsService
var body: some View {
NavigationView {
List(list.tasks) { task in
TaskRow(task: task)
.onAppear {
analyticsService.track(TaskRowViewed(task: task, list: list))
}
}
.toolbar {
Button("Add") {
analyticsService.track(ButtonTapped(name: .add, context: .taskList))
}
}
.onAppear {
analyticsService.track(TaskListViewed(list))
analyticsService.track(SomeOtherEvent(param: something))
analyticsService.track(YetAnotherEvent(param: somethingElse))
}
.onDisappear {
analyticsService.track(TaskListClosed(list))
}
}
}
}
This is not very readable. In fact, it’s pretty stressful to sift through if you’re trying to make sense of what a given view is actually doing.
Wouldn’t it be great if we could group our tracking code together in a logical fashion that doesn’t litter our actual view logic?
struct TaskListView: View, AnalyticsTracking {
@Binding var list: TaskList
var body: some View {
NavigationView {
List(list.tasks) { task in
TaskRow(task: task)
.track(/// Trackevent in response to lifecycle event)
}
.track {
/// Track a bunch of events for different lifecycle events
...
}
.toolbar {
Button("Add") {
track(ButtonTapped(name: .add, context: .taskList))
...
}
}
}
}
}
Thanks to custom view modifiers, we can!
We first need to take stock of all the possible lifecycle and user-triggered events that we might respond to:
.onAppear { ... }
.onDisappear { ... }
.onTapGesture { ... }
.onLongPressGesture { ... }
.onDrag { ... }
.onDrop { ... }
...
And we’ll turn those into a nice enum we can easily reference (trust me, it will be worth it):
/// A trigger that causes analytics events to be tracked
enum AnalyticsEventTrigger {
case onAppear
case onDisappear
case onTapGesture
case onLongPressGesture
...
}
Then we create a struct that groups events that will be tracked together in response to a trigger:
/// A group of analytics events tracked together in response to a common trigger
struct AnalyticsEventGroup {
// MARK: Properties
/// The trigger causing the analytics events to be tracked
let trigger: AnalyticsEventTrigger
/// The events being tracked
let events: [AnalyticsEvent]
// MARK: Initializers
init(_ trigger: AnalyticsEventTrigger, events: [AnalyticsEvent]) {
self.trigger = trigger
self.events = events
}
}
And then we create a custom struct that conforms to ViewModifier:
/// A view modifier that tracks analytics events
struct AnalyticsEventTrackingModifier: ViewModifier {
...
}
Now this is where the magic happens.
First, we require our modifier be initialized with the event groups we are going to track as well as the analytics service it will use for tracking:
/// A view modifier that tracks analytics events
struct AnalyticsEventTrackingModifier: ViewModifier {
// MARK: Private Properties
/// The analytics service used to track events
private let service: AnalyticsService
/// The groups of analytics events to track
private let groups: [AnalyticsEventGroup]
// MARK: Lifecycle
/// Initialize with an analytics service and groups of events to be tracked by the analytics service
init(service: AnalyticsService, groups: [AnalyticsEventGroup]) {
self.service = service
self.groups = groups
}
...
}
Then, in the body of our modifier, we respond to each lifecycle and user-triggered event we want to track in response to, tracking all analytics events in the corresponding group:
/// A view modifier that tracks analytics events
struct AnalyticsEventTrackingModifier: ViewModifier {
/// The groups of analytics events to track
private let groups: [AnalyticsEventGroup]
...
// MARK: Body
func body(content: Content) -> some View {
content
.onAppear {
trackAll(in: groups.group(for: .onAppear))
}
.onDisappear {
trackAll(in: groups.group(for: .onDisappear))
}
/// Account for additional triggers
...
}
// MARK: Helpers
/// Track all events in the given group
private func trackAll(in group: AnalyticsEventGroup?) {
if let group = group {
group.events.forEach { event in
service.track(event)
}
}
}
}
Recall that in a view modifier, the content passed into the body represents the view to which you applied the modifier. That’s why we apply onAppear and onDisappear to content in the above code. It’s as if we applied these closures directly to our original view, and adding this here doesn’t prohibit these closures from being declared for other purposes directly in the original view declaration as well!
But if we used this modifier as-is, our code would be a nightmare:
TaskRow(task: task)
.modifier(AnalyticsEventTrackingModifier(service: self.analyticsService, groups: [AnalyticsEventGroup(trigger: .onAppear, events: [TaskRowViewed(task: task, list: list)])]))
So we need to clean it up with a view extension.
We start by working backwards from most complicated to least complicated. The most complicated call would involve tracking multiple events in response to multiple triggers. For that, we need a convenience function that takes an array of event groups and the service used to track:
extension View {
/// Track all analytics events for the given groups using the provided service
func track(with service: AnalyticsService, _ groups: [AnalyticsEventGroup]) -> some View {
let modifier = AnalyticsEventTrackingModifier(service: service, groups: groups)
return self.modifier(modifier)
}
}
Then in our views, we’d use this convenience function like so:
List {
...
}
.track(with: analyticsService, [
AnalyticsEventGroup(.onAppear, events: [
TaskListViewed(list)
SomeOtherEvent(param: something)
YetAnotherEvent(param: somethingElse))
])
AnalyticsEventGroup(.onDisappear, events: [
TaskListClosed(list)
])
])
But this is overkill for the likely common case of tracking events in response to a single service:
TaskRow(task: task)
.track(with: analyticsService, [
AnalyticsEventGroup(.onAppear, events: [
TaskRowViewed(task: task, list: list)
])
])
So we simplify with another convenience function that accepts a trigger and array of events:
extension View {
...
/// Track an array of events in response to the trigger with the provided service.
func track(_ trigger: AnalyticsEventTrigger, with service: AnalyticsService, events: [AnalyticsEvent]) -> some View {
let group = AnalyticsEventGroup(trigger, events: events)
return self.track(with: service, [group])
}
}
Then we update our view like so:
TaskRow(task: task)
.track(.onAppear, with: analyticsService, events: [
TaskRowViewed(task: task, list: list)
])
This is a marked improvement over our prior approach for a bunch of reasons. We avoid cluttering our lifecycle modifiers, we reference our analyticsService once per group of events rather than once per event, and we visually groups of events in a single track call.
But we can still do better, for one, by getting rid of all these clunky [] brackets and un-Swifty use of arrays.
Get cleaner syntax with result builders
Ever wonder how SwiftUI’s declarative syntax works? How a List can be declared as a bunch of Text statements or a .toolbar can take a bunch of items and “build” an actual toolbar out of them?
List {
Text("First item in the list")
Text("Second item in the list")
Text("Third item in the list")
Text("Fourth item in the list")
}.toolbar {
ToolbarItem(placement: .navigation) {
EditButton()
}
ToolbarItemGroup(placement: .bottomBar) {
AddButton(...)
FilterButton(...)
SortButton(...)
}
}
Under the hood, SwiftUI uses something called a ViewBuilder which takes a bunch of code statements and builds views out of them.
But view builders are just one kind of a broader type called result builders, which take code statements of any kind into whatever you might define (within certain limitations).
For example, we could create a result builder that takes a closure of statements, each of which represents an AnalyticsEvent, and use it to return an array of analytics events:
/// A builder resulting in an array of analytics events
@resultBuilder struct AnalyticsEventBuilder {
/// Return an array of analytics events given a closure containing statements of analytics events.
static func buildBlock(_ events: AnalyticsEvent...) -> [AnalyticsEvent] {
events
}
}
And we could do the same for AnalyticsEventGroup statements:
/// A builder resulting in an array of analytics event groups
@resultBuilder struct AnalyticsEventGroupBuilder {
/// Return an array of analytics event groups given a closure containing statements of analytics event groups
static func buildBlock(_ eventGroups: AnalyticsEventGroup...) -> [AnalyticsEventGroup] {
eventGroups
}
}
Then we can add an initializer to our AnalyticsEventGroup that takes an AnalyticsEventBuilder instead of an array of events:
extension AnalyticsEventGroup {
/// Initialize with a trigger and event builder
init(_ trigger: AnalyticsEventTrigger, @AnalyticsEventBuilder events: () -> [AnalyticsEvent]) {
self.init(trigger, events: events())
}
}
And from there, we could adjust our convenience functions for our view modifiers to accept instances of AnalyticsEventBuilder and AnalyticsEventGroupBuilder in lieu of arrays as well:
extension View {
// Track all analytics events for the given groups
func track(with service: AnalyticsService, @AnalyticsEventGroupBuilder _ groups: () -> [AnalyticsEventGroup]) -> some View {
let groups = groups()
let modifier = AnalyticsEventTrackingModifier(service: service, groups: groups)
return self.modifier(modifier)
}
/// Track a group of events in response to the trigger with the provided service.
func track(_ trigger: AnalyticsEventTrigger, with service: AnalyticsService, @AnalyticsEventBuilder _ events: () -> [AnalyticsEvent]) -> some View {
let eventsArray = events()
let group = AnalyticsEventGroup(trigger, events: eventsArray)
return track(with: service, [group])
}
}
Then, as a result (pun intended), tracking events becomes super slick and Swifty like this:
/// Multiple triggers, multiple events
List {
...
}
.track(with: analyticsService) {
AnalyticsEventGroup(.onAppear) {
TaskListViewed(list)
SomeOtherEvent(param: something)
YetAnotherEvent(param: somethingElse))
}
AnalyticsEventGroup(.onDisappear) {
TaskListClosed(list)
}
}
/// Single trigger, multiple events
TaskRow(task: task)
.track(.onAppear, with: analyticsService) {
TaskRowViewed(task: task, list: list)
SomeOtherEvent(param: something)
YetAnotherEvent(param: somethingElse)
}
/// Single trigger, single event
TaskRow(task: task)
.track(.onAppear, with: analyticsService) {
TaskRowViewed(task: task, list: list)
}
Isn’t that exquisite? Your event tracking code couldn’t blend in more naturally with the rest of your SwiftUI view code. Not to mention, the length of any given line is only about as long as any single event initializer plus indentation!
Leverage additional convenience functions for consistency
The only thing left is to tie everything back together. Using the above techniques, our original view code goes from this:
struct TaskListView: View {
@Binding var list: TaskList
var body: some View {
NavigationView {
List(list.tasks) { task in
TaskRow(task: task)
.onAppear {
Mixpanel.mainInstance().track(event: "Task List Item Viewed", properties: [
"Task Type": task.type,
"List Title": list.title,
"List Type": list.type,
...
])
}
}
.onAppear {
Mixpanel.mainInstance().track(event: "Task List Viewed", properties: [
"List Title": list.title,
"List Type": list.type,
...
])
Mixpanel.mainInstance().track(event: "Some Other Event", properties: [
...
])
Mixpanel.mainInstance().track(event: "Yet Another Event", properties: [
...
])
}
.onDisappear {
Mixpanel.mainInstance().track(event: "Task List Closed", properties: [
...
])
}
.toolbar {
Button("Add") {
Mixpanel.mainInstance().track(event: "Button Tapped", properties: [
"Button Name": "Add",
"Button Context": "Task List",
...
])
}
}
}
}
}
…to this:
struct TaskListView: View {
@Binding var list: TaskList
var body: some View {
NavigationView {
List(list.tasks) { task in
TaskRow(task: task)
.track(.onAppear, with: analyticsService) {
TaskRowViewed(task: task, list: list)
}
}
.toolbar {
Button("Add") {
analyticsService.track(ButtonTapped(name: .add, context: .taskList))
}
}
.track(with: analyticsService) {
AnalyticsEventGroup(.onAppear) {
TaskListViewed(list)
SomeOtherEvent(param: something)
YetAnotherEvent(param: somethingElse))
}
AnalyticsEventGroup(.onDisappear) {
TaskListClosed(list)
}
}
}
}
}
As you can see, it’s condensed and vastly more clear.
But it would be nice if analytics calls inside things like buttons had the same structure as the view modifiers, wouldn’t it?
All we need to accomplish this is a couple of additional convenience functions in our view extension that allow us to track events independent of triggers:
extension View {
func track(with service: AnalyticsService, _ event: AnalyticsEvent) {
service.track(event)
}
func track(with service: AnalyticsService, @AnalyticsEventBuilder _ events: () -> [AnalyticsEvent]) {
let events = events()
events.forEach { event in
service.track(event)
}
}
}
Now we can can track a single event with the same style or multiple events in a closure from anywhere else in the view:
Button("Add") {
track(with: analyticsService, ButtonTapped(name: .add, context: .taskList))
}
Button("Something Special") {
track {
ButtonTapped(name: .somethingSpecial, context: .taskList)
VerySpecialEvent(...)
MostSpecialEvent(...)
}
}
Next steps
SwiftUI is an incredible framework that allows for UI development at rates that are orders of magnitude above many UIKit approaches.
But at its young age, it’s still not without growing pains that could impact the accuracy of your tracking data. We’ve heard reports of a variety of types of unexpected behavior that can sometimes occur in your lifecycle callbacks, so its important to test each view thoroughly to see where workarounds may be required, particularly as updates to SwiftUI are released.
That said, if you’re following the approaches above and simplifying your implementation, identifying and managing such hiccups will be that much simpler—not to mention allow you to fully enjoy SwiftUI as it was meant to be used.
About Joseph Pacheco
Joseph is the founder of App Boss, a knowledge source for idea people (with little or no tech background) to turn their apps into viable businesses. He’s been developing apps for almost as long as the App Store has existed—wearing every hat from full-time engineer to product manager, UX designer, founder, content creator, and technical cofounder. He’s also given technical interviews to 1,400 software engineers who have gone on to accept roles at Apple, Dropbox, Yelp, and other major Bay Area firms.
Gain insights into how best to convert, engage, and retain your users with Mixpanel’s powerful product analytics. Try it free.
Posted on October 22, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.