一个简单的绘图应用

在接下来的教程中,您将学习如何创建您的第一个控件。这在编程Kivy应用时提供了强大且重要的知识,因为它让您能够为特定目的创建带有自定义元素的完全新颖的用户界面。

基本注意事项

在创建应用程序时,您必须问自己三个重要的问题:

  • 我的应用程序处理哪些数据?

  • 我该如何直观地表示这些数据?

  • 用户如何与该数据进行交互?

例如,如果你想编写一个非常简单的线条绘图应用,很可能希望用户直接用手指在屏幕上绘制。这就是用户与你的应用*交互*的方式。在此过程中,你的应用会记住用户手指所在的位置,以便之后在这些位置之间绘制线条。因此,手指所在的位置就是你的*数据*,而你在这些位置之间绘制的线条则是你的*视觉表示*。

在Kivy中,应用程序的用户界面由Widgets组成。你在屏幕上看到的一切都是由某个widget绘制的。通常,你希望能够在不同的上下文中重用已经编写的代码,这就是为什么widget通常代表一个具体的实例,回答上述三个问题。一个widget封装数据,定义用户与该数据的交互,并绘制其视觉表示。通过嵌套widgets,你可以构建从简单到复杂的用户界面。Kivy内置了许多widgets,如按钮、滑块和其他常见组件。然而,在许多情况下,你需要一个超出Kivy自带范围的自定义widget(例如,一个医学可视化widget)。

在设计你的控件时,请牢记这三个问题。尽量以最小化和可复用的方式编写它们(即,一个控件只做它应该做的事,不多也不少。如果你需要更多功能,就编写更多控件,或者用更小的控件组合成其他控件。我们努力遵循`单一职责原则 <https://en.wikipedia.org/wiki/Single_responsibility_principle>`_)。

Paint 组件

我们确信你童年时的梦想之一就是创建属于自己的多点触控绘画程序。让我们助你实现这一愿望。在接下来的章节中,你将逐步学习如何使用Kivy编写这样的程序。请确保你已经阅读并理解了:ref:quickstart。读过了?太棒了!让我们开始吧!

初始结构

让我们从编写所需的最基本代码结构开始。顺便提一下,本节中使用的所有不同代码片段也包含在Kivy自带的``examples/guide/firstwidget``目录中,因此您无需频繁复制粘贴。以下是我们将需要的基本代码框架:

 1from kivy.app import App
 2from kivy.uix.widget import Widget
 3
 4
 5class MyPaintWidget(Widget):
 6    pass
 7
 8
 9class MyPaintApp(App):
10    def build(self):
11        return MyPaintWidget()
12
13
14if __name__ == '__main__':
15    MyPaintApp().run()

这其实非常简单。将其保存为 paint.py。如果你运行它,应该只会看到一个黑色屏幕。如你所见,我们没有使用内置控件如按钮(参见 创建一个应用程序),而是打算编写自己的控件来进行绘制。我们通过创建一个继承自 Widget 的类来实现这一点(第5-6行),尽管这个类目前还没有做任何事情,我们仍然可以像对待普通 Kivy 控件一样使用它(第11行)。if __name__ ... 结构(第14行)是一种 Python 机制,防止你在从文件导入时执行 if 语句中的代码,也就是说,如果你写 import paint,它不会产生意外行为,而是仅仅提供文件中定义的类。

备注

你可能会好奇,为什么必须分别导入App和Widget,而不是像``from kivy import *``那样一次性导入。虽然那样更简短,但这样做会有缺点,比如会`污染你的命名空间 <https://en.wikipedia.org/wiki/Namespace_%28computer_science%29#Python>`_,并且可能使应用程序启动时变得慢得多。此外,它还可能给类和变量命名带来歧义,因此在Python社区中通常不推荐这种做法。我们采用的方式既更快又更清晰。

添加行为

现在让我们为这个widget添加一些实际的行为,即让它对用户输入做出反应。请将代码修改如下:

 1from kivy.app import App
 2from kivy.uix.widget import Widget
 3
 4
 5class MyPaintWidget(Widget):
 6    def on_touch_down(self, touch):
 7        print(touch)
 8
 9
10class MyPaintApp(App):
11    def build(self):
12        return MyPaintWidget()
13
14
15if __name__ == '__main__':
16    MyPaintApp().run()

这仅是为了展示响应用户输入有多么简单。当发生一个 :class:`~kivy.input.motionevent.MotionEvent`(即触摸、点击等)时,我们只需将触摸对象的信息打印到控制台。屏幕上不会显示任何内容,但如果你观察运行程序的命令行,你会看到每次触摸都会有一条消息。这也证明了控件并不需要有视觉表示。

这确实算不上令人惊艳的用户体验。让我们添加一些代码,真正在我们的窗口中绘制内容:

 1from kivy.app import App
 2from kivy.uix.widget import Widget
 3from kivy.graphics import Color, Ellipse
 4
 5
 6class MyPaintWidget(Widget):
 7
 8    def on_touch_down(self, touch):
 9        with self.canvas:
10            Color(1, 1, 0)
11            d = 30.
12            Ellipse(pos=(touch.x - d / 2, touch.y - d / 2), size=(d, d))
13
14
15class MyPaintApp(App):
16
17    def build(self):
18        return MyPaintWidget()
19
20
21if __name__ == '__main__':
22    MyPaintApp().run()
../_images/guide-3.jpg

如果你用这些修改运行代码,你会看到每次触摸时,触摸位置都会出现一个小黄圈。这是如何实现的呢?

  • 第9行:我们使用Python的``with``语句配合部件的:class:`~kivy.graphics.instructions.Canvas`对象。这类似于一个区域,部件可以在其中绘制内容以在屏幕上表示自身。通过使用``with``语句,所有正确缩进的后续绘制命令都将修改此画布。``with``语句还确保在我们的绘制完成后,内部状态能够被正确清理。

  • 第10行:你可能已经猜到了:这将后续绘图操作的:class:~kivy.graphics.context_instructions.Color`设置为黄色(默认颜色格式为RGB,因此(1, 1, 0)是黄色)。这一设置一直有效,直到设置了另一个:class:`~kivy.graphics.context_instructions.Color。可以将其想象为将画笔浸入该颜色中,然后你可以用它在画布上绘制,直到再次将画笔浸入另一种颜色。

  • 第11行:我们为即将绘制的圆形指定直径。使用变量来存储该值是更可取的,因为我们需要多次引用这个值,并且如果希望圆形变大或变小,我们不想在多个地方修改它。

  • 第12行:要绘制一个圆形,我们只需绘制一个宽度和高度相等的 Ellipse。由于我们希望圆形绘制在用户触摸的位置,我们将触摸的位置传递给椭圆。注意,我们需要在x和y方向上(即向左和向下)将椭圆偏移 -d/2,因为位置指定的是椭圆边界框的左下角,而我们希望它围绕触摸点居中。

这很简单,不是吗?还有更好的!将代码更新为如下所示:

 1from kivy.app import App
 2from kivy.uix.widget import Widget
 3from kivy.graphics import Color, Ellipse, Line
 4
 5
 6class MyPaintWidget(Widget):
 7
 8    def on_touch_down(self, touch):
 9        with self.canvas:
10            Color(1, 1, 0)
11            d = 30.
12            Ellipse(pos=(touch.x - d / 2, touch.y - d / 2), size=(d, d))
13            touch.ud['line'] = Line(points=(touch.x, touch.y))
14
15    def on_touch_move(self, touch):
16        touch.ud['line'].points += [touch.x, touch.y]
17
18
19class MyPaintApp(App):
20
21    def build(self):
22        return MyPaintWidget()
23
24
25if __name__ == '__main__':
26    MyPaintApp().run()
../_images/guide-4.jpg
以下是变更内容:
  • 第3行:我们现在不仅导入了 Ellipse 绘图指令,还导入了 Line 绘图指令。如果你查看 Line 的文档,你会看到它接受一个 points 参数,该参数必须是一个二维点坐标的列表,例如 (x1, y1, x2, y2, ..., xN, yN)

  • 第13行:这里开始变得有趣了。touch.ud 是一个 Python 字典(类型为 <dict>),它允许我们为触摸存储*自定义属性*。

  • 第13行:我们使用了导入的Line指令,并设置了一条用于绘制的Line。由于这是在``on_touch_down``中完成的,每次新触摸都会产生一条新线。通过在``with``块内创建这条线,画布会自动识别该线并进行绘制。我们只想稍后修改这条线,因此将其引用存储在``touch.ud``字典中,使用任意选择但恰当命名的键'line'。我们传入正在创建的线的初始触摸位置,因为那是我们线的起点。

  • 第15行:我们为控件添加了一个新方法。这与``on_touch_down``方法类似,但不是在*新*触摸发生时调用,而是在*已有*触摸(即已经调用过``on_touch_down``的触摸)移动时,即其位置发生变化时调用。请注意,这是**同一个** MotionEvent 对象,但其属性已更新。我们发现这一点非常实用,稍后您将看到原因。

  • 第16行:请记住:这是我们之前在``on_touch_down``中获得的同一个触摸对象,因此我们可以直接访问存储在``touch.ud``字典中的数据!对于之前为此触摸设置的线条,我们现在将触摸的当前位置作为新点添加进去。我们知道需要延长线条,因为这发生在``on_touch_move``中,该回调仅在触摸移动时被调用,这正是我们想要更新线条的原因。将线条存储在``touch.ud``中大大简化了我们的工作,因为我们无需自行维护触摸与线条之间的对应关系。

到目前为止还不错。不过,这看起来还不太美观,有点像意大利肉酱面。我们给每个触摸点分配各自的颜色怎么样?好主意,就这么办:

 1from random import random
 2from kivy.app import App
 3from kivy.uix.widget import Widget
 4from kivy.graphics import Color, Ellipse, Line
 5
 6
 7class MyPaintWidget(Widget):
 8
 9    def on_touch_down(self, touch):
10        color = (random(), random(), random())
11        with self.canvas:
12            Color(*color)
13            d = 30.
14            Ellipse(pos=(touch.x - d / 2, touch.y - d / 2), size=(d, d))
15            touch.ud['line'] = Line(points=(touch.x, touch.y))
16
17    def on_touch_move(self, touch):
18        touch.ud['line'].points += [touch.x, touch.y]
19
20
21class MyPaintApp(App):
22
23    def build(self):
24        return MyPaintWidget()
25
26
27if __name__ == '__main__':
28    MyPaintApp().run()
../_images/guide-5.jpg

以下是更改内容:

  • 第1行:我们导入Python的random()函数,它将为我们提供范围在[0., 1.)内的随机值。

  • 第10行:在这种情况下,我们简单地创建一个包含3个随机浮点值的新元组,用来表示一个随机的RGB颜色。由于我们在``on_touch_down``中执行此操作,每次新的触摸都会获得自己的颜色。不要因为使用了`元组 <https://docs.python.org/2/tutorial/datastructures.html#tuples-and-sequences>`_而感到困惑。我们只是将元组绑定到``color``,以便在此方法中作为快捷方式使用,因为我们比较懒。

  • 第12行:和之前一样,我们为画布设置颜色。但这次我们使用生成的随机值,并通过Python的元组解包语法将它们传递给颜色类(因为Color类期望三个独立的颜色分量,而不是一个。如果我们直接传递元组,那只会传递一个值,尽管元组本身包含三个值)。

看起来已经好多了!凭借大量的技巧和耐心,你甚至可能创作出一幅不错的小画作!

备注

由于默认情况下,Color 指令假定为 RGB 模式,而我们向其传入一个包含三个随机浮点值的元组,很可能最终会得到许多暗色甚至黑色,如果运气不佳的话。这很糟糕,因为默认背景色也是暗色的,这样你就无法(轻易)看到所绘制的线条。有一个巧妙的方法可以避免这种情况:不要创建包含三个随机值的元组,而是创建像这样的元组:(random(), 1., 1.)。然后,在将其传递给颜色指令时,将模式设置为 HSV 色彩空间:Color(*color, mode='hsv')。这样,可能的颜色数量会减少,但得到的颜色总是同样明亮:只有色调会变化。

加分项

至此,我们可以说已经完成了。该控件实现了其应有的功能:追踪触摸并绘制线条,甚至在每条线开始的位置绘制圆圈。

但如果用户想开始新的绘图呢?在当前代码下,清除窗口的唯一方法是重启整个应用。幸运的是,我们可以做得更好。让我们添加一个*清除*按钮,用来擦除到目前为止已绘制的所有线条和圆形。现在有两个选择:

  • 我们可以将按钮创建为控件的子控件。这意味着如果你创建了多个控件,每个控件都会拥有自己的按钮。如果不小心处理,这还会允许用户在按钮上绘制内容,这可能并非你所期望的。

  • 或者,我们可以在应用类中初始时只设置一次按钮,并在按下时清除该控件。

对于我们的简单示例来说,这其实关系不大。对于更大的应用程序,你应该考虑一下应用中各部分的分工。这里我们选择第二种方式,以便你了解如何在应用类的 build() 方法中构建应用的控件树。我们还会改用 HSV 颜色空间(参见前面的说明):

 1from random import random
 2from kivy.app import App
 3from kivy.uix.widget import Widget
 4from kivy.uix.button import Button
 5from kivy.graphics import Color, Ellipse, Line
 6
 7
 8class MyPaintWidget(Widget):
 9
10    def on_touch_down(self, touch):
11        color = (random(), 1, 1)
12        with self.canvas:
13            Color(*color, mode='hsv')
14            d = 30.
15            Ellipse(pos=(touch.x - d / 2, touch.y - d / 2), size=(d, d))
16            touch.ud['line'] = Line(points=(touch.x, touch.y))
17
18    def on_touch_move(self, touch):
19        touch.ud['line'].points += [touch.x, touch.y]
20
21
22class MyPaintApp(App):
23
24    def build(self):
25        parent = Widget()
26        self.painter = MyPaintWidget()
27        clearbtn = Button(text='Clear')
28        clearbtn.bind(on_release=self.clear_canvas)
29        parent.add_widget(self.painter)
30        parent.add_widget(clearbtn)
31        return parent
32
33    def clear_canvas(self, obj, touch):
34        self.painter.canvas.clear()
35
36
37if __name__ == '__main__':
38    MyPaintApp().run()
../_images/guide-6.jpg

以下是发生的情况:

  • 第4行:我们添加了一条导入语句,以便能够使用 Button 类。

  • 第25行:我们创建一个虚拟的``Widget()``对象,作为绘画控件和即将添加的按钮的父级。这仅仅是一种简易的方法来建立控件树层级结构。我们同样可以使用布局或进行其他复杂操作。再次强调:这个控件除了作为我们即将添加的两个子控件的容器外,不做任何其他事情。

  • 第26行:我们像往常一样创建了 MyPaintWidget(),但这次我们不是直接返回它,而是将其绑定到一个变量名上。

  • 第27行:我们创建了一个按钮控件。它上面会有一个标签,显示文本“Clear”。

  • 第28行:然后我们将按钮的 on_release 事件(当按钮被按下并释放时触发)绑定到下方第33行和第34行定义的 callback function clear_canvas 上。

  • 第29和30行:我们通过将painter和clearbtn都设为虚拟父级widget的子级来建立widget层级结构。这意味着在通常的计算机科学树术语中,`painter`和`clearbtn`现在是兄弟节点。

  • 第33和34行:到目前为止,按钮没有任何功能。它存在、可见,你可以按下它,但不会发生任何事情。我们在这里改变这一点:我们创建一个小型、临时的函数,它将作为按钮按下时的`回调函数 <https://en.wikipedia.org/wiki/Callback_function#Python>`_。该函数仅清除画布的内容,使其再次变为黑色。

备注

Kivy 的 Widget 类,在设计上保持简洁。它没有诸如背景色和边框色这样的通用属性。相反,示例和文档展示了如何轻松自行处理这些简单事项,正如我们在此所做的那样,为画布设置颜色并绘制形状。从简单的起点出发,您可以迈向更精细的自定义。从 Widget 派生出的高级内置控件,如 Button,确实拥有诸如 background_color 这样的便捷属性,但这些属性因控件而异。请查阅 API 文档以了解某个控件提供了哪些功能,并在需要添加更多功能时进行子类化。

恭喜!你已经编写了你的第一个Kivy组件。显然,这只是一个快速入门。还有更多内容等待你去探索。我们建议稍作休息,让刚才学到的知识沉淀一下。也许画几幅漂亮的图画来放松一下?如果你觉得自己已经理解了所有内容,并准备好迎接更多挑战,我们鼓励你继续阅读下去。