João Gabriel
João GabrielJune 1, 2022

Make a floating panel in SwiftUI for macOS

Learn how to make a versatile floating panel component for Mac using SwiftUI and AppKit.
Make a floating panel in SwiftUI for macOS

This article is the first one of a three-part series on building a custom floating panel component for SwiftUI. You can find part two here.


In this tutorial, you'll learn how to build a SwiftUI floating panel for macOS, from scratch, step-by-step.

Starting up

Floating panels are around everywhere nowadays. They are a modern, elegant way of presenting data or asking the user for input, and have many important features such as being adaptable, often draggable and also quick to access.

Some famous apps that use them are Xcode, Notion and Spotlight.

The Xcode library is presented as a floating panel
Notion's quick find is presented as a floating panel, albeit it's not draggable
Spotlight is also a floating panel

Despite its popularity though, there is no pre-existing component for doing this in SwiftUI, AppKit or UIKit. Turns out building an implementation of this type of control for macOS is pretty straightforward, and you'll learn to do so throughout this tutorial.

Let's begin

As I said, this component can be built in a very modular way using SwiftUI and a little bit of AppKit, so make sure to have a bit of experience with both of those before diving in. The core part of this component is AppKit's NSPanel class, a subclass of NSWindow that has panel properties such as:

  • Floating on top of all other windows.
  • Staying in memory after it's closed.
  • Hiding when the application isn't active.

The backbones

The first main thing we're doing is subclassing NSPanel with a custom implementation that takes in a view:

FloatingPanel.swift
import SwiftUI
 
/// An NSPanel subclass that implements floating panel traits.
class FloatingPanel<Content: View>: NSPanel {
 
}

Please note the Content generic type declared at the top – this type will serve as the underlying SwiftUI view type stored in the panel.

We need to make sure to declare a binding property that will dictate wether or not the panel should be presented:

@Binding var isPresented: Bool

Now a neat SwiftUI trick: to extend the usability of this class, we can create and EnvironmentValue key path to the FloatingPanel parent of any view and then inject the FloatingPanel instance inside the children view.

FloatingPanel.swift
private struct FloatingPanelKey: EnvironmentKey {
    static let defaultValue: NSPanel? = nil
}
 
extension EnvironmentValues {
  var floatingPanel: NSPanel? {
    get { self[FloatingPanelKey.self] }
    set { self[FloatingPanelKey.self] = newValue }
  }
}

We should then write an initializer for our class, so that we can set all its properties and details. This is an adaption of Markus Bodner's blog post from 2021.

FloatingPanel.swift
init(view: () -> Content,
         contentRect: NSRect,
         backing: NSWindow.BackingStoreType = .buffered,
         defer flag: Bool = false,
         isPresented: Binding<Bool>) {
    /// Initialize the binding variable by assigning the whole value via an underscore
    self._isPresented = isPresented
 
    /// Init the window as usual
    super.init(contentRect: contentRect,
                styleMask: [.nonactivatingPanel, .titled, .resizable, .closable, .fullSizeContentView],
                backing: backing,
                defer: flag)
 
    /// Allow the panel to be on top of other windows
    isFloatingPanel = true
    level = .floating
 
    /// Allow the pannel to be overlaid in a fullscreen space
    collectionBehavior.insert(.fullScreenAuxiliary)
 
    /// Don't show a window title, even if it's set
    titleVisibility = .hidden
    titlebarAppearsTransparent = true
 
    /// Since there is no title bar make the window moveable by dragging on the background
    isMovableByWindowBackground = true
 
    /// Hide when unfocused
    hidesOnDeactivate = true
 
    /// Hide all traffic light buttons
    standardWindowButton(.closeButton)?.isHidden = true
    standardWindowButton(.miniaturizeButton)?.isHidden = true
    standardWindowButton(.zoomButton)?.isHidden = true
 
    /// Sets animations accordingly
    animationBehavior = .utilityWindow
 
    /// Set the content view.
    /// The safe area is ignored because the title bar still interferes with the geometry
    contentView = NSHostingView(rootView: view()
        .ignoresSafeArea()
        .environment(\.floatingPanel, self))
}

And finally, let's implement a few required methods:

FloatingPanel.swift
/// Close automatically when out of focus, e.g. outside click
override func resignMain() {
    super.resignMain()
    close()
}
 
/// Close and toggle presentation, so that it matches the current state of the panel
override func close() {
    super.close()
    isPresented = false
}
 
/// `canBecomeKey` and `canBecomeMain` are both required so that text inputs inside the panel can receive focus
override var canBecomeKey: Bool {
    return true
}
 
override var canBecomeMain: Bool {
    return true
}

This is it! Now our class lives in AppKit – and there's no way to interface it with SwiftUI. This is part of our next step: adding custom modifiers.

Modifiers

Great that you made it through here! Modifiers are functions that modify a view to produce a new output. It's important to first understand that, in SwiftUI, there are two different concepts of modifiers:

ViewModifier

This is the shining star of modifiers in SwiftUI: it works via structs that conform to the ViewModifier protocol . All that this protocol needs to be implemented is a function that returns a new modified view.

Below is how we're doing ours, so that when attached to a view it presents a floating panel according to a binding value.

Modifier+Extension.swift
import SwiftUI
 
/// Add a  ``FloatingPanel`` to a view hierarchy
fileprivate struct FloatingPanelModifier<PanelContent: View>: ViewModifier {
    /// Determines wheter the panel should be presented or not
    @Binding var isPresented: Bool
 
    /// Determines the starting size of the panel
    var contentRect: CGRect = CGRect(x: 0, y: 0, width: 624, height: 512)
 
    /// Holds the panel content's view closure
    @ViewBuilder let view: () -> PanelContent
 
    /// Stores the panel instance with the same generic type as the view closure
    @State var panel: FloatingPanel<PanelContent>?
 
    func body(content: Content) -> some View {
        content
            .onAppear {
                /// When the view appears, create, center and present the panel if ordered
                panel = FloatingPanel(view: view, contentRect: contentRect, isPresented: $isPresented)
                panel?.center()
                if isPresented {
                    present()
                }
            }.onDisappear {
                /// When the view disappears, close and kill the panel
                panel?.close()
                panel = nil
            }.onChange(of: isPresented) { value in
                /// On change of the presentation state, make the panel react accordingly
                if value {
                    present()
                } else {
                    panel?.close()
                }
            }
    }
 
    /// Present the panel and make it the key window
    func present() {
        panel?.orderFront(nil)
        panel?.makeKey()
    }
}

Please note a few things:

  • The modifier struct is capable of storing variables that are useful in the lifecycle of the view, in this case, panel.
    • It is instantiated when the view appears, and set to nil when it disappears.
  • The modifier manages the isPresented binding variable, so that it reflects the state of the presented panel and makes the panel be controlled by the outside.
    • Whenever the value changes, the panel is either presented or hidden.
  • The modifier also accepts a view closure and a contentRect for the size of the panel (as it won't be automatically managed by SwiftUI nor AppKit).

To use our modifier in a view, we must do it as so:

view.modifier(FloatingPanelModifier(isPresented: $isPresented, contentRect: contentRect, view: content))

Modifier functions

However, when most people talk about modifiers on SwiftUI, they refer to modifier functions. Those are extension functions that act just like modifiers on views, such as padding(), font() and foregroundStyle().

To make our codebase more flexible, let's go the extra mile and make a function out of our modifier like so:

Modifier+Extension.swift
extension View {
    /** Present a ``FloatingPanel`` in SwiftUI fashion
     - Parameter isPresented: A boolean binding that keeps track of the panel's presentation state
     - Parameter contentRect: The initial content frame of the window
     - Parameter content: The displayed content
     **/
    func floatingPanel<Content: View>(isPresented: Binding<Bool>,
                                      contentRect: CGRect = CGRect(x: 0, y: 0, width: 624, height: 512),
                                      @ViewBuilder content: @escaping () -> Content) -> some View {
        self.modifier(FloatingPanelModifier(isPresented: isPresented, contentRect: contentRect, view: content))
    }
}

Note that the function is declared as an extension to the View protocol. Remember that we didn't write a modifier function right away because we need to hold the panel variable inside the modifier, and modifier functions don't do that well.

Since this function will be made available for every view, it's nice and thoughtful to leave a valuable description of its properties and its features through comments.

Great, now we can create and present a panel in SwiftUI, and present a custom view inside of it just like so:

ContentView.swift
import SwiftUI
 
struct ContentView: View {
    @State var showingPanel = false
 
    var body: some View {
        Button("Present panel") {
            showingPanel.toggle()
        }
        .floatingPanel(isPresented: $showingPanel, content: {
            ZStack {
                Rectangle()
                    .fill(.white)
                Text("I'm a floating panel. Click anywhere to dismiss me.")
            }
        })
    }
}

And this is what it looks, and behaves, like:

A demo of the floating panel

One more thing: blur

What's been done so far is beautiful, and works really well. But a distinctive look of macOS's design guidelines is blur, and it lacks here. There is an easy way to make the background of the panel blurred in a way that partially shows what's behind it with SwiftUI through a representable:

VisualEffectView.swift
import SwiftUI
 
/// Bridge AppKit's NSVisualEffectView into SwiftUI
struct VisualEffectView: NSViewRepresentable {
    var material: NSVisualEffectView.Material
    var blendingMode: NSVisualEffectView.BlendingMode
    var state: NSVisualEffectView.State
    var emphasized: Bool
 
    func makeNSView(context: Context) -> NSVisualEffectView {
        context.coordinator.visualEffectView
    }
 
    func updateNSView(_ view: NSVisualEffectView, context: Context) {
        context.coordinator.update(
            material: material,
            blendingMode: blendingMode,
            state: state,
            emphasized: emphasized
        )
    }
 
    func makeCoordinator() -> Coordinator {
        Coordinator()
    }
 
    class Coordinator {
        let visualEffectView = NSVisualEffectView()
 
        init() {
            visualEffectView.blendingMode = .withinWindow
        }
 
        func update(material: NSVisualEffectView.Material,
                        blendingMode: NSVisualEffectView.BlendingMode,
                        state: NSVisualEffectView.State,
                        emphasized: Bool) {
            visualEffectView.material = material
        }
    }
  }

Alright! Now, to use this on the panel view, use a VisualEffectView as a background that fills the entire panel:

ContentView.swift
import SwiftUI
 
struct ContentView: View {
    @State var showingPanel = false
 
    var body: some View {
        Button("Present panel") {
            showingPanel.toggle()
        }
        .floatingPanel(isPresented: $showingPanel, content: {
            VisualEffectView(material: .sidebar, blendingMode: .behindWindow)
        })
    }
}
The blur on light mode
The blur on dark mode

Note that the material we're using is the same one of system sidebars, and that blendingMode being set to behindWindow: that's important for the panel's background to incorporate what's behind it across the system, and not just within the contents of the window.

What we've done so far

At this point you've probably already learned a few more things about panels, SwiftUI and how it works. We've:

  • Created a subclass of NSPanel that implements panel traits;
  • Learned how to pass an instance of a panel down the view hierarchy;
  • Saw how to interface that with SwiftUI in a clean and practical way; and
  • Implemented a visual effect view that blurs the content behind the window.

Now try to think of ways of adding a floating panel to your app, maybe for presenting specific content or as a selection control of sorts. Try to bridge it to a keyboard shortcut, and use this new ability to improve your app's UI flow and aesthetics.


Thank you so much for reading through here!

Today's tutorial is over but hey – don't fret – there's more. Learn how to take the foundations we've built to the next level with expandable views and a slick layout in part two, here.

Make sure to stay tuned to Cindori's blog for more, and have a good day!