Search code examples
pythontkinter

Slowly scrollable with multiple plots in Tkinter


The primary problem here is when plotting too much data (~36 subplots) using FigureCanvasTkAgg the Scrollbar of Tkinter being too sluggish and slow when the user is scrolling. Is there any solution to this problem, I don't want my user to experience this. Thanks.

You can try this code here:

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
import tkinter as tk

class App:
    def __init__(self, root):
        self.root = root
        self.root.title("Scrollable Matplotlib Plot with 9 Subplots")

        # Create a frame for the canvas and scrollbars
        frame = tk.Frame(root)
        frame.pack(fill=tk.BOTH, expand=True)

        # Create a canvas for the Matplotlib figure
        self.canvas = tk.Canvas(frame)
        self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

        # Create vertical scrollbar
        self.v_scrollbar = tk.Scrollbar(frame, orient=tk.VERTICAL, command=self.canvas.yview)
        self.v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)

        # Create horizontal scrollbar
        self.h_scrollbar = tk.Scrollbar(root, orient=tk.HORIZONTAL, command=self.canvas.xview)
        self.h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X)

        # Configure canvas with scrollbars
        self.canvas.configure(yscrollcommand=self.v_scrollbar.set)
        self.canvas.configure(xscrollcommand=self.h_scrollbar.set)

        # Create a figure and multiple subplots
        self.fig, self.axs = plt.subplots(6, 6, figsize=(10, 10))  # 3x3 grid of subplots

        # Generate some data for the plots
        x = np.linspace(0, 10, 100)
        for i, ax in enumerate(self.axs.flat):
            ax.plot(x, np.sin(x + i), label=f'Sine Wave {i+1}')
            ax.set_title(f'Plot {i+1}')
            ax.legend()

        # Add the Matplotlib figure to the canvas
        self.figure_canvas = FigureCanvasTkAgg(self.fig, master=self.canvas)
        self.figure_canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)

        # Bind the canvas to scrolling
        self.canvas.create_window((0, 0), window=self.figure_canvas.get_tk_widget(), anchor='nw')

        # Update the scroll region
        self.update_scroll_region()

        # Bind the resize event to update scroll region
        self.root.bind("<Configure>", self.on_resize)

    def update_scroll_region(self):
        """Update the scroll region based on the canvas content."""
        self.canvas.configure(scrollregion=self.canvas.bbox("all"))

    def on_resize(self, event):
        """Update the scroll region when the window is resized."""
        self.update_scroll_region()

# Create the main window
root = tk.Tk()
app = App(root)
root.mainloop()

Solution

  • The slowdown is caused by the scrollbar triggering an unnecessary redraw of the underlying matplotlib plot, and matplotlib is slow, i think this could be fixed on matplotlib side, but you could work around it.

    You can skip the matplotlib redraw by subclassing the FigureCanvasTkAgg and skipping the next redraw if the canvas is being scrolled, since the redraw happens in the future, not inside the scrolling callback.

    import numpy as np
    import matplotlib.pyplot as plt
    from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
    import tkinter as tk
    import time
    
    class SkippableFigureCanvas(FigureCanvasTkAgg):
        def __init__(self, *args, **kwargs):
            super().__init__(*args,**kwargs)
            self._skip_redraw = False
        def skip_redraw(self):
            self._skip_redraw = True
        def draw(self):
            if self._skip_redraw:
                self._skip_redraw = False
                return
            super().draw()
    
    
    class App:
        def __init__(self, root):
            self.root = root
            self.root.title("Scrollable Matplotlib Plot with 9 Subplots")
            self.last_update_time = time.time()
            # Create a frame for the canvas and scrollbars
            frame = tk.Frame(root)
            frame.pack(fill=tk.BOTH, expand=True)
    
            # Create a canvas for the Matplotlib figure
            self.canvas = tk.Canvas(frame)
            self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
    
            # Create vertical scrollbar
            self.v_scrollbar = tk.Scrollbar(frame, orient=tk.VERTICAL, command=self.canvas.yview)
            self.v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
    
            # Create horizontal scrollbar
            self.h_scrollbar = tk.Scrollbar(root, orient=tk.HORIZONTAL, command=self.canvas.xview)
            self.h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X)
    
            def my_h_scroll_command(*args):
                self.figure_canvas.skip_redraw()
                self.canvas.xview(*args)
            def my_v_scroll_command(*args):
                self.figure_canvas.skip_redraw()
                self.canvas.yview(*args)
            self.v_scrollbar.configure(command=my_v_scroll_command)
            self.h_scrollbar.configure(command=my_h_scroll_command)
    
            # Configure canvas with scrollbars
            self.canvas.configure(yscrollcommand=self.v_scrollbar.set)
            self.canvas.configure(xscrollcommand=self.h_scrollbar.set)
    
            # Create a figure and multiple subplots
            self.fig, self.axs = plt.subplots(6, 6, figsize=(10, 10))  # 3x3 grid of subplots
    
            # Generate some data for the plots
            x = np.linspace(0, 10, 100)
            for i, ax in enumerate(self.axs.flat):
                ax.plot(x, np.sin(x + i), label=f'Sine Wave {i+1}')
                ax.set_title(f'Plot {i+1}')
                ax.legend()
    
            # Add the Matplotlib figure to the canvas
            self.figure_canvas = SkippableFigureCanvas(self.fig, master=self.canvas)
            self.figure_canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)
    
            # Bind the canvas to scrolling
            self.canvas.create_window((0, 0), window=self.figure_canvas.get_tk_widget(), anchor='nw')
    
            # Update the scroll region
            self.update_scroll_region()
    
            # Bind the resize event to update scroll region
            self.root.bind("<Configure>", self.on_resize)
    
        def update_scroll_region(self):
            """Update the scroll region based on the canvas content."""
            self.canvas.configure(scrollregion=self.canvas.bbox("all"))
        def on_resize(self, event):
            """Update the scroll region when the window is resized."""
            self.update_scroll_region()
    
    # Create the main window
    root = tk.Tk()
    app = App(root)
    root.mainloop()
    

    the new parts are the SkippableFigureCanvas which allows you to skip drawing the next matplotlib plot when needed, and this next part which attaches a callback that is called whenever the user moves the scrollbar to skip the next redraw of the matplotlib plot.

    def my_h_scroll_command(*args):
        self.figure_canvas.skip_redraw()
        self.canvas.xview(*args)
    def my_v_scroll_command(*args):
        self.figure_canvas.skip_redraw()
        self.canvas.yview(*args)
    self.v_scrollbar.configure(command=my_v_scroll_command)
    self.h_scrollbar.configure(command=my_h_scroll_command)
    

    Note that the above "work around" may break things in the future if matplotlib "fixes" it on their side, use at your own risk, it is also bug-prone if you update the plot while the user is scrolling it ... so don't write code that updates the plot while scrolling, it could also break if you have animations or other things that update the plot over time. (you might need to manually call Draw to force an update)