SwiftUI Overlays and Their Issue With Sheets
In the first app I came out with I created a popup overlay to let you know when code was executing in the background and what step it was on. I loved this but realized it was hidden behind the sheets so if you had a sheet open while the code was executing you wouldn't have any feedback until after it finished or the sheet had closed. This wouldn't do... and so I figured out how to have the popup show up everywhere including over the sheets!
I had the popup running using an overlay and loved how it worked but then realized the overlay wasn't showing up overtop of the sheets. It was still running though as if you close the sheet you were able to see it. I realized I needed to simplify my issue and created a minimal viable example project so I could play around and figure it out.
The Issue
When I executed code that would take awhile I did so on a background thread so the interface wasn't locked and you could move around. As such I wanted to create a message so, when running the app, you know that it's still executing and at which step it's at. As such I found some code online I built upon to create a bar across the screen, using an overlay, with an undateable message telling you what's going on.
I then realized that when a sheet was open the popup would show up behind it so you couldn't see the popup at all and it would appear as if nothing was happening until the code finished and the alert popped up. While debugging it I realized if I was quick and closed the sheet the overlaid popup was still there and had just been hidden. This was further confirmed in my sample code where I could more quickly swipe the sheet away to verify.
Backstory: Custom Popup in SwiftUI
A while back I realized that my code was taking awhile to execute and I wanted some way to notify the person using my app that the code was, in fact, progressing and what was happening. After looking around for a while I came across Vadim Bulavin's post from Yet Another Swift Blog that showed how to create a Custom Popup in SwiftUI.
In the post popups are explained as
a kind of modal that appears in front of app content to provide critical information or ask for a decision.
The post then goes on to explain just how to create a popup view modifier, add alignment, animate the popup transition, and add direction. At the end of the post it shows a loading indicator and a snackbar message which both had aspects I wanted to use. Included is also a link to their GitHub code example here.
This loading indicator and the snackbar message shown at the end of their post greatly interested me and I decided to combine the two together to create my own take on the popup for my own app. Once I got it this worked perfectly until I came across my issue it being hidden behind the sheet views.
Path to My Solution
Alert System
Around this time, when I realized my issue, I had already been working on fixing an issue with my alert system, posted earlier about it here, so I quickly realized I could use a similar solution to hide my time consuming tasks with it's own class, MessageAndAsyncClass, and use an observable controller class, MessageController, to access it and have it show up quickly. I already had these timed tasks grouped in their own class so I just went over the class and variable names to confirm they were indeed set properly compared to my alert system and then created the controller wrapper class to access it.
Observation
Like the alerts before the above controller class worked great to both show the popup and close it when I wanted it to open and close... but I had issues with the message itself not showing the updated text which meant the message itself couldn't be updated... which I wanted to happen potentially several times before going away. Looking for a solution I came across the StackOverflow question SwiftUI view is not updating to EnvironmentObject change and specifically found Tobias Hesselink's updated answer helpful.
Adding Combine
I grew excited by this previous answer and added a version of it to my code but then realized that the compiler couldn't understand what AnyCancellable
was... and I wasn't sure what it was either. While hunting it down I came across a post on Medium: Swift Combine: What is AnyCancellable? And how to avoid Memory Leak?.
Reading the first bit made me realize I needed to import combine
into my code, which I had heard about but hadn't used, and it compiled... and the message updated in the popup as the code executed! With it working I went back to the article to read the rest so I wouldn't, hopefully, start a memory issue and as such added a deinit
and set it to nil
in the hopes of fixing any problems before it starts. I'm not as researched in this area so if you know a better way feel free to comment below for both my benefit and anyone coming along later.
The Solution Works
With that the popup overlay works! Showing up both on the sheet and on the main app level which means it's still shown when a sheet is opened or closed. Allowing swipe down to close the sheets in my sample project highlights that nicely.
My Solution: The Code
Popup: Get the Popup Working
Before we go too far into the updates above I figured I should show the popup code I'm using to display the message. The original code came from Vadim Bulavin's post from Yet Another Swift Blog that showed how to create a Custom Popup in SwiftUI and I can't quite remember if I made any changes to it and if so what they were. As such sharing it all.
@MainActor
struct Popup<T: View>: ViewModifier {
let popup: T
let isPresented: Bool
let alignment: Alignment
let direction: Direction
enum Direction {
case top, bottom
@MainActor
func offset(popupFrame: CGRect) -> CGFloat {
switch self {
case .top:
let aboveScreenEdge = -popupFrame.maxY
return aboveScreenEdge
case .bottom:
#if os(macOS)
let belowScreenEdge = NSScreen.main!.frame.height - popupFrame.minY
return belowScreenEdge
#else
let belowScreenEdge = UIScreen.main.bounds.height - popupFrame.minY
return belowScreenEdge
#endif
}
}
}
init(isPresented: Bool, alignment: Alignment, direction: Direction, @ViewBuilder content: () -> T) {
self.isPresented = isPresented
self.alignment = alignment
self.direction = direction
popup = content()
}
func body(content: Content) -> some View {
ZStack {
content
if isPresented {
popupContent()
}
}
}
@ViewBuilder private func popupContent() -> some View {
GeometryReader { geometry in
if isPresented {
withAnimation {
popup
.transition(.offset(x: 0, y: direction.offset(popupFrame: geometry.frame(in: .global))))
.frame(width: geometry.size.width, height: geometry.size.height, alignment: alignment)
}
}
}
}
}
private extension GeometryProxy {
@MainActor
var belowScreenEdge: CGFloat {
#if os(macOS)
NSScreen.main!.frame.height - frame(in: .global).minY
#else
UIScreen.main.bounds.height - frame(in: .global).minY
#endif
}
}
extension View {
@MainActor func popup<T: View>(
isPresented: Bool,
alignment: Alignment = .center,
direction: Popup<T>.Direction = .bottom,
@ViewBuilder content: () -> T
) -> some View {
return modifier(Popup(isPresented: isPresented, alignment: alignment, direction: direction, content: content))
}
}
Popup: The User Messaging
Then this view is used to dictate what exactly is shown to the user viewing the popup. Here the class, where I set the message variables, is passed in to show the updating message and we can change here how it will look.
struct TopUserMessage: View {
// Needs to be passed in so I can access the variables.
@ObservedObject var messageAndAsyncClass: MessageAndAsyncClass
var body: some View {
VStack(alignment: .center, spacing: 4) {
Text(messageAndAsyncClass.title)
.font(.headline)
}
.frame(minWidth: 200)
.padding(15)
.frame(maxWidth: .infinity, idealHeight: 100)
.background(Color.teal)
}
}
Background Tasks: Group Time Consuming Tasks With AsyncCallsClass
For the time consuming async classes, modeled here with sleep pauses, I grouped them together in their own class.
import Foundation
class AsyncCallsClass {
func fetchData() async throws -> String {
// Simulating an asynchronous network call
try await Task.sleep(nanoseconds: 2_000_000_000)
return "Fetched Data"
}
func fetchAnotherData() async throws -> String {
// Simulating an asynchronous network call
try await Task.sleep(nanoseconds: 2_000_000_000)
return "Fetched Other Data"
}
}
Background Tasks: Message Updates With MessageAndAsyncClass
Then I used this class to both handle the published
message specific variables and their update calls with the calls to the time consuming tasks above.
import Foundation
@MainActor
public class MessageAndAsyncClass: ObservableObject {
private var asyncCallsClass: AsyncCallsClass
@Published var errorMessage: String?
@Published var showMessage: Bool = false
@Published var title: String = "title"
init(asyncCallsClass: AsyncCallsClass) {
self.asyncCallsClass = asyncCallsClass
}
/// Makes the message disappear and resets the other values.
private func messageValuesEnd() {
self.showMessage = false
self.title = "title"
}
/// Starts the message by updating the values to those past in and then showing it.
private func messageValuesStart(newTitle:String, newSubTitle:String, newImageName:String? = nil) {
self.showMessage = true
self.title = newTitle
}
/// The function to "fetch the data"
func fetchData() async throws -> String {
messageValuesStart(newTitle: "Fetch Data", newSubTitle: "Fetching Data")
do {
let result = try await asyncCallsClass.fetchData()
messageValuesEnd()
return result
} catch {
self.errorMessage = "Failed to fetch data: \(error.localizedDescription)"
messageValuesEnd()
throw error
}
}
/// The function to "fetch more data"
func fetchAnotherData() async throws -> String {
messageValuesStart(newTitle: "Fetch Another Data", newSubTitle: "Fetching More Data")
do {
let result = try await asyncCallsClass.fetchAnotherData()
messageValuesEnd()
return result
} catch {
self.errorMessage = "Failed to fetch another data: \(error.localizedDescription)"
messageValuesEnd()
throw error
}
}
}
Background Tasks: Message Controller
Then the above two classes are hidden within the MessageController
here with the anyCancellable
set to sync any message updates to the popup window.
import Foundation
import Combine
/// Allows us to use one shared messagind class and use it how we'd like on the front end.
@MainActor
public class MessageController: ObservableObject {
@Published public var myMessenger: MessageAndAsyncClass
// AnyCancellable is a data type that can hold any subscription.
var anyCancellable: AnyCancellable? = nil
public init() {
self.myMessenger = MessageAndAsyncClass(asyncCallsClass: AsyncCallsClass())
anyCancellable = myMessenger.objectWillChange.sink { (_) in
self.objectWillChange.send()
}
}
deinit {
// Not sure if needed but just in case. Not printed when forced closed etc.
anyCancellable = nil
}
}
Hooking It Up: Main App
With all the required classes set up it's now time to hook it all up with the front end. I use the main app to instantiate the controller class, put it into the environment, and call the popup modifier to show it all.
import SwiftUI
@main
struct TestMuplitplatformAppApp: App {
@StateObject private var messageController = MessageController()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(messageController)
.popup(isPresented: messageController.myMessenger.showMessage, content: {
TopUserMessage(messageAndAsyncClass: messageController.myMessenger)
})
}
}
}
Hooking It Up: Content View
Then where ever you want to use it, here the example shows the ContentView
, you grab the MessageController
from the environment and use it to call the time consuming tasks which will make the popup appear, update the messaging, and hide the popup when done. Here I also open up a sheet to demo how the popup works from and with both locations.
import SwiftUI
struct ContentView: View {
// Shows the async messages
@EnvironmentObject var messageController: MessageController
@State private var showAlert = false
@State private var alertMessage = ""
@State private var showSheet = false
var body: some View {
VStack {
if let errorMessage = messageController.myMessenger.errorMessage {
Text(errorMessage).foregroundColor(.red)
}
Spacer()
Button(action: {
showSheet = true
}) {
Text("Show Sheet")
}
Spacer()
Button(action: {
Task {
do {
let data = try await messageController.myMessenger.fetchData()
alertMessage = "Fetched Data: \(data)"
} catch {
alertMessage = "Error fetching data"
}
showAlert = true
}
}) {
Text("Fetch Data")
}
Spacer()
Button(action: {
Task {
do {
let data = try await messageController.myMessenger.fetchAnotherData()
alertMessage = "Fetched Another Data: \(data)"
} catch {
alertMessage = "Error fetching another data"
}
showAlert = true
}
}) {
Text("Fetch Another Data")
}
Spacer()
}
.sheet(isPresented: $showSheet, content: {
SheetView()
})
.buttonStyle(BorderedButtonStyle())
.alert(isPresented: $showAlert) {
Alert(
title: Text("Result"),
message: Text(alertMessage),
dismissButton: .default(Text("OK"))
)
}
}
}
Hooking It Up: Sheet View
My issue using my old way was the popup was hidden behind any sheets that were shown. To have the popup be started I do the same as above with grabbing the MessageController
from the environment and using it to call the time consuming tasks. This starts the popup but it's hidden unless you set the popup modifier directly on this view. If so it popup shows up here and on the main app regardless where it kicks off.
struct SheetView: View {
// Shows the async messages
@EnvironmentObject var messageController: MessageController
@State private var showAlert = false
@State private var alertMessage = ""
var body: some View {
VStack {
if let errorMessage = messageController.myMessenger.errorMessage {
Text(errorMessage).foregroundColor(.red)
}
Spacer()
Button(action: {
Task {
do {
let data = try await messageController.myMessenger.fetchData()
alertMessage = "Fetched Data: \(data)"
} catch {
alertMessage = "Error fetching data"
}
showAlert = true
}
}) {
Text("Fetch Data")
}
Spacer()
Button(action: {
Task {
do {
let data = try await messageController.myMessenger.fetchAnotherData()
alertMessage = "Fetched Another Data: \(data)"
} catch {
alertMessage = "Error fetching another data"
}
showAlert = true
}
}) {
Text("Fetch Another Data")
}
Spacer()
}
.buttonStyle(BorderedButtonStyle())
.alert(isPresented: $showAlert) {
Alert(
title: Text("Result"),
message: Text(alertMessage),
dismissButton: .default(Text("OK"))
)
}
// Add this so the popup shows on the sheet too.
.popup(isPresented: messageController.myMessenger.showMessage, content: {
TopUserMessage(messageAndAsyncClass: messageController.myMessenger)
})
}
}
And with that you too can get the popup working. Hope it goes well and I'd love to see how it turns out and what you chose to change to make it work for you! Feel free to comment below or tag me on social media. And overall I hope youโre having a great day.
If youโre interested in getting any of my future blog updates I normally share them to my Facebook page and Instagram account. Youโre also more than welcome to join my email list located right under the search bar or underneath this post.