SwiftUI Tutorial — Creating an In-App Banner Notification System

Tutorial on building an In-App Notification System using Dependency Injection, Animation and Drag Gesture. ~30–45 minutes

Dallin Jared
9 min readMar 27, 2023

--

Video showing the animations and global demonstration of the banner
Image of the three displays — Success, Warning and Error

Creating a custom banner system can be a great way to enhance the user experience of your app by providing important notifications and alerts. In this article, I will take you through the process I took while creating a custom banner system using SwiftUI. In this example, I have taken the liberty of deciding fairly generic notifications — Success, Warning and Error — but the design is very flexible to whatever needs your application may have.

When designing this banner system, I wanted to create something that was flexible and customizable, allowing developers to easily incorporate it into their own projects. I also wanted to ensure that the banner system was user-friendly, providing a simple and intuitive interface for users to interact with.

Throughout the article, I will explain the thought process behind each design decision, as well as providing detailed code examples for each step of the process, additionally the source code will be provided on GitHub. By the end of this article, you will have a solid understanding of how to create a custom banner system in SwiftUI, which you can then tailor to your own specific needs. So, let’s get started!

Setup

Xcode File Structure for BannerDemo

Open Xcode and create a new project called “BannerDemo”. In the project navigator, create three new Swift files — BannerView.swift, BannerService.swift, and BannerType.swift. These files will be used to implement the banner notification system. The BannerView file will be responsible for displaying the banner view, the BannerService file will handle the logic for showing and hiding the banner, and the BannerType file will define the different types of banners that can be displayed.

Implement BannerType

Now that we have created our Xcode project and added the necessary files, the next step is to implement the BannerType. An enum was chosen for BannerType because it is a concise way to represent a fixed set of possible values, in this case the different types of banners that can be displayed. Using an enum ensures that only valid banner types can be created, preventing errors and making the code more robust. We will use an enum to represent the different types of banners that can be displayed: success, warning, and error.

enum BannerType {
var id: Self { self }
case success(message: String, isPersistent: Bool = false)
case error(message: String, isPersistent: Bool = false)
case warning(message: String, isPersistent: Bool = false)
// ... Computed Properties
}

Each case has a message parameter which is the text that will be displayed in the banner. There is also an optional isPersistent parameter that defaults to false and the banner will automatically disappear after 1.5 seconds. If isPersistent is set to true, the banner will not be automatically dismissed and will require the user to manually dismiss it.

Next, we define computed properties to set the background color and image for each banner type:

var backgroundColor: Color {
switch self {
case .success: return Color.green
case .warning: return Color.yellow
case .error: return Color.red
}
}
var imageName: String {
switch self {
case .success: return "checkmark.circle.fill"
case .warning: return "exclamationmark.triangle.fill"
case .error: return "xmark.circle.fill"
}
}

The backgroundColor property returns a color for each banner type, and the imageName property returns an image name for each banner type. We will use these properties to set the background color and image of our banner view.

Finally, we define computed properties to get the message and isPersistent values for each banner type:

var message: String {
switch self {
case let .success(message, _), let .warning(message, _), let .error(message, _):
return message
}
}
var isPersistent: Bool {
switch self {
case let .success(_, isPersistent), let .warning(_, isPersistent), let .error(_, isPersistent):
return isPersistent
}
}

The message property returns the message parameter for each banner type, and the isPersistent property returns the isPersistent parameter for each banner type.

Now that we have implemented the BannerType, we can move on to creating the BannerService and BannerView.

BannerService

We now need to create a class that will handle displaying and removing the banner. Essentially this is the pivot point of a teeter-totter, the notification system depends on the status of this class. We will inherit the ‘ObservableObject’ to allow us to publish the variables that will update our views.

class BannerService: ObservableObject {
@Published var isAnimating = false
@Published var dragOffset = CGSize.zero
@Published var bannerType: BannerType? {
didSet {
withAnimation {
switch bannerType {
case .none:
isAnimating = false
case .some:
isAnimating = true
}
}
}
}
let maxDragOffsetHeight: CGFloat = -50.0
// ... Methods
}

BannerService will publish three variables — isAnimating, dragOffset and bannerType. isAnimating will be the trigger for the animations we will implement in BannerView. dragOffset will hold the values of the view when the user interacts and moves the notification — this will allow us to reduce the opacity of the view as the user swipes the notification upwards to dismiss it. Lastly, BannerService will publish an Optional BannerType. The bannerType will default to nil so no notifications are displayed. Setting a value to bannerType will kick off the notification. maxDragOffsetHeight is a constant that is used in BannerView to calculate the change of opacity when the user interacts with the notification.

BannerService has two simple methods to set the Banner and remove the Banner. Wrapping the variable updates in withAnimation will smooth the transition of the Views.

func setBanner(banner: BannerType) {
withAnimation {
self.bannerType = banner
}
}

func removeBanner() {
withAnimation {
self.bannerType = nil
self.isAnimating = false
self.dragOffset = .zero
}
}

Next, go to the BannerDemoApp file. We will make some edits to this file in a minute to complete the tutorial, but for now lets implement a key piece of the pie.

@main
struct BannerDemoApp: App {
let bannerService = BannerService()

var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(bannerService)
}
}
}

Instantiate BannerService and and pass it through the .environmentObject(_:) modifier on ContentView() as shown above. This creates a single instance of BannerService that will be accessible throughout the application to trigger notifications. This utilizes a design pattern called Dependency Injection. Instead of creating a new instance of BannerService wherever needed, child views of ContentView() will be able to access BannerService through the environment and use the setBanner() and removeBanner() methods we created in the last step.

BannerView

Now let’s create the actual UI of the notification! Let’s start by dipping into the environment to grab the BannerService that we set there, add a ‘@State’ variable showAllText as a Boolean and the variable bannerType that is passed in when the view is instantiated.

struct SwiftUIView: View {
@EnvironmentObject private var bannerService: BannerService
@State private var showAllText: Bool = false
let banner: BannerType
var body: some View {
Text("Hello, World!")
}
}

Next, update the body(_:) variable with the following, this will be the setup for the UI we will implement for the notifications.

var body: some View {
VStack {
Group {
bannerContent()
}
}
}

private func bannerContent() -> some View {
// UI Code we will set next!
}

Within the bannerContent() function add the following code.

HStack(spacing: 10) {
Image(systemName: banner.imageName)
.padding(5)
.background(banner.backgroundColor)
.cornerRadius(5)
.shadow(color: .black.opacity(0.2), radius: 3.0, x: -3, y:4)
VStack(spacing: 5) {
Text(banner.message)
.foregroundColor(.black)
.fontWeight(.light)
.font(banner.message.count > 25 ? .caption : .footnote)
.multilineTextAlignment(.leading)
.lineLimit(showAllText ? nil : 2)
(banner.message.count > 100 && banner.isPersistent) ?
Image(systemName: self.showAllText ? "chevron.compact.up" : "chevron.compact.down")
.foregroundColor(Color.white.opacity(0.6))
.fontWeight(.light)
: nil
}
banner.isPersistent ?
Button {
withAnimation{
bannerService.removeBanner()
}
} label: {
Image(systemName: "xmark.circle.fill")
}
.shadow(color: .black.opacity(0.2), radius: 3.0, x: 3, y:4)
: nil
}
.foregroundColor(.white)
.padding(8)
.padding(.trailing, 2)
.background(banner.backgroundColor)
.cornerRadius(10)
.shadow(radius: 3.0, x: -2, y:2)
.onTapGesture {
withAnimation {
self.showAllText.toggle()
}
}

Here we have a ‘HStack’ with a couple modifiers including the background that uses the background color from the BannerType enum we set earlier. A cornerRadius(_:) is added along with a shadow(_:) and padding(_:) modifiers.

There are two consistent and one dynamic elements in the ‘HStack’. The first is the Image element that displays the BannerType Image. The second is a ‘VStack’ that has the bannerType message and for longer messages an arrow will display to prompt the user to tap the notification to showAllText (This .onTapGesture can be seen on the parent ‘HStack’). The third object will only display when the notification is marked with isPersistent = true. This is a closure button to dismiss the notification.

Alright, now back on in the body variable add the following modifiers to the ‘VStack’:

.onAppear {
guard !banner.isPersistent else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation {
bannerService.isAnimating = false
bannerService.bannerType = nil
}
}
}
.offset(y: bannerService.dragOffset.height)
.opacity(bannerService.isAnimating ? max(0, (1.0 - Double(bannerService.dragOffset.height) / bannerService.maxDragOffsetHeight)) : 0)
.gesture(
DragGesture()
.onChanged{ gesture in
if gesture.translation.height < 0 {
bannerService.dragOffset = gesture.translation
}
}
.onEnded { value in
if bannerService.dragOffset.height < -20 {
withAnimation{
bannerService.removeBanner()
}
} else {
bannerService.dragOffset = .zero
}
}
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.horizontal)

In the onAppear(_:) modifier we continue for non-Persistent notification and dismiss the notification after 1.5 seconds. The gesture(_:) modifier tracks the movement of the view when a user drags it. The onChanged modifier will update the dragOffset for any movement in the vertical direction (gesture.translation.height sets the vertical direction, using width would set it as horizontal displacement), and ‘ < 0’ only updates when the view is moved upwards which causes a negative value of displacement. onEnded(_:) triggers when the release of the view, if the view has been displaced more than 20, then it will trigger the removal of the banner. If it is less than 20, setting the dragOffset to .zero will snap it back to place.

The opacity(_:) is computed by taking the dragOffset and taking the percent traveled to the maxDragOffsetHeight. One minus the result is casted to a Double and causes a value that can change the opacity. For example, if the banner has been displaced -25, then it is computed as 1 — (-25/-50) which equals 0.5. So halfway the opacity is set to 50%, which will continue to decrease as its dragged upwards.

Final Steps

Update the BannerDemoApp view to the following:

@main
struct BannerApp: App {
@StateObject var bannerService = BannerService()

var body: some Scene {
WindowGroup {
ZStack {
NavigationView{
ContentView()
}
if let type = bannerService.bannerType {
BannerView(banner: type)
}
}
.environmentObject(bannerService)
}
}
}

Here we dynamically show a BannerView when bannerService.bannerType is set. Because we are setting the BannerView in a ‘VStack’ around the ContentView(), the banner will ALWAYS appear above all content! AWESOME. If bannerService.bannerType is nil, then the app will be unchanged.

Copy the following and toss it in the ContentView(). This will let you test the banners, change the message, set them as persistent and experience the awesomness you just created!

struct ContentView: View {
@EnvironmentObject var bannerService: BannerService

@State private var successMessage: String = "Successfully added Trip!"
@State private var successIsPersistent: Bool = false
@State private var warningMessage: String = "Here is a warning message that is super long! Adding text to start testing ability to get a drop down. It needs to be really long I guess"
@State private var warningIsPersistent: Bool = false
@State private var errorMessage: String = "Error: Email is invalid"
@State private var errorIsPersistent: Bool = false

var body: some View {
VStack {
Form {
Section("Success"){
TextField("Success Message", text: $successMessage)
Toggle(isOn: $successIsPersistent, label: {
Text("Is Persistent:")
})
Button("Show Success Banner") {
bannerService.setBanner(banner: .success(message: successMessage, isPersistent: successIsPersistent))
}
.padding(.vertical, 8)
.padding(.horizontal, 10)
.background(.green)
.clipShape(RoundedRectangle(cornerRadius: 10))
.foregroundColor(.black)
.fontWeight(.thin)
.frame(maxWidth: .infinity, alignment: .center)
}
Section("Warning"){
TextField("Warning Message", text: $warningMessage)
Toggle(isOn: $warningIsPersistent, label: {
Text("Is Persistent:")
})
Button("Show Warning Banner") {
bannerService.setBanner(banner: .warning(message: warningMessage, isPersistent: warningIsPersistent))
}
.padding(.vertical, 8)
.padding(.horizontal, 10)
.background(.yellow)
.clipShape(RoundedRectangle(cornerRadius: 10))
.foregroundColor(.black)
.fontWeight(.thin)
.frame(maxWidth: .infinity, alignment: .center)
}
NavigationLink("Try Error Banner") {
Form {
Section("Error"){
TextField("Error Message", text: $errorMessage)
Toggle(isOn: $errorIsPersistent, label: {
Text("Is Persistent:")
})
Button("Show Error Banner") {
bannerService.setBanner(banner: .error(message: errorMessage, isPersistent: errorIsPersistent))
}
.padding(.vertical, 8)
.padding(.horizontal, 10)
.background(.red)
.clipShape(RoundedRectangle(cornerRadius: 10))
.foregroundColor(.black)
.fontWeight(.thin)
.frame(maxWidth: .infinity, alignment: .center)
}
}
.navigationBarTitle("Error View")
}

}
}
.navigationTitle("Banner")
.ignoresSafeArea(.all, edges: .bottom)
}
}

And there you go! You just created your own custom Banner Notification System in SwiftUI. You can update the bannerContent() in BannerView and BannerType to nail down your own UI for your application! Thanks for following along and happy coding!

--

--

Dallin Jared

Software Engineer at BambooHR and Developing iOS Developer