Easy Onboarding with a New Visual Callout Framework

A callout displayed by the new framework, guiding the user to one of the most import features in the app.

The Swift Package and demo for this onboarding library is available here on GitHub.

Bringing people into your app and keeping them engaged can be a challenge. If you do it well, it can make all the difference in how people understand and use your app’s features.

Callouts are a popular UI component that can help with this task. They can draw people’s focus, and show them what’s important, in a way that feels like a natural part of using the app.

From an engineering perspective, though, callouts can get tricky to implement, particularly if you want to start adding them to an existing app.

A callout being displayed using Apple’s TipKit.

Apple gave us TipKit last year to help with this. I started trying it out for my apps, and found that it was well designed and easy to integrate into existing code. The only real issue I started running into was that it wasn’t flexible enough with the information I could present with it, and I couldn’t customize its appearance beyond a few basics.

I wanted more control.

So with TipKit as inspiration, plus a real need to start integrating onboarding into a few of my own projects as soon as possible, I set out to see if I could build something that fit my needs a little better.

The Basic Requirements

To implement onboarding the way I had hoped to start implementing it, I felt I needed:

  • An onboarding framework that could be easily integrated (with a minimal amount of code) into both new and existing apps.

  • A framework with an APIs that had a clean “point-of-use”, making its usage clear and explicit.

  • Callouts that could point directly to any view on the screen without affecting the layout and design of the rest of the views.

  • At a minimum, callouts that could display a title and a text message to start with, but could eventually grow into custom views as I started to figure out how to best use callouts in my onboarding.

  • A clear and simple way to help manage the display of callouts, with the framework helping me when it could, and then giving me the freedom to implement all the logic to deal with the more trickier onboarding scenarios.

  • Callouts that looked professional and animated nicely.

  • The ability to customize the appearance of the callouts for different types of apps.

  • A solution that worked the same for both iOS and macOS.

Locating the View on the Screen

Callouts need the screen location of a view to be able to point to it, including locations of items in the toolbar.

The trickiest part of developing this whole framework was figuring out how to actually locate the view that the callout would be pointing to. In traditional UI frameworks like UIKit, you simply get the frame of a view at any given time during runtime and use that. But with a declarative UI like SwiftUI, it’s a bit more obfuscated because your code is only declaring where you’d like the view to be, relative to others around it, and you let the system do the actual placement on the screen for you. By default, you don’t get any insight during runtime where that placement might be.

Fortunately, SwiftUI does give us the GeometryReader that we can wrap our view in to get its frame on the screen. Unfortunately, the GeometryReader is another view that you have to add to your layout, and it can significantly mess with that layout, especially when it’s added to smaller views that have already been carefully arranged. And often these are the views you want to draw attention to with callouts.

One of the primary design requirements of this framework is that using it will have little or no impact on an app’s existing code or layouts.

Luckily, it turns out, there’s a little trick we can use. If we add a transparent background to an original view, and put a GeometryReader around that background instead, it doesn’t affect the layout of the original view, and you get the actual frame you’re looking for.

The code looks something like this:


public var body: some View {
    SomeOriginalView()
        .background(
            GeometryReader { geometry in
                generateClearBackground(withGeometry: geometry)
            }
        )
}

@ViewBuilder
public func generateClearBackground(withGeometry geometry: GeometryProxy) -> some View {
    let frame = geometry.frame(in: .global)
    /* Here's the frame! Save it somewhere for later use. */
    return Color.clear
}

Relying on View Modifiers

I really wanted this onboarding library to be a framework you could integrate into an existing app with very little impact. One of the more powerful things about SwiftUI is that you can create your own custom view modifiers (with the help of Swift’s extensions) that allow you to elegantly alter an existing view with a single line of code.

My framework relies on using custom view modifiers for two important things:

#1 - To Create the View to Display Callouts

There needed to be single, full-screen view where the callouts could be displayed without affecting the underlying layout of the views they pointed to. How do you add something like this without altering the app’s existing view structure? With a view modifier. The framework includes a new .enableGuideCallouts(…) view modifier that you add to your app’s root content view, and this single modifier adds a the new full-screen view on top of your app where the callouts will be displayed.

#2 - Attaching Callouts to Individual Views

To attach callouts to views in the app, the framework provides another new custom view modifier called .with(GuideCallout(…)). This modifier adds the necessary code for locating the view on the screen (i.e. adding the clear background and getting its frame) and it marks it as a view that can be pointed to, all without altering the existing layout. This works for virtually any SwiftUI View in your app, including views in the navigation bars.

Presenting and Dismissing Callouts

Another aspect of the framework was figuring out the APIs for showing and dismissing the callouts. I ended up introducing a new data structure as part of the library, called GuideCallout, which represents a callout, and encapsulating the necessary display and dismissal functionality.

To display a callout (say in the .onAppear of a screen, for example), you simply create a new GuideCallout and initialize it with the ID of the callout you attached to the view. Then you call its show(…) function to make the callout appear on the screen.

The show(…) function also accepts an optional completion handler, called onDismiss, that gets called if the user dismisses the callout themselves. You can use this, for example, to show another callout, allowing you to chain a few callouts together to describe a set of related app features. The code looks something like this:


.onTapGesture {
    GuideCallout("callout-one").show() {
        GuideCallout("callout-two").show() {
            GuideCallout("callout-three").show()
        }
    }
}

The framework also does its part to hide callouts for you, when it can. For example, when the view that the callout is attached to gets removed from the screen, the callout will go away too. It most cases this means you will not need to write code to dismiss a callout. But when you do, the GuideCallout object has a close() function you can use.

Beyond that, you simply wrap your calls to the show(…) function with all logic you need to determine if and when a callout should get displayed.

I’m certain there will be a lot more logic (or rules) that can be built into the framework to help, but this functionality was the basics I needed to get started. As I use the framework more, I hope to learn about what logic can be added to help manage callouts more automatically. An early concept I’m playing with, for example, is adding more variations of the show() function that explicitly encapsulate additional rules. A showOnlyOnce() type of variation, for example, that will only show the callout one time, or a showIfNeverDismissed() that might keep showing the callout each time, until the user dismisses it. I want to get more experience with implementing and testing real onboarding scenarios first, though, before I start making those kinds of API enhancements.

Appearance Customization

By default, callouts display correctly in light or dark modes. The demo also includes a custom appearance example that displays callouts in the app’s current tint color.

The final requirement, and the one that made me look for a custom solution in the first place, was the ability to customize the appearance of the callout.

At a minimum, I wanted to control colors (foreground, background, outline, shadow, etc.) so that I could make the callouts a little more fun for entertainment-type apps, etc. I’ll be slowly expanding on this to include other customization as well, particularly around showing custom views beyond simple title and text messages.

Currently, to provide this customization, the framework includes a GuideAppearance class that you can subclass and override with custom appearance values. Then you simply pass that object in the .enableGuideCallouts(withAppearance:) view modifier that you added to your app’s root view.


Check Out the Demo!

The Swift Package and demo for this onboarding library is available here on GitHub.

At a high level, these were the main challenges and considerations I worked through when developing this initial version of the library. To really see it in action, and learn how it works, grab a copy of the repo, take a look at the README, and try the demo!

If you have any suggestions or feedback, please let me know! I’ll be evolving it over the coming months as I start incorporating it into my own apps.

Next
Next

The Path to Making and Maintaining a Professional-Grade Mobile App