Introduction
Lorsque l’on développe une application macOS basée sur une menu bar, une limite apparaît rapidement : comment afficher une interface plus riche sans casser l’expérience utilisateur ?
Dans mon cas, après avoir mis en place une menu bar pour mon outil de nettoyage de caches développeur, j’ai eu besoin d’aller plus loin. L’objectif était simple en apparence : afficher une vue détaillée des caches lorsqu’un utilisateur clique sur un élément, tout en gardant une interaction fluide, légère et cohérente avec macOS.
C’est là qu’intervient le concept de floating panel avec NSPanel.
Dans cet article, nous allons voir comment implémenter un NSPanel avec SwiftUI, pourquoi ce choix est souvent plus pertinent qu’une fenêtre classique, et comment résoudre deux problématiques concrètes :
- fermer automatiquement le panel lorsque la vue principale disparaît
- positionner dynamiquement le panel à côté de la fenêtre principale
Le tout avec une approche pragmatique issue d’un projet réel.
Pourquoi utiliser un floating panel plutôt qu’une fenêtre classique ?
NSWindow vs NSPanel : une différence clé
Sur macOS, ouvrir une nouvelle interface peut se faire de plusieurs manières. La plus classique reste l’utilisation d’un NSWindow, mais ce n’est pas toujours la meilleure option.
Un NSPanel est en réalité une sous-classe de NSWindow, mais avec un comportement spécifique conçu pour des interfaces secondaires, comme :
- des panneaux flottants
- des inspecteurs
- des fenêtres contextuelles non bloquantes
Contrairement à une fenêtre classique, un NSPanel permet :
- de rester au-dessus des autres fenêtres
- de ne pas forcément apparaître dans le dock
- de se comporter comme un élément temporaire de l’interface
- de mieux gérer le focus utilisateur
Dans le cadre d’une application en menu bar, ces caractéristiques sont essentielles.
Une fenêtre classique aurait donné une sensation de rupture dans l’expérience. À l’inverse, le floating panel s’intègre naturellement, comme une extension de la vue principale.
Cas concret : afficher une vue de détail des caches
Dans mon application, chaque catégorie de cache peut être explorée plus en profondeur. Lorsqu’un utilisateur clique sur un élément, un panneau vient s’ouvrir à côté de la vue principale pour afficher les chemins détaillés.

Cette approche permet de conserver le contexte tout en apportant un niveau de détail supplémentaire. L’utilisateur n’a pas besoin de naviguer entre plusieurs fenêtres. l’information apparaît immédiatement, au bon endroit.
Ce type d’interaction est particulièrement adapté aux outils développeur, où la lisibilité et la rapidité d’accès sont primordiales.
Une base solide : l’approche proposée par l’article du site Cindori
SwiftUI ne propose pas encore de solution native complète pour gérer les NSPanel. Il est donc nécessaire de s’appuyer sur AppKit.
Pour cela, je me suis basé sur un article de référence proposé par le site Cindori, qui présente une approche propre et structurée pour intégrer un floating panel dans une application SwiftUI.

Ce qui rend cette approche intéressante, ce n’est pas uniquement le code, mais surtout la manière dont elle organise les responsabilités :
- une sous-classe de
NSPanel - une intégration via
NSHostingView - un
Bindingpour piloter l’état - un
ViewModifierpour exposer le comportement côté SwiftUI
Cette base permet de construire une implémentation claire, évolutive, et surtout réutilisable.
Implémentation : créer un FloatingPanel avec SwiftUI
Créer une sous-classe de NSPanel
La première étape consiste à encapsuler la logique dans une classe dédiée.
import SwiftUI
import AppKit
final class FloatingPanel<Content: View>: NSPanel {
@Binding private var isPresented: Bool
init(
contentRect: NSRect,
isPresented: Binding<Bool>,
@ViewBuilder content: () -> Content
) {
self._isPresented = isPresented
super.init(
contentRect: contentRect,
styleMask: [.nonactivatingPanel, .titled, .fullSizeContentView],
backing: .buffered,
defer: false
)
/// Autoriser le placement du panneau au-dessus des autres fenêtres
isFloatingPanel = true
level = .floating
/// Autoriser la superposition du panneau dans un espace plein écran
collectionBehavior.insert(.fullScreenAuxiliary)
/// Ne pas afficher le titre de la fenêtre, même s'il est défini.
titleVisibility = .hidden
titlebarAppearsTransparent = true
backgroundColor = .clear
isOpaque = false
// Ne pas autoriser le déplacement du panneau
isMovable = false
isReleasedWhenClosed = false
/// Masquer tous les boutons de la fenêtre
standardWindowButton(.closeButton)?.isHidden = true
standardWindowButton(.miniaturizeButton)?.isHidden = true
standardWindowButton(.zoomButton)?.isHidden = true
/// Configure les animations en conséquence
animationBehavior = .utilityWindow
contentView = NSHostingView(rootView: content())
}
override func close() {
super.close()
isPresented = false
}
/// Les propriétés `canBecomeKey` et `canBecomeMain` sont toutes deux requises
/// pour que les champs de texte à l'intérieur du panneau puissent recevoir le focus.
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }
}
Cette implémentation permet d’intégrer une vue SwiftUI dans un NSPanel, tout en gardant un contrôle sur son état d’affichage.
Intégration côté SwiftUI
Pour rendre l’utilisation plus naturelle, j’ai encapsulé l’ouverture du panel dans un ViewModifier.
import SwiftUI
import AppKit
struct FloatingPanelModifier<PanelContent: View>: ViewModifier {
/// Détermine si le panel doit être présenté ou non
@Binding var isPresented: Bool
/// Contient la vue du contenu du panneau
let content: () -> PanelContent
/// Stocke l'instance du panneau
@State private var panel: FloatingPanel<PanelContent>?
func body(content view: Content) -> some View {
view
.onAppear {
ensurePanel()
}
.onChange(of: isPresented) { _, newValue in
if newValue {
present()
} else {
panel?.close()
}
}
}
/// Présentez le panneau et en faire la fenêtre principale
func present() {
panel?.orderFront(nil)
panel?.makeKey()
}
/// Lorsque la vue apparaît, créez et présentez le panneau si nécessaire.
func ensurePanel() {
if panel == nil {
panel = FloatingPanel(
contentRect: NSRect(x: 0, y: 0, width: 400, height: 500),
isPresented: $isPresented,
content: content
)
}
}
}Cette approche permet de garder un code SwiftUI propre et lisible, tout en déléguant la complexité à AppKit.
Exposer le panel avec une extension SwiftUI
Une fois le FloatingPanelModifier en place, il reste encore une étape importante : proposer une API simple à utiliser depuis les vues SwiftUI.
L’idée est d’éviter d’appliquer manuellement le modifier à chaque fois avec une syntaxe trop verbeuse. À la place, on peut ajouter une extension sur View pour exposer une méthode dédiée, plus lisible et plus naturelle à utiliser dans le code.
extension View {
func floatingPanel<Content: View>(
isPresented: Binding<Bool>,
@ViewBuilder content: @escaping () -> Content
) -> some View {
self.modifier(
FloatingPanelModifier(
isPresented: isPresented,
content: content
)
)
}
}Cette extension ne fait pas grand-chose en apparence, mais elle joue un rôle important dans la qualité de l’intégration. Elle permet de masquer la complexité du ViewModifier derrière une extension beaucoup plus expressive.
Une fois en place, l’utilisation devient très simple :
@State private var showPanel = false
Button("Voir le détail") {
showPanel.toggle()
}
.floatingPanel(isPresented: $showPanel) {
DetailView()
}On retrouve ici toute la simplicité de SwiftUI, avec en arrière-plan une implémentation robuste basée sur NSPanel.
Gérer le cycle de vie : fermer le panel correctement
L’un des principaux défis rencontrés concerne la fermeture du panel.
Par défaut, un NSPanel peut rester affiché même si la vue principale disparaît. Dans une application en menu bar, ce comportement devient rapidement problématique.
Pour répondre à ce besoin, j’ai choisi de fermer automatiquement le panel dès qu’il perd le focus.
Cela se fait directement dans la sous-classe FloatingPanel, en surchargeant la méthode resignKey :
override func resignKey() {
super.resignKey()
if isVisible {
close()
}
}Cette méthode est appelée dès que le panel n’est plus la fenêtre active, par exemple lorsqu’un utilisateur clique en dehors.
Le comportement devient alors beaucoup plus naturel, le panel agit comme une interface contextuelle, qui apparaît lorsqu’on en a besoin, puis disparaît automatiquement dès que l’on passe à autre chose.
Ce type de détail peut sembler mineur, mais il joue un rôle essentiel dans la perception globale de l’application. Sans cette logique, on se retrouve facilement avec des panels persistants qui donnent une impression d’interface mal maîtrisée.
Positionner le panel à côté de la fenêtre principale
Une autre difficulté importante dans cette implémentation concerne le positionnement du panel.
Par défaut, un NSPanel s’ouvre sans tenir compte du contexte visuel de l’application. Dans mon cas, ce n’était pas acceptable. L’objectif était clair : le panel devait apparaître comme une extension directe de la fenêtre principale, et non comme une fenêtre indépendante.
Pour cela, j’ai ajouté une logique de positionnement directement dans le FloatingPanelModifier, afin de calculer dynamiquement la position du panel au moment de son affichage.
func positionPanel() {
// Si la fenêtre du panel n'existe pas, on ne peut rien faire.
guard let panel else { return }
// Si la fenêtre hôte n'existe pas, on centre simplement le panel.
guard let hostWindow = NSApplication.shared.windows.first else {
panel.center()
return
}
let gap: CGFloat = 12
let hostFrame = hostWindow.frame
let panelSize = panel.frame.size
// Zone visible de l’écran.
// On prend d'abord l’écran de la fenêtre hôte, puis celui du panel,
// puis l’écran principal comme secours.
let visibleFrame =
hostWindow.screen?.visibleFrame ??
panel.screen?.visibleFrame ??
NSScreen.main?.visibleFrame ??
.zero
var newOrigin = panel.frame.origin
// Positions possibles en X :
// - à gauche de la fenêtre hôte
// - à droite de la fenêtre hôte
let leftX = hostFrame.minX - gap - panelSize.width
let rightX = hostFrame.maxX + gap
if leftX >= visibleFrame.minX {
// On préfère afficher le panel à gauche si ça rentre.
newOrigin.x = leftX
} else if rightX + panelSize.width <= visibleFrame.maxX {
// Sinon, on essaye à droite.
newOrigin.x = rightX
} else {
// Sinon, on le garde dans la zone visible, même s’il ne peut pas
// être parfaitement placé à gauche ou à droite.
newOrigin.x = min(
max(rightX, visibleFrame.minX),
visibleFrame.maxX - panelSize.width
)
}
// Alignement vertical :
// on aligne le haut du panel avec le haut de la fenêtre hôte.
let topAlignedY = hostFrame.maxY - panelSize.height
// On force ensuite la position dans les limites visibles de l’écran.
newOrigin.y = min(
max(topAlignedY, visibleFrame.minY),
visibleFrame.maxY - panelSize.height
)
let newFrame = NSRect(origin: newOrigin, size: panelSize)
panel.setFrame(newFrame, display: false)
}L’idée ici est de positionner le panel intelligemment en fonction de l’espace disponible à l’écran.
Plutôt que de le placer systématiquement à droite ou à gauche, la logique s’adapte automatiquement. Si l’espace est suffisant, le panel s’affiche à gauche de la fenêtre principale. Sinon, il se positionne à droite. Et si aucune des deux positions n’est idéale, il reste contraint dans la zone visible de l’écran.
L’alignement vertical suit la même logique. Le panel est aligné avec le haut de la fenêtre principale, puis ajusté si nécessaire pour rester entièrement visible.
Mais pour que ce positionnement soit réellement efficace, il doit être appliqué au bon moment.
Dans mon cas, cette logique est appelée juste avant l’affichage du panel, dans la méthode present() de la classe FloatingPanelModifier:
func present() {
positionPanel()
panel?.orderFront(nil)
panel?.makeKey()
}L’ordre des opérations est important. Le panel est d’abord positionné, puis affiché et activé. Cela évite un effet visuel où il apparaîtrait à un endroit avant de se déplacer, ce qui donnerait une impression d’interface approximative.
Avec cette approche, le panel est immédiatement affiché au bon endroit, ce qui renforce la sensation de fluidité.
Ce que ce choix apporte concrètement
Avec le recul, l’utilisation d’un NSPanel dans ce contexte apporte plusieurs bénéfices.
L’interface reste légère, tout en permettant d’afficher des informations complexes. L’utilisateur conserve son contexte, ce qui améliore la lisibilité et la compréhension.
D’un point de vue technique, cette approche permet également de structurer proprement le code, en séparant clairement les responsabilités entre SwiftUI et AppKit.
Enfin, elle offre une grande flexibilité pour faire évoluer l’interface sans remettre en cause l’architecture globale.
Conclusion
Mettre en place un floating panel avec SwiftUI demande un peu de travail, notamment pour gérer correctement le cycle de vie et le positionnement.
Mais c’est précisément ce type de détail qui permet de passer d’une application fonctionnelle à une application réellement agréable à utiliser.
Si vous développez une application macOS, en particulier une application basée sur la menu bar, le NSPanel est une solution particulièrement pertinente pour enrichir votre interface sans la complexifier.
Et comme souvent, ce sont ces choix d’architecture et d’expérience utilisateur qui font toute la différence.
L’implémentation complète est disponible sur GitHub pour aller plus loin et adapter cette approche à vos propres projets : 👇
Article similaire
- Créer un outil macOS pour nettoyer les caches développeur : retour d’expérience
- Comment créer une menu bar utile sur macOS avec SwiftUI


