tkinter绘制组件(40)——滚动选值框

tkinter绘制组件(40)——滚动选值框

  • 引言
  • 布局
    • 函数结构
    • 文本展示
    • 选择器布局
    • 完整函数代码
  • 效果
    • 测试代码
    • 最终效果
  • github项目
  • pip下载
  • 结语

引言

2023年基本没有怎么更新TinUI组件部分,而滚动选值框(picker),是在2023年底、2024年初磨洋工磨出来的。

因为一些原因,TinUI更新速度在这段时间被放得“极缓”,但是好歹还是冒了个泡。picker作为TinUI5预发布组件,将在TinUI4.7(5-pre1)首次可用。这也是2024年暑假之前,TinUI唯一的大更新。

前情提要结束。开始正题。

本控件目的可以参考WinUI的TimePikcer和DataPicker,不过更加通用,没有限制数据选择类型。滚动选值框(选择器)提供了一套标准化方式,可使用户选择强相关的系列取值。


布局

函数结构

def add_picker(self,pos:tuple,height=250,fg='#1b1b1b',bg='#fbfbfb',outline='#ececec',activefg='#1b1b1b',activebg='#f6f6f6',onfg='#eaecfb',onbg='#3748d9',font=('微软雅黑',10),text=(('year',60),('season',100),),data=(('2022','2023','2024'),('spring','summer','autumn','winter')),tran='#01FF11',command=None):#绘制滚动选值框
'''
pos-位置
height-选择框高度
fg-文本颜色
bg-背景色
outline-边框色
activefg-选择时文本颜色
activebg-选择时背景颜色
onfg-选定时文本颜色
onbg-选定时背景颜色
font-字体
text-文本内容,需要与`data`对应。`((选值文本,元素宽度),...)`
data-选值内容,需要与`text`对应
tran-透明处理规避颜色
command-响应接受函数。需要接受一个参数:所有选值列表,全被选定时触发
'''  

文本展示

这一部分比较简单,就是通过text参数中给定的文本和元素宽度,在当前画布上绘制文本元素。既然比较复杂的文字排版table控件早就加入到TinUI中了,这个小操作不在话下。如果不太熟悉或没看懂绘制逻辑,可以看看本专栏的表格绘制。

        out_line=self.create_polygon((*pos,*pos),fill=outline,outline=outline,width=9)
        uid='picker'+str(out_line)
        self.addtag_withtag(uid,out_line)
        back=self.create_polygon((*pos,*pos),fill=bg,outline=bg,width=7,tags=uid)
        end_x=pos[0]+9
        y=pos[1]+9
        texts=[]#文本元素
        #测试文本高度
        txtest=self.create_text(pos,text=text[0][0],fill=fg,font=font)
        bbox=self.bbox(txtest)
        self.delete(txtest)
        uidheight=bbox[3]-bbox[1]
        for i in text:
            t,w=i#文本,宽度
            tx=self.create_text((end_x,y),anchor='w',text=t,fill=fg,font=font,tags=(uid,uid+'content'))
            texts.append(tx)
            end_x+=w
            if text.index(i)+1==len(text):#最后一个省略分隔符
                _outline=outline
                outline=''
            self.create_line((end_x-3,pos[1],end_x-3,pos[1]+uidheight),fill=outline,tags=(uid,uid+'content'))
        outline=_outline
        del _outline

不过需要注意的是,因为picker的选择器窗口是以像menu一样地使用子窗口,因此我们需要先行确定窗口的宽度。

顺便绑定一下响应事件。

        def _mouseenter(event):
            self.itemconfig(back,fill=activebg,outline=activebg)
            for i in texts:
                self.itemconfig(i,fill=activefg)
        def _mouseleave(event):
            self.itemconfig(back,fill=bg,outline=bg)
            for i in texts:
                self.itemconfig(i,fill=fg)
        def show(event):
            #这部分待会会用来现实选择器窗口
            ...
        ...
        del _outline
        width=end_x-pos[0]+9#窗口宽度
        cds=self.bbox(uid+'content')
        #变换背景元素尺寸
        coords=(cds[0],cds[1],cds[2],cds[1],cds[2],cds[3],cds[0],cds[3])
        self.coords(out_line,coords)
        self.coords(back,coords)
        #绑定事件
        self.tag_bind(uid,'<Enter>',_mouseenter)
        self.tag_bind(uid,'<Leave>',_mouseleave)
        self.tag_bind(uid,'<Button-1>',show)

选择器布局

这才是重点。

选择器应该遵循以下布局要求:

  1. 有几个选项,就要有几个选择器元素,且宽度与text中指定宽度基本一致

  2. 需要有确定和取消按钮

  3. 窗口默认位置应该与在TinUI中的文本元素对应

对于要求【1】,我参考了自己写的listbox代码。(真的是万事开头难,现在应该写不出当时的代码了……)

然后通过循环创建选择器元素。

        def _loaddata(box,items,mw):#这是listbox中的逻辑与绘制代码
            def __set_y_view(event):
                box.yview_scroll(int(-1*(event.delta/120)), "units")
            #mw: 元素宽度
            for i in items:
                end=box.bbox('all')
                end=5 if end==None else end[-1]
                text=box.create_text((5,end+7),text=i,fill=fg,font=font,anchor='nw',tags=('textcid'))
                bbox=box.bbox(text)#获取文本宽度
                back=box.create_rectangle((3,bbox[1]-4,3+mw,bbox[3]+4),width=0,fill=bg)
                box.tkraise(text)
                box.choices[text]=[i,text,back,False]#用文本id代表键,避免选项文本重复带来的逻辑错误
                #box.all_keys.append(text)
                box.tag_bind(text,'<Enter>',lambda event,text=text : pick_in_mouse(event,text))
                box.tag_bind(text,'<Leave>',lambda event,text=text : pick_out_mouse(event,text))
                box.tag_bind(text,'<Button-1>',lambda event,text=text : pick_sel_it(event,text))
                box.tag_bind(back,'<Enter>',lambda event,text=text : pick_in_mouse(event,text))
                box.tag_bind(back,'<Leave>',lambda event,text=text : pick_out_mouse(event,text))
                box.tag_bind(back,'<Button-1>',lambda event,text=text : pick_sel_it(event,text))
            bbox=box.bbox('all')
            box.config(scrollregion=bbox)
            box.bind('<MouseWheel>',__set_y_view)
        ...
        for i in data:
            barw=text[__count][1]#本选择列表元素宽度
            pickbar=BasicTinUI(picker,bg=bg)
            pickbar.place(x=end_x,y=y,width=barw,height=height-50)
            maxwidth=0
            pickbar.newres=''#待选
            pickbar.res=''#选择结果
            #pickbar.all_keys=[]#[a-id,b-id,...]
            pickbar.choices={}#'a-id':[a,a_text,a_back,is_sel:bool]
            _loaddata(pickbar,i,barw)
            pickerbars.append(pickbar)
            __count+=1
            end_x+=barw+3
        del __count

要求【2】则简单多了。这里使用button2,但是需要调整背景元素。也相当于在TinUI的自身应用中给出控件元素返回值的操作范例。

        okpos=((5+(width-9)/2)/2,height-22)
        ok=bar.add_button2(okpos,text='??',font='{Segoe UI Emoji} 12',fg=fg,bg=bg,line='',activefg=activefg,activebg=activebg,activeline='',anchor='center',command=set_it)
        bar.coords(ok[1],(10,height-35,(width-9)/2-5,height-35,(width-9)/2-5,height-9,10,height-9))
        nopos=(((width-9)/2+width-4)/2,height-22)
        no=bar.add_button2(nopos,text='?',font='{Segoe UI Emoji} 12',fg=fg,bg=bg,line='',activefg=activefg,activebg=activebg,activeline='',anchor='center',command=cancel)
        bar.coords(no[1],((width-9)/2+5,height-35,width-9,height-35,width-9,height-9,(width-9)/2+5,height-9))
        readyshow()

代码中的readyshow,就是要求【3】的内容了。

这部分主要用来计算和记录选择器窗口的位置信息,稍后会用在show函数中。

        def readyshow():#计算显示位置
            allpos=bar.bbox('all')
            #菜单尺寸
            winw=allpos[2]-allpos[0]+5
            winh=allpos[3]-allpos[1]+5
            #屏幕尺寸
            maxx=self.winfo_screenwidth()
            maxy=self.winfo_screenheight()
            wind.data=(maxx,maxy,winw,winh)

不同于menupicker的窗口需要直接贴近文本元素,因此需要额外计算文本元素边缘与点击的位置差,然后在与屏幕坐标相减。

此外,picker采用淡入动画。

        def show(event):#显示的起始位置
            #初始位置
            maxx,maxy,winw,winh=wind.data
            bbox=self.bbox(uid)
            scx,scy=event.x_root,event.y_root#屏幕坐标
            dx,dy=round(self.canvasx(event.x,)-bbox[0]),round(self.canvasy(event.y)-bbox[3])#画布坐标差值
            sx,sy=scx-dx,scy-dy
            if sx+winw>maxx:
                x=sx-winw
            else:
                x=sx
            if sy+winh>maxy:
                y=sy-winh
            else:
                y=sy
            picker.geometry(f'{winw+15}x{winh+15}+{x-4}+{y}')
            picker.attributes('-alpha',0)
            picker.deiconify()
            picker.focus_set()
            for i in [0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9,1]:
                picker.attributes('-alpha',i)
                picker.update()
                time.sleep(0.05)
            picker.bind('<FocusOut>',unshow)

好了,到此,picker的两部分内容已经完成绘制。完整的逻辑代码会在下方给出。

完整函数代码

    def add_picker(self,pos:tuple,height=250,fg='#1b1b1b',bg='#fbfbfb',outline='#ececec',activefg='#1b1b1b',activebg='#f6f6f6',onfg='#eaecfb',onbg='#3748d9',font=('微软雅黑',10),text=(('year',60),('season',100),),data=(('2022','2023','2024'),('spring','summer','autumn','winter')),tran='#01FF11',command=None):#绘制滚动选值框
        def _mouseenter(event):
            self.itemconfig(back,fill=activebg,outline=activebg)
            for i in texts:
                self.itemconfig(i,fill=activefg)
        def _mouseleave(event):
            self.itemconfig(back,fill=bg,outline=bg)
            for i in texts:
                self.itemconfig(i,fill=fg)
        def set_it(e):#确定选择
            results=[]#结果列表
            for ipicker in pickerbars:
                num=pickerbars.index(ipicker)
                if ipicker.newres=='':#没有选择
                    unshow(e)
                    return
                ipicker.res=ipicker.newres
                tx=texts[num]
                self.itemconfig(tx,text=ipicker.res)
                results.append(ipicker.res)
            unshow(e)
            if command!=None:
                command(results)
        def cancel(e):#取消选择
            for ipicker in pickerbars:
                if ipicker.res=='':
                    pass
            unshow(e)
            #以后或许回考虑元素选择复原,也不一定,或许不更改交互选项更方便
        def pick_in_mouse(e,t):
            box=e.widget
            if box.choices[t][-1]==True:#已被选中
                return
            box.itemconfig(box.choices[t][2],fill=activebg)
            box.itemconfig(box.choices[t][1],fill=activefg)
        def pick_out_mouse(e,t):
            box=e.widget
            if box.choices[t][-1]==True:#已被选中
                box.itemconfig(box.choices[t][2],fill=onbg)
                box.itemconfig(box.choices[t][1],fill=onfg)
            else:
                box.itemconfig(box.choices[t][2],fill=bg)
                box.itemconfig(box.choices[t][1],fill=fg)
        def pick_sel_it(e,t):
            box=e.widget
            box.itemconfig(box.choices[t][2],fill=onbg)
            box.itemconfig(box.choices[t][1],fill=onfg)
            box.choices[t][-1]=True
            for i in box.choices.keys():
                if i==t:
                    continue
                box.choices[i][-1]=False
                pick_out_mouse(e,i)
            box.newres=box.choices[t][0]
        def readyshow():#计算显示位置
            allpos=bar.bbox('all')
            #菜单尺寸
            winw=allpos[2]-allpos[0]+5
            winh=allpos[3]-allpos[1]+5
            #屏幕尺寸
            maxx=self.winfo_screenwidth()
            maxy=self.winfo_screenheight()
            wind.data=(maxx,maxy,winw,winh)
        def show(event):#显示的起始位置
            #初始位置
            maxx,maxy,winw,winh=wind.data
            bbox=self.bbox(uid)
            scx,scy=event.x_root,event.y_root#屏幕坐标
            dx,dy=round(self.canvasx(event.x,)-bbox[0]),round(self.canvasy(event.y)-bbox[3])#画布坐标差值
            sx,sy=scx-dx,scy-dy
            if sx+winw>maxx:
                x=sx-winw
            else:
                x=sx
            if sy+winh>maxy:
                y=sy-winh
            else:
                y=sy
            picker.geometry(f'{winw+15}x{winh+15}+{x-4}+{y}')
            picker.attributes('-alpha',0)
            picker.deiconify()
            picker.focus_set()
            for i in [0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9,1]:
                picker.attributes('-alpha',i)
                picker.update()
                time.sleep(0.05)
            picker.bind('<FocusOut>',unshow)
        def unshow(event):
            picker.withdraw()
            picker.unbind('<FocusOut>')
        def _loaddata(box,items,mw):
            def __set_y_view(event):
                box.yview_scroll(int(-1*(event.delta/120)), "units")
            #mw: 元素宽度
            for i in items:
                end=box.bbox('all')
                end=5 if end==None else end[-1]
                text=box.create_text((5,end+7),text=i,fill=fg,font=font,anchor='nw',tags=('textcid'))
                bbox=box.bbox(text)#获取文本宽度
                back=box.create_rectangle((3,bbox[1]-4,3+mw,bbox[3]+4),width=0,fill=bg)
                box.tkraise(text)
                box.choices[text]=[i,text,back,False]#用文本id代表键,避免选项文本重复带来的逻辑错误
                #box.all_keys.append(text)
                box.tag_bind(text,'<Enter>',lambda event,text=text : pick_in_mouse(event,text))
                box.tag_bind(text,'<Leave>',lambda event,text=text : pick_out_mouse(event,text))
                box.tag_bind(text,'<Button-1>',lambda event,text=text : pick_sel_it(event,text))
                box.tag_bind(back,'<Enter>',lambda event,text=text : pick_in_mouse(event,text))
                box.tag_bind(back,'<Leave>',lambda event,text=text : pick_out_mouse(event,text))
                box.tag_bind(back,'<Button-1>',lambda event,text=text : pick_sel_it(event,text))
            bbox=box.bbox('all')
            box.config(scrollregion=bbox)
            box.bind('<MouseWheel>',__set_y_view)
        out_line=self.create_polygon((*pos,*pos),fill=outline,outline=outline,width=9)
        uid='picker'+str(out_line)
        self.addtag_withtag(uid,out_line)
        back=self.create_polygon((*pos,*pos),fill=bg,outline=bg,width=7,tags=uid)
        end_x=pos[0]+9
        y=pos[1]+9
        texts=[]#文本元素
        #测试文本高度
        txtest=self.create_text(pos,text=text[0][0],fill=fg,font=font)
        bbox=self.bbox(txtest)
        self.delete(txtest)
        uidheight=bbox[3]-bbox[1]
        for i in text:
            t,w=i#文本,宽度
            tx=self.create_text((end_x,y),anchor='w',text=t,fill=fg,font=font,tags=(uid,uid+'content'))
            texts.append(tx)
            end_x+=w
            if text.index(i)+1==len(text):#最后一个省略分隔符
                _outline=outline
                outline=''
            self.create_line((end_x-3,pos[1],end_x-3,pos[1]+uidheight),fill=outline,tags=(uid,uid+'content'))
        outline=_outline
        del _outline
        width=end_x-pos[0]+9#窗口宽度
        cds=self.bbox(uid+'content')
        coords=(cds[0],cds[1],cds[2],cds[1],cds[2],cds[3],cds[0],cds[3])
        self.coords(out_line,coords)
        self.coords(back,coords)
        self.tag_bind(uid,'<Enter>',_mouseenter)
        self.tag_bind(uid,'<Leave>',_mouseleave)
        self.tag_bind(uid,'<Button-1>',show)
        #创建窗口
        picker=Toplevel(self)
        picker.geometry(f'{width}x{height}')
        picker.overrideredirect(True)
        picker.attributes('-topmost',1)
        picker.withdraw()#隐藏窗口
        picker.attributes('-transparent',tran)
        wind=TinUINum()#记录数据
        bar=BasicTinUI(picker,bg=tran)
        bar.pack(fill='both',expand=True)
        bar.create_polygon((9,9,width-9,9,width-9,height-9,9,height-9),fill=bg,outline=bg,width=9)
        bar.lower(bar.create_polygon((8,8,width-8,8,width-8,height-8,8,height-8),fill=outline,outline=outline,width=9))
        __count=0
        end_x=8
        y=9
        pickerbars=[]#选择UI列表
        for i in data:
            barw=text[__count][1]#本选择列表元素宽度
            pickbar=BasicTinUI(picker,bg=bg)
            pickbar.place(x=end_x,y=y,width=barw,height=height-50)
            maxwidth=0
            pickbar.newres=''#待选
            pickbar.res=''#选择结果
            #pickbar.all_keys=[]#[a-id,b-id,...]
            pickbar.choices={}#'a-id':[a,a_text,a_back,is_sel:bool]
            _loaddata(pickbar,i,barw)
            pickerbars.append(pickbar)
            __count+=1
            end_x+=barw+3
        del __count
        #ok button
        okpos=((5+(width-9)/2)/2,height-22)
        ok=bar.add_button2(okpos,text='??',font='{Segoe UI Emoji} 12',fg=fg,bg=bg,line='',activefg=activefg,activebg=activebg,activeline='',anchor='center',command=set_it)
        bar.coords(ok[1],(10,height-35,(width-9)/2-5,height-35,(width-9)/2-5,height-9,10,height-9))
        #cancel button
        nopos=(((width-9)/2+width-4)/2,height-22)
        no=bar.add_button2(nopos,text='?',font='{Segoe UI Emoji} 12',fg=fg,bg=bg,line='',activefg=activefg,activebg=activebg,activeline='',anchor='center',command=cancel)
        bar.coords(no[1],((width-9)/2+5,height-35,width-9,height-35,width-9,height-9,(width-9)/2+5,height-9))
        readyshow()
        #texts=[],pickerbars=[]
        return picker,bar,texts,pickerbars,uid

效果

测试代码

b.add_picker((1400,230),command=print)

最终效果

在这里插入图片描述

左下角是expander友情出演。


github项目

TinUI的github项目地址

pip下载

pip install tinui

结语

这样相当于一个比较粗糙的选择器吧。TinUI5将对大部分控件进行样式升级。

??tkinter创新??