跳到主要内容

交互控件

除此以外,wanim 几乎兼容 manim 原版代码。如果你想让 AI 编写 wanim 演示,请把本页复制给你的 LLM。

拖动一个数值,拨动一个开关,图形随之变化。本页介绍如何编写、操作和配置这样的场景。

编写交互场景​

交互场景继承自 InteractiveScene,并把脚本拆成两个方法:

class Demo(InteractiveScene):
def blueprint(self):
... # 搭建图形和控件

def construct(self):
... # 读者按下「下一页」之后播放的动画

试试下面的示例:拖动滑块、拨动开关,或在数字中输入。然后按下画布右下角的 > 按钮运行动画。

两个方法如何工作​

  • blueprint() 搭建图形,放置控件。它保持实时:读者停留在这一页时,它会在每一帧重新运行, 所以图形会跟随读者的操作。
  • construct() 在读者按下 > 之后运行。从这里开始,场景就是普通的 manim 场景:self.play、 self.wait 和 add_updater 都可以使用。下面介绍的 reaction 在这里会失效。

控件本身是普通的图形对象。你可以放置它们、为它们添加动画,并用 get_value() 读取它们的值。 三维场景应继承 InteractiveThreeDScene,下文的三维部分有示例。

Reaction:add_reaction​

控件与图形之间的联动通过 add_reaction 完成。reaction 是一个小函数,它根据控件的当前值去修改某个对象:

circle.add_reaction(lambda m: m.set_width(slider.get_value()))

可以从里往外读:

  • lambda m: ... 是一个接收一个参数的函数,参数名为 m。这里的 m 就是 reaction 所属的对象,也就是 circle。 (lambda 是 Python 中写简单函数的一种简写。)
  • m.set_width(...) 改变这个对象的宽度。
  • slider.get_value() 返回滑块的当前值。

wanim 会在蓝图阶段每一帧调用一次这个函数,所以圆的宽度会跟随滑块变化。你不需要自己写循环或更新步骤。

几条规则:

  • 一个对象可以有多个 reaction,它们按添加的先后顺序执行,因此多个控件可以同时驱动同一个对象。
  • reaction 也可以属于整个场景。self.add_reaction(refresh) 会在每一帧、所有对象的 reaction 执行之后, 调用一次 refresh(scene)。适合需要同时更新多个对象的情况,例如重新排列一组图形。
  • reaction 只在蓝图阶段运行。进入 construct() 之后,请改用 add_updater。
  • reaction 每帧都会运行,因此要保持轻量。最常见的性能问题是在 reaction 中创建新的 Text。 对于会变化的数字,应当只创建一次 DecimalNumber,每帧调用它的 set_value(...)。

三维交互场景​

InteractiveThreeDScene 是它的三维版本。它拥有 ThreeDScene 的三维相机,并采用上面的蓝图与构造模型。 在空闲的蓝图页上,读者可以拖动空白处旋转视角,滚动滚轮缩放。控件依然可用:指针事件先交给控件, 只有在背景上拖动才会移动相机。

标签、控件和 HUD 文字都应当用 add_fixed_in_frame_mobjects 添加,否则它们会被放进三维场景中,并随相机倾斜。 相机只在空闲的蓝图页上响应操作;self.play(...) 运行期间,旋转与缩放会暂停。

如何操作​

在蓝图页空闲时,你可以:

  • 拖动滑块,沿轨道移动旋钮。
  • 点击开关进行切换。
  • 点击数字并输入新值。按 Enter 或点击其他位置确认,按 Escape 取消。上下拖动数字可以连续调节。
  • 在三维场景的背景上拖动可以旋转视角,滚动滚轮可以缩放。

要进入下一页,使用画布右下角的按钮:

  • > 进入下一页。在蓝图页上,这会运行它的 construct()。
  • < 回到上一页。
  • ↻ 重播本页。

键盘快捷键:Space 或 → 进入下一页,← 回到上一页,↑ 重播本页。在数字输入框中输入时,快捷键不会触发。

控件一览​

控件操作方式get_value() 返回
Slider(start, end, value_range, init_value)沿直线轨道拖动float
PathSlider(path, value_range, is_closed)沿任意曲线或闭合环拖动float
Switch(value)点击切换bool
InputDecimalNumber(value, num_decimal_places)点击输入,拖动调节float
InputInteger(value)点击输入,拖动时按整数步进int
InputDecimalMatrix([[...], ...])编辑任意格子;矩阵自动重新排版格子的 get_value()
InputDecimalTable([[...], ...])编辑任意格子;表格线自动重建格子的 get_value()

Slider 与 PathSlider​

Slider 沿着从 start 到 end 的直线移动。value_range 设置两端对应的值,init_value 设置初始值。

slider = Slider(
start=axes.c2p(0, 0),
end=axes.c2p(4, 0),
value_range=(0.5, 3), # 左端为 0.5,右端为 3
init_value=1,
color=YELLOW,
track_opacity=1, # 设为 0 可隐藏轨道
knob_opacity=1, # 设为 0 可隐藏旋钮
)

PathSlider 的用法相同,但可以沿任意 VMobject 移动,例如圆弧、折线,或由 VGroup 拼成的路径。 取值按弧长比例对应,拖动始终停留在路径上。对于 Circle 这类闭合环,设置 is_closed=True: 取值会首尾相接,拖过接缝时从另一端继续。

path = Arc(radius=2, start_angle=0, angle=PI)
angle_slider = PathSlider(path, value_range=(0, 180), init_value=90, color=YELLOW)

鼠标悬停在旋钮上或拖动旋钮时,旋钮会略微放大。可用 knob_active_scale 调整放大倍数,设为 1 则关闭该效果。

如果想用其他形状作为旋钮,可以隐藏内置旋钮,然后附着你自己的图形:

thumb = Triangle(fill_opacity=1, color=BLUE)
thumb.move_to(slider.knob.get_center())
slider.attach(thumb) # 内置旋钮变为不可见,thumb 跟随取值移动

unattach() 会移除附着的图形,attached 列出当前附着的图形。命中判定始终基于轨道, 因此即使旋钮不可见,控件依然可以拖动。

Switch​

is_green = Switch(True) # 第一个参数是初始状态
is_green.set_value(False)
is_green.toggle()
square.add_reaction(lambda m: m.set_color(GREEN if is_green.get_value() else RED))

数字输入框​

InputDecimalNumber 接受与 DecimalNumber 相同的参数,另外还有:

参数默认值含义
value_range无闭区间 (low, high)。输入超出范围的值时会被限制在范围内
drag_pixels150垂直拖动多少像素对应整个范围
drag_span范围宽度拖动 drag_pixels 像素时数值的变化量
highlight_opacity0.25悬停时数字背后高亮层的浓淡

默认不使用千分位分隔符,所以 10000 显示为 10000,而不是 10,000。

InputInteger 用法相同,但没有小数位。取值始终是整数,拖动时按整数步进。

备注

num_decimal_places 也会改变数值的显示格式。当小数位数为 1 时,输入的 2.03 会显示为 2.0。 如果读者输入的文本必须与显示完全一致,请使用固定精度的输入框。

矩阵与表格输入​

InputDecimalMatrix 和 InputDecimalTable 接受与 DecimalMatrix、DecimalTable 相同的参数, 每个格子都是可编辑的数字。格子的值改变后,布局会更新,矩阵的中心保持不变。如需设置每个格子的选项, 请通过 element_to_mobject_config 传入,例如 {"value_range": (0, 10)}。

A = InputDecimalMatrix([[1, 2], [3, 4]], num_decimal_places=1, h_buff=1.5)
A.to_edge(LEFT)
cells = A.get_entries()

det = DecimalNumber(0, num_decimal_places=2).to_edge(DOWN)
det.add_reaction(lambda m: m.set_value(
cells[0].get_value() * cells[3].get_value()
- cells[1].get_value() * cells[2].get_value()
))
self.add(A, det)

对于表格,可以用 T.get_entries_without_labels([1, 1]).get_value() 读取单个格子。 如果代码修改了格子的值,请调用 relayout() 更新布局。

分步演示​

只有一组 blueprint() 和 construct() 的场景是单独的一页。要按顺序经过多页,请使用 set_storyboard。 它接受一组 [blueprint, construct] 配对,每一对是一页。set_script 是它的别名。请在 setup() 中定义:

每一对是一页,> 按钮会按顺序依次前进。在一对中,任一半可以写成 None 以跳过它。

幻灯片模式​

默认情况下,演示会把每个 construct() 连续播放完,然后进入下一个场景,只在每个蓝图开始处暂停。 如果是课堂讲解风格的内容,可以在文件顶部开启幻灯片模式:

wanim_config({"slides_mode": True})

开启后,每个蓝图和每段 construct 结束时都会暂停,各自成为一页。< 会回到上一页,并恢复读者在该蓝图上设置过的控件值。 ↻ 在蓝图页上会从干净的状态重新开始,在 construct 页上则重播动画。

同一文件中的多个 Scene 类会按顺序组成一个演示。不是场景的辅助类会被忽略。