drag & drop dans un arbre
(niveau : moyen)


Table des matières

Introduction
Application basée sur CTreeView
  Drag & drop simple
    Initialisation
    Exécution
    Sortie
    Résultat
  Annulation
  Plier / déplier des éléments
  Défilement
Application basée sur CTreeCtrl
Références


Introduction

Déplacer des éléments à l'intérieur d'un contrôle "arbre" (tree control) n'est pas excessivement compliqué avec les MFC. Cependant, il est nécessaire comme souvent de connaître quelques subtilités qui ne sont pas toujours mises en avant dans les exemples, et peuvent conduire à passer du temps dans la documentation ou sur internet. Cela a été mon cas, c'est pourquoi j'ai écrit cet article ainsi que le petit programme de démonstration (377 Ko) qui l'accompagne.


l'élément "camera 3" en cours de déplacement

Le fichier zip ci-dessus contient en fait 2 projets pour Visual C++ 6 : dans l'un l'arbre est géré par une classe dérivée de CTreeView (image précédente), dans l'autre c'est un CTreeCtrl placé dans une CFormView. Les deux implémentations ont beaucoup de points communs, et quelques différences qui seront détaillées un peu plus loin.


Application basée sur CTreeView

Cette version est située dans le répertoire DragDropView. Mis à part l'ajout dans les ressources d'un bitmap IDB_SCENE_TREE contenant quelques icônes pour les éléments de l'arbre, toutes les modifications faites à l'application MDI après sa création par le wizard sont concentrées dans la classe CDragDropView.


IDB_SCENE_TREE

En dehors du drag & drop, cette classe (dérivée de CTreeView) effectue les opérations suivantes :

- elle fixe les styles de l'arbre dans la fonction PreCreateWindow. Attention, ceci doit être fait après l'appel à la classe de base par
CTreeView::PreCreateWindow(cs);
BOOL CDragDropView::PreCreateWindow(CREATESTRUCT& cs)
{
  BOOL Res  = CTreeView::PreCreateWindow(cs);
  cs.style |= TVS_FULLROWSELECT | TVS_HASBUTTONS | TVS_HASLINES | TVS_LINESATROOT | TVS_SHOWSELALWAYS;
  return Res;
}

- elle associe le bitmap IDB_SCENE_TREE à l'arbre par l'intermédiaire des lignes :

dans CDragDropView::CDragDropView()
  m_imgList.Create(IDB_SCENE_TREE,16,1,RGB(255,255,255));
dans CDragDropView::OnInitialUpdate()
  CTreeCtrl& Tree = GetTreeCtrl();
  Tree.SetImageList(&m_imgList,TVSIL_NORMAL);
dans CDragDropView::~CDragDropView() : ne pas oublier de libérer la ressource
  m_imgList.DeleteImageList();

- elle insère des éléments dans l'arbre à sa création (CDragDropView::OnInitialUpdate()) afin de pouvoir tester le drag & drop.


Drag & drop simple

Le code d'un drag & drop "simple" (tel qu'il est décrit dans la documentation) se décompose en 3 parties :

- l'initialisation : lorsque l'utilisateur commence un drag & drop (il clique sur un élément et déplace la souris sans relâcher le bouton gauche), l'arbre envoie un message TVN_BEGINDRAG, géré par la fonction CDragDropView::OnBegindrag.

- l'exécution : le drag & drop se poursuit tant que l'utilisateur maintient le bouton de la souris enfoncé. Les déplacements sont généralement gérés dans la fonction OnMouseMove (en réponse aux messages WM_MOUSEMOVE).

- la sortie : l'opération se termine lorsque le bouton gauche est relâché, c'est à dire à la réception d'un événement WM_LBUTTONUP.


Quelques variables sont nécessaires :
bool              m_boDragging;
HTREEITEM         m_hDragItem;
HTREEITEM         m_hDragTarget;
CImageList*       m_pDragImgList;
m_boDragging permet de savoir si une opération de drag & drop est en cours. Dans le cas contraire, le code ajouté pour traiter les événements WM_MOUSEMOVE et WM_LBUTTONUP ne sera par exemple pas exécuté. Cette variable est mise à true dans OnBeginDrag, et à false dans OnLButtonUp.

m_hDragItem contient le handle de l'élément qui est déplacé. Sa valeur est initialisée dans OnBeginDrag.

m_hDragTarget contient le handle de l'élément sélectionné à un instant t pour être la "cible" du drag & drop, c'est à dire devenir le nouveau parent de l'élément déplacé. Sur l'image de l'introduction, "camera 3" est l'élément déplacé et "camera 6" est la cible actuelle. m_hDragTarget est modifié en fonction des déplacements de la souris.

m_pDragImgList est une CImageList (comme celle, vue précédemment, qui contient les icônes utilisables par l'arbre), qui sert à afficher un dessin de l'élément déplacé, à l'emplacement de la souris.


Initialisation

Le code de l'initialisation est le suivant :

void CDragDropView::OnBegindrag(NMHDR* pNMHDR, LRESULT* pResult) 
{
  NM_TREEVIEW* pNMTreeView = (NM_TREEVIEW*)pNMHDR;
  HTREEITEM    hItem       = pNMTreeView->itemNew.hItem;
  if(!hItem) return;
  GetTreeCtrl().SelectItem(hItem);

  m_hDragItem    = hItem;
  m_pDragImgList = GetTreeCtrl().CreateDragImage(hItem);
  if(!m_pDragImgList) return;
  
  m_pDragImgList->BeginDrag(0,CPoint(0,0));
  m_pDragImgList->DragEnter(this,pNMTreeView->ptDrag);
  m_boDragging = true;

  ShowCursor(false);
  SetCapture();
  m_hDragTarget = NULL;

  SetTimer(1,25,NULL);
  *pResult = 0;
}

Il est similaire à ce que l'on trouve dans la documentation, excepté le SetTimer qui sera expliqué dans le paragraphe sur le défilement de l'arbre.
Notez le SetCapture, qui permet à la vue contenant l'arbre de continuer à recevoir les messages de la souris même lorsque cette dernière n'est pas au-dessus d'elle.


Exécution

Le déplacement de la souris est géré ainsi :

void CDragDropView::OnMouseMove(UINT nFlags, CPoint point) 
{
  if(m_boDragging)
  {
    // highlight target

    CTreeCtrl& Tree = GetTreeCtrl();
    TVHITTESTINFO tvHit;
    tvHit.pt = point;
    HTREEITEM hTarget = Tree.HitTest(&tvHit);

    if(hTarget)
    {
      if(hTarget != m_hDragTarget)
      {                                                     // this test avoids flickering
        m_pDragImgList->DragShowNolock(false);
        Tree.SelectDropTarget(hTarget);
        m_pDragImgList->DragShowNolock(true);
        m_hDragTarget = hTarget;
      }
    }

    // move image being dragged

    m_pDragImgList->DragMove(point);
  }

  CTreeView::OnMouseMove(nFlags, point);
}

L'appel à HitTest permet de déterminer si le curseur est sur un élément de l'arbre, et lequel; si oui, et si cet élément est différent de la cible actuelle, il devient la nouvelle cible.

Notez que SelectDropTarget est encadré par 2 appels à DragShowNolock. Lorsque la cible change, les portions concernées de l'arbre sont redessinées pour désélectionner l'ancienne cible et mettre la nouvelle en évidence. Or une image de l'élément déplacé est affichée par dessus l'arbre à la façon d'un sprite, c'est à dire en sauvant le morceau d'écran qu'elle recouvre afin de le restituer lorsqu'elle sera déplacée. Si ce qui est sous cette image change sans qu'elle en soit informée, on obtient ce genre de "trainées" indésirables :


La règle est donc la suivante : tout appel de fonction modifiant l'affichage de l'arbre (comme SelectDropTarget) doit être précédé d'un DragShowNolock(false) qui efface l'image de l'élément déplacé, et en général suivi d'un DragShowNolock(true) sauf si l'on ne veut pas remettre l'image.

Enfin, l'appel à DragMove déplace l'image de l'élément draggé en fonction de la position de la souris, qu'une nouvelle cible soit sélectionnée ou non.


Sortie

La fin d'un drag & drop correspond à la fonction ci-dessous :

void CDragDropView::OnLButtonUp(UINT nFlags, CPoint point) 
{
  if(m_boDragging)
  {
    KillTimer(1);

    ReleaseCapture();
    ShowCursor(true);
    m_boDragging = false;

    m_pDragImgList->DragLeave(this);
    m_pDragImgList->EndDrag();
    delete m_pDragImgList;
    m_pDragImgList = NULL;

    GetTreeCtrl().SelectDropTarget(NULL);
    GetTreeCtrl().SelectItem(m_hDragItem);

    if(m_hDragTarget && (m_hDragTarget != m_hDragItem))
    {
      SuccessfulDrag(m_hDragTarget,m_hDragItem);
    }
  }   
  
  CTreeView::OnLButtonUp(nFlags, point);
}

Elle ne contient rien d'extraordinaire. Notez le KillTimer qui annule le SetTimer de OnBeginDrag, et sera expliqué dans le paragraphe "défilement". De même ReleaseCapture met fin au SetCapture de l'initialisation.


Résultat

La fonction SuccessfulDrag ne fait pas partie des MFC : c'est une méthode que j'ai ajoutée à la classe CDragDropView, et qui est appelée chaque fois qu'un drag & drop se termine. En effet, lorsque l'utilisateur relâche le bouton de la souris, le travail des MFC s'arrête et il vous appartient de faire ce que vous voulez avec les paramètres dont vous disposez maintenant : le handle de l'élément draggé, et celui de la cible choisie.

Votre travail consiste au minimum à réellement effectuer le déplacement demandé, c'est à dire à ajouter l'élément choisi ainsi que toute sa descendance en tant que sous-arbre de la cible, et à supprimer l'élément d'origine (ce qui détruit également sa descendance); il n'y a en effet pas de fonction "Move" dans un CTreeCtrl. Dans les 2 programmes d'exemple, ces opérations sont réalisées par les fonctions SuccessfulDrag, InsertItemAndSubtree, CopySubtree (qui est récursive), et CopyItem (qui copie un élément avec tous ses attributs - texte, images, ItemData -, ainsi que son état - déplié, coché -). Reportez-vous aux sources pour plus de détails.

Vous pouvez évidemment en plus effectuer d'autres opérations en réponse à un drag & drop, ou des tests de validation supplémentaires (n'importe quel type d'élément ne peut peut-être pas devenir un fils de n'importe quel autre, etc).


Annulation

L'utilisateur peut vouloir annuler un drag & drop en cours, mais s'il lâche le bouton gauche de la souris alors le déplacement sera validé. Une bonne solution consiste à dire que l'on peut annuler un drag & drop en cliquant sur le bouton droit, et à gérer l'événement WM_RBUTTONDOWN ainsi :

void CDragDropView::OnRButtonDown(UINT nFlags, CPoint point) 
{
  if(m_boDragging)
  {
    KillTimer(1);

    ReleaseCapture();
    ShowCursor(true);
    m_boDragging = false;

    m_pDragImgList->DragLeave(this);
    m_pDragImgList->EndDrag();
    delete m_pDragImgList;
    m_pDragImgList = NULL;

    GetTreeCtrl().SelectDropTarget(NULL);
    GetTreeCtrl().SelectItem(m_hDragItem);
    GetTreeCtrl().EnsureVisible(m_hDragItem);
  }   
  
  CTreeView::OnRButtonDown(nFlags, point);
}

Ce code est très proche de celui de OnLButtonUp, hormis le fait que SuccessfulDrag n'est bien sûr pas appelé, et que la ligne EnsureVisible assure que l'élément que l'on a voulu dragger est bien visible à l'écran (en cas de défilement de l'arbre, voir plus bas).


Plier / déplier des éléments

Un arbre n'est pas toujours entièrement déplié, il est pratique de cacher certaines branches lorsqu'on ne s'en sert pas. Quand on dragge un élément, on peut au contraire avoir besoin de voir le contenu de telles branches, à la recherche de la bonne cible. Certains programmes procèdent ainsi : si la souris reste 2 ou 3 secondes sans bouger sur un élément plié (= précédé du symbole "+"), celui-ci va être déplié automatiquement ; l'opération inverse (= replier) n'est en général pas possible.


"meshs" et "characters" sont dépliés,
"blocks" et "buildings" sont pliés

Cette méthode peut conduire à de fausses manipulations, j'en ai choisi une autre encore plus simple : lors d'un drag & drop, la touche control plie ou déplie alternativement la cible courante. Ceci est très facile à implémenter :

- en réponse à un événement WM_KEYDOWN, il faut vérifier si c'est la touche control qui est pressée :

void CDragDropView::OnKeydown(NMHDR* pNMHDR, LRESULT* pResult) 
{
  TV_KEYDOWN*  pTVKeyDown = (TV_KEYDOWN*)pNMHDR;
  WORD wVKey = pTVKeyDown->wVKey;

  if(wVKey == VK_CONTROL) DragToggleItem();
  
  *pResult = 0;
}

- la fonction DragToggleItem qui effectue l'action correspondante est la suivante :

void CDragDropView::DragToggleItem()
{
  if(m_boDragging) 
  {
    if(m_hDragTarget)
    {
      m_pDragImgList->DragShowNolock(false);
      GetTreeCtrl().Expand(m_hDragTarget,TVE_TOGGLE);
      GetTreeCtrl().RedrawWindow();
      m_pDragImgList->DragShowNolock(true);
    }
  }
}

NB : malgré son nom, la méthode CTreeCtrl::Expand sert également à plier (= collapse) un élément de l'arbre.


Défilement

L'élément à déplacer et sa cible n'apparaissent pas forcément ensemble à l'écran, même en repliant des branches de l'arbre, surtout si ce dernier contient beaucoup d'éléments. On a donc besoin de faire défiler le contenu de l'arbre pendant le drag & drop, mais on ne peut pas cliquer sur la barre verticale prévue à cet effet sans relâcher le bouton de la souris et mettre fin au déplacement. Il existe 2 possibilités :

- faire défiler l'arbre en utilisant la molette de la souris. L'implémentation par défaut qui existe dans la classe CTreeView remplit très bien ce rôle, mis à part le fait qu'elle peut produire le problème de "trainées" déjà évoqué (dans le paragraphe "exécution"). Afin d'y remédier, il suffit de gérer l'événement WM_MOUSEWHEEL :

BOOL CDragDropView::OnMouseWheel(UINT nFlags, short zDelta, CPoint pt) 
{
  if(m_pDragImgList) m_pDragImgList->DragShowNolock(false);
  BOOL Res = CTreeView::OnMouseWheel(nFlags, zDelta, pt);
  if(m_pDragImgList) m_pDragImgList->DragShowNolock(true);
  return Res;
}

- faire soi-même défiler l'arbre lorsque l'élément draggé sort des limites de la vue, par exemple vers le haut lorsqu'il est au-dessus de l'arbre. Le problème est que le contenu de l'arbre doit continuer à défiler tant que l'élément est au-dessus de l'arbre, même si la souris ne bouge plus, c'est à dire même si CDragDropView ne reçoit plus de message Windows. C'est là qu'intervient le timer dont j'ai parlé plus tôt : il va permettre de vérifier régulièrement la position du curseur par rapport à l'arbre, et d'effectuer les défilements nécessaires (qui plus est à une vitesse constante).

Dans le même temps, il intègre le code auparavant situé dans OnMouseMove. Comme nous l'avons déjà vu, ce timer est initialisé dans OnBeginDrag (début du drag & drop) et détruit dans OnLButtonUp (fin du drag & drop) ou OnRButtonDown (annulation). Son code est un peu long en raison des 4 directions possibles de défilement, mais pas spécialement compliqué à comprendre.

void CDragDropView::OnTimer(UINT nIDEvent)
{
  if(m_boDragging)
  {
    POINT point;
    GetCursorPos(&point);
    ScreenToClient(&point);

    // highlight target

    CTreeCtrl& Tree = GetTreeCtrl();
    TVHITTESTINFO tvHit;
    tvHit.pt = point;
    HTREEITEM hTarget = Tree.HitTest(&tvHit);

    if(hTarget)
    {
      if(hTarget != m_hDragTarget)
      {                                                     // this test avoids flickering
        m_pDragImgList->DragShowNolock(false);
        Tree.EnsureVisible(hTarget);
        Tree.SelectDropTarget(hTarget);
        m_pDragImgList->DragShowNolock(true);
        m_hDragTarget = hTarget;
      }
    }

    // scroll tree

    else
    {
      RECT rect;
      Tree.GetClientRect(&rect);

      int iMaxV = Tree.GetScrollLimit(SB_VERT);
      int iPosV = Tree.GetScrollPos  (SB_VERT);

      // up
      if((point.y < rect.top -10) && iPosV)
      {
        HTREEITEM hPrev = Tree.GetPrevVisibleItem(Tree.GetFirstVisibleItem());
        m_pDragImgList->DragShowNolock(false);
        Tree.EnsureVisible(hPrev);
        m_pDragImgList->DragShowNolock(true);
      }

      // down
      if((point.y > rect.bottom +10) && (iPosV != iMaxV))
      {
        UINT Nb = Tree.GetVisibleCount();
        if(Nb != -1)
        {
          HTREEITEM hNext = Tree.GetFirstVisibleItem();
          for(UINT i = 0; i < Nb; i++) hNext = Tree.GetNextVisibleItem(hNext);
          m_pDragImgList->DragShowNolock(false);
          Tree.EnsureVisible(hNext);
          m_pDragImgList->DragShowNolock(true);
        }
      }

      int iPosH = Tree.GetScrollPos  (SB_HORZ);
      int iMaxH = Tree.GetScrollLimit(SB_HORZ);

      // left
      if((point.x < rect.left) && iPosH)
      {
        m_pDragImgList->DragShowNolock(false);
        Tree.SendMessage(WM_HSCROLL,SB_LINELEFT);
        m_pDragImgList->DragShowNolock(true);
      }

      // right
      if((point.x > rect.right) && (iPosH != iMaxH))
      {
        m_pDragImgList->DragShowNolock(false);
        Tree.SendMessage(WM_HSCROLL,SB_LINERIGHT);
        m_pDragImgList->DragShowNolock(true);
      }
    }

    m_pDragImgList->DragMove(point);
  }
  
  CTreeView::OnTimer(nIDEvent);
}


Application basée sur CTreeCtrl

Vous pouvez être amené à utiliser un CTreeCtrl si votre vue ne contient pas uniquement un arbre, mais aussi d'autres contrôles (boutons, etc). Cette version est située dans le répertoire DragDropCtrl. Cette fois-ci la classe CDragDropView est dérivée de CFormView, et la boîte de dialogue correspondante IDD_FORMVIEW contient un contrôle MFC de type arbre (tree control) :

 
IDD_FORMVIEW et les styles principaux de son arbre

Les styles de l'arbre ne sont plus définis dans une fonction (PreCreateWindow) mais dans l'éditeur de ressources, ce qui est plus simple.

Autre différence : pour éviter des "trainées" indésirables lors du défilement de l'arbre, il a fallu précédemment traiter au niveau de CDragDropView les messages WM_MOUSEWHEEL reçus par l'arbre ; à présent ce n'est plus directement cette classe qui gère l'arbre, et c'est au niveau du contrôle qu'il faut modifier le comportement par défaut de OnMouseWheel. Ceci signifie qu'il est nécessaire de créer une classe dérivée de CTreeCtrl, que j'ai appelée CTreeCtrlDrag.

BOOL CTreeCtrlDrag::OnMouseWheel(UINT nFlags, short zDelta, CPoint pt) 
{
  if(m_pDragImgList) m_pDragImgList->DragShowNolock(false);
  BOOL Res = CTreeCtrl::OnMouseWheel(nFlags, zDelta, pt);
  if(m_pDragImgList) m_pDragImgList->DragShowNolock(true);
  return Res;
}

Ceci a un avantage supplémentaire : puisque nous avons une classe qui s'occupe de l'arbre et de lui seul, certaines fonctions qui se trouvaient dans CDragDropView et ne concernaient que l'arbre peuvent maintenant faire partie de CTreeCtrlDrag. Il s'agit de celles qui copient un élément (CopyItem) ou une branche entière (CopySubtree, InsertItemAndSubtree) afin de refléter à l'écran le drag & drop de l'utilisateur. Cette séparation du code, absente dans l'autre exemple, est évidemment plus satisfaisante.

(petite remarque : CTreeCtrlDrag contient une variable-membre m_pDragImgList qui est utilisée dans OnMouseWheel ci-dessus ; c'est une copie de l'adresse de la CImageList stockée dans CDragDropView. La vue pourrait en fait utiliser la variable de son arbre chaque fois qu'elle en a besoin, à vous de voir)

Le reste de l'application ne nécessite pas beaucoup de commentaires :
- la seule nouvelle fonction est CDragDropView::OnSize, qui redimensionne le contrôle arbre lorsque la vue change de taille
- les messages TVN_KEYDOWN et TVN_BEGINDRAG ne sont pas associés à leurs fonctions dans la message map par les même macros que dans l'exemple avec CTreeView, mais c'est le ClassWizard qui se charge de créer ces lignes lorsque vous le lui demandez


Références

Pour combler mes lacunes sur le drag & drop, j'ai principalement consulté :
MSDN
codeguru


haut de la page