SwiftUI Overlays and Their Issue With Sheets

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!

Pinterest geared image showing my post title, images from below, and my main URL.

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.

Image shows the cover image of the post: Custom Popup in SwiftUI.
Screenshot taken from Custom Popup in SwiftUI on May 24th, 2024.

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.

Image is a collage of four images all found on the Custom Popup in SwiftUI post from https://www.vadimbulavin.com/swiftui-popup-sheet-popover/
Series of popups shown, with code, on the website Custom Popup in SwiftUI. The original screenshots used to create this collage were taken from the Custom Popup in SwiftUI post on May 24th, 2024.

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.

Image is a screenshot of the updated answer part that helped me with a solution.
Screenshot of the part of the StackOverflow answer that helped me was taken on May 24th, 2024.

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?.

Swift Combine: What is AnyCancellable? And how to avoid Memory Leak?
Good at development Or a high-level engineer. Developers who are evaluated in the market in such a statement are thorough in memoryโ€ฆ

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.

Image shows the main app dimmed out but showing the teal overlay. In front and center is a small modal with the same banner across saying: Fetch Another Data.
Realized when taking screenshots for this post that I hadn't tried it on macOS. The sample project has a small sheet with no dismiss or close button... but the overlay works in both places!

My Solution: The Code

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))
    }
}

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.




Related Posts

Latest Posts