Introduction

Out of the box, a list will not scroll during drag and drop handling to show content that is scrolled out of view.

This is a little mixin to auto scroll a ListCtrl (report mode only) when it is being used as a drop target, and will optionally show an indicator line to show where the dragged content will be dropped.

It uses a timer to scroll the list based on a direction that you supply during an OnDragOver event handler. When the drop cursor is near the top or bottom of the list, it will scroll the list one item at a time as long as the cursor remains near the top or bottom. Moving the cursor off the list or to the center part of the list stops the scrolling.

The mixin itself is called ListDropScrollerMixin; in the sample below there's also a test program to demonstrate the mixin.

ListDropScrollerMixin

   1 #-----------------------------------------------------------------------------
   2 # Name:        dropscroller.py
   3 # Purpose:     auto scrolling for a list that's being used as a drop target
   4 #
   5 # Author:      Rob McMullen
   6 #
   7 # Created:     2007
   8 # RCS-ID:      $Id: $
   9 # Copyright:   (c) 2007 Rob McMullen
  10 # License:     wxPython
  11 #-----------------------------------------------------------------------------
  12 """
  13 Automatic scrolling mixin for a list control, including an indicator
  14 showing where the items will be dropped.
  15 
  16 It would be nice to have somethin similar for a tree control as well,
  17 but I haven't tackled that yet.
  18 """
  19 import sys
  20 
  21 import wx
  22 
  23 class ListDropScrollerMixin(object):
  24     """Automatic scrolling for ListCtrls for use when using drag and drop.
  25 
  26     This mixin is used to automatically scroll a list control when
  27     approaching the top or bottom edge of a list.  Currently, this
  28     only works for lists in report mode.
  29 
  30     Add this as a mixin in your list, and then call processListScroll
  31     in your DropTarget's OnDragOver method.  When the drop ends, call
  32     finishListScroll to clean up the resources (i.e. the wx.Timer)
  33     that the dropscroller uses and make sure that the insertion
  34     indicator is erased.
  35 
  36     The parameter interval is the delay time in milliseconds between
  37     list scroll steps.
  38 
  39     If indicator_width is negative, then the indicator will be the
  40     width of the list.  If positive, the width will be that number of
  41     pixels, and zero means to display no indicator.
  42     """
  43     def __init__(self, interval=200, width=-1):
  44         """Don't forget to call this mixin's init method in your List.
  45 
  46         Interval is in milliseconds.
  47         """
  48         self._auto_scroll_timer = None
  49         self._auto_scroll_interval = interval
  50         self._auto_scroll = 0
  51         self._auto_scroll_save_y = -1
  52         self._auto_scroll_save_width = width
  53         self.Bind(wx.EVT_TIMER, self.OnAutoScrollTimer)
  54         
  55     def _startAutoScrollTimer(self, direction = 0):
  56         """Set the direction of the next scroll, and start the
  57         interval timer if it's not already running.
  58         """
  59         if self._auto_scroll_timer == None:
  60             self._auto_scroll_timer = wx.Timer(self, wx.TIMER_ONE_SHOT)
  61             self._auto_scroll_timer.Start(self._auto_scroll_interval)
  62         self._auto_scroll = direction
  63 
  64     def _stopAutoScrollTimer(self):
  65         """Clean up the timer resources.
  66         """
  67         self._auto_scroll_timer = None
  68         self._auto_scroll = 0
  69 
  70     def _getAutoScrollDirection(self, index):
  71         """Determine the scroll step direction that the list should
  72         move, based on the index reported by HitTest.
  73         """
  74         first_displayed = self.GetTopItem()
  75 
  76         if first_displayed == index:
  77             # If the mouse is over the first index...
  78             if index > 0:
  79                 # scroll the list up unless...
  80                 return -1
  81             else:
  82                 # we're already at the top.
  83                 return 0
  84         elif index >= first_displayed + self.GetCountPerPage() - 1:
  85             # If the mouse is over the last visible item, but we're
  86             # not at the last physical item, scroll down.
  87             return 1
  88         # we're somewhere in the middle of the list.  Don't scroll
  89         return 0
  90 
  91     def getDropIndex(self, x, y, index=None, flags=None):
  92         """Find the index to insert the new item, which could be
  93         before or after the index passed in.
  94         """
  95         if index is None:
  96             index, flags = self.HitTest((x, y))
  97 
  98         if index == wx.NOT_FOUND: # not clicked on an item
  99             if (flags & (wx.LIST_HITTEST_NOWHERE|wx.LIST_HITTEST_ABOVE|wx.LIST_HITTEST_BELOW)): # empty list or below last item
 100                 index = sys.maxint # append to end of list
 101                 #print "getDropIndex: append to end of list: index=%d" % index
 102             elif (self.GetItemCount() > 0):
 103                 if y <= self.GetItemRect(0).y: # clicked just above first item
 104                     index = 0 # append to top of list
 105                     #print "getDropIndex: before first item: index=%d, y=%d, rect.y=%d" % (index, y, self.GetItemRect(0).y)
 106                 else:
 107                     index = self.GetItemCount() + 1 # append to end of list
 108                     #print "getDropIndex: after last item: index=%d" % index
 109         else: # clicked on an item
 110             # Get bounding rectangle for the item the user is dropping over.
 111             rect = self.GetItemRect(index)
 112             #print "getDropIndex: landed on %d, y=%d, rect=%s" % (index, y, rect)
 113 
 114             # NOTE: On all platforms, the y coordinate used by HitTest
 115             # is relative to the scrolled window.  There are platform
 116             # differences, however, because on GTK the top of the
 117             # vertical scrollbar stops below the header, while on MSW
 118             # the top of the vertical scrollbar is equal to the top of
 119             # the header.  The result is the y used in HitTest and the
 120             # y returned by GetItemRect are offset by a certain amount
 121             # on GTK.  The HitTest's y=0 in GTK corresponds to the top
 122             # of the first item, while y=0 on MSW is in the header.
 123             
 124             # From Robin Dunn: use GetMainWindow on the list to find
 125             # the actual window on which to draw
 126             if self != self.GetMainWindow():
 127                 y += self.GetMainWindow().GetPositionTuple()[1]
 128 
 129             # If the user is dropping into the lower half of the rect,
 130             # we want to insert _after_ this item.
 131             if y >= (rect.y + rect.height/2):
 132                 index = index + 1
 133 
 134         return index
 135 
 136     def processListScroll(self, x, y):
 137         """Main handler: call this with the x and y coordinates of the
 138         mouse cursor as determined from the OnDragOver callback.
 139 
 140         This method will determine which direction the list should be
 141         scrolled, and start the interval timer if necessary.
 142         """
 143         index, flags = self.HitTest((x, y))
 144 
 145         direction = self._getAutoScrollDirection(index)
 146         if direction == 0:
 147             self._stopAutoScrollTimer()
 148         else:
 149             self._startAutoScrollTimer(direction)
 150             
 151         drop_index = self.getDropIndex(x, y, index=index, flags=flags)
 152         count = self.GetItemCount()
 153         if drop_index >= count:
 154             rect = self.GetItemRect(count - 1)
 155             y = rect.y + rect.height + 1
 156         else:
 157             rect = self.GetItemRect(drop_index)
 158             y = rect.y
 159 
 160         # From Robin Dunn: on GTK & MAC the list is implemented as
 161         # a subwindow, so have to use GetMainWindow on the list to
 162         # find the actual window on which to draw
 163         if self != self.GetMainWindow():
 164             y -= self.GetMainWindow().GetPositionTuple()[1]
 165 
 166         if self._auto_scroll_save_y == -1 or self._auto_scroll_save_y != y:
 167             #print "main window=%s, self=%s, pos=%s" % (self, self.GetMainWindow(), self.GetMainWindow().GetPositionTuple())
 168             if self._auto_scroll_save_width < 0:
 169                 self._auto_scroll_save_width = rect.width
 170             dc = self._getIndicatorDC()
 171             self._eraseIndicator(dc)
 172             dc.DrawLine(0, y, self._auto_scroll_save_width, y)
 173             self._auto_scroll_save_y = y
 174 
 175     def finishListScroll(self):
 176         """Clean up timer resource and erase indicator.
 177         """
 178         self._stopAutoScrollTimer()
 179         self._eraseIndicator()
 180         
 181     def OnAutoScrollTimer(self, evt):
 182         """Timer event handler to scroll the list in the requested
 183         direction.
 184         """
 185         #print "_auto_scroll = %d, timer = %s" % (self._auto_scroll, self._auto_scroll_timer is not None)
 186         if self._auto_scroll == 0:
 187             # clean up timer resource
 188             self._auto_scroll_timer = None
 189         else:
 190             dc = self._getIndicatorDC()
 191             self._eraseIndicator(dc)
 192             if self._auto_scroll < 0:
 193                 self.EnsureVisible(self.GetTopItem() + self._auto_scroll)
 194                 self._auto_scroll_timer.Start()
 195             else:
 196                 self.EnsureVisible(self.GetTopItem() + self.GetCountPerPage())
 197                 self._auto_scroll_timer.Start()
 198         evt.Skip()
 199 
 200     def _getIndicatorDC(self):
 201         dc = wx.ClientDC(self.GetMainWindow())
 202         dc.SetPen(wx.Pen(wx.WHITE, 3))
 203         dc.SetBrush(wx.TRANSPARENT_BRUSH)
 204         dc.SetLogicalFunction(wx.XOR)
 205         return dc
 206 
 207     def _eraseIndicator(self, dc=None):
 208         if dc is None:
 209             dc = self._getIndicatorDC()
 210         if self._auto_scroll_save_y >= 0:
 211             # erase the old line
 212             dc.DrawLine(0, self._auto_scroll_save_y,
 213                         self._auto_scroll_save_width, self._auto_scroll_save_y)
 214         self._auto_scroll_save_y = -1
 215 
 216         
 217 
 218 if __name__ == '__main__':
 219     import cPickle as pickle
 220     
 221     class TestDataObject(wx.CustomDataObject):
 222         """Sample custom data object"""
 223         def __init__(self):
 224             wx.CustomDataObject.__init__(self, "TestData")
 225 
 226     class TestDropTarget(wx.PyDropTarget):
 227         """Custom drop target modified from the wxPython demo."""
 228 
 229         def __init__(self, window):
 230             wx.PyDropTarget.__init__(self)
 231             self.dv = window
 232 
 233             # specify the type of data we will accept
 234             self.data = TestDataObject()
 235             self.SetDataObject(self.data)
 236 
 237         def cleanup(self):
 238             self.dv.finishListScroll()
 239 
 240         # some virtual methods that track the progress of the drag
 241         def OnEnter(self, x, y, d):
 242             print "OnEnter: %d, %d, %d\n" % (x, y, d)
 243             return d
 244 
 245         def OnLeave(self):
 246             print "OnLeave\n"
 247             self.cleanup()
 248 
 249         def OnDrop(self, x, y):
 250             print "OnDrop: %d %d\n" % (x, y)
 251             self.cleanup()
 252             return True
 253 
 254         def OnDragOver(self, x, y, d):
 255             top = self.dv.GetTopItem()
 256             print "OnDragOver: %d, %d, %d, top=%s" % (x, y, d, top)
 257 
 258             self.dv.processListScroll(x, y)
 259 
 260             # The value returned here tells the source what kind of visual
 261             # feedback to give.  For example, if wxDragCopy is returned then
 262             # only the copy cursor will be shown, even if the source allows
 263             # moves.  You can use the passed in (x,y) to determine what kind
 264             # of feedback to give.  In this case we return the suggested value
 265             # which is based on whether the Ctrl key is pressed.
 266             return d
 267 
 268         # Called when OnDrop returns True.  We need to get the data and
 269         # do something with it.
 270         def OnData(self, x, y, d):
 271             print "OnData: %d, %d, %d\n" % (x, y, d)
 272 
 273             self.cleanup()
 274             # copy the data from the drag source to our data object
 275             if self.GetData():
 276                 # convert it back to a list of lines and give it to the viewer
 277                 items = pickle.loads(self.data.GetData())
 278                 self.dv.AddDroppedItems(x, y, items)
 279 
 280             # what is returned signals the source what to do
 281             # with the original data (move, copy, etc.)  In this
 282             # case we just return the suggested value given to us.
 283             return d
 284 
 285     class TestList(wx.ListCtrl, ListDropScrollerMixin):
 286         """Simple list control that provides a drop target and uses
 287         the new mixin for automatic scrolling.
 288         """
 289         
 290         def __init__(self, parent, name, count=100):
 291             wx.ListCtrl.__init__(self, parent, style=wx.LC_REPORT)
 292 
 293             # The mixin needs to be initialized
 294             ListDropScrollerMixin.__init__(self, interval=200)
 295             
 296             self.dropTarget=TestDropTarget(self)
 297             self.SetDropTarget(self.dropTarget)
 298 
 299             self.create(name, count)
 300             
 301             self.Bind(wx.EVT_LIST_BEGIN_DRAG, self.OnStartDrag)
 302 
 303         def create(self, name, count):
 304             """Set up some test data."""
 305             
 306             self.InsertColumn(0, "#")
 307             self.InsertColumn(1, "Title")
 308             for i in range(count):
 309                 self.InsertStringItem(sys.maxint, str(i))
 310                 self.SetStringItem(i, 1, "%s-%d" % (name, i))
 311 
 312         def OnStartDrag(self, evt):
 313             index = evt.GetIndex()
 314             print "beginning drag of item %d" % index
 315 
 316             # Create the data object containing all currently selected
 317             # items
 318             data = TestDataObject()
 319             items = []
 320             index = self.GetFirstSelected()
 321             while index != -1:
 322                 items.append((self.GetItem(index, 0).GetText(),
 323                               self.GetItem(index, 1).GetText()))
 324                 index = self.GetNextSelected(index)
 325             data.SetData(pickle.dumps(items,-1))
 326 
 327             # And finally, create the drop source and begin the drag
 328             # and drop opperation
 329             dropSource = wx.DropSource(self)
 330             dropSource.SetData(data)
 331             print "Begining DragDrop\n"
 332             result = dropSource.DoDragDrop(wx.Drag_AllowMove)
 333             print "DragDrop completed: %d\n" % result
 334 
 335         def AddDroppedItems(self, x, y, items):
 336             index = self.getDropIndex(x, y)
 337             print "At (%d,%d), index=%d, adding %s" % (x, y, index, items)
 338 
 339             list_count = self.GetItemCount()
 340             for item in items:
 341                 index = self.InsertStringItem(index, item[0])
 342                 self.SetStringItem(index, 1, item[1])
 343                 index += 1
 344                
 345             
 346     class ListPanel(wx.SplitterWindow):
 347         def __init__(self, parent):
 348             wx.SplitterWindow.__init__(self, parent)
 349 
 350             self.list1 = TestList(self, "left", 100)
 351             self.list2 = TestList(self, "right", 10)
 352             self.SplitVertically(self.list1, self.list2)
 353             self.Layout()
 354 
 355     app   = wx.PySimpleApp()
 356     frame = wx.Frame(None, -1, title='List Drag Test', size=(400,500))
 357     frame.CreateStatusBar()
 358     
 359     panel = ListPanel(frame)
 360     label = wx.StaticText(frame, -1, "Drag items from a list to either list.\nThe lists will scroll when the cursor\nis near the first and last visible items")
 361 
 362     sizer = wx.BoxSizer(wx.VERTICAL)
 363     sizer.Add(label, 0, wx.ALIGN_CENTRE|wx.ALL, 5)
 364     sizer.Add(panel, 1, wx.EXPAND | wx.ALL, 5)
 365     
 366     frame.SetAutoLayout(1)
 367     frame.SetSizer(sizer)
 368     frame.Show(1)
 369     
 370     app.MainLoop()


-- RobMcMullen

ListCtrlWithAutoScrolling (last edited 2008-03-11 10:50:31 by localhost)

NOTE: To edit pages in this wiki you must be a member of the TrustedEditorsGroup.