Glisser/déposer une image avec Swift

Avec Swift et Cocoa, le pouvoir dont on dispose en seulement quelques lignes de code est impressionant.

Un mécanisme typique d'OS X est le “drag & drop” (“glisser/déposer”) : avec la souris ou le trackpad, on attrape un fichier image à partir du Finder et on le dépose sur la fenêtre d'une application, et l'image apparaît instantanément dans l'application, donnant à l'utilisateur la sensation d'avoir effectué un geste naturel.

Nous allons créer en quelques minutes une micro application pour démontrer ce principe.

Article mis-à-jour le 2015-06-12.

Les exemples sont écrits pour Swift 1.2 mais une mise-à-jour pour Swift 2.0 est présente à la fin du tutoriel.

Le principe

Il faut décider quelle zone de l'interface de l'application sera réceptive au glisser-déposer.

Dans notre cas, ce sera la partie haute d'une fenêtre divisée en deux parties (nous ferons apparaître du texte dans la partie basse), mais cela pourrait être n'importe quelle zone de l'application.

Cette partie haute de la fenêtre sera une sous-classe de NSImageView, que nous allons nommer DemoImageView dans ce tutoriel.

Cette classe, qui va être l'objet représentant la zone affichant une image, devra également souscrire au protocle NSDraggingDestination pour être capable d'utiliser le drag & drop.

En utilisant les méthodes dont notre classe va hériter, nous allons pouvoir détecter si un “drag” a commencé, est en cours, ou a terminé.

Et en ajoutant une aire de “tracking” (suivi) à cette classe nous allons pouvoir détecter les mouvements de la souris (c'est optionnel, mais utile pour cette démo).

En personnalisant un peu notre classe, nous allons également pouvoir détecter si l'objet déposé par l'utilisateur est bien une image, et sinon gérer l'erreur; tout cela par messages affichés dans la partie texte de la fenêtre.

L'interface

Créez un nouveau projet Mac OS X dans Xcode (Cocoa Application), cliquez sur le fichier .xib pour afficher Interface Builder.

Sélectionnez la fenêtre créée par défaut par Xcode et allongez-là verticalement. Dans ses attributs, première section de l'inspecteur, décochez “Use auto-layout”.

Tapez “image” dans le champ en bas à droite de Xcode pour faire apparaître un template “Image View”, attrapez-le et déposez le sur la fenêtre. Modifiez-le pour qu'il prenne toute la partie haute, coupant la fenêtre en deux.

Tapez “text” dans le champ et choisissez un “Text View”, déposez-le sur la partie basse de la fenêtre et également, faites-le occuper toute la zone restante.

Optionnellement, comme dans mon exemple, vous pouvez ajouter un label par-dessus l'ImageView, label qu'on fera disparaître dès qu'une image sera déposée sur la zone.

Création de notre classe

Nous n'allons pas utiliser le fichier AppDelegate car nous n'en aurons pas besoin.

Créez un nouveau fichier de type “Cocoa Class”, nommez-le DemoImageView dans le premier champ et spécifiez NSImageView dans le second champ.

Ajoutez le protocole NSDraggingDestination, et vous devriez avoir ça :

import Cocoa

class DemoImageView: NSImageView, NSDraggingDestination {

}

Relier l'interface au code

Retournez sur MainMenu.xib, sélectionnez la vue que l'on a placé dans la partie haute de la fenêtre et dans le “Identity Inspector” (troisième icone) remplacez NSImageView par DemoImageView, indiquant ainsi que nous souhaitons que cet objet hérite de notre sous-classe plutôt que de l'original.

Nous allons référencer l'objet qui est en bas de la fenêtre, la vue texte, pour pouvoir y accéder. Si vous avez mis un label “Déposez une image ici”, référencez-le également.

Pour cela on va déclarer des “IBOutlet” dans notre classe :

@IBOutlet var textView: NSTextView!
@IBOutlet var placeholderMessage: NSTextField!

Retournez une fois de plus sur MainMenu.xib, sélectionnez la vue texte, puis dans le “Connections Inspector” (sixième icone) connectez un “Referencing Outlet” vers notre “Demo Image View” (une petite liste s'affiche, cliquez sur le label “textView”), puis faites pareil pour le label en le connectant à “placeholderMessage” dans “Demo Image View”.

Voilà, tous les élements graphiques sont reliés au code : la vue image vers une sous-classe de NSView nommée DemoImageView, la vue texte par un outlet NSTextView dans notre sous-classe, et optionnellement un label NSTextField par un outlet dans notre sous-classe.

Préparation de la classe

Nous allons rendre notre classe conforme au protocole dont elle hérite en ajoutant un init dans lequel nous allons déclarer que notre classe surveillera les drag & drop de fichiers :

required init?(coder: NSCoder) {
    super.init(coder: coder)
    self.registerForDraggedTypes([NSFilenamesPboardType])
}

Puis on va utiliser les méthodes modèles, pour le moment vides et retournant true par défaut :

override func draggingEntered(sender: NSDraggingInfo) -> NSDragOperation {
    return true
}

override func performDragOperation(sender: NSDraggingInfo) -> Bool {
    return true
}

Pour constater que le drag & drop fonctionne, ajoutez des println dans les deux méthodes.

Vous devriez avoir ceci :

import Cocoa

class DemoImageView: NSImageView, NSDraggingDestination {

    @IBOutlet var textView: NSTextView!
    @IBOutlet var placeholderMessage: NSTextField!

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        self.registerForDraggedTypes([NSFilenamesPboardType])
    }

    override func draggingEntered(sender: NSDraggingInfo) -> NSDragOperation {
        println("Le drag a commencé")
        return true
    }

    override func performDragOperation(sender: NSDraggingInfo) -> Bool {
        println("Le drop a été effectué")
        return true
    }

}

La méthode draggingEntered sera appellée automatiquement par Cocoa dès qu'un drag vers notre classe aura commencé, et la méthode performDragOperation dès que l'utilisateur relâche le bouton de la souris alors qu'il est sur la fenêtre, c'est-à-dire lorsqu'il effectue le drop.

C'est le moment de compiler le projet (CMD+R) et de vérifier que la fenêtre vide apparaisse correctement, et que les messages s'affichent bien dans la console de Xcode quand vous droppez des fichiers sur la partie ImageView de notre démo app.

Récupérer le fichier déposé lors du drop

Lorsque le fichier est déposé, la méthode performDragOperation reçoit un objet sender de type NSDraggingInfo, cet objet contient entre autres le pasteBoard, c'est-à-dire un espace mémoire partagé entre les applications qui contient ce que vous copiez (avec CMD+C par exemple) ainsi que les drag & drop.

Ce pasteBoard contient un champ “NSFilenamesPboardType” qui contient le(s) chemin(s) vers le(s) fichier(s) en cours de dépôt (notre app a le droit d'y accèder car elle y souscrit avec registerForDraggedTypes).

On y accède comme ceci :

if let pasteBoard = sender.draggingPasteboard().propertyListForType("NSFilenamesPboardType") as? [AnyObject] {
    // ici `pasteBoard` est un array de (peut-être) chemins
}

On pourrait faire, par exemple :

override func performDragOperation(sender: NSDraggingInfo) -> Bool {
    if let path = getFilePathFromPasteBoard(sender) {
        if let img = NSImage(contentsOfFile: path) {
            // on prend l'image et on la place dans notre vue
            self.image = img
            // ok, c'est fait !
            return true
        }
        // le fichier n'est pas une image
        return false
    }
    // rien dans le pasteboard
    return false
}

private func getFilePathFromPasteBoard(sender: NSDraggingInfo) -> String? {
    if let pasteBoard = sender.draggingPasteboard().propertyListForType("NSFilenamesPboardType") as? [AnyObject], path = pasteBoard[0] as? String {
        println("Pasteboard contents:\n\n \(pasteBoard)")
        return path
    }
    return nil
}

et déjà constater que ça fonctionne très bien, les images déposées apparaissent dans notre vue.

Aire de tracking

Notre objet DemoImageView sait déjà faire le drag & drop de fichiers, mais on va lui ajouter, juste pour le plaisir, le suivi des évènements du curseur.

Ca peut être très utile pour par exemple ajouter des animations autour du curseur de la souris lors du drop.

On va “override” la méthode viewDidMoveToWindow et y initialiser notre aire de tracking comme ceci :

override func viewDidMoveToWindow() {
    let trackingOptions: NSTrackingAreaOptions = (.ActiveInActiveApp | .MouseEnteredAndExited | .MouseMoved)
    let trackingArea = NSTrackingArea(rect: self.bounds, options: trackingOptions, owner: self, userInfo: nil)
    self.addTrackingArea(trackingArea)
}

Le tuple trackingOptions contient une liste des situations dans lesquelles notre zone aura le tracking actif.

Dans notre cas : quand l'app est active, quand la souris entre dans la zone ou en sort, et quand le souris se déplace dans la zone.

Ensuite trackingArea définit la zone elle-même, dans notre cas ce sera toute la surface de notre classe, et donc les bounds de self.

Il ne reste plus qu'à activer cette zone de tracking en l'ajoutant à la classe avec addTrackingArea.

On va ensuite implémenter les méthodes mouseEntered et mouseExited, dont le paramètre fourni par Cocoa est au format NSEvent et contient toutes les informations à propos de la position du pointeur.

override func mouseEntered(theEvent: NSEvent) {
    println(theEvent.description)
}

override func mouseExited(theEvent: NSEvent) {
    println(theEvent.description)
}

On pourrait aussi implémenter mouseMoved pour suivre le déplacement dans la zone, mouseDown pour le clic et mouseUp pour le relâchement du clic.

Prompteur de texte

Notre drag & drop et notre tracking fonctionnent, maintenant nous allons afficher des messages en couleur dans la zone de texte scrollante au lieu de les faire sortir dans la console.

C'est optionnel pour ce tutoriel, mais c'est intéressant car ça permet de terminer une implémentation de drag & drop pour l'exemple.

Nos messages textes seront affichés avec une belle police et en couleurs, nous utiliserons donc des NSAttributedString dans notre textView.

Pour garder le code propre nous allons créer une classe TextManager.

Contrairement à notre classe DemoImageView, celle-ci n'hérite pas de NSObject, on peut la créer en faisant simplement un nouveau fichier TextManager.swift.

Dans ce même fichier, au niveau global (à la racine, hors de la classe) nous allons créer un enum, c'est-à-dire une petite structure contenant des étiquettes. Explications juste après.

Ca donne ça :

import Cocoa

enum MessageType {
    case Success, Error, Info, Standard
}

class TextManager {

}

Revenez dans DemoImageView.swift et ajoutez une fonction à notre classe :

private func appendToTextStorage(text: String, messageType: MessageType = .Standard) {

}

Voilà à quoi va servir notre “enum”, à typer les messages, ce qui permettra au manager de texte d'adapter la couleur, et de découpler cette décision de notre classe “ImageView”.

Ici dans notre fonction appendToTextStorage les paramètres sont le texte du message et le type du message : succès, erreur, info ou standard (que l'on a mis par défaut).

Cette fonction va elle-même utiliser le manager de texte et recevoir en retour des “NSAttributedStrings”, c'est-à-dire du texte contenant des attributs, tels des couleurs qui correspondront au type du message.

La fonction va ensuite ajouter chaque message à la vue texte, de manière à la faire scroller.

Le manager de texte

Nous avons besoin d'une fonction qui transforme du texte standard en texte coloré, et d'une fonction qui ajoute ce nouveau texte coloré à la vue texte.

Cette première fonction que l'on nommera makeAttributedText va prendre en paramètres le texte du message, la taille de la police (ici 14 par défaut) et le type du message.

class TextManager {
    class func makeAttributedText(string: String, size: CGFloat = 14.0, messageType: MessageType) -> NSAttributedString {

    }
}

Notez que func est précédé de class : notre fonction est une fonction de classe, pas une méthode d'instance : ça veut dire que nous n'avons pas à créer une instance de TextManager avant d'utiliser cette fonction, on peut l'appeller directement sur la classe elle-même (on verra pourquoi ensuite).

Que veut-on dans cette fonction ? Attribuer une belle police si possible, pour commencer.

Dans makeAttributedText, on va déclarer une constante font qui va créer une police Helvetica ou, si pour une raison inconnue ça échoue, créer une police système à la place :

let font: NSFont
if let helv = NSFont(name: "Helvetica Neue Light", size: size) {
    font = helv
} else {
    font = NSFont.systemFontOfSize(size)
}

Il nous faut ensuite une couleur pour le texte. Ca va dépendre de quel type de message, on va utiliser un switch pour ça :

var color: NSColor
switch messageType {
case .Success:
    color = .greenColor()
case .Error:
    color = .redColor()
case .Info:
    color = .blueColor()
default:
    color = .blackColor()
}

Ici donc on dit “considère le type du message (un des éléments de notre enum MessageType)” et selon cet élément, on attribue une couleur à la variable.

Ca permet de découpler le type de la couleur : on peut très bien décider ensuite de changer les couleurs ici sans avoir à toucher au code de l'autre côté.

Et cette-fois on utilise une variable au lieu d'une constante, mais simplement parce que le compilateur considère bizarrement le switch comme un embranchement non-fini malgré l'obligation d'y placer une option par défaut (peut-être un bug de jeunesse, à la date de rédaction de ce tutorial on utilise Swift 1.2 avec Xcode 6.3).

Remarquez d'ailleurs que l'on ne met pas d'option pour le type .Standard, car sa couleur est celle du switch par défaut, on évite ainsi une redondance.

Si on décidait que .Standard devait avoir une couleur différente que le texte “par défaut” du switch, il suffirait d'ajouter un case pour lui, mais il faudrait tout de même laisser la clause par défaut.

Ne pas oublier d'ailleurs qu'en Swift les switches ne sont pas fallthrough (en cascade), c'est-à-dire que si une première condition est validée, les autres cas sont ignorés, on sort du switch (contrairement à certains langages où le switch évalue tous les cas les uns après les autres, en cascade, qu'ils soient validés ou pas).

Ensuite nous créons un dictionnaire avec des clés spécifiques et nos attributs en valeurs, dictionnaire que nous passons avec le texte à une NSAttributedString et que, finalement, la fonction retourne :

let attributes = [NSForegroundColorAttributeName: color, NSFontAttributeName: font]
return NSAttributedString(string: string, attributes: attributes)

Voici la fonction au complet :

class func makeAttributedText(string: String, size: CGFloat = 14.0, messageType: MessageType) -> NSAttributedString {
    let font: NSFont
    if let helv = NSFont(name: "Helvetica Neue Light", size: size) {
        font = helv
    } else {
        font = NSFont.systemFontOfSize(size)
    }
    var color: NSColor
    switch messageType {
    case .Success:
        color = .greenColor()
    case .Error:
        color = .redColor()
    case .Info:
        color = .blueColor()
    default:
        color = .blackColor()
    }
    let attributes = [NSForegroundColorAttributeName: color, NSFontAttributeName: font]
    return NSAttributedString(string: string, attributes: attributes)
}

Maintenant, il faut ajouter cette “attributed string” à notre “text view”, et on va créer une dernière fonction pour ça.

Cette fonction nommée appendAttributedStringToTextView va prendre en paramètres une NSAttributedString et une NSTextView, et renvoyer un booléen (pour indiquer si l'opération a fonctionné).

Cette NSTextView, ce sera notre outlet textView déclaré avec @IBOutlet var textView: NSTextView! dans notre classe DemoImageView : textView étant donc l'objet qui représente la vue texte en bas de notre fenêtre.

On peut faire ça car DemoImageView est une classe, et en Swift les classes sont passées par référence et non pas par valeur; c'est-à-dire que la vue texte reçue ici en paramètre ne sera pas une copie de notre objet mais un pointeur vers l'objet lui-même.

Voici notre fonction :

class func appendAttributedStringToTextView(attibutedString: NSAttributedString, textView: NSTextView) -> Bool {

}

Dans cette fonction nous accédons directement au textStorage de la vue texte, c'est une technique qui permet d'ajouter du texte à la vue (au lieu de remplacer le texte). On fait ensuite scroller jusqu'en bas après l'ajout :

class func appendAttributedStringToTextView(attibutedString: NSAttributedString, textView: NSTextView) -> Bool {
    if let storage = textView.textStorage {
        storage.appendAttributedString(attibutedString)
        textView.scrollRangeToVisible(NSMakeRange(textView.attributedString().length, 0))
        return true
    }
    return false        
}

Voici donc notre fichier TextManager.swift au complet :

import Cocoa

enum MessageType {
    case Success, Error, Info, Standard
}

class TextManager {

    class func makeAttributedText(string: String, size: CGFloat = 14.0, messageType: MessageType) -> NSAttributedString {
        let font: NSFont
        if let helv = NSFont(name: "Helvetica Neue Light", size: size) {
            font = helv
        } else {
            font = NSFont.systemFontOfSize(size)
        }
        var color: NSColor
        switch messageType {
        case .Success:
            color = .greenColor()
        case .Error:
            color = .redColor()
        case .Info:
            color = .blueColor()
        default:
            color = .blackColor()
        }
        let attributes = [NSForegroundColorAttributeName: color, NSFontAttributeName: font]
        return NSAttributedString(string: string, attributes: attributes)
    }

    class func appendAttributedStringToTextView(attibutedString: NSAttributedString, textView: NSTextView) -> Bool {
        if let storage = textView.textStorage {
            storage.appendAttributedString(attibutedString)
            textView.scrollRangeToVisible(NSMakeRange(textView.attributedString().length, 0))
            return true
        }
        return false        
    }

}

Pour revenir à la différence entre “référence” et “valeur” : c'est pour cette raison que nous pouvons utiliser des fonctions de classe et pas d'instance, car notre classe n'a pas d'état, c'est-à-dire qu'elle ne conserve pas de données.

Dernière étape

Revenons dans DemoImageView.swift et ajoutons une fonction appendToTextStorage qui va utiliser notre manager de texte.

Cette fonction va prendre en paramètres le texte du message et le type du message, et va ajouter le texte transformé/colorisé à la vue texte :

private func appendToTextStorage(text: String, messageType: MessageType = .Standard) {
    let attributed = TextManager.makeAttributedText("\(text)\n\n", messageType: messageType)
    let didAppendText = TextManager.appendAttributedStringToTextView(attributed, textView: textView)
    if !didAppendText { NSLog("%@", "Error with the textView") }
}

Passons à l'implémentation de performDragOperation, dont nous avons déjà vu les détails mais qui est ici reformulée grâce à l'aide d'une petite fonction d'assistance :

override func performDragOperation(sender: NSDraggingInfo) -> Bool {
    if let path = getFilePathFromPasteBoard(sender) {
        if let img = NSImage(contentsOfFile: path) {
            self.image = img
            appendToTextStorage("Success: image grabbed from pasteboard", messageType: .Success)
            placeholderMessage.hidden = true
            return true
        }
        appendToTextStorage("Error: can't make an image from '\(path)'", messageType: .Error)
        return false
    }
    appendToTextStorage("Error: no files in the pasteboard", messageType: .Error)
    return false
}

private func getFilePathFromPasteBoard(sender: NSDraggingInfo) -> String? {
    if let pasteBoard = sender.draggingPasteboard().propertyListForType("NSFilenamesPboardType") as? [AnyObject], path = pasteBoard[0] as? String {
        appendToTextStorage("Pasteboard contents:\n\n \(pasteBoard)", messageType: .Info)
        return path
    }
    return nil
}

Il ne reste plus qu'à ajouter les appels à appendToTextStorage dans les autres fonctions, à la place des println utilisés précédemment :

override func mouseEntered(theEvent: NSEvent) {
    appendToTextStorage(theEvent.description)
}

override func mouseExited(theEvent: NSEvent) {
    appendToTextStorage(theEvent.description)
}

override func draggingEntered(sender: NSDraggingInfo) -> NSDragOperation {
    appendToTextStorage(sender.description)
    return .Copy
}

Finish

Voici notre fichier DemoImageView.swift au complet :

import Cocoa

class DemoImageView: NSImageView, NSDraggingDestination {

    @IBOutlet var textView: NSTextView!
    @IBOutlet var placeholderMessage: NSTextField!

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        self.registerForDraggedTypes([NSFilenamesPboardType])
    }

    override func viewDidMoveToWindow() {
        let trackingOptions: NSTrackingAreaOptions = (.ActiveInActiveApp | .MouseEnteredAndExited | .MouseMoved)
        let trackingArea = NSTrackingArea(rect: self.bounds, options: trackingOptions, owner: self, userInfo: nil)
        self.addTrackingArea(trackingArea)
        self.toolTip = "Déposez une image ici"
    }

    override func mouseEntered(theEvent: NSEvent) {
        appendToTextStorage(theEvent.description)
    }

    override func mouseExited(theEvent: NSEvent) {
        appendToTextStorage(theEvent.description)
    }

    override func draggingEntered(sender: NSDraggingInfo) -> NSDragOperation {
        appendToTextStorage(sender.description)
        return .Copy
    }

    override func performDragOperation(sender: NSDraggingInfo) -> Bool {
        if let path = getFilePathFromPasteBoard(sender) {
            if let img = NSImage(contentsOfFile: path) {
                self.image = img
                appendToTextStorage("Success: image grabbed from pasteboard", messageType: .Success)
                placeholderMessage.hidden = true
                return true
            }
            appendToTextStorage("Error: can't make an image from '\(path)'", messageType: .Error)
            return false
        }
        appendToTextStorage("Error: no files in the pasteboard", messageType: .Error)
        return false
    }

    private func getFilePathFromPasteBoard(sender: NSDraggingInfo) -> String? {
        if let pasteBoard = sender.draggingPasteboard().propertyListForType("NSFilenamesPboardType") as? [AnyObject], path = pasteBoard[0] as? String {
            appendToTextStorage("Pasteboard contents:\n\n \(pasteBoard)", messageType: .Info)
            return path
        }
        return nil
    }

    private func appendToTextStorage(text: String, messageType: MessageType = .Standard) {
        let attributed = TextManager.makeAttributedText("\(text)\n\n", messageType: messageType)
        let didAppendText = TextManager.appendAttributedStringToTextView(attributed, textView: textView)
        if !didAppendText { NSLog("%@", "Error with the textView") }
    }

}

On peut lancer l'application, tout est en place.

Au démarrage de l'app, la classe DemoImageView sera automatiquement instanciée puisqu'elle est reliée à la vue dans la fenêtre automatiquement créée; à partir de là, la zone est prête pour le drag & drop et pour afficher les messages dans la partie texte.

Nous avons vu comment associer une classe à une vue, cette classe étant réceptive aux méthodes et protocoles du drag & drop.

Dans notre cas nous avons utilisé la situation “fichier provenant du Finder vers notre app”, mais il y a aussi “fichier provenant d'un browser”, “objet provenant d'une application tierce”, “objet de notre app vers le Finder ou une autre app”, “objet dans notre app vers une autre zone de notre app”, etc.

J'ai aussi volontairement brodé autour de certains détails, pour accentuer le côté tutoriel, mais pour créer notre vue il aurait suffit de peu de choses, les voici résumées  :

C'est tout; le reste n'est qu'habillage. :)

Le projet Xcode accompagnant ce tutorial est disponible sur GitHub.


Mise-à-jour Swift 2

Avec Swift 2 (apparu dans Xcode 7 beta), notre classe DemoImageView n'a plus besoin d'hériter explicitement de NSDraggingDestination car nous héritons déjà de NSImageView qui lui-même désormais se conforme à NSDraggingDestination.

La déclaration de la classe devient donc :

class DemoImageView: NSImageView {
    ...
}

D'autre part, les étranges tuples ‘bitwise’ hérités de vieilles API sont désormais exprimés dans un simple array.

Pour nous, cela signifie que :

let trackingOptions: NSTrackingAreaOptions = (.ActiveInActiveApp | .MouseEnteredAndExited | .MouseMoved)

devient :

let trackingOptions: NSTrackingAreaOptions = [.ActiveInActiveApp, .MouseEnteredAndExited, .MouseMoved]

Auteur: Eric Dejonckheere