将 Matplotlib 图形添加到 KivyMD 中的小部件

Add a Matplotlib Graph to a Widget in KivyMD

我目前正在使用 KivyMD 创建一个移动应用程序,用于管理差旅费用请求。用户将在 MDTextField 上为不同类型的费用输入所需的请求金额。我想将使用 patplotlib 制作的 donut 图添加到 MDBoxLayout 中。此类图表应在请求填满时自动更新。 (为清楚起见,我将包括一个屏幕截图。红色方块是我的图表所需的位置)。

我创建了一个名为 update_method_graph 的方法并使用了固定数字,我可以成功地创建一个 Plot,但是我没有成功地在应用程序上添加这样的图表。一旦我可以成功地将图表添加到我的应用程序中,我将 link 这些值添加到用户添加的请求中。现在我关心的是正确添加图形。当然完成的代码不会包含 plt.show() 行,图表应该直接在应用程序上更新。

至于现在,当我关闭图表的 window 时,我的代码在

中显示错误
self.ids.expense_graph.add_widget(FigureCanvasKivyAgg(plt.gcf()))
File "kivy\properties.pyx", line 863, in kivy.properties.ObservableDict.__getattr__
 AttributeError: 'super' object has no attribute '__getattr__'`

expense_graph.

中出现密钥错误

我已尝试使用 an answer to a similar question and with matplotlib.use('module://kivy.garden.matplotlib.backend_kivy'), as done in examples of use in garden.matplotlib 中建议的 from kivy.garden.matplotlib.backend_kivyagg import FigureCanvasKivyAgg,但我仍然无法让我的应用程序正常工作。

最小可重现示例的代码

Python代码:

from kivy.properties import ObjectProperty
from kivy.uix.screenmanager import ScreenManager, Screen
from kivymd.app import MDApp
from kivymd.uix.expansionpanel import MDExpansionPanel, MDExpansionPanelOneLine
from kivy.uix.boxlayout import BoxLayout
import matplotlib.pyplot as plt
from kivy.garden.matplotlib.backend_kivyagg import FigureCanvasKivyAgg
from kivy.uix.image import Image


class MyContentAliment(BoxLayout):
    monto_alimento = 0

    def apply_currency_format(self):
        # if len <= 3
        if len(self.ids.monto_aliment_viaje.text) <= 3 and self.ids.monto_aliment_viaje.text.isnumeric():
            self.ids.monto_aliment_viaje.text = "$" + self.ids.monto_aliment_viaje.text + '.00'
        # n,nnn
        elif len(self.ids.monto_aliment_viaje.text) == 4 and self.ids.monto_aliment_viaje.text.isnumeric():
            self.ids.monto_aliment_viaje.text = "$" + self.ids.monto_aliment_viaje.text[0] + "," + \
                                            self.ids.monto_aliment_viaje.text[1:] + '.00'
        # nn,nnn
        elif len(self.ids.monto_aliment_viaje.text) == 5 and self.ids.monto_aliment_viaje.text.isnumeric():
            self.ids.monto_aliment_viaje.text = "$" + self.ids.monto_aliment_viaje.text[:2] + "," + \
                                            self.ids.monto_aliment_viaje.text[2:] + '.00'

    def limit_currency(self):
        if len(self.ids.monto_aliment_viaje.text) > 5 and self.ids.monto_aliment_viaje.text.startswith('$') == False:
            self.ids.monto_aliment_viaje.text = self.ids.monto_aliment_viaje.text[:-1]

    def sumar_gasto(self):
        if self.ids.monto_aliment_viaje.text == "":
            pass
        elif self.ids.monto_aliment_viaje.text.startswith('$'):
            pass
        else:
            travel_manager = MDApp.get_running_app().root.get_screen('travelManager')
            monto_total = float(travel_manager.ids.suma_solic_viaje.text[2:])
            monto_total += float(self.ids.monto_aliment_viaje.text)
            travel_manager.ids.suma_solic_viaje.text = "$ " + str(monto_total)
            self.apply_currency_format()

    # USE THIS METHOD TO UPDATE THE VALUE OF ALIMENTOS (donut)
    def update_requested_value(self):
        MyContentAliment.monto_alimento = 0
        if len(self.ids.monto_aliment_viaje.text) > 0:
            MyContentAliment.monto_alimento = self.ids.monto_aliment_viaje.text
        else:
            MyContentAliment.monto_alimento = 0  
        TravelManagerWindow.update_donut_graph(MyContentAliment.monto_alimento)

class MyContentCasetas(BoxLayout):
    monto_casetas = 0
    def apply_currency_format(self):
        # if len <= 3
        if len(self.ids.monto_casetas_viaje.text) <= 3 and self.ids.monto_casetas_viaje.text.isnumeric():
            self.ids.monto_casetas_viaje.text = "$" + self.ids.monto_casetas_viaje.text + '.00'
        # n,nnn
        elif len(self.ids.monto_casetas_viaje.text) == 4 and self.ids.monto_casetas_viaje.text.isnumeric():
            self.ids.monto_casetas_viaje.text = "$" + self.ids.monto_casetas_viaje.text[0] + "," + \
                                            self.ids.monto_casetas_viaje.text[1:] + '.00'
        # nn,nnn
        elif len(self.ids.monto_casetas_viaje.text) == 5 and self.ids.monto_casetas_viaje.text.isnumeric():
            self.ids.monto_casetas_viaje.text = "$" + self.ids.monto_casetas_viaje.text[:2] + "," + \
                                            self.ids.monto_casetas_viaje.text[2:] + '.00'

    def limit_currency(self):
        if len(self.ids.monto_casetas_viaje.text) > 5 and self.ids.monto_casetas_viaje.text.startswith('$') == False:
            self.ids.monto_casetas_viaje.text = self.ids.monto_casetas_viaje.text[:-1]

    def sumar_gasto(self):
        if self.ids.monto_casetas_viaje.text == "":
            pass
        elif self.ids.monto_casetas_viaje.text.startswith('$'):
            pass
        else:
            travel_manager = MDApp.get_running_app().root.get_screen('travelManager')
            monto_total = float(travel_manager.ids.suma_solic_viaje.text[2:])
            monto_total += float(self.ids.monto_casetas_viaje.text)
            travel_manager.ids.suma_solic_viaje.text = "$ " + str(monto_total)
            self.apply_currency_format()

    # USE THIS METHOD TO UPDATE THE VALUE OF CASETAS (donut)
    def update_requested_value(self):
        MyContentCasetas.monto_casetas = 0
        if len(self.ids.monto_casetas_viaje.text) > 0:
            MyContentCasetas.monto_casetas = self.ids.monto_casetas_viaje.text
        else:
            MyContentCasetas.monto_casetas = 0
        TravelManagerWindow.update_donut_graph(MyContentCasetas.monto_casetas)


class MyContentGasolina(BoxLayout):
    monto_gasolina = 0

    def apply_currency_format(self):
        # if len <= 3
        if len(self.ids.monto_gas_viaje.text) <= 3 and self.ids.monto_gas_viaje.text.isnumeric():
            self.ids.monto_gas_viaje.text = "$" + self.ids.monto_gas_viaje.text + '.00'
        # n,nnn
        elif len(self.ids.monto_gas_viaje.text) == 4 and self.ids.monto_gas_viaje.text.isnumeric():
            self.ids.monto_gas_viaje.text = "$" + self.ids.monto_gas_viaje.text[0] + "," + \
                                        self.ids.monto_gas_viaje.text[1:] + '.00'
        # nn,nnn
        elif len(self.ids.monto_gas_viaje.text) == 5 and self.ids.monto_gas_viaje.text.isnumeric():
            self.ids.monto_gas_viaje.text = "$" + self.ids.monto_gas_viaje.text[:2] + "," + \
                                        self.ids.monto_gas_viaje.text[2:] + '.00'

    def limit_currency(self):
        if len(self.ids.monto_gas_viaje.text) > 5 and self.ids.monto_gas_viaje.text.startswith('$') == False:
            self.ids.monto_gas_viaje.text = self.ids.monto_gas_viaje.text[:-1]

    def sumar_gasto(self):
        if self.ids.monto_gas_viaje.text == "":
            pass
        elif self.ids.monto_gas_viaje.text.startswith('$'):
            pass
        else:
            travel_manager = MDApp.get_running_app().root.get_screen('travelManager')
            monto_total = float(travel_manager.ids.suma_solic_viaje.text[2:])
            monto_total += float(self.ids.monto_gas_viaje.text)
            travel_manager.ids.suma_solic_viaje.text = "$ " + str(monto_total)
            self.apply_currency_format()

    # USE THIS METHOD TO UPDATE THE VALUE OF GASOLINA (donut)
    def update_requested_value(self):
        MyContentGasolina.monto_gasolina = 0
        if len(self.ids.monto_gas_viaje.text) > 0:
            MyContentGasolina.monto_gasolina = self.ids.monto_gas_viaje.text
        else:
            MyContentGasolina.monto_gasolina = 0             
        TravelManagerWindow.update_donut_graph \
            (MyContentGasolina.monto_gasolina)

class LoginWindow(Screen):
    pass


class TravelManagerWindow(Screen):
    panel_container = ObjectProperty(None)
    expense_graph = ObjectProperty(None)

    # EXPANSION PANEL PARA SOLICITAR GV
    def set_expansion_panel(self):
        self.ids.panel_container.clear_widgets()
        # FOOD PANEL
        self.ids.panel_container.add_widget(MDExpansionPanel(icon="food", content=MyContentAliment(),
                                                         panel_cls=MDExpansionPanelOneLine(text="Alimentacion")))
        # CASETAS PANEL
        self.ids.panel_container.add_widget(MDExpansionPanel(icon="food", content=MyContentCasetas(),
                                                         panel_cls=MDExpansionPanelOneLine(text="Casetas")))
        # GAS PANEL
        self.ids.panel_container.add_widget(MDExpansionPanel(icon="food", content=MyContentGasolina(),
                                                         panel_cls=MDExpansionPanelOneLine(text="Gasolina")))

    def update_donut_graph(self):
        travel_manager = MDApp.get_running_app().root.get_screen('travelManager')
        travel_manager.ids.expense_graph.clear_widgets()
        # create data
        names = 'Alimentación', 'Casetas', 'Gasolina',
        data_values = [MyContentAliment.monto_alimento, MyContentCasetas.monto_casetas,
                   MyContentGasolina.monto_gasolina]

        # Create a white circle for the center of the plot
        my_circle = plt.Circle((0, 0), 0.65, color='white')
        # Create graph, add and place percentage labels
        # Add spaces to separate elements from the donut
        explode = (0.05, 0.05, 0.05)
        plt.pie(data_values, autopct="%.1f%%", startangle=0, pctdistance=0.80, labeldistance=1.2, explode=explode)

        p = plt.gcf()
        p.gca().add_artist(my_circle)
        # Create and place legend of the graph
        plt.legend(labels=names, loc="center")
        # Add graph to Kivy App
        plt.show()
        # THE DESIRED RESULT IS TO ADD THE GRAPH TO THE APP WITH THE LINE OF CODE BELOW, INSTEAD OF THE plt.show() line
        travel_manager.ids.expense_graph.add_widget(Image(source='donut_graph_image.png')) 


# WINDOW MANAGER ################################
class WindowManager(ScreenManager):
    pass


class ReprodExample3(MDApp):
    travel_manager_window = TravelManagerWindow()

    def build(self):
        self.theme_cls.primary_palette = "Teal"
        return WindowManager()


if __name__ == "__main__":
    ReprodExample3().run()

KV代码:

<WindowManager>:
    LoginWindow:
    TravelManagerWindow:

<LoginWindow>:
    name: 'login'
    MDRaisedButton:
        text: 'Enter'
        pos_hint: {'center_x': 0.5, 'center_y': 0.5}
        size_hint: None, None
        on_release:
            root.manager.transition.direction = 'up'
            root.manager.current = 'travelManager'

<TravelManagerWindow>:
    name:'travelManager'
    on_pre_enter: root.set_expansion_panel()

    MDRaisedButton:
        text: 'Back'
        pos_hint: {'center_x': 0.5, 'center_y': 0.85}
        size_hint: None, None
        on_release:
            root.manager.transition.direction = 'down'
            root.manager.current = 'login'

    BoxLayout:
        orientation: 'vertical'
        size_hint:1,0.85
        pos_hint: {"center_x": 0.5, "center_y":0.37}
        adaptive_height:True
        height: self.minimum_height

        ScrollView:
            adaptive_height:True

            GridLayout:
                size_hint_y: None
                cols: 1
                row_default_height: root.height*0.10
                height: self.minimum_height

                BoxLayout:
                    adaptive_height: True
                    orientation: 'horizontal'

                    GridLayout:
                        id: panel_container
                        size_hint_x: 0.6
                        cols: 1
                        adaptive_height: True

                    BoxLayout:
                        size_hint_x: 0.05
                    MDCard:
                        id: resumen_solicitud
                        size_hint: None, None
                        size: "250dp", "350dp"
                        pos_hint: {"top": 0.9, "center_x": .5}
                        elevation: 0.1

                        BoxLayout:
                            orientation: 'vertical'
                            canvas.before:
                                Color:
                                    rgba: 0.8, 0.8, 0.8, 1
                                Rectangle:
                                    pos: self.pos
                                    size: self.size
                            MDLabel:
                                text: 'Monto Total Solicitado'
                                font_style: 'Button'
                                halign: 'center'
                                font_size: (root.width**2 + root.height**2) / 15.5**4
                                size_hint_y: 0.2
                            MDSeparator:
                                height: "1dp"
                            MDTextField:
                                id: suma_solic_viaje
                                text: "$ 0.00"
                                bold: True
                                line_color_normal: app.theme_cls.primary_color
                                halign: "center"
                                size_hint_x: 0.8
                                pos_hint: {'center_x': 0.5, 'center_y': 0.5}
                            MDSeparator:
                                height: "1dp"
                            # DESIRED LOCATION FOR THE MATPLOTLIB GRAPH
                            MDBoxLayout:
                                id: expense_graph    


<MyContentAliment>:
    adaptive_height: True
    MDBoxLayout:
        orientation:'horizontal'
        adaptive_height:True
        size_hint_x:self.width
        pos_hint: {"center_x":0.5, "center_y":0.5}
        spacing: dp(10)
        padding_horizontal: dp(10)
        MDLabel:
            text: 'Monto:'
            multiline: 'True'
            halign: 'center'
            pos_hint: {"x":0, "top":0.5}
            size_hint_x: 0.15
            font_style: 'Button'
            font_size: 19

        MDTextField:
            id: monto_aliment_viaje
            hint_text: 'Monto a solicitar'
            pos_hint: {"x":0, "top":0.5}
            halign: 'left'
            size_hint_x: 0.3
            helper_text: 'Ingresar el monto a solicitar'
            helper_text_mode: 'on_focus'
            write_tab: False
            required: True
            on_text: root.limit_currency()

        MDRaisedButton:
            id: boton_aliment_viaje
            pos_hint: {"x":0, "top":0.5}
            text:'Ingresar Gasto'
            on_press:
                root.update_requested_value()
            on_release:
                root.sumar_gasto()

### CASETAS
<MyContentCasetas>:
    adaptive_height: True
    MDBoxLayout:
        orientation:'horizontal'
        adaptive_height:True
        size_hint_x:self.width
        pos_hint: {"center_x":0.5, "center_y":0.5}
        spacing: dp(10)
        padding_horizontal: dp(10)
        MDLabel:
            text: 'Monto:'
            multiline: 'True'
            halign: 'center'
            pos_hint: {"x":0, "top":0.5}
            size_hint_x: 0.15
            font_style: 'Button'
            font_size: 19

        MDTextField:
            id: monto_casetas_viaje
            hint_text: 'Monto a solicitar'
            pos_hint: {"x":0, "top":0.5}
            halign: 'left'
            size_hint_x: 0.3
            helper_text: 'Ingresar el monto a solicitar'
            helper_text_mode: 'on_focus'
            write_tab: False
            #input_filter: 'float'
            required: True
            on_text: root.limit_currency()

        MDRaisedButton:
            id: boton_casetas_viaje
            pos_hint: {"x":0, "top":0.5}
            text:'Ingresar Gasto'
            on_press:
                root.update_requested_value()
            on_release:
                root.sumar_gasto()

        BoxLayout:
            size_hint_x: 0.05

### GASOLINA
<MyContentGasolina>:
    adaptive_height: True
    MDBoxLayout:
        orientation:'horizontal'
        adaptive_height:True
        size_hint_x:self.width
        pos_hint: {"center_x":0.5, "center_y":0.5}
        spacing: dp(10)
        padding_horizontal: dp(10)
        MDLabel:
            text: 'Monto:'
            multiline: 'True'
            halign: 'center'
            pos_hint: {"x":0, "top":0.5}
            size_hint_x: 0.15
            font_style: 'Button'
            font_size: 19

        MDTextField:
            id: monto_gas_viaje
            hint_text: 'Monto a solicitar'
            pos_hint: {"x":0, "top":0.5}
            halign: 'left'
            size_hint_x: 0.3
            helper_text: 'Ingresar el monto a solicitar'
            helper_text_mode: 'on_focus'
            write_tab: False
            required: True
            on_text: root.limit_currency()

        MDRaisedButton:
            id: boton_gas_viaje
            pos_hint: {"x":0, "top":0.5}
            text:'Ingresar Gasto'
            on_press:
                root.update_requested_value()
            on_release:
                root.sumar_gasto()

        BoxLayout:
            size_hint_x: 0.05

对我的代码提出任何建议或更正将不胜感激。非常感谢。

编辑 我设法 link MDTextFields 到图中的数据值。因此,图表将随着值的输入而更新。每次添加一个值时,都会出现一个更新的图表,以便您自己查看(最小可重现示例的代码已经更新)。尽管如此,我仍然无法将图表添加到我的应用程序中。我将非常感谢你的帮助。非常感谢!

编辑 #2

我改变了方法,我决定将图形转换为图像,并将图像添加到 MDBoxLayout。 (如果第一种方法更好,请告诉我)。代码已经更新。但是我得到一个错误:

self.ids.expense_graph.add_widget(updated_graph)
 AttributeError: 'str' object has no attribute 'ids'

我在网上搜索了针对此错误的不同解决方案,但我无法解决这个问题。

编辑 3

所以我终于能够解决 EDIT 2 中描述的错误代码。我能够将我的图表正确添加到应用程序中。然而,该图表没有更新新的费用(尽管文件确实更新并且 plt.show() 代码行确实显示了更新的图表)。知道为什么应用程序中的图表无法更新吗?最小可重现示例的代码已更新。

我认为你只需要在每次更改时重建剧情即可。尝试将您的 update_donut_graph() 更改为:

def update_donut_graph(self):
    plt.clf()  # clear the plot
    travel_manager = MDApp.get_running_app().root.get_screen('travelManager')
    travel_manager.ids.expense_graph.clear_widgets()
    # create data
    names = 'Alimentación', 'Casetas', 'Gasolina',
    data_values = [MyContentAliment.monto_alimento, MyContentCasetas.monto_casetas,
               MyContentGasolina.monto_gasolina]

    # Create a white circle for the center of the plot
    my_circle = plt.Circle((0, 0), 0.65, color='white')
    # Create graph, add and place percentage labels
    # Add spaces to separate elements from the donut
    explode = (0.05, 0.05, 0.05)
    plt.pie(data_values, autopct="%.1f%%", startangle=0, pctdistance=0.80, labeldistance=1.2, explode=explode)

    p = plt.gcf()
    p.gca().add_artist(my_circle)
    # Create and place legend of the graph
    plt.legend(labels=names, loc="center")
    # Add graph to Kivy App
    # plt.show()
    # THE DESIRED RESULT IS TO ADD THE GRAPH TO THE APP WITH THE LINE OF CODE BELOW, INSTEAD OF THE plt.show() line
    travel_manager.ids.expense_graph.add_widget(FigureCanvasKivyAgg(figure=plt.gcf()))

感谢@John Anderson 的回复。一如既往,你帮了大忙。 但是我遇到了一个简单的问题。我的图形大小已修改,它小得多,但图例的大小保持不变。因此,图例现在没有按照我的意愿显示图表,而是掩盖了图表。

有什么办法可以防止这种情况发生吗?我一直在想,也许一种使图表占据整个 canvas 大小的方法是合适的。正如您在 donut_graph_image.png 屏幕截图中所见,图表周围有很多空白。或者可以帮助我保持图形大小的命令?我尝试使用 Axes.set_aspect 但这确实有效。

再次感谢。