Floating Panel sur macOS avec SwiftUI : ouvrir une fenêtre avancée depuis la menu bar

NSPanel - SwiftUI

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.

NSPanel - DevCacheCleaner

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 Binding pour piloter l’état
  • un ViewModifier pour 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


Besoin d’un regard technique sur votre projet ?
Je peux vous accompagner sur vos développements.