Zhich
Zhich

Reputation: 43

How do I hide my toolbar in SwiftUI when full screened?

I have a SwiftUI app with a toolbar with multiple WindowGroups. I want all of my windows except the main one to have a toolbar with behaviour similar to the Preview app, hiding/collapsing the toolbar when it is fullscreen.

I have .presentedWindowToolbarStyle(.unified) attached to my views and .windowStyle(.titleBar) attached to my WindowGroups.

I have tried searching for methods, asked ChatGPT multiple times and I have tried to find open-source SwiftUI apps with a collapsing toolbar but they all did not work because they were either about iOS or about an AppKit app. In code, I have tried different settings of .windowStyle and .presentedWindowToolbarStyle as well as setting .windowToolbarStyle.

Upvotes: 3

Views: 2852

Answers (4)

dchest
dchest

Reputation: 1563

Since macOS 15:

   ContentView()
        .windowToolbarFullScreenVisibility(.onHover)

Upvotes: 0

Kushagra
Kushagra

Reputation: 1006

I managed to achieve this by doing the following:

  • First get a reference to the underlying window
  • Set the window's deleate to a custom delegate which implements willUseFullScreenPresentationOptions

For the first step, you can create a NSViewRepresentable like so:

struct HostingWindowFinder: NSViewRepresentable {
    var callback: (NSWindow?) -> ()
    
    func makeNSView(context: Self.Context) -> NSView {
        let view = NSView()
        DispatchQueue.main.async { self.callback(view.window) }
        return view
    }
    
    func updateNSView(_ nsView: NSView, context: Context) {
        DispatchQueue.main.async { self.callback(nsView.window) }
    }
}

Now create the custom delegate like this:

class CustomWindowDelegate: NSObject, NSWindowDelegate {
    override init() {
        super.init()
    }
    
    func window(_ window: NSWindow, willUseFullScreenPresentationOptions proposedOptions: NSApplication.PresentationOptions = []) -> NSApplication.PresentationOptions {
        return [.autoHideToolbar, .autoHideMenuBar, .fullScreen]
    }
}

Finally, in your view, do this to set the window delegate:

struct ContentView: View {
    private var customWindowDelegate = CustomWindowDelegate()
    
    var body: some View {
        Text("Hello World")
            .background {
                HostingWindowFinder { window in
                    guard let window else { return }
                    window.delegate = self.customWindowDelegate
                }
            }
    }
}

And now whenever your ContentView goes fullscreen, it will auto hide the toolbar which can be revealed by moving your cursor to the top.

Upvotes: 2

Klay
Klay

Reputation: 105

I've had the same question for about a month, and just now came to a solution.

NSWindowDelegate has a method called window(_:willUseFullScreenPresentationOptions:), which controls the presentation options to use when entering full screen mode. Specifically, there's a NSApplication.PresentationOptions.autoHideToolbar property that does exactly what you want. The issue, however, is this lives on NSWindow's delegate, and in SwiftUI, modifying the delegate is not simple, as it's controlled by SwiftUI. If you try to, for example, create your own NSWindowDelegate and set it on the NSWindow, you'll find that some behaviors no longer work, such as clicking on the app icon in the Dock.

To circumvent this, you can get the NSWindow for your view and add a method to the NSWindowDelegate instance dynamically. First, here's some code demonstrating how to get the window:

@Observable
class Window {
  // NSWindow needs to be weak to avoid capturing it in views (else, views will not be deallocated, which I've experienced with .onDisappear)
  weak var window: NSWindow?
}

class AppearanceView: NSView {
  var win: Window

  init(window: Window) {
    self.win = window

    super.init(frame: .zero)
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  override func viewDidMoveToWindow() {
    super.viewDidMoveToWindow()

    win.window = self.window
  }
}

struct WindowCaptureView: NSViewRepresentable {
  let window: Window

  func makeNSView(context: Context) -> AppearanceView {
    .init(window: window)
  }

  func updateNSView(_ appearanceView: AppearanceView, context: Context) {
    appearanceView.win = window
  }
}

struct WindowViewModifier: ViewModifier {
  @State private var window = Window()

  func body(content: Content) -> some View {
    content
      .environment(window)
      .background {
        WindowCaptureView(window: window)
      }
  }
}

extension View {
  func windowed() -> some View {
    // Don't forget to apply this modifier on your WindowGroup's *view* (note: not *in* `SceneView`, but *on* it)
    self.modifier(WindowViewModifier())
  }
}

Now, for the method NSWindowDelegate should call when entering full screen mode, we first need an implementation:

class WindowDelegate {
  @MainActor
  @objc static func window(
    _ window: NSWindow,
    willUseFullScreenPresentationOptions proposedOptions: NSApplication.PresentationOptions = []
  ) -> NSApplication.PresentationOptions {
    return proposedOptions.union(.autoHideToolbar)
  }
}

Finally, in the WindowGroup's view, we can run some code before it appears to add the method to the delegate.

struct SceneView: View {
  @Environment(Window.self) private var win
  private var window: NSWindow? { win.window }

  var body: some View {
    NavigationSplitView {
      Text("Hello,")
    } detail: {
      Text("World!")
    }.task {
      guard let delegate = window?.delegate else {
        return
      }

      // Get the delegate's method in question. The method doesn't seem to be implemented by SwiftUI, which is why addMethod is being used later rather than method_exchangeImplementations.
      let prior = #selector(delegate.window(_:willUseFullScreenPresentationOptions:))
      let selector = #selector(WindowDelegate.window(_:willUseFullScreenPresentationOptions:))
      let method = class_getClassMethod(WindowDelegate.self, selector)!
      let impl = method_getImplementation(method)

      class_addMethod(delegate.superclass, prior, impl, nil)
    }
  }
}

And with that, the toolbar should be hidden when entering full screen mode and appear once the user moves their mouse to the top of the screen (similar to how Preview does it). The main issue this implementation has is it doesn't apply on scene restoration when the window is already in full screen mode. Users can fix it by exiting then entering full screen mode again, but there are probably better solutions there.

Upvotes: 1

Fault
Fault

Reputation: 1339

here is a demo for how to show and hide a ContentView toolbar when clicking the green zoom button. govern the visibility of the toolbar using a state variable. then toggle that variable using window notifications.

import SwiftUI
import AppKit

@main
struct MyApp: App {
    @State var isToolbarHidden:Bool = false

    var body: some Scene {
        WindowGroup {
            ContentView()
                .presentedWindowToolbarStyle(.unified)
                .toolbar {
                    if !isToolbarHidden {
                        ToolbarItem(placement: .automatic, content: {
                            Text("Toolfoo Item")
                        })
                    }
                }
                .onAppear {
                    NotificationCenter.default.addObserver(forName: NSWindow.willEnterFullScreenNotification, object: nil, queue: OperationQueue.main, using: { note in
                        self.isToolbarHidden = true
                    })

                    NotificationCenter.default.addObserver(forName: NSWindow.willExitFullScreenNotification, object: nil, queue: OperationQueue.main, using: { note in
                        self.isToolbarHidden = false
                    })
                }
        }
        .windowStyle(.titleBar)
    }
}

Upvotes: 1

Related Questions