Trucs et astuces pour AppKit

AppKit est puissant, mais AppKit est vieux et bougon. Contrairement à UIKit pour iOS, AppKit est souvent obscur et mystérieux. Voici quelques solutions glanées au fil du temps.

Fichier > Ouvrir éléments récents

Si votre application est “Document-based” alors vous bénéficiez directement et automatiquement de cette fonction.

Sinon, il vous faut implémenter vous-même quelques éléments.

Ouvrir à partir du menu

Il faut tout d'abord créer une sous-classe de NSDocumentController.

Dans cette sous-classe, il faut surcharger la méthode maximumRecentDocumentCount pour retourner le nombre maximum de fichiers affichés dans le menu “Fichier > Ouvrir l'élément récent”.

class MyDocumentController: NSDocumentController {

    override var maximumRecentDocumentCount: Int {
        return 10
    }

}

Et pour ouvrir un fichier quand l'utilisateur clique sur un élément de la liste, il faut implémenter la méthode application(_ sender: NSApplication, openFile filename: String) dans AppDelegate.

A partir de cette méthode on récupère le chemin du fichier.

Pour rediriger ça vers une autre classe je propose d'utiliser un “delegate" :

protocol MenuDelegate {
    func open(path: String)
}

Puis dans AppDelegate :

var menuDelegate: MenuDelegate?

func application(_ sender: NSApplication, openFile filename: String) -> Bool {
    menuDelegate?.open(path: filename)
    return true
}

Ensuite dans votre ViewController vous récupérez l'instance de l'AppDelegate et vous déclarez le ViewController comme délégué du protocole.

var appDelegate: AppDelegate?
let myDocumentController = MyDocumentController()

override func viewDidAppear() {
    super.viewDidAppear()
    if let appDel = NSApplication.shared.delegate as? AppDelegate {
        appDelegate = appDel
        appDelegate?.menuDelegate = self
    }
}

Puis vous lisez le contenu du fichier à partir de la méthode du delegate.

extension ViewController: MenuDelegate {
    func open(path: String) {
        // lisez le contenu du fichier
    }
}

Ajouter un fichier au menu

Quand vous ouvrez un nouveau fichier qui n'est pas encore dans connu de l'application (à partir d'un NSOpenPanel ou autre moyen), faites passer son URL à votre sous-classe de NSDocumentController :

myDocumentController.noteNewRecentDocumentURL(url)

Déposer un fichier sur l'application

Ce bon vieux "Drag n drop” bien pratique. L'exemple classique donné partout c'est de déposer une image sur un NSImageView pour remplacer son contenu.

Mais comment faire si vous voulez déposer n'importe quel fichier sur n'importe quelle NSView ?

Drop sur NSView

Faites une sous-classe de NSView, et passez le type de drop que vous souhaitez utiliser à registerForDraggedTypes, ici des chemins et noms de fichier.

Puis dans draggingEntered(_ sender: NSDraggingInfo) vérifiez que l'extension du fichier déposé corresponde à ce que vous aceptez.

Enfin passez l'URL du fichier à un delegate (ou NSNotification ou autre).

protocol DragDropDelegate {
    func dropped(path: String)
}

class MainView: NSView {

    var delegate: DragDropDelegate?
    let allowed = ["txt", "md", "markdown", "rtf"]
    let type = NSPasteboard.PasteboardType("NSFilenamesPboardType")

    override init(frame frameRect: NSRect) {
        super.init(frame: frameRect)
        registerForDraggedTypes([type])
    }

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

    override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
        if checkExtension(sender) {
            return .copy
        }
        return []
    }

    override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
        guard let board = sender.draggingPasteboard.propertyList(forType: type) as? [String], let path = board.first
        else { return false }

        delegate?.dropped(path: path)
        return true
    }

    private func checkExtension(_ drag: NSDraggingInfo) -> Bool {
        guard let board = drag.draggingPasteboard.propertyList(forType: type) as? [String], let path = board.first
        else { return false }

        let suffix = URL(fileURLWithPath: path).pathExtension
        return allowed.contains(suffix.lowercased())
    }

}

Fenêtres “Polices” et “Couleurs”

Si vous voulez utiliser les fenêtres Polices et Couleurs avec votre NSTextView, faites une sous-classe et surchargez les méthodes changeFont(_ sender: Any?) et changeColor(_ sender: Any?).

class MyTextView: NSTextView {

    override func changeFont(_ sender: Any?) {
        guard let fm = sender as? NSFontManager, let currentFont = self.font else {
            return
        }
        let newFont = fm.convert(currentFont)
        self.font = newFont
    }

    override func changeColor(_ sender: Any?) {
        guard let cp = sender as? NSColorPanel else {
            return
        }
        let newColor = cp.color
        textColor = newColor
        // inverted for selected text:
        selectedTextAttributes = [NSAttributedString.Key.foregroundColor: backgroundColor, NSAttributedString.Key.backgroundColor: newColor]
    }

}

Ces objets sont des singletons, ils sont automatiquement disponibles pour toute NSTextView qui implémente ces méthodes.

Si Xcode n'a pas connecté ces éléments, faites-le vous-même : connectez le menu “Format > Police > Montrer les polices” à la méthode orderFrontPanel de l'objet Font Manager, et connectez le menu “Format > Police > Montrer les couleurs” à la méthode orderFrontColorPanel de l'objet First Responder.

Ajouter un langage

Un nouveau projet dans Xcode n'est par défaut pas localisé, il n'utilise qu'une langue. Imaginons que vous ayez créé une app et que tous les textes sont en anglais, et vous voulez maintenant ajouter le français.

Voici les étapes à suivre.

Storyboards et Xibs

Sélectionnez votre projet (en haut à gauche de la liste de fichiers dans Xcode) et dans “Général” cliquez sur la box “Use Base Internationalization”.

Juste au-dessus, dans le champ “Localizations”, cliquez sur le signe “+” et ajoutez le nouveau langage, “Français” dans notre exemple.

Xcode va automatiquement créer des fichiers spécifiques pour les traductions des Storyboard et Xibs.

Attention : une fois que c'est fait, si vous ajoutez des nouveaux éléments à vos Storyboards ou Xibs, Xcode ne mettra pas à jour les fichiers de langues, il faudra le faire vous-même.

Texte déclaré dans le code

Il faut maintenant créer des fichiers de langue pour le texte qui est déclaré dans le code, comme par exemple :

myLoginButton.title = "Click here"

Allez dans le menu “Fichier > Nouveau > Fichier” et choisissez “Strings File”, nommez ce fichier “Localizable.strings” et sauvez-le dans le projet.

Une fois fait, sélectionnez le fichier nouvellement créé et cliquez sur le bouton “Localize” et cliquez sur la box du nouveau langage. Xcode va créer deux fichiers, un pour le langage de base (anglais) et un pour le nouveau langage (français dans notre exemple).

Il faut maintenant remplir ces fichiers avec les traductions, et utiliser NSLocalizedString dans le code au lieu des textes eux-mêmes.

Je suggère fortement que vous utilisiez des noms de code pour les éléments, tout comme Xcode le fait pour les traductions de Storyboards et Xibs.

Par exemple, dans “Localizable.strings (Base)" :

"loginButton.title" = "Click here";

Et dans "Localizable.strings (French)" :

"loginButton.title" = "Cliquez ici";

Attention de ne pas oublier le point virgule à la fin de chaque ligne sinon ça ne marchera pas.

Ensuite dans le code :

myLoginButton.title = NSLocalizedString("loginButton.title", comment: "")

Quand l'applications sera lancée, elle affichera automatiquement les textes d'une langue ou de l'autre en fonction des réglages du système.

Comme c'est un peu lourd de taper NSLocalizedString("thing.stuff", comment: "") pour chaque objet, j'aime bien utiliser cette extension :

extension String {

    func localized() -> String {
        return NSLocalizedString(self, comment: "")
    }

}

Comme ça on peut faire ensuite :

myLoginButton.title = "loginButton.title".localized()

Et pour éviter les problèmes de fautes de frappe, vous pouvez faire une classe qui contiendra tous les textes localisés :

class Literals {

    static let loginButtonTitle = "loginButton.title".localized()

}

Ensuite il suffira de faire :

myLoginButton.title = Literals.loginButtonTitle
anotherLoginButtonElsewhere.title = Literals.loginButtonTitle

De cette façon on ne tape les codes correspondant aux textes qu'une seule fois, dans la classe.

Pour tester la localisation pendant qu'on développe, il n'y pas besoin de changer la langue du système, il suffit d'indiquer à Xcode quelle langue utiliser : allez dans "Edit Scheme”, sélectionnez “Run” et choisissez le langage de l'application dans le popup “Application language”.

Je précise qu'utiliser des codes pour les éléments texte peut paraître bizarre mais en fait c'est plus sécurisé que d'utiliser les textes eux-même comme ceci :

Dans “Localizable.strings (Base)" :

"Click here" = "Click here";

Et dans "Localizable.strings (French)" :

"Click here" = "Cliquez ici";

Et dans le code :

myLoginButton.title = NSLocalizedString("Click here", comment: "")

Bien sûr ça marche aussi mais je ne le recommande vraiment pas, vous ferez forcément des fautes de frappe et c'est ensuite une tannée pour débugger.

Utiliser NSImageView comme un bouton

Bien sûr pour faire un bouton cliquable vous pouvez utiliser NSButton. Mais NSButton est très bizarre, et parfois vous voulez juste pouvoir cliquer sur une image pour déclencher une action.

La souris

La solution est simple, il faut créer une sous-classe de NSImageView et surcharger la méthode mouseDown(with event: NSEvent) :

class MyImageButton: NSImageView {

    var someDelegate: SomeDelegate?

    override init(frame frameRect: NSRect) {
        super.init(frame: frameRect)
        translatesAutoresizingMaskIntoConstraints = false // si vous utilisez autolayout avec Swift au lieu d'Interface Builder
        wantsLayer = true
        layer?.cornerRadius = 0
        isEnabled = true
        image = NSImage(named: "monImage")
    }

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

    override func mouseDown(with event: NSEvent) {
        super.mouseDown(with: event)
        someDelegate?.doSomething()
    }

}

NSTextField background

Vous avez essayé d'afficher un NSTextField avec une couleur en background, ou de mettre le background transparent ? Ca ne marche pas, bizarrement.

C'est que NSTextField est antique, il descend presque directement de NextStep sans avoir évolué, et il a besoin de trois choses pour être capable de dessiner un background, que ce soit en couleur ou transparent :

myTextField.drawsBackground = true
myTextField.isEditable = false
myTextField.isBezeled = false

Et oui, il faut lui dire tu n'as pas de bezel, tu n'es pas editable, et tu dois dessiner ton background… et là, et seulement là, ça marche.

myTextField.backgroundColor = .clear
myTextField.textColor = some color

NSUnderlineStyle

C'est facile de changer la couleur ou le style d'un texte, par exemple dans une NSTextView, en utilisant NSAttributedString.

Oui mais voilà, avez-vous essayé de souligner un mot ou une phrase ?

La documentation nous dit par exemple d'utiliser NSUnderlineStyle.single pour une ligne simple. Sauf que, quand on le fait… ça fait planter l'application !

Il y a un bug sévère quelque part qui n'a jamais été corrigé. La solution, au lieu d'utiliser NSUnderlineStyle.single, est d'utiliser… NSUnderlineStyle.single.rawValue. Wow, alors celui-là il est beau.

class MyButton: NSButton {
    init(title: String, enabled: Bool) {
    super.init(frame: .zero)
    self.title = title
    self.font = NSFont.systemFont(ofSize: 16)
    self.translatesAutoresizingMaskIntoConstraints = false // si vous utilisez autolayout avec Swift au lieu d'Interface Builder
    self.isBordered = false
    self.focusRingType = .none

    let attrTitle = NSMutableAttributedString(string: self.title)
    attrTitle.setAttributes([NSAttributedString.Key.backgroundColor : NSColor.clear, NSAttributedString.Key.foregroundColor: NSColor.red], range: NSRange(location: 0, length: (title as NSString).length))
    self.attributedTitle = attrTitle

    let attrUnder = NSMutableAttributedString(attributedString: self.attributedTitle)
    attrUnder.addAttribute(NSAttributedString.Key.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: NSRange(location: 0, length: (attributedTitle.string as NSString).length))
    attrUnder.addAttribute(NSAttributedString.Key.underlineColor, value: NSColor.red, range: NSRange(location: 0, length: (attributedTitle.string as NSString).length))
    self.attributedTitle = attrUnder
    }
}

Aussi, notez que parfois la longueur de NSAttributedString est différente de celle de la String qu'il contient, donc une fois les attributs appliqués, il vaut mieux utiliser NSRange(location: 0, length: (attributedTitle.string as NSString).length) plutôt que NSRange(location: 0, length: (title as NSString).length), juste au cas où.

NSVisualEffectView

Il est aisé dans Interface Builder de faire en sorte que le background d'une fenêtre soit semi-transparent au lieu d'opaque, il suffit d'ajouter un objet NSVisualEffectView juste derrière la vue principale.

Mais comment faire pareil en code ?

Faites comme ceci dans votre ViewController :

override func loadView() {
    super.loadView()
    let v = MainView(frame: .zero)
    view = v
    view.wantsLayer = true
    let visu = NSVisualEffectView(frame: .zero)
    visu.translatesAutoresizingMaskIntoConstraints = false
    visu.blendingMode = .behindWindow
    visu.material = .dark
    visu.state = .active
    view.addSubview(visu)
    NSLayoutConstraint.activate([
        visu.topAnchor.constraint(equalTo: view.topAnchor),
        visu.leadingAnchor.constraint(equalTo: view.leadingAnchor),
        visu.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        visu.trailingAnchor.constraint(equalTo: view.trailingAnchor)
    ])
}

Fenêtre "A propos de…”

AppKit génère automatiquement une fenêtre “A propos” pour votre application, et cette fenêtre contient les informations de base telles que le nom de l'app et le numéro de version.

Comment faire pour à la place obtenir une fenêtre personnalisée ?

Credits.rtf

Une première solution est d'ajouter un fichier RTF nommé “Credits.rtf” dans le projet : AppKit injectera alors automatiquement le contenu de ce fichier dans cette fenêtre !

Manuellement

On peut aussi créer soi-même une fenêtre “A propos”, voici une solution proposée par Nicolas Miari.

Créez une fenêtre dans Interface Builder, avec toutes les vues et contenus que vous souhaitez.

Créez ensuite un NSWindowController qui contient ceci, pour en faire un singleton :

static let defaultController: AboutWindowController = {
    let storyboard = NSStoryboard(name: NSStoryboard.Name("AboutWindow"), bundle:nil)
    guard let windowController = storyboard.instantiateInitialController() as? AboutWindowController else {
        fatalError("Storyboard inconsistency")
    }
    return windowController
}()

Connectez les vues à des outlets dans Interface Builder, et spécifiez en tant que “File Owner” le nom de votre NSWindowController, et connectez un “Referencing Outlet” à File Owner.

Allez dans MainMenu.xib (ou dans le storyboard) et supprimez l'action qui est attachée au menu “A propos”, et à la place connectez-le à la méthode about du l'objet “First Responder”.

Créez une IBAction de ce menu vers AppDelegate :

@IBAction func about(_ sender: Any) {
    AboutWindowController.defaultController.window?.orderFront(self)
}

Dans votre sous-classe de NSViewController, attribuez vos contenus aux objets.

Si vous voulez avoir accès aux informations de l'application :

class AboutViewController: NSViewController {

    @IBOutlet weak var appNameLabel: NSTextField!
    @IBOutlet weak var appVersionLabel: NSTextField!
    @IBOutlet weak var appCopyrightLabel: NSTextField!
    @IBOutlet weak var appIconImageView: NSImageView!

    override func viewDidLoad() {
        super.viewDidLoad()
        self.appIconImageView.image = NSApp.applicationIconImage
        if let infoDictionary = Bundle.main.infoDictionary {
            self.appNameLabel.stringValue = infoDictionary["CFBundleName"] as? String ?? ""
            self.appVersionLabel.stringValue = infoDictionary["CFBundleShortVersionString"] as? String ?? ""
            self.appCopyrightLabel.stringValue = infoDictionary["NSHumanReadableCopyright"] as? String ?? ""
        }
    }
}

Conclusion

Voilà, ce ne sont que quelques trucs et astuces que j'ai accumulé à propos de sujets qui reviennent souvent et qui à chaque fois me faisaient demander mais pourquoi ça ne marche pas… C'est une sorte de pense-bête que je partage avec vous. Rien de révolutionnaire, mais bien utile !

Auteur: Eric Dejonckheere