5

Can anyone point me to where I can find info on making a listbox with the ability to drag and drop items for re-arranging? I've found some related to Perl, but I know nothing of that language and I'm pretty new to tkinter, so it was pretty confusing. I know how to generate listboxes, but I'm not sure how to re-order it through drag and drop.

3 Answers 3

8

Here is the code from Recipe 11.4:

import Tkinter 

class DragDropListbox(Tkinter.Listbox):
    """ A Tkinter listbox with drag'n'drop reordering of entries. """
    def __init__(self, master, **kw):
        kw['selectmode'] = Tkinter.SINGLE
        Tkinter.Listbox.__init__(self, master, kw)
        self.bind('<Button-1>', self.setCurrent)
        self.bind('<B1-Motion>', self.shiftSelection)
        self.curIndex = None

    def setCurrent(self, event):
        self.curIndex = self.nearest(event.y)

    def shiftSelection(self, event):
        i = self.nearest(event.y)
        if i < self.curIndex:
            x = self.get(i)
            self.delete(i)
            self.insert(i+1, x)
            self.curIndex = i
        elif i > self.curIndex:
            x = self.get(i)
            self.delete(i)
            self.insert(i-1, x)
            self.curIndex = i
4

Here's a modified recipe if you're dealing with MULTIPLE as the selectmode (as opposed to SINGLE).

Changes made:

  1. When dragging over an already selected item, it would deselect it which was a bad user-experience.
  2. When clicking an item that was selected, it would become unselected from the click. So I added a self.curState bit that kept track of whether the clicked-on item's state was initially selected or not. When you drag it around, it doesn't lose its state.
  3. I also bound two events to the Button-1 event using add='+' but that might avoidable by simply keeping it all under setCurrent.
  4. I prefer activestyle equals 'none'.
  5. Made this Listbox tk.MULTIPLE instead of tk.SINGLE.

Here is the code:

class Drag_and_Drop_Listbox(tk.Listbox):
  """ A tk listbox with drag'n'drop reordering of entries. """
  def __init__(self, master, **kw):
    kw['selectmode'] = tk.MULTIPLE
    kw['activestyle'] = 'none'
    tk.Listbox.__init__(self, master, kw)
    self.bind('<Button-1>', self.getState, add='+')
    self.bind('<Button-1>', self.setCurrent, add='+')
    self.bind('<B1-Motion>', self.shiftSelection)
    self.curIndex = None
    self.curState = None
  def setCurrent(self, event):
    ''' gets the current index of the clicked item in the listbox '''
    self.curIndex = self.nearest(event.y)
  def getState(self, event):
    ''' checks if the clicked item in listbox is selected '''
    i = self.nearest(event.y)
    self.curState = self.selection_includes(i)
  def shiftSelection(self, event):
    ''' shifts item up or down in listbox '''
    i = self.nearest(event.y)
    if self.curState == 1:
      self.selection_set(self.curIndex)
    else:
      self.selection_clear(self.curIndex)
    if i < self.curIndex:
      # Moves up
      x = self.get(i)
      selected = self.selection_includes(i)
      self.delete(i)
      self.insert(i+1, x)
      if selected:
        self.selection_set(i+1)
      self.curIndex = i
    elif i > self.curIndex:
      # Moves down
      x = self.get(i)
      selected = self.selection_includes(i)
      self.delete(i)
      self.insert(i-1, x)
      if selected:
        self.selection_set(i-1)
      self.curIndex = i

Example demo:

root = tk.Tk()
listbox = Drag_and_Drop_Listbox(root)
for i,name in enumerate(['name'+str(i) for i in range(10)]):
  listbox.insert(tk.END, name)
  if i % 2 == 0:
    listbox.selection_set(i)
listbox.pack(fill=tk.BOTH, expand=True)
root.mainloop()
1
  • I played with this a bit, and noticed that it only allows moving one item at a time no matter how many are selected. User would expect all selected items to move together instead so I recommend only using this in SINGLE mode until that is resolved. I also changed the last line in the getState() method to self.curState = 1 so that when an unselected line is moved it just stays selected instead of weirdly flashing selected. This again looks really well in SINGLE mode because it highlights what the user is moving/last moved. Commented Apr 18, 2017 at 5:45
2

The following class is a Listbox with EXTENDED selection mode that enables dragging around multiple selected items.

  • Default selecting mechanisms are preserved (by dragging and clicking, including holding down Ctrl or Shift), with the exception of dragging an already selected item without holding Ctrl.
  • To drag the selection, drag one of the selected items below the last selected item or above the first selected item.
  • To scroll the listbox while dragging selection, use the mousewheel or move the cursor near or beyond the top or bottom of the listbox. => This could be improved: since it's bound to the B1‑Motion event, extra movement of the mouse is needed to continue the scroll. Feels buggy in longer listboxes.
  • If the selection is discontinuous, dragging will make it continuous by moving the unselected items up or down, respectively.

The above means that to drag just one item, it needs to be selected first, then clicked again and dragged.

import tkinter as tk;

class ReorderableListbox(tk.Listbox):
    """ A Tkinter listbox with drag & drop reordering of lines """
    def __init__(self, master, **kw):
        kw['selectmode'] = tk.EXTENDED
        tk.Listbox.__init__(self, master, kw)
        self.bind('<Button-1>', self.setCurrent)
        self.bind('<Control-1>', self.toggleSelection)
        self.bind('<B1-Motion>', self.shiftSelection)
        self.bind('<Leave>',  self.onLeave)
        self.bind('<Enter>',  self.onEnter)
        self.selectionClicked = False
        self.left = False
        self.unlockShifting()
        self.ctrlClicked = False
    def orderChangedEventHandler(self):
        pass

    def onLeave(self, event):
        # prevents changing selection when dragging
        # already selected items beyond the edge of the listbox
        if self.selectionClicked:
            self.left = True
            return 'break'
    def onEnter(self, event):
        #TODO
        self.left = False

    def setCurrent(self, event):
        self.ctrlClicked = False
        i = self.nearest(event.y)
        self.selectionClicked = self.selection_includes(i)
        if (self.selectionClicked):
            return 'break'

    def toggleSelection(self, event):
        self.ctrlClicked = True

    def moveElement(self, source, target):
        if not self.ctrlClicked:
            element = self.get(source)
            self.delete(source)
            self.insert(target, element)

    def unlockShifting(self):
        self.shifting = False
    def lockShifting(self):
        # prevent moving processes from disturbing each other
        # and prevent scrolling too fast
        # when dragged to the top/bottom of visible area
        self.shifting = True

    def shiftSelection(self, event):
        if self.ctrlClicked:
            return
        selection = self.curselection()
        if not self.selectionClicked or len(selection) == 0:
            return

        selectionRange = range(min(selection), max(selection))
        currentIndex = self.nearest(event.y)

        if self.shifting:
            return 'break'

        lineHeight = 15
        bottomY = self.winfo_height()
        if event.y >= bottomY - lineHeight:
            self.lockShifting()
            self.see(self.nearest(bottomY - lineHeight) + 1)
            self.master.after(500, self.unlockShifting)
        if event.y <= lineHeight:
            self.lockShifting()
            self.see(self.nearest(lineHeight) - 1)
            self.master.after(500, self.unlockShifting)

        if currentIndex < min(selection):
            self.lockShifting()
            notInSelectionIndex = 0
            for i in selectionRange[::-1]:
                if not self.selection_includes(i):
                    self.moveElement(i, max(selection)-notInSelectionIndex)
                    notInSelectionIndex += 1
            currentIndex = min(selection)-1
            self.moveElement(currentIndex, currentIndex + len(selection))
            self.orderChangedEventHandler()
        elif currentIndex > max(selection):
            self.lockShifting()
            notInSelectionIndex = 0
            for i in selectionRange:
                if not self.selection_includes(i):
                    self.moveElement(i, min(selection)+notInSelectionIndex)
                    notInSelectionIndex += 1
            currentIndex = max(selection)+1
            self.moveElement(currentIndex, currentIndex - len(selection))
            self.orderChangedEventHandler()
        self.unlockShifting()
        return 'break'

Not the answer you're looking for? Browse other questions tagged or ask your own question.