🐍

tkinter でスクロールできるフレーム:マウスホイールにも対応![ScrollableFrame]

2022/06/19に公開

はじめに

PythonでCanvasをリサイズできるようにしてみた を元に自分好みに使いやすくしてみました.
略儀ながら、ここに感謝の意を表します.

こんな感じのフレームのクラスです.

概要

Python の Tkinter のスクロールバー付きのフレームのクラスです。
ttkwidgetsAutoHideScrollbar を使用することで、自動的に隠れるスクロールバーを使用できます。
また、クラス内の bind_child 関数を使用することで、入れ子となっている全てのウィジェットに bind を設定できます。

ソースコード

こちらをクリックしてください.
ScrollableFrame.py
class ScrollableFrame(ttk.Frame):
    def __init__(self, parent, size=(1, 1), fit_w=False, fit_h=False):
        """
        parent: tk.Frame
            親となるフレーム
        minimal_size: tuple
            最小キャンバスサイズ
        fit_w: bool
            キャンバス内のフレームの幅をキャンバスに合わせるか否か
        fit_h: bool
            キャンバス内のフレームの高さをキャンバスに合わせるか否か
        """
        ttk.Frame.__init__(self, parent)
        
        # 変数の初期化
        self.minimal_canvas_size = size
        self.fit_width = fit_w
        self.fit_height = fit_h
        
        # 縦スクロールバー
        #vscrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL)
        vscrollbar = AutoHideScrollbar(self, orient=tk.VERTICAL)
        vscrollbar.pack(fill=tk.Y, side=tk.RIGHT, expand=False)
        # 横スクロールバー
        #hscrollbar = ttk.Scrollbar(self, orient=tk.HORIZONTAL)
        hscrollbar = AutoHideScrollbar(self, orient=tk.HORIZONTAL)
        hscrollbar.pack(fill=tk.X, side=tk.BOTTOM, expand=False)
        # Canvas
        self.canvas = tk.Canvas(
            self, bd=0, highlightthickness=0,
            yscrollcommand=vscrollbar.set, xscrollcommand=hscrollbar.set
        )
        self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        
        # スクロールバーを Canvas に関連付け
        vscrollbar.config(command=self.canvas.yview)
        hscrollbar.config(command=self.canvas.xview)
        # Canvas の位置の初期化
        self.canvas.xview_moveto(0)
        self.canvas.yview_moveto(0)
        # スクロール範囲の設定
        self.canvas.config(
            scrollregion=(0, 0, self.minimal_canvas_size[0], self.minimal_canvas_size[1])
        )
        
        # Canvas 内にフレーム作成
        self.interior = ttk.Frame(self.canvas)
        self.interior_id = self.canvas.create_window(
            0, 0, window=self.interior, anchor=tk.NW, 
        )
        
        # 内部フレームの大きさが変わったらCanvasの大きさを変える関数を呼び出す
        self.interior.bind('<Configure>', self._configure_interior)
        self.canvas.bind('<Configure>', self._configure_canvas)
        #self.canvas.bind_all(sequence="<MouseWheel>", func=self._on_mousewheel, add="+")
    
    def bind_child(self, frame=None, sequence=None, func=None, add=None):
        """
        入れ子に bind を設定
        
        Parameters
        ----------
        frame: tk.Frame
            bind を設定するフレーム
        sequuence: str
            イベント内容
        func: function
            イベント内容が実行された場合に呼ばれる関数
        add: str
            一つ前に宣言されるbind関数を実行するのか設定
            "": default, "+"
        """
        children = frame.winfo_children()
        for child in children:
            c_type = type(child)
            if (c_type == tk.Canvas) or (c_type == tk.Frame) or (c_type == ttk.Frame):
                self.bind_child(frame=child, sequence=sequence, func=func, add=add)
            child.bind(sequence=sequence, func=func, add=add)
    
    def update_minimal_canvas_size(self, size):
        """
        Parameters
        ----------
        size: tuple
            最小キャンバスサイズ
        """
        self.minimal_canvas_size = size
    
    def destroy_child(self, frame=None):
        """
        入れ子のウィジェットを削除
        
        Parameters
        ----------
        frame: tk.Frame
            親となるフレーム
        """
        for child in frame.winfo_children():
            child.destroy()
    
    def _configure_interior(self, event):
        """
        Canvas の大きさを変える関数
        
        Parameters
        ----------
        event
            実行される関数の引数へ付与されるイベント情報
        """
        size = (
            max(self.interior.winfo_reqwidth(), self.minimal_canvas_size[0]), 
            max(self.interior.winfo_reqheight(), self.minimal_canvas_size[1])
        )
        self.canvas.config(scrollregion=(0, 0, size[0], size[1]))
        if self.interior.winfo_reqwidth() != self.canvas.winfo_width():
            self.canvas.config(width = self.interior.winfo_reqwidth())
        if self.interior.winfo_reqheight() != self.canvas.winfo_height():
            self.canvas.config(height = self.interior.winfo_reqheight())
    
    def _configure_canvas(self, event):
        """
        Canvas 内のアイテムの大きさを変える関数
        
        Parameters
        ----------
        event
            実行される関数の引数へ付与されるイベント情報
        """
        if (self.interior.winfo_reqwidth() != self.canvas.winfo_width()) and self.fit_width:
            # update the inner frame's width to fill the canvas
            self.canvas.itemconfigure(self.interior_id, width=self.canvas.winfo_width())
        if (self.interior.winfo_reqheight() != self.canvas.winfo_height()) and self.fit_height:
            # update the inner frame's height to fill the canvas
            self.canvas.itemconfigure(self.interior_id, height=self.canvas.winfo_height())
    
    def _on_mousewheel(self, event=None):
        """
        キャンバスの Y スクロールとマウスホイールスクロールを関連付け
        
        Parameters
        ----------
        event
            実行される関数の引数へ付与されるイベント情報
        """
        #if event: self.canvas.yview_scroll(int(-1*(event.delta//120)), 'units')
        if event: self.canvas.yview_scroll(int(-1 * (event.delta / abs(event.delta))), "units")

ソースコードの説明

ScrollableFrame.init()

  1. 縦スクロールバーをフレームの右側に、横スクロールバーをフレームの底に配置して、空いたスペースに Canvas を配置.

  2. スクロールバーと Canvas の連携を設定して、Canvas のスクロール範囲を設定.

  3. interior という名前のフレームを canvas の子に作って、create_window というメソッドでinterior フレームを canvas に配置.

  4. _configure_interior という関数を interior に bind ._configure_interior メソッドについては以下を参照.

canvas の子の要素として Frame を配置することで、フレームウィジェットのサイズに canvas を合わせることができます.

bind_child

  • 引数の frame の子の要素に bind を設定.

destroy_child

  • 引数の frame の子の要素を削除.

_configure_interior

  • interior のサイズを取得して、canvas のスクロール範囲を interior のサイズに合わせる.さらに canvas のサイズを interior のサイズに合わせる.

つまり、interior に _configure_interior をバインドして、interior のサイズが変更されると、このイベントが呼び出されます.イベントが発行されると、バインドされた関数が実行されます。

_configure_canvas

  • canvas 内の子の要素のサイズを canvas のサイズに合わせる.
  • ScrollableFrame をインスタンス化する時に fit_w, fit_h パラメータで幅、高さをサイズに合わせるかを決定できる.

_on_mousewheel

  • キャンバスの Y スクロールとマウスホイールスクロールを関連付け.

テストコード


test_figure.png

こちらをクリックしてください.
test.py
# -*- coding: utf-8 -*-
# /* Import library */
import tkinter as tk
from tkinter import ttk
from PIL import Image, ImageTk
try:
    from ttkwidgets import AutoHideScrollbar
except ImportError:
    from ttkwidgets import AutoHideScrollbar

class ScrollableFrame(ttk.Frame):
    def __init__(self, parent, size=(1, 1), fit_w=False, fit_h=False):
        """
        parent: tk.Frame
            親となるフレーム
        minimal_size: tuple
            最小キャンバスサイズ
        fit_w: bool
            キャンバス内のフレームの幅をキャンバスに合わせるか否か
        fit_h: bool
            キャンバス内のフレームの高さをキャンバスに合わせるか否か
        """
        ttk.Frame.__init__(self, parent)
        
        # 変数の初期化
        self.minimal_canvas_size = size
        self.fit_width = fit_w
        self.fit_height = fit_h
        
        # 縦スクロールバー
        #vscrollbar = AutoHideScrollbar(self, orient=tk.VERTICAL)
        vscrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL)
        vscrollbar.pack(fill=tk.Y, side=tk.RIGHT, expand=False)
        # 横スクロールバー
        #hscrollbar = AutoHideScrollbar(self, orient=tk.HORIZONTAL)
        hscrollbar = ttk.Scrollbar(self, orient=tk.HORIZONTAL)
        hscrollbar.pack(fill=tk.X, side=tk.BOTTOM, expand=False)
        # Canvas
        self.canvas = tk.Canvas(
            self, bd=0, highlightthickness=0,
            yscrollcommand=vscrollbar.set, xscrollcommand=hscrollbar.set
        )
        self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        
        # スクロールバーを Canvas に関連付け
        vscrollbar.config(command=self.canvas.yview)
        hscrollbar.config(command=self.canvas.xview)
        # Canvas の位置の初期化
        self.canvas.xview_moveto(0)
        self.canvas.yview_moveto(0)
        # スクロール範囲の設定
        self.canvas.config(
            scrollregion=(0, 0, self.minimal_canvas_size[0], self.minimal_canvas_size[1])
        )
        
        # Canvas 内にフレーム作成
        self.interior = ttk.Frame(self.canvas)
        self.interior_id = self.canvas.create_window(
            0, 0, window=self.interior, anchor=tk.NW, 
        )
        
        # 内部フレームの大きさが変わったらCanvasの大きさを変える関数を呼び出す
        self.canvas.bind('<Configure>', self._configure_canvas)
        #self.canvas.bind_all(sequence="<MouseWheel>", func=self._on_mousewheel, add="+")
        self.interior.bind('<Configure>', self._configure_interior)
    
    def bind_child(self, frame=None, sequence=None, func=None, add=None):
        """
        入れ子に bind を設定
        
        Parameters
        ----------
        frame: tk.Frame
            bind を設定するフレーム
        sequuence: str
            イベント内容
        func: function
            イベント内容が実行された場合に呼ばれる関数
        add: str
            一つ前に宣言されるbind関数を実行するのか設定
            "": default, "+"
        """
        children = frame.winfo_children()
        for child in children:
            c_type = type(child)
            if (c_type == tk.Canvas) or (c_type == tk.Frame) or (c_type == ttk.Frame):
                self.bind_child(frame=child, sequence=sequence, func=func, add=add)
            child.bind(sequence=sequence, func=func, add=add)
    
    def update_minimal_canvas_size(self, size):
        """
        Parameters
        ----------
        size: tuple
            最小キャンバスサイズ
        """
        self.minimal_canvas_size = size
    
    def destroy_child(self, frame=None):
        """
        入れ子のウィジェットを削除
        
        Parameters
        ----------
        frame: tk.Frame
            親となるフレーム
        """
        for child in frame.winfo_children():
            child.destroy()
    
    def _configure_interior(self, event):
        """
        Canvas の大きさを変える関数
        
        Parameters
        ----------
        event
            実行される関数の引数へ付与されるイベント情報
        """
        size = (
            max(self.interior.winfo_reqwidth(), self.minimal_canvas_size[0]), 
            max(self.interior.winfo_reqheight(), self.minimal_canvas_size[1])
        )
        self.canvas.config(scrollregion=(0, 0, size[0], size[1]))
        if self.interior.winfo_reqwidth() != self.canvas.winfo_width():
            self.canvas.config(width = self.interior.winfo_reqwidth())
        if self.interior.winfo_reqheight() != self.canvas.winfo_height():
            self.canvas.config(height = self.interior.winfo_reqheight())
    
    def _configure_canvas(self, event):
        """
        Canvas 内のアイテムの大きさを変える関数
        
        Parameters
        ----------
        event
            実行される関数の引数へ付与されるイベント情報
        """
        if (self.interior.winfo_reqwidth() != self.canvas.winfo_width()) and self.fit_width:
            # update the inner frame's width to fill the canvas
            self.canvas.itemconfigure(self.interior_id, width=self.canvas.winfo_width())
        if (self.interior.winfo_reqheight() != self.canvas.winfo_height()) and self.fit_height:
            # update the inner frame's height to fill the canvas
            self.canvas.itemconfigure(self.interior_id, height=self.canvas.winfo_height())
    
    def _on_mousewheel(self, event=None):
        """
        キャンバスの Y スクロールとマウスホイールスクロールを関連付け
        
        Parameters
        ----------
        event
            実行される関数の引数へ付与されるイベント情報
        """
        #if event: self.canvas.yview_scroll(int(-1*(event.delta//120)), 'units')
        if event: self.canvas.yview_scroll(int(-1 * (event.delta / abs(event.delta))), "units")

class App(ttk.Frame):
    def __init__(self, read_image, size, master=None):
        super().__init__(master)
        self.master = master
        self.read_img = read_image
        self.size = size
        self.master.title('scrollbar trial')
        self.pack(fill=tk.BOTH, expand=True)
        self.create_widgets()
    
    def create_widgets(self):
        self.canvas_frame = ScrollableFrame(self, size=self.size)
        self.canvas_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        
        self.control_frame = ttk.Frame(self)
        self.control_frame.pack(side=tk.TOP, fill=tk.Y, expand=False)
        
        self.label_title1 = ttk.Label(self.control_frame, text='Window coordinate')
        self.label_title1.pack()
        self.point_x = tk.StringVar()
        self.point_y = tk.StringVar()
        self.label_x = ttk.Label(self.control_frame, textvariable=self.point_x)
        self.label_x.pack()
        self.label_y = ttk.Label(self.control_frame, textvariable=self.point_y)
        self.label_y.pack()
        self.label_title2 = ttk.Label(self.control_frame, text='Canvas coordinate')
        self.label_title2.pack()
        self.point_xc = tk.StringVar()
        self.point_yc = tk.StringVar()
        self.label_xc = ttk.Label(self.control_frame, textvariable=self.point_xc)
        self.label_xc.pack()
        self.label_yc = ttk.Label(self.control_frame, textvariable=self.point_yc)
        self.label_yc.pack()
        
        # canvasに画像をセットする
        self.img_canvas = tk.Canvas(self.canvas_frame.interior)
        self.img_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        self.img_canvas.bind('<ButtonPress-1>', self.pickup_point)
        self.im = ImageTk.PhotoImage(self.read_img, master=self.master)
        self.img_canvas.config(width=self.read_img.width, height=self.read_img.height)
        self.img_canvas.photo = self.im
        self.img_canvas.create_image((0, 0), anchor=tk.NW, image=self.im)
    
    # ポインタの座標を取得する
    def pickup_point(self, event):
        self.point_x.set('x : ' + str(event.x))
        self.point_y.set('y : ' + str(event.y))
        self.point_xc.set('x : ' + str(self.img_canvas.canvasx(event.x)))
        self.point_yc.set('y : ' + str(self.img_canvas.canvasy(event.y)))
        print(event.x, event.y, self.img_canvas.canvasx(event.x), self.img_canvas.canvasy(event.y))

if __name__ == "__main__":
    read_image = Image.open("test_figure.png")
    canvas_width, canvas_height = read_image.size
    minimal_canvas_size = read_image.size
    
    # アプリケーション起動
    root = tk.Tk()
    app = App(master=root, read_image=read_image, size=minimal_canvas_size)
    app.mainloop()

起動すると、このように表示されます.

ウィンドウを大きくすると、このようになります.スクロールバーがウィンドウの大きさに追従することが確認できます.

ウィンドウを小さくすると、このようになります.スクロールバーがアクティブになります.

参考

https://water2litter.net/rum/post/python_tkinter_resizeable_canvas/

https://pypi.org/project/ttkwidgets/

https://ttkwidgets.readthedocs.io/en/sphinx_doc/ttkwidgets/ttkwidgets/ttkwidgets.AutoHideScrollbar.html

https://office54.net/python/tkinter/tkinter-bind-event

Discussion