4

I'm using the code from this answer to make a list with checkboxes.

import tkinter as tk

root = tk.Tk()

class ChecklistBox(tk.Frame):
    def __init__(self, parent, choices, **kwargs):
        tk.Frame.__init__(self, parent, **kwargs)
        
        self.vars = []
        bg = self.cget("background")
        for choice in choices:
            var = tk.StringVar(value=choice)
            self.vars.append(var)
            cb = tk.Checkbutton(self, var=var, text=choice,
                                onvalue=choice, offvalue="",
                                anchor="w", width=20, background=bg,
                                relief="flat", highlightthickness=0
            )
            cb.pack(side="top", fill="x", anchor="w")
    
    
    def getCheckedItems(self):
        values = []
        for var in self.vars:
            value =  var.get()
            if value:
                values.append(value)
        return values

choices = [str(e) for e in range(100)]
checklist = ChecklistBox(root, choices, bd=1, relief="sunken", background="white")
checklist.pack()

Since the list of choices is very long, I would like to add a scrollbar to this list. What is the best way to do this ?


I tried to follow the example here, but ChecklistBox doesn't have a yview method, and has no yscrollcommand option. I don't know how to circumvent this problem.

usernumber
  • 1,958
  • 1
  • 21
  • 58
  • You can create a [Scrollable](https://stackoverflow.com/a/16198198/10364425) frame to contain all those check buttons. – Saad Jun 22 '20 at 17:47

2 Answers2

3

The root of the problem is that frames aren't scrollable. So, you have to find a widget that supports scrolling and use that as a basis for adding scrolling to a group of widgets.

The Canvas widget is commonly used for this purpose. Often it's used in conjunction with an interior frame, which makes it easy to use pack or grid to arrange the widgets. However, because you're creating a vertical stack of identical widgets, it's easier to draw the checkbuttons directly on the canvas.

The first step is to add a canvas and scrollbar to the frame:

class ChecklistBox(tk.Frame):
    def __init__(self, parent, choices, **kwargs):
        tk.Frame.__init__(self, parent, **kwargs)

        canvas = tk.Canvas(self, background=self.cget("background"))
        vsb = tk.Scrollbar(self, command=canvas.yview)
        canvas.configure(yscrollcommand=vsb.set)
        vsb.pack(side="right", fill="y")
        canvas.pack(side="left", fill="both", expand=True)
        ...

Next, instead of calling pack on the checkbutton we'll call create_window. We can get the y coordinate of the previous item to determine where to put the next item. We'll use the pady option of the frame for spacing.

        pady = int(str(self.cget("pady")))
        for choice in choices:
            ...
            bbox = canvas.bbox("all")
            y0 = pady if bbox is None else bbox[3]+pady
            canvas.create_window(0, y0, window=cb, anchor="nw")

Finally, you need to make sure that the scrollregion is set properly:

        canvas.configure(scrollregion=canvas.bbox("all"))
Bryan Oakley
  • 370,779
  • 53
  • 539
  • 685
  • I'm curious to know, will it make any difference if we inherit `Canvas` directly instead of inheriting `Frame`? – Saad Jun 22 '20 at 18:26
  • @Saad: no, it won't make any difference, though you shouldn't pack the scrollbar inside the canvas. – Bryan Oakley Jun 22 '20 at 18:31
  • Thanks for the response. I was thinking we can just skip `Frame` but forgot that scrollbar takes some space if packed inside of `Canvas` which is not a good practice. – Saad Jun 22 '20 at 18:37
  • Is the second block of code also part of the definition of the class ? – usernumber Jun 23 '20 at 07:32
  • Bryan, thanks! -- the scroll wheel still doesn't work, but at least the scrollbar now is functional. – Cris Luengo Nov 05 '21 at 22:33
  • To enable the scroll wheel, [this answer](https://stackoverflow.com/a/17457843/7328782) is useful. – Cris Luengo Nov 05 '21 at 23:09
0

I had a similar need and cobbled this control widget CheckListBox it is based off of numerous web examples. Had I known I would have shared this code I would have kept the references. Note I cobbled it together I did not originate the major sections like the ScrolledWindow class.

Main features, Checks [0,1,-1] 0 unchecked, 1 checked , -1 no check image Options Item image

Appdata on items: Any id text like tooltext

CheckListBox Demo Image

import os, sys
import tkinter as tk
from tkinter import ttk

from PIL import Image,ImageTk

class ScrolledWindow(tk.Frame):

    def __init__(self, parent, canv_w = 400, canv_h = 400, *args, **kwargs):
        self.up = False
        super().__init__(parent, *args, **kwargs)

        self.parent = parent

        # creating a scrollbars
        self.xscrlbr = ttk.Scrollbar(self.parent, orient = 'horizontal')
        self.xscrlbr.grid(column = 0, row = 1, sticky = 'ew', columnspan = 2)
        self.yscrlbr = ttk.Scrollbar(self.parent)
        self.yscrlbr.grid(column = 1, row = 0, sticky = 'ns')  # ***** Grid

        # creating a canvas
        self.canv = tk.Canvas(self.parent , border=1 , borderwidth=2)
        # placing a canvas into frame
        self.canv.grid(column = 0, row = 0, sticky = 'nsew') # ***** Grid
        # accociating scrollbar comands to canvas scroling
        self.xscrlbr.config(command = self.canv.xview)
        self.yscrlbr.config(command = self.canv.yview)

        # creating a frame to inserto to canvas
        self.scrollwindow = ttk.Frame(self.parent, width=canv_w, height=canv_h)
        self.scrollwindow.grid(column = 0, row = 0, sticky = 'nsew')  # grid
        self.canv.create_window(0, 0, window = self.scrollwindow, anchor = 'nw')

        self.canv.config(xscrollcommand = self.xscrlbr.set,
                         yscrollcommand = self.yscrlbr.set,
                         scrollregion = (0, 0, 100, 100) # scrollregion = (0, 0, 100, 100)
                         )

        self.yscrlbr.lift(self.scrollwindow)
        self.xscrlbr.lift(self.scrollwindow)

        self.scrollwindow.bind('<Configure>', self._configure_window)
        self.scrollwindow.bind('<Enter>', self._bound_to_mousewheel)
        self.scrollwindow.bind('<Leave>', self._unbound_to_mousewheel)


    def _bound_to_mousewheel(self, event):
        self.canv.bind_all("<MouseWheel>", self._on_mousewheel)

    def _unbound_to_mousewheel(self, event):
        self.canv.unbind_all("<MouseWheel>")

    def _on_mousewheel(self, event):
        self.canv.yview_scroll(int(-1*(event.delta/120)), "units")

    def _configure_window(self, event):
        # update the scrollbars to match the size of the inner frame
        size = (self.scrollwindow.winfo_reqwidth(), self.scrollwindow.winfo_reqheight())
        self.canv.config(scrollregion='0 0 %s %s' % size)

        if self.scrollwindow.winfo_reqwidth() != self.canv.winfo_width():
            # update the canvas's width to fit the inner frame
            self.canv.config(width = self.scrollwindow.winfo_reqwidth())

        if self.up == True and self.scrollwindow.winfo_reqheight() != self.canv.winfo_height():
        #if self.scrollwindow.winfo_reqheight() != self.canv.winfo_height():
            # update the canvas's width to fit the inner frame
            self.canv.config(height = self.scrollwindow.winfo_reqheight())


class CheckListBox(  tk.Frame ):
    def __init__(self, parent, **kw):
        # x = kw.pop( 'x' )  if 'x' in kw else 300
        # y = kw.pop( 'y' ) if 'y' in kw else 300
        #tk.Toplevel.__init__(self, self.parent, **kw)
        #self.geometry(f'{width}x{height}+{x}+{y}')
        if 'master' in kw and parent is None:
            parent = kw.pop('master')
        self.parent = parent

        tk.Frame.__init__(self, parent, **kw )
        self.height = kw.pop( 'height') if 'height' in kw else 250
        self.width  = kw.pop( 'width') if 'width' in kw else 550

        self.win = ScrolledWindow( self  , self.width , self.height)

        self.tframe= self.win.scrollwindow
        #self.tframe.pack_propagate(False)

        pngd =  os.path.dirname(os.path.realpath(__file__)) + "/_common/images/"
        self.checked = checkedImg = tk.PhotoImage( file= pngd+"Checked_18.png" )
        self.unchecked = uncheckedImg = tk.PhotoImage( file= pngd+"CheckedNot_18.png" )

        #canv.create_image(0, 0, image=photoImg)
        self.items=[]
        self.win.canv.config(height = self.height)
        self.win.up=False

    def getvalue( self, values , matchstr , default ):
         it = matchstr.strip().lower()                                   # case insensitive match
         for idx in range( 1, len(values) ):                      # skip the first entry that is the widget id
            try:                                                                      # do not let an execption stop us
             p = values[ idx ].split( '=')
             if p[0].strip().lower() == it:                               # case insensitive match
                 return p[1]
            except: pass;
         return default                                                       # Return the default

    def setvalue( self, values , matchstr , data=None ):
         it = matchstr.strip().lower()                                   # case insensitive match
         for idx in range( 1, len(values ) ):                      # skip the first entry that is the widget id
            try:
             p = values[ idx ].split( '=')
             if p[0].strip().lower() == it:
                 if data == None or data =="":                    # empty data  indicates deletion request
                        return values.remove( values[ idx ] )   # remove the entry in the list
                 values[idx] = matchstr + '=' + str(data)      # Since it exists update the data
                 return
            except: pass;
         values.append( matchstr + '=' + str(data)  )        # New data so append it
         return

    def OnItemDouble( self, ev, idx ) :
         values = self.items[ idx ]                                       # Get the values assigned to the item at [ idx ]
         b = int( self.getvalue( values, 'chk',0 ) )               # Get the chk value set it to 0 if not set
         b = 0 if b == 1 else 1                                            # Toggle the value and then get the image
         self.setvalue( values, 'chk' , b )                              # Save its new state

         img = self.checked if  b == 1  else self.unchecked
         textctl=ev.widget                                                   # textctl=values[0] either will work
         textctl.config(state='normal')                                # Set state to normale so we can overwrite
         textctl.delete("1.0", "1.1")                                      # Delete the line data including image
         textctl.image_create( "1.0", image=img)              # Add the checkmark image first
         textctl.config( state='disabled')                             # Then set the state to readonly again
         #textctl.config( state='disabled', bg='blue')         # Then set the state to readonly again
         print( values )

    def append( self , txt,  **kw ):
             values=[]

             bchk=int( kw.pop( 'chk')) if 'chk' in kw else -1
             appdata = kw.pop( 'appdata') if 'appdata' in kw else []
             pic = kw.pop( 'image') if 'image' in kw else None

             textctl = tk.Text( self.tframe,height=1,width=self.width, **kw)
             textctl.grid( column=0,row= len( self.items),sticky = 'nsew') # +++ nsew???

             values.append( textctl )                                       # The text control is always first
             self.setvalue( values , 'chk' , str(bchk) )
             self.setvalue( values , 'image' , pic )
             self.setvalue( values , 'text' , txt )

             if bchk>=0:
                 img = self.checked if bchk==1 else self.unchecked
                 textctl.image_create( "end", image=img)

             if pic : textctl.image_create( "end", image=pic)
             textctl.insert("end", txt )

             values += appdata
             self.items.append(  values )
             idx = len( self.items ) -1
             textctl.config(state=tk.DISABLED, highlightthickness = 0, borderwidth=1)
             textctl.bind("<Double-Button-1>", lambda ev=None, x=idx:  self.OnItemDouble( ev, x) )
             return idx

    def insert( self, idx, txt  , **kw ):
             values=[]

             if idx < 0 : return -1
             if idx > len( self.items ):  return self.append( txt,**kw)


             bchk=int( kw.pop( 'chk')) if 'chk' in kw else -1
             appdata = kw.pop( 'appdata') if 'appdata' in kw else []
             pic = kw.pop( 'image') if 'image' in kw else None

             textctl = tk.Text( self.tframe,height=1,width=self.width, **kw)

             values.append( textctl )                                       # The text control is always first
             self.setvalue( values , 'chk' , str(bchk) )
             self.setvalue( values , 'image' , pic )
             self.setvalue( values , 'text' , txt )
             self.items.insert(  idx, values )

             for i in range( 0 , len( self.items )):
                         self.items[i][0].grid_forget()
             for i in range( 0, len( self.items )):
                     values = self.items[i]
                     textctl = values[0]
                     textctl.config(state='normal')                             # To change the text and images
                     textctl.grid( column=0,row=i,sticky='nsew')
                     if i==idx:
                         textctl.delete('1.0','end')
                         bchk = int( self.getvalue(  values ,'chk',0))
                         if bchk >= 0:
                             img = self.checked if bchk == 1 else self.unchecked
                             textctl.image_create( "end", image=img)

                         pic = self.getvalue(  values ,'image',"")
                         if pic !="None" :  textctl.image_create( "end", image=pic)

                         txt = self.getvalue( values, 'text' ,"")
                         textctl.insert( "end", txt )

                     textctl.config(state=tk.DISABLED, highlightthickness = 0, borderwidth=1)
                     textctl.bind("<Double-Button-1>", lambda ev=None, x=i:  self.OnItemDouble( ev, x) )
             return len( self.items )

    def curselection( self ):
         sellist = []
         for idx in range( 0, len( self.items ) ):
             values = self.items[ idx ]
             if int( self.getvalue( values, 'chk', '0') ) > 0:
                 sellist.append( idx )
         return sellist

    def get( self, idx, match='text' , default='N/A'):
         sellist = []
         if idx in range( 0, len( self.items )) :
             values = self.items[ idx ]
             return self.getvalue( values, match,default)
         return default

    def delete( self, idx ):
             if idx < 0  or  idx > len( self.items ):  return -1
             ctl=self.items[idx][0]
             ctl.grid_forget()                      # forget it existed
             ctl=None                                   # delete the existing text control
             return len( self.items )



if __name__ == "__main__":

        def getsel( ctl ):
            lst = ctl.curselection( )
            for i in lst:
                print( "Got ", ctl.get( i ) , ctl.get( i , 'altdata') ,ctl.get(i,'chk') )

        # create a root window.
        top = tk.Tk()
        top.geometry("+300+300")
        top.title("Check Listbox Demo")
        topframe = tk.Frame( top)
        topframe.pack()
        label = tk.Label( topframe, width=10,text="Double click to toggle check.")
        label.pack( side='top',fill='x')


        pngd =  os.path.dirname(os.path.realpath(__file__)) + "/_common/images/"
        fileImg = tk.PhotoImage( file= pngd+"list_16.png" )
        folderImg = tk.PhotoImage( file= pngd+"network_folder_18.png" )

        leftframe = tk.Frame( topframe)
        leftframe.pack(side='left')
        # create listbox object
        example = CheckListBox(leftframe,height=60,width=20)
        example.pack(side="left", fill="both", expand=True)
        for i in range( 0,10):
            data = []
            example.setvalue( data , "altData","Some data here."+str( i +1) )
            example.setvalue( data ,"tip", "Item Tool Tip here")
            pic = folderImg if i%2==0 else fileImg
            example.append(  "First text " + str( i +1) , bg='lightgray' , chk=i%2 , image=pic, appdata=data)


        rightframe = tk.Frame( topframe)
        rightframe.pack(side='left')
        example2 = CheckListBox(rightframe ,height=100,width=30 )
        example2.pack(side="left", fill="both", expand=True)
        for i in range( 0,10):
             if i%2==0:
                 example2.append(  "Second text " + str( i + 1 ) , chk=1 , bg='#C0F862')
             else:
                 example2.append(  "Second text " + str( i + 1 ) , chk=1 , bg='#7FF1C0')

        testframe = tk.Frame( top )
        testframe.pack()


        example3 = CheckListBox(testframe,height=80,width=30 )
        example3.pack()
        for i in range( 0,10):
            if i%2==0:
                example3.append(  "Third text " + str( i + 1 ) , image=folderImg )
            else:
                example3.append( "Third text " + str( i +1), chk=1 , image=fileImg)

        #example.insert(  11, "New First text 11" , chk=1 )
        example.delete( 3)
        example.insert(  3, "New Four " , chk=1 )
        #example.insert(  0, "New First text  1" , chk=1 )

        button = tk.Button( text="print selected in first", command=lambda x=example : getsel(x) ).pack()

        top.mainloop()

DrHoule
  • 1
  • 1