动画¶
lilt 里所有会动的东西都由同一种阻尼弹簧驱动:列表行的滑动、光标、页面飞入、勾选框的方框。动画的基本单位是 anim.Value,一个朝目标值弹过去的 float32。
anim.Value¶
Value 的零值就能用:停在 0,使用 anim.Default 弹簧。设一个目标,它就自己动过去;绘制时读 Get()。
下面的自定义节点把一个 Value 画成线上的一个点,0 在左端,1 在右端:
// lane draws a dot that springs between the ends of a line.
type lane struct {
ui.Box
pos anim.Value // 0 at the left end, 1 at the right
}
func newLane(s *anim.Spring) *lane {
l := &lane{}
l.pos.SetSpring(s)
l.pos.SetPrecision(0.002) // the default 0.05 suits pixels, not 0..1
l.SetGrow(1)
return l
}
func (l *lane) Leaf() {}
func (l *lane) Measure(max ui.Vec2) ui.Vec2 { return ui.Vec2{X: max.X, Y: 7} }
func (l *lane) Draw(c gfx.Canvas) {
r := l.Rect().Pixels()
col := ui.CurrentTheme.Foreground
left, w, y := int16(6), r.W-16, r.H/2
c.VLine(left, y-2, 5, col)
c.VLine(left+w, y-2, 5, col)
c.FillCircle(left+ui.Round(l.pos.Get()*float32(w)), y, 2, col)
}
Draw 里只管读 Get(),不需要自己调用 ui.Invalidate():只要有 Value 还在动,帧循环就会重画。Value 嵌在结构体里,结构体用指针传递,见下面的警告。
方法¶
| 方法 | 作用 |
|---|---|
Set(x) |
以当前位置和速度为起点,弹向 x。目标没变时什么也不做 |
Jump(x) |
立刻停在 x,速度清零 |
Shift(d) |
当前值挪 d,目标不变,于是再滑回来。布局的滑动就是这样做的 |
Kick(v) |
速度加上 v(单位每秒),例如组件失败时的抖动 |
Bump(px) |
按弹簧换算一个初速度,让静止的值大约荡开 px 再回来,比 Kick 好估计幅度 |
Get() |
显示值,包括父值 |
Current() |
不含父值的当前值 |
Target() |
目标值,不含父值 |
Velocity() |
速度,单位每秒 |
Settled() |
是否已经静止 |
SetSpring(s) |
换弹簧,nil 表示 anim.Default |
SetParent(p) |
让 Get() 加上 p.Get(),在父值的运动上叠加自己的 |
SetPrecision(p) |
距离目标多近算静止,默认 0.05 |
anim.NewValue(v, s) 返回一个停在 v、使用弹簧 s 的 *Value。
精度的默认值 0.05 是按像素定的。做 0 到 1 的进度时,0.05 就是 5%,值会在离终点还差一截时突然跳过去,所以上面的 lane 把精度设成 0.002,标准组件里勾选框的方框、按钮的长按进度也都设了更小的精度。值离目标小于精度、速度小于精度的 10 倍时,它直接落在目标上并停下。
Bump 只对弹簧有效;Ease 曲线(见下文)没有动量,Kick 和 Bump 加进去的速度会被忽略。
动起来之后不要复制 Value
调度器持有每个运动中 Value 的指针。把一个正在动的 Value 按值复制,副本不会动,原件还在被推进。把 Value 放在用指针传递的结构体里,像 ui.Box 和所有组件那样。Value 也没有加锁,只在 UI goroutine 上或 ui.Do 里改它。
调度器¶
anim 包有一个全局的运动列表。Value 的目标或速度一变,它就加进列表;静止后自己离开。所以静止的值不花任何时间,也不需要注册和释放。
func Step(dt float32) bool // 推进所有运动中的值,返回是否有值在动
func Busy() bool // 是否还有值在动
var TimeScale float32 = 1 // 所有动画的时间倍率
UI goroutine 每帧调用一次 anim.Step(dt),返回 true 时那一帧重画。自己的代码不需要调用它。TimeScale 设成 0.2 可以把所有动画放慢 5 倍,调手感时用来看清楚细节。
弹簧用半隐式欧拉积分,每帧再细分成 1/240 秒的小步,帧率低、弹簧硬的时候也不会发散。因为动画按时间增量计算,帧率不同时运动的快慢一样,只是采样密度不同。
弹簧¶
type Spring struct {
Response float32 // 无阻尼振动的周期,秒
Damping float32 // 临界阻尼的比例:1 不过冲,越小越弹
Ease bool // 改用指数缓动:没有动量,不过冲,忽略 Damping
}
参数采用 SwiftUI 的写法,比刚度和阻尼系数好调:Response 决定快慢,Damping 决定弹不弹,两者互不影响。Damping 为 1 时临界阻尼,最快到达且不越过目标;0.5 左右会明显回弹一次。
Ease 为真时不再是弹簧:值第一帧就全速出发,每过 Response/2π 秒缩小剩余距离的 1−1/e,没有惯性也不会越过。光标的边用它,换焦点时立即响应,不会甩出去。其中的 e^−x 用 float32 有理逼近计算,因为 ESP32-S3 的 FPU 只做单精度,math.Exp 的 float64 要靠软件模拟。
预设¶

t := ui.CurrentTheme
rows := ui.NewColumn(ui.FillWidth(), ui.Padding(ui.XY(2, 0)))
presets := []struct {
name string
spring *anim.Spring
}{
{"Default", anim.Default},
{"Smooth", anim.Smooth},
{"Snappy", anim.Snappy},
{"Bouncy", anim.Bouncy},
{"Interactive", anim.Interactive},
}
var lanes []*lane
for _, p := range presets {
l := newLane(p.spring)
lanes = append(lanes, l)
name := widget.NewLabel(p.name, widget.Font(t.SmallFont), ui.Width(ui.Px(64)))
ui.Add(rows, ui.NewRow(ui.Cross(ui.CrossCenter), ui.Children(name, l)))
}
right := false
swap := widget.NewButton("Swap", widget.Font(t.SmallFont), ui.Padding(ui.XY(4, 0)),
ui.Align(ui.BottomRight), widget.OnClick(func() {
ui.Do(func() {
right = !right
for _, l := range lanes {
if right {
l.pos.Set(1) // the same target, five springs
} else {
l.pos.Set(0)
}
}
})
}))
五个点在同一帧拿到同一个目标,区别只在弹簧。Interactive 最先到且不过冲,Bouncy 冲过终点又弹回,Smooth 最慢。改目标的代码在 OnClick 里,所以包在 ui.Do 里(见线程模型)。
| 预设 | Response | Damping | 用在 |
|---|---|---|---|
anim.Default |
0.29 | 0.75 | 滚动时行的波浪(每次滚动从它派生,离焦点越远越慢)、对话框落下,也是 Value 的默认弹簧 |
anim.Smooth |
0.39 | 0.85 | 页面、抽屉、Toast 的进出 |
anim.Snappy |
0.31 | 0.8 | 需要紧跟的东西:滚动条、长按进度、按钮里文字和勾叉的上下切换、行尾控件的进出 |
anim.Bouncy |
0.25 | 0.45 | 勾选框和单选框的标记,按钮、行、输入框、Chip 行失败时的抖动 |
anim.Interactive |
0.14 | 1 | 跟随按下的键或手指:光标按下时的收缩 |
除了 Interactive,每个预设都带一点过冲,这是有意的。
预设是指针,Value 保存的也是指针,所以运行时改预设的字段,所有用它的值从下一帧起立即生效。这就是全局调整手感的方式:
框架里还有几个专用的弹簧,同样是可以改的指针:光标的 ui.CursorLead、CursorTrail、CursorRelease、CursorLeap、CursorLean(见光标),Chip 横条的 widget.ChipSpring。
节点的偏移¶
每个节点的 Box 带两个 Value:OffsetX() 和 OffsetY(),叠加在布局给出的位置上。子节点相对父节点绘制,所以偏移会带着整棵子树一起动,页面推入推出、弹窗滑入、组件抖动都是改这两个值。偏移不影响布局,也不影响焦点查找(焦点用的是落定后的位置)。

pin := widget.NewLabel("Enter PIN", ui.Align(ui.Center))
kick := widget.NewListItem("Kick sideways", widget.OnClick(func() {
ui.Do(func() { pin.OffsetX().Kick(-500) }) // px per second
}))
bump := widget.NewListItem("Bump down", widget.OnClick(func() {
ui.Do(func() { pin.OffsetY().Bump(8) }) // about 8px away
}))
Kick 给的是速度,幅度取决于弹簧;Bump 给的是大致的幅度。标准组件失败时的抖动就是 OffsetX().Kick(-260),配合组件自己设的 anim.Bouncy。
回弹阻力¶
RubberBand 把越界的距离 x 换算成显示出来的距离,结果趋近但永远到不了 dim,推得越多动得越少。系数 0.55 和 UIScrollView 的越界阻力相同。滚动容器拖过头时用它。