Search code examples
swiftuifloating

how to build a "floating" text view without influencing/updating the content under it?


I want to add a small “floating” text view showing the current view number (viewID) on top of _every_ screen my app displays:

iPhone Simulator with small red floating text

Notice the little red “11” right above the notch.

I have a settings menu reachable with the hamburger button on the left, where the user can switch a “Developer Mode” to show or hide this small viewID.

My implementation has a controller singleton...

class DebugController: ObservableObject {
    public static var shared = DebugController()
    @AppStorage("developerMode") var developerMode: Bool = false

    @Published var viewID: Int = 0

    @MainActor func setViewID(_ newID: Int) -> Void {
        viewID = developerMode ? newID : 0
    }

    init() {
    }
}

… which is allocated by the main app and then passed as environment object to the views created by ContentView...

@main
struct MyApp: App {
    @StateObject private var debugController = DebugController.shared

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

… but ContentView itself does NOT use the DebugController. It has a ZStack for the main NavigationView with quite some view hierarchy, and the floating ViewIDView on top.

struct ContentView: View {

    var body: some View {
        ZStack(alignment: .top) {
            NavigationView {   // the one and only for all non-sheet views
                //- - - a decent view hierarchy here - - -
            }
            // Show the viewID on top of the app's NavigationView
            ViewIDView()
                .id("ViewID")
        }
    }
}

Only the floating ViewIDView listens to the DebugController published viewID value. If it is 0 then the string is cleared (“”), otherwise the Int value is converted to a String and shown in the Text item.

struct ViewIDView: View {
    @EnvironmentObject private var debugController: DebugController

    var body: some View {
        let _ = Self._printChanges()
        let idString = debugController.viewID > 0 ? String(debugController.viewID)
                                                  : ""
        HStack {
            Spacer()
            Spacer()
            Text(idString)
                .font(.caption2)
                .foregroundColor(.red)
                .edgesIgnoringSafeArea(.top)
            Spacer()
        }
    }
}

The Spacers are needed to shift the Text a little bit to the right, because iPhones with TouchID don’t have a notch but show the time in the middle.

iPhone SE1 with floating red text

On my iPhone SE 1st gen it just fits perfect with two Spacers left and one right.

This works fine in the top two levels of the navigation stack. Whenever a new view is pushed on the stack, it sets the global viewID value of the singleton DebugController

    .onAppear() {
        DebugController.shared.setViewID(VIEW_11)
    }

which is then published from DebugController, and leads to the red Text in ViewIDView to update.

However, when I push a third view on the navigation stack with a NavigationLink, which on appearing updates the viewID number, then the main ContentView body also gets updated by SwiftUI WHICH SHOULD NOT HAPPEN, leading to a new instance of the views in the hierarchy, which then call their .onAppear again which overwrites the global viewID with a wrong value. Even worse, its .task also gets called again, which loads some more data and destroys the data I wanted to show in the first place.

When I just comment out the call DebugController.shared.setViewID(VIEW_123) in this 3rd level view’s .onAppear(), then SwiftUI does not update the main ContentView and my 3rd level view shows the correct data - except the floating viewID of course which didn't get updated and still shows the second level view number.

So, my problem here is that updating the floating Debug ViewIDView also updates the Navigation stack under it, which must not happen.

What is wrong with my approach?

How can I implement a true floating view which can be updated from everywhere without it's view update influencing the rest of the app?

EDIT:

@ScottM suggested to use a .overlay instead of the ZStack:

struct ContentView: View {

    var body: some View {
        NavigationView {   // the one and only for all non-sheet views
            //- - - a decent view hierarchy here - - -
        }.overlay(debugOverlay, alignment: .top)
    }

    @ViewBuilder private var debugOverlay: some View {
        // Show the viewID on top of the app's NavigationView
        ViewIDView()
            .id("ViewID")
    }
}

but unfortunately this didn't change anything at all. I still see the NavigationView getting re-drawn after I update the viewID value (which shouldn't happen).


Solution

  • By making your DebugController a @StateObject at a level higher than the NavigationView, you risk the navigation view being redrawn every time something changes within the observable object.

    You can get round this by removing the @StateObject property wrapper when declaring it in ContentView. This means that ContentView won't respond to any changes in the debug controller.

    @main
    struct MyApp: App {
        private var debugController = DebugController.shared
    
        var body: some Scene {
            WindowGroup {
                ContentView()
                    .environmentObject(debugController)
            }
        }
    }
    

    Your ViewIDView will continue to work, because the @EnvironmentObject property wrapper subscribes to the observable object's changes.