引言

滚动条关系到所有可视范围可变的组件显示功能,所有与用户交互很少,但它就像一个机器中的螺丝钉,缺一不可。但同时,滚动条是唯一一个双向绑定组件,计算参数多,绘制难度大。

我应该是我已知的开源tkinter拓展组件库开发者中第一个用画布绘制出滚动条的了,完成时间在2022年2月6日,第一版最终样式修改时间在2022年2月中旬。

做好准备。之前我在绘制TinUI表格(table)控件时,说那是我最难绘制出的组件,但是前几天我完成scrollbar的绘制后,发现我用了150行左右的代码。

我知道,150行代码根本不算多,相比于TinUI的1000+行和TinGroup的6000+行,真的不算什么,但是相比于其它单个组件的绘制,这已经十分“给面子”了。

注意了,因为源码量有点大,这里只考虑纵向滚动条。但是放心,TinUI均支持横向和纵向滚动条。


布局

函数结构

def add_scrollbar(self,pos:tuple,widget,height:int=200,direction='y',bg='#f0f0f0',color='#999999',oncolor='#89898b'):#绘制滚动条
    '''
    pos::起始位置
    widget::绑定的组件,如文本框、画布、列表框。注意是tkinter组件,不是TinUI组件
    height::长度
    direction::方向,x或y。大小写无关
    bg::背景色
    color::滚动条未激活颜色
    oncolor::滚动条激活颜色
    '''

背景

这次的滚动条是仿winUI3的,因此背景框的两端为圆角。

if mode=='y':
    back=self.create_polygon((pos[0]+5,pos[1]+5,pos[0]+5,pos[1]+height-5,pos[0]+5,pos[1]+5),
    width=12,outline=bg)
    uid='scrollbar'+str(back)
    self.itemconfig(back,tags=uid)

上标和下标

我发现字符集本来就有“▲▼”两个字符,因此我就不绘制了。

top=self.create_text(pos,text='▲',font='微软雅黑 8',anchor='nw',fill=oncolor,tags=uid)
bottom=self.create_text((pos[0],pos[1]+height),text='▼',font='微软雅黑 8',anchor='sw',fill=oncolor,tags=uid)

横向时,使用angle参数,设置角度为90°。

滚动块

起始位置

因为我们无法确定在绑定滚动条前,被绑定组件的可视范围在哪,不过我们可以先粗略地绘制一下滚动块,再通过后期的样式调整设置它的范围。

sc=self.create_polygon((pos[0]+5,pos[1]+20,pos[0]+5,pos[1]+height-20,pos[0]+5,pos[1]+20,),width=3,outline=color,tags=uid)

接下来,我们计算出滚动块的三个参数:

  • 最小位置
  • 最大位置
  • 可动范围

其中,可动范围可以继续计算出滚动块顶端在可动范围中的百分比,继而显示绑定组件的可视范围。记住,这计划很重要。

样式

这个版块我们很熟悉,直接跳过,等一下看源码。

组件控制滚动块

这个很简单了,直接绑定组件的yscrollcommand参数事件,调整滚动条的位置。

def widget_move(sp,ep):#控件控制滚动条滚动
    if mode=='y':
        startp=start+canmove*float(sp)
        endp=start+canmove*float(ep)
        self.coords(sc,(pos[0]+5,startp+5,pos[0]+5,endp-5))
#绑定组件
widget.config(yscrollcommand=widget_move)

滚动条控制组件

滚动块

先来看看滚动块如何控制组件。借鉴之前的调整框(spinbox)的滑块,我们不难得出以下代码:

def mousedown(event):
    if mode=='y':
        scroll.start=self.canvasy(event.y)#定义起始纵坐标
    elif mode=='x':
        scroll.start=self.canvasx(event.x)#横坐标
def drag(event):
    bbox=self.bbox(sc)
    if mode=='y':#纵向
        move=self.canvasy(event.y)-scroll.start#将窗口坐标转化为画布坐标
        #防止被拖出范围
        if bbox[1]+move<start-1 or bbox[3]+move>end+1:
            return
        self.move(sc,0,move)
    elif mode=='x':#横向
        move=self.canvasx(event.x)-scroll.start
        if bbox[0]+move<start-1 or bbox[2]+move>end+1:
            return
        self.move(sc,move,0)
    #重新定义画布中的起始拖动位置
    scroll.start+=move
    sc_move()
def sc_move():#滚动条控制控件滚动
    bbox=self.bbox(sc)
    if mode=='y':
        startp=(bbox[1]-start)/canmove
        widget.yview('moveto',startp)
#...
scroll=TinUINum()

作者:你以为这就可以了?

读者:对啊,这可是直接copy调整框的拖动代码啊。

作者:你往上拖没毛病,你往下试试😏。

读者:what?居然拖不动!

没想到吧,因为在你改变组件可视范围时,组件同时也在调用widget_move参数,这样就会形成逻辑矛盾。解决的办法是是用一个中间变量,限制组件控制滚动条。

def widget_move(sp,ep):#控件控制滚动条滚动
    if mode=='y' and use_widget:
        #...
def mousedown(event):
    nonlocal use_widget#当该值为真,才允许响应widget_move函数
    use_widget=False
    #...
def mouseup(event):
    nonlocal use_widget
    use_widget=True
#...
use_widget=True#是否允许控件控制滚动条

标识

注意到我们之前绘制的上标和下标了吗?它们有大用处。

def topmove(event):#top
    bbox=self.bbox(sc)
    if mode=='y':
        move=-(bbox[3]-bbox[1])/2
        if bbox[1]+move<start:
            move=-(bbox[1]-start)
        self.move(sc,0,move)
    sc_move()
def bottommove(event):#bottom
    bbox=self.bbox(sc)
    if mode=='y':
        move=(bbox[3]-bbox[1])/2
        if bbox[3]+move>end:
            move=(end-bbox[3])
        self.move(sc,0,move)
    sc_move()

点一次划过半个滚动块,除非超出滚动范围。

背景响应

当你点击滚动条背景时,滚动条也会滚动,但是每个UI库响应这个事件的效果都不一样。TinUI选择点哪滚哪,以上(左)端为标准。

def backmove(event):#bottom
    bbox=self.bbox(sc)
    if mode=='y':
        posy=self.canvasy(event.y)
        move=posy-bbox[1]
        if move>0 and move+bbox[3]>end:
            move=end-bbox[3]
        if move<0 and move+bbox[1]<start:
            move=start-bbox[1]
        self.move(sc,0,move)
    sc_move()

绑定所有事件

逻辑代码完成了,只剩下绑定该绑定的事件了。

self.tag_bind(sc,'<Button-1>',mousedown)
self.tag_bind(sc,'<ButtonRelease-1>',mouseup)
self.tag_bind(sc,'<B1-Motion>',drag)
#绑定样式
self.tag_bind(sc,'<Enter>',enter)
self.tag_bind(sc,'<Leave>',leave)
#绑定点击滚动
self.tag_bind(top,'<Button-1>',topmove)
self.tag_bind(bottom,'<Button-1>',bottommove)
self.tag_bind(back,'<Button-1>',backmove)

完整代码函数

def add_scrollbar(self,pos:tuple,widget,height:int=200,direction='y',bg='#f0f0f0',color='#999999',oncolor='#89898b'):#绘制滚动条
    #滚动条宽度7px,未激活宽度3px;建议与widget相隔5xp
    def enter(event):#鼠标进入
        self.itemconfig(sc,outline=oncolor,width=7)
    def leave(event):#鼠标离开
        self.itemconfig(sc,outline=color,width=3)
    def widget_move(sp,ep):#控件控制滚动条滚动
        if mode=='y' and use_widget:
            startp=start+canmove*float(sp)
            endp=start+canmove*float(ep)
            self.coords(sc,(pos[0]+5,startp+5,pos[0]+5,endp-5))
        elif mode=='x' and use_widget:
            startp=start+canmove*float(sp)
            endp=start+canmove*float(ep)
            self.coords(sc,(startp+5,pos[1]+5,endp+5,pos[1]+5))
    def mousedown(event):
        nonlocal use_widget#当该值为真,才允许响应widget_move函数
        use_widget=False
        if mode=='y':
            scroll.start=self.canvasy(event.y)#定义起始纵坐标
        elif mode=='x':
            scroll.start=self.canvasx(event.x)#横坐标
    def mouseup(event):
        nonlocal use_widget
        use_widget=True
    def drag(event):
        bbox=self.bbox(sc)
        if mode=='y':#纵向
            move=self.canvasy(event.y)-scroll.start#将窗口坐标转化为画布坐标
            #防止被拖出范围
            if bbox[1]+move<start-1 or bbox[3]+move>end+1:
                return
            self.move(sc,0,move)
        elif mode=='x':#横向
            move=self.canvasx(event.x)-scroll.start
            if bbox[0]+move<start-1 or bbox[2]+move>end+1:
                return
            self.move(sc,move,0)
        #重新定义画布中的起始拖动位置
        scroll.start+=move
        sc_move()
    def topmove(event):#top
        bbox=self.bbox(sc)
        if mode=='y':
            move=-(bbox[3]-bbox[1])/2
            if bbox[1]+move<start:
                move=-(bbox[1]-start)
            self.move(sc,0,move)
        elif mode=='x':
            move=-(bbox[2]-bbox[0])/2
            if bbox[0]+move<start:
                move=-(bbox[0]-start)
            self.move(sc,move,0)
        sc_move()
    def bottommove(event):#bottom
        bbox=self.bbox(sc)
        if mode=='y':
            move=(bbox[3]-bbox[1])/2
            if bbox[3]+move>end:
                move=(end-bbox[3])
            self.move(sc,0,move)
        elif mode=='x':
            move=(bbox[2]-bbox[0])/2
            if bbox[2]+move>end:
                move=(end-bbox[2])
            self.move(sc,0,move)
        sc_move()
    def backmove(event):#back
        bbox=self.bbox(sc)
        if mode=='y':
            posy=self.canvasy(event.y)
            move=posy-bbox[1]
            if move>0 and move+bbox[3]>end:
                move=end-bbox[3]
            if move<0 and move+bbox[1]<start:
                move=start-bbox[1]
            self.move(sc,0,move)
        elif mode=='x':
            posx=self.canvasx(event.x)
            move=posx-bbox[0]
            if move>0 and move+bbox[2]>end:
                move=end-bbox[2]
            if move<0 and move+bbox[0]<start:
                move=start-bbox[0]
            self.move(sc,move,0)
        sc_move()
    def sc_move():#滚动条控制控件滚动
        bbox=self.bbox(sc)
        if mode=='y':
            startp=(bbox[1]-start)/canmove
            widget.yview('moveto',startp)
        elif mode=='x':
            startp=(bbox[0]-start)/canmove
            widget.xview('moveto',startp*1.2)
    if direction.upper()=='X':
        mode='x'
    elif direction.upper()=='Y':
        mode='y'
    else:
        return None
    #上标、下标 ▲▼
    if mode=='y':
        #back=self.create_rectangle((pos[0],pos[1],pos[0]+10,pos[1]+height),fill=bg,width=0)
        back=self.create_polygon((pos[0]+5,pos[1]+5,pos[0]+5,pos[1]+height-5,pos[0]+5,pos[1]+5),
        width=12,outline=bg)
        uid='scrollbar'+str(back)
        self.itemconfig(back,tags=uid)
        top=self.create_text(pos,text='▲',font='微软雅黑 8',anchor='nw',fill=oncolor,tags=uid)
        bottom=self.create_text((pos[0],pos[1]+height),text='▼',font='微软雅黑 8',anchor='sw',fill=oncolor,tags=uid)
        #sc=self.create_rectangle((pos[0],pos[1]+15,pos[0]+10,pos[1]+height-15),fill=color,width=0,tags=uid)
        sc=self.create_polygon((pos[0]+5,pos[1]+20,pos[0]+5,pos[1]+height-20,pos[0]+5,pos[1]+20,),
        width=3,outline=color,tags=uid)
        #起始和终止位置
        start=pos[1]+15
        end=pos[1]+height-15
        canmove=end-start
        #绑定组件
        widget.config(yscrollcommand=widget_move)
    elif mode=='x':
        back=self.create_polygon((pos[0]+5,pos[1]+5,pos[0]+height-5,pos[1]+5,pos[0],pos[1]+5),
        width=12,outline=bg)
        uid='scrollbar'+str(back)
        self.itemconfig(back,tags=uid)
        top=self.create_text((pos[0]+2,pos[1]+11),text='▲',angle=90,font='微软雅黑 8',anchor='w',fill=oncolor,tags=uid)
        bottom=self.create_text((pos[0]+height,pos[1]),text='▼',angle=90,font='微软雅黑 8',anchor='se',fill=oncolor,tags=uid)
        sc=self.create_polygon((pos[0]+20,pos[1]+5,pos[0]+height-20,pos[1]+5,pos[0]+20,pos[1]+5),
        width=3,outline=color,tags=uid)
        start=pos[0]+8
        end=pos[0]+height-13
        canmove=(end-start)*0.95
        widget.config(xscrollcommand=widget_move)
    scroll=TinUINum()
    use_widget=True#是否允许控件控制滚动条
    self.tag_bind(sc,'<Button-1>',mousedown)
    self.tag_bind(sc,'<ButtonRelease-1>',mouseup)
    self.tag_bind(sc,'<B1-Motion>',drag)
    #绑定样式
    self.tag_bind(sc,'<Enter>',enter)
    self.tag_bind(sc,'<Leave>',leave)
    #绑定点击滚动
    self.tag_bind(top,'<Button-1>',topmove)
    self.tag_bind(bottom,'<Button-1>',bottommove)
    self.tag_bind(back,'<Button-1>',backmove)
    return top,bottom,back,sc,uid

效果

测试代码

def test(event):
    a.title('TinUI Test')
    b.add_paragraph((50,150),'这是TinUI按钮触达的事件函数回显,此外,窗口标题也被改变、首行标题缩进减小')
    b.coords(m,100,5)
def test1(word):
    print(word)
def test2(event):
    ok1()
def test3(event):
    ok2()
def test4(event):
    from time import sleep
    for i in range(1,101):
        sleep(0.02)
        progressgoto(i)
def test5(result):
    b.itemconfig(scale_text,text='当前选值:'+str(result))

if __name__=='__main__':
    a=Tk()
    a.geometry('700x700+5+5')

    b=TinUI(a,bg='white')
    b.pack(fill='both',expand=True)
    m=b.add_title((600,0),'TinUI is a modern way to show tkinter widget in your application')
    m1=b.add_title((0,680),'test TinUI scrolled',size=2,angle=24)
    b.add_paragraph((20,290),'''     TinUI是基于tkinter画布开发的界面UI布局方案,作为tkinter拓展和TinEngine的拓展而存在。目前,TinUI已可应用于项目。''',
    angle=-18)
    b.add_paragraph((20,100),'下面的段落是测试画布的非平行字体显示效果,也是TinUI的简单介绍')
    b.add_button((250,450),'测试按钮',activefg='white',activebg='red',command=test,anchor='center')
    b.add_checkbutton((80,430),'允许TinUI测试',command=test1)
    b.add_label((10,220),'这是由画布TinUI绘制的Label组件')
    b.add_entry((250,330),350,'这里用来输入')
    b.add_separate((20,200),600)
    b.add_radiobutton((50,480),300,'sky is blue, water is blue, too. So, what is your heart',('red','blue','black'),command=test1)
    b.add_link((400,500),'TinGroup知识库','http://tinhome.baklib-free.com/')
    b.add_link((400,530),'执行print函数',print)
    _,ok1,_=b.add_waitbar1((500,220),bg='#CCCCCC')
    b.add_button((500,270),'停止等待动画',activefg='cyan',activebg='black',command=test2)
    bu1=b.add_button((700,200),'停止点状滚动条',activefg='white',activebg='black',command=test3)[1]
    bu2=b.add_button((700,250),'nothing button 2')[1]
    bu3=b.add_button((700,300),'nothing button 3')[1]
    b.add_labelframe((bu1,bu2,bu3),'box buttons')
    _,_,ok2,_=b.add_waitbar2((600,400))
    b.add_combobox((600,550),text='你有多大可能去珠穆朗玛峰',content=('20%','40%','60%','80%','100%','1000%'))
    b.add_button((600,480),text='测试进度条(无事件版本)',command=test4)
    _,_,_,progressgoto,_,_=b.add_progressbar((600,510))
    b.add_table((180,630),data=(('a','space fans over the\nworld','c'),('you\ncan','2','3'),('I','II','have a dream, then try your best to get it!')))
    b.add_paragraph((300,850),text='上面是一个表格')
    b.add_onoff((600,100))
    b.add_spinbox((680,100))
    b.add_scalebar((680,50),command=test5)
    scale_text,_=b.add_label((890,50),text='当前选值:2')
    b.add_info((680,140),info_text='this is info widget in TinUI')
    mtb=b.add_paragraph((0,720),'测试菜单(右键单击)')
    b.add_menubar(mtb,cont=(('command',print),('menu',test1),'-',('TinUI文本移动',test)))
    ttb=b.add_paragraph((0,800),'TinUI能做些什么?')
    b.add_tooltip(ttb,'很多很多')
    b.add_back(pos=(0,0),uids=(ttb,),bg='cyan')
    _,_,ok3,_=b.add_waitbar3((600,800),width=240)
    b.add_button((600,750),text='停止带状等待框',command=lambda event:ok3())
    textbox=b.add_textbox((890,100),text='这是文本输入框,当然,无法在textbox的参数中绑定横向滚动'+'\n换行'*30)[0]
    textbox['wrap']='none'
    b.add_scrollbar((1095,100),textbox)
    b.add_scrollbar((890,305),textbox,direction='x')

    a.mainloop()

最终效果

在这里插入图片描述
没错,我就是第一个用tkinter画布绘制出滚动条的tkinter爱好者。

隔壁customtkinter的滚动条于2022年6月份首次添加,比TinUI晚四个月。

2022-8-15修复

修复横向滚动条到最大值后按右侧按钮,滚动条向下移动的问题。

2022-8-25新样式

在这里插入图片描述

  • 背景元素圆角左右对称(宽度改为13)
  • 不使用时保持静默状态

2024-2-11新样式

  • 上下(左右)顶点标识符使用Segoe Fluent Icons

github项目

TinUI的github项目地址

pip下载

pip install tinui

修改开源协议

从2022年2月开始,TinUI(包括TinUIXml技术)使用GPLv3开源协议。

开源条款见TinUI的github项目地址

结语

TinUI推出了最新的现代化xml布局方式——TinUIXml。赶快去使用吧!!!

🔆tkinter创新🔆

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐