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.
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:
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.
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.
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:
/// 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.
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.
- It is instantiated when the view appears, and set to
- 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:
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:
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:
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:
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:
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)
})
}
}
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!