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)
{
CTreeCtrl& Tree = GetTreeCtrl();
TVHITTESTINFO tvHit;
tvHit.pt = point;
HTREEITEM hTarget = Tree.HitTest(&tvHit);
if(hTarget)
{
if(hTarget != m_hDragTarget)
{
m_pDragImgList->DragShowNolock(false);
Tree.SelectDropTarget(hTarget);
m_pDragImgList->DragShowNolock(true);
m_hDragTarget = hTarget;
}
}
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);
CTreeCtrl& Tree = GetTreeCtrl();
TVHITTESTINFO tvHit;
tvHit.pt = point;
HTREEITEM hTarget = Tree.HitTest(&tvHit);
if(hTarget)
{
if(hTarget != m_hDragTarget)
{
m_pDragImgList->DragShowNolock(false);
Tree.EnsureVisible(hTarget);
Tree.SelectDropTarget(hTarget);
m_pDragImgList->DragShowNolock(true);
m_hDragTarget = hTarget;
}
}
else
{
RECT rect;
Tree.GetClientRect(&rect);
int iMaxV = Tree.GetScrollLimit(SB_VERT);
int iPosV = Tree.GetScrollPos (SB_VERT);
if((point.y < rect.top -10) && iPosV)
{
HTREEITEM hPrev = Tree.GetPrevVisibleItem(Tree.GetFirstVisibleItem());
m_pDragImgList->DragShowNolock(false);
Tree.EnsureVisible(hPrev);
m_pDragImgList->DragShowNolock(true);
}
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);
if((point.x < rect.left) && iPosH)
{
m_pDragImgList->DragShowNolock(false);
Tree.SendMessage(WM_HSCROLL,SB_LINELEFT);
m_pDragImgList->DragShowNolock(true);
}
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