外观
动态效果
元素可以帮你对值做动画:你给出目标值,它返回本帧应显示的值,并持续绘制帧直到值到达目标。视图说明东西在哪里;动画负责找到过去的路:
go
card := ui.Box(c).Size(120, 80).Radius(12).Background(t.Accent)
target := float32(0)
if app.open {
target = 200
}
card.Translate(card.AnimateTo("x", target, ui.Snappy), 0)键("x")用来区分同一元素的多个值,而元素需要在各帧之间保持相同的 ID:一个 Key,或在兄弟元素中的相同位置。值从它的第一个目标开始。值的用法不限:变换(见变换)、尺寸、不透明度、颜色混合,或 Draw 绘制的内容。
动画
Animation 描述一个值如何走向新目标:
- 弹簧:零值
Animation以及预设ui.Smooth(不回弹)、ui.Snappy(轻微回弹)和ui.Bouncy(明显回弹)。弹簧在目标中途改变时保持速度:上面的卡片在飞行途中被再次点击时会平滑地掉头,而不是戛然而止。 - 曲线:一个
Duration和一个Ease(为 nil 时是ui.EaseOut),如ui.Animation{Duration: 150 * time.Millisecond, Ease: ui.EaseInOut}。Animate(key, target, d)和AnimateWith(key, target, d, ease)是它们的简写。
弹簧的 SpringOptions 从感知角度描述它:Duration(为零时 500 ms)大致表示到达所需的时间;Bounce 从 0(不回弹地到达)趋向 1;负值则到达得更慢。这些就是 SwiftUI 和 Motion 的参数。Stiffness、Damping 和 Mass 则从物理角度描述它:
go
gentle := ui.Animation{Spring: ui.SpringOptions{Duration: 700 * time.Millisecond, Bounce: 0.1}}
stiff := ui.Animation{Spring: ui.SpringOptions{Stiffness: 400, Damping: 30}}
x := card.AnimateTo("x", target, gentle)Spring(key, target, options) 是使用弹簧的 AnimateTo 的简写。弹簧是精确求解而非逐步迭代的,因此慢帧不会改变其轨迹。值可以在途中更换动画类型:弹簧接管曲线动画时会保留曲线的速度。
Easing 缓动函数有 ui.Linear、ui.EaseIn、ui.EaseOut、ui.EaseInOut、ui.Bounce(e)、CSS 风格的 ui.CubicBezier(x1, y1, x2, y2),或任意 func(t float32) float32。
AnimateColor(key, target, a) 用同样的方式对颜色做动画,按预乘 alpha 混合,使从透明淡入的颜色保持其色相:
go
row.Background(row.AnimateColor("bg", bg, ui.Animation{Duration: 120 * time.Millisecond}))只绘制的姿态
上面的值由视图读取,因此每个移动的帧都会运行视图。AnimatePose 则直接移动元素:在元素自身变换之后对其平移、缩放、旋转和淡化,移动的帧只重绘、不运行视图,就像 Core Animation 和 Web Animations API 处理变换与不透明度那样。这是开销最小的动态效果,能让大型视图保持流畅:
go
lift := ui.Pose{}
if card.Hovered() {
lift = ui.Pose{Y: -4, Scale: 1.02}
}
card.AnimatePose(lift, ui.Snappy)Pose 用 X 和 Y 移动,用 Scale 缩放(0 保持原尺寸),用 Rotate 旋转若干度,用 Fade 淡化——从 0(完全不透明)到 1(完全消失)。指针、光标和覆盖层会跟随移动的元素,Transitioning() 报告其姿态是否正在移动。
姿态可以嵌套:每个姿态在周围元素的姿态之后生效,因此嵌套元素会在父元素运动的基础上叠加自己的运动,沿自己的曲线。在彩带示例中,一簇彩带整体下落并淡出,其中每一片沿减速曲线向外迸发,并在内部旋转:数百片彩带飞舞,却无需重新构建视图。每个有姿态的元素仍会在绘制它的每一帧产生开销,因此运动规律相同的元素(如一簇的下落)应共享一个姿态,而不是各自给一个。
延迟与交错
Delay 让值在目标改变后按原样继续一段时间。随索引递增的延迟可以让列表错开动画:
go
for i, it := range app.items {
row := ui.Row(c.Key(it.ID))
a := ui.Snappy
a.Delay = time.Duration(i) * 30 * time.Millisecond
row.Opacity(row.AnimateTo("shown", 1, a))
}手势
Draggable 让指针移动并"抛掷"元素,就像 motion.dev 的 drag:元素跟随指针,松手后,指针的速度带着它滑向静止位置,以弹簧收尾:
go
card := ui.Box(c).Size(90, 60).Radius(10).Background(t.Accent)
card.Draggable(&app.card, ui.DraggableOptions{
Axis: ui.Horizontal,
Bounds: &ui.Rect{W: 200},
Snap: ui.Nearest(ui.Point{}, ui.Point{X: 200}),
})app.card 是一个 ui.Point,即元素相对其布局位置的偏移。指针拖动元素时,偏移跟随指针;指针松手后,偏移就是元素静止的位置;设置偏移会把元素移到那里,就像翻页按钮那样。偏移为 nil 时由元素自己保存。选项决定它如何移动:
Axis:ui.Horizontal或ui.Vertical,移动的方向;零值为两个方向都可移动。Bounds:静止时把偏移限制在一个矩形内,即X到X+W、Y到Y+H。指针把元素拉出边界时,越拉越费劲,就像 iOS 对拉出边缘的内容那样,松手后它会弹回边界内。Rigid则直接停在边界上。Snap:从抛掷将要到达的位置(ui.Project,限制在边界内)决定静止点,使轻轻一甩就能翻到下一页或下一个高度:ui.Nearest(points...)选择最近的点。若Snap返回传入的点,元素就顺着抛掷的惯性滑行。没有Snap时,元素停在指针松手处,就像放在画布上的物品。Animation:滑向静止位置以及应用设置的偏移的方式:零值为ui.Smooth。弹簧从指针的速度起步。
指针移动几个 DIP 后开始拖动元素,那次按下不再算作点击;元素移动中被按下则会被"接住"。元素在自身变换和姿态之后移动,沿父元素的轴向,因此在缩放或旋转时仍保持在指针之下;它滑向静止的过程在只重绘的帧中完成,无需构建视图,与 AnimatePose 的移动方式相同。Dragging() 报告指针正在拖动它,可借此让它抬起:
go
lift := ui.Pose{}
if card.Dragging() {
lift = ui.Pose{Scale: 1.05}
}
card.AnimatePose(lift, ui.Snappy)静止后 Transitioning() 变为 false,视图便可继续处理后续逻辑,例如移除被划走的行:
go
for i := range app.rows {
r := &app.rows[i]
row := ui.Row(c).Key(r.ID)
row.Draggable(&r.swipe, ui.DraggableOptions{
Axis: ui.Horizontal,
Snap: func(p ui.Point) ui.Point {
if p.X < -160 {
return ui.Point{X: -400} // 向左划走
}
return ui.Point{}
},
})
if r.swipe.X == -400 && !row.Transitioning() {
app.remove(r.ID)
}
}DragOffset() 返回元素本帧显示的位置——跟随指针或在滑向静止的途中——供需要跟随它的东西使用,例如随底部弹层下降而淡出的背景遮罩。读取它会让元素移动的每个帧都构建视图,与 AnimateTo 相同;请在 Draggable 之后调用:
go
backdrop := ui.Box(c).Absolute().Fill().Background(ui.RGBA(0, 0, 0, 0.4))
sheet := ui.Column(c).Draggable(&app.sheet, ui.DraggableOptions{
Axis: ui.Vertical,
Bounds: &ui.Rect{H: 400},
Snap: ui.Nearest(ui.Point{}, ui.Point{Y: 400}),
})
backdrop.Opacity(1 - sheet.DragOffset().Y/400)当桌面要求减少动态效果时,元素仍跟随指针,但会立即到达静止位置。
手动实现
若要跟随指针的不是偏移而是别的值(如表盘的角度),可以用更小的部件自行搭出手势。Jump 让值立即跟随指针,并记录其移动速度;指针松手后接管的弹簧以该速度起步。DragEnded 报告指针松手时的速度,SpringOptions.Velocity 把它交给弹簧:
go
dial := ui.Box(c).Size(80, 80)
var turn float32
if dx, _, ok := dial.Dragged(); ok {
app.turn += dx
turn = dial.Jump("turn", app.turn)
} else {
a := ui.Snappy
if vx, _, ok := dial.DragEnded(); ok {
// 抛掷将到达的位置决定它落在哪个档位。
app.turn = float32(math.Round(float64(ui.Project(app.turn, vx)/45))) * 45
a.Spring.Velocity = vx
}
turn = dial.AnimateTo("turn", app.turn, a)
}
dial.Transform(ui.Rotation(turn))DragVelocity 是按住期间指针的速度,以沿元素轴向的 DIP/秒计,静止片刻后为零。Velocity(key) 是某个动画值的速度。没有移动的点击不会结束拖动。
ui.Project(value, velocity) 计算按 iOS 滚动视图的减速方式一甩会把值带到哪里,可用来选择落点,如翻页器的某一页。ui.Rubberband(value, lo, hi, dimension) 像 iOS 拉住被拖出边缘的内容那样,把拖过 lo 或 hi 的值拉住,越拖越沉:
go
if _, dy, ok := sheet.Dragged(); ok {
app.y += dy
y = sheet.Jump("y", ui.Rubberband(app.y, 0, 400, 600))
}何时完成
Settled(key) 报告一个值是否静止在目标上;Arrived(key) 只在它到达静止的那一帧为 true,供视图继续处理后续逻辑,例如移除已淡出的内容:
go
toast.Opacity(toast.AnimateTo("fade", 0, ui.Animation{Duration: 200 * time.Millisecond}))
if toast.Arrived("fade") {
app.dismiss(toast)
}不做动画
c.DisableAnimations() 让本帧的值、过渡、姿态和路由器页面立即到达目标,就像 SwiftUI 的 disablesAnimations:适用于不应表现为动态效果的变化,如恢复窗口状态或跳到搜索结果。请在构建发生变化的内容之前调用它。Keyframes 和时间线是自行播放而非朝目标移动的,会照常继续;要让时间线跳转,用 seek。
关键帧
Keyframes 播放按时间排布的值,每段沿其到达帧的 Ease 缓动,并保持在最后一个值:
go
tile := ui.Box(c).Key("tile").Size(80, 50)
x := tile.Keyframes("x", []ui.Keyframe{
{Value: 0},
{At: 200 * time.Millisecond, Value: 100, Ease: ui.EaseOut},
{At: 700 * time.Millisecond, Value: 40},
{At: time.Second, Value: 0, Ease: ui.EaseInOut},
}, ui.KeyframeOptions{Run: app.shakes})
tile.Translate(x, 0)第一个时间必须为零,之后的时间必须递增。改变 Run 可重新播放序列或播放修改后的序列:被打断时,它从当前显示的值开始,而不会跳到第一个值(首次播放除外)。Repeat 是首次之后再播放的次数,RepeatForever 表示只要还在构建就一直播放,配合 Alternate 会让每隔一次反向播放;Delay 在播放前保持第一个值一段时间,RepeatDelay 在每次循环之间保持到达的值。
时间线
Timeline 沿同一个播放头播放多个补间动画,让本应在一起的运动可以一起编排、控制和测试:卡片飞入的同时标题淡入,一行行依次跟进,卡片落地后轻微弹跳。AnimateTo 把一个元素的一个值移向目标,Keyframes 让一个值沿按时间排布的停留点播放;时间线围绕同一个时钟协调许多这样的值,每个值有自己的补间。
元素按键保存时间线,就像保存 Keyframes 一样:和别的东西一样在视图中构建它,让每个补间返回本帧应显示的值:
go
card := ui.Column(c)
tl := card.Timeline("intro", ui.TimelineOptions{Run: app.opened})
x := tl.Tween(ui.Tween{From: -200, To: 0, Duration: 400 * time.Millisecond})
fade := tl.Tween(ui.Tween{From: 0, To: 1, Duration: 300 * time.Millisecond},
ui.AfterPrevious(-150*time.Millisecond))
card.Translate(x, 0).Opacity(fade)除非其 Ease 另有说明,补间默认缓出(EaseOut),与 Animation 相同。没有指定位置的补间会紧接前一个补间之后开始,因此这里第二个补间会在 400ms 处开始;位置参数可把它放到别处,就像 GSAP 的 position 参数:
| 位置 | 补间从何处开始 |
|---|---|
AfterPrevious(50*time.Millisecond) | 前一个结束 50ms 后;负偏移会与前面的重叠,如上面的 -150ms 让淡入在 250ms 开始 |
WithPrevious(0) | 与前一个同时开始,一起移动,或在其开始之后的某个偏移处 |
AtLabel("rows", 100*time.Millisecond) | 标签之后 100ms |
AtTime(500*time.Millisecond) | 时间线上的某个时间点 |
任何补间都不会早于时间线本身开始:位置早于时间线起点的,从起点开始。
标签与交错
Label 标记时间线上的一个时间点供补间开始,相对它的偏移可按索引让补间错开:
go
tl := list.Timeline("rows", ui.TimelineOptions{Run: app.shown})
tl.Label("rows", ui.AtTime(300*time.Millisecond))
for i := range app.rows {
app.rows[i] = tl.Tween(ui.Tween{From: 0, To: 1, Duration: 200 * time.Millisecond},
ui.AtLabel("rows", time.Duration(i)*40*time.Millisecond))
}
for i, shown := range app.rows {
rows[i].Opacity(shown).Translate(0, (1-shown)*12)
}播放
Duration 是最后一个补间的结束,Time 是播放头所在的位置,Progress 是播放进度(0 到 1),供拖动条使用。Paused 把播放头停在原处:时间线暂停所花的时间不计入播放时间,因此取消暂停后从停下的地方继续播放,无论暂停了多久。Reverse 让播放头从当前位置向起点倒放,就像面板按打开的方式关闭(Reverse: !app.open);倒放中播完的时间线会从其末尾开始播放。只要给出 Seek 或 Progress,时间线就停在指定的时间点或进度上,不给时从那里继续;两者最多给一个。选项的效果在其被设置的那一帧生效,因此暂停或恢复时间线的按钮在被点击的那一帧就会体现:
go
o := ui.TimelineOptions{Run: app.run, Paused: app.paused, Reverse: app.reverse}
if app.scrubbing {
o.Progress = &app.scrub
}
tl := card.Timeline("intro", o)Repeat 是首次之后再播放的次数,含义与 KeyframeOptions.Repeat 相同;RepeatForever 表示只要视图还在构建它就一直播放,配合 Alternate 让每隔一次反向播放;Delay 在播放前让时间线保持第一个值一段时间,RepeatDelay 在每次循环之间保持到达的值。Settled 报告它已静止——在末尾,或倒放时在起点;Arrived 只在它到达静止的那一帧为 true,与值的 Settled(key)、Arrived(key) 相同。
改变 Run 可重新播放时间线或播放修改后的时间线。不改变 Run 而重新构建会保持播放头不动,因此视图可以逐帧重新编排其补间:时间线播放本帧编排好的补间。编排一个更长的时间线会从播放头所在处继续,并且直到新编排的时间线播放完才 Settled。视图停止构建元素时,时间线随元素的状态一起消失。每次构建中,元素对每个时间线只请求一次,返回的 *Timeline 在视图构建该帧期间有效。
当桌面要求减少动态效果时,本应播放的时间线会立即到达其静止位置——每个补间处于其 To,若是倒放回起点则处于其 From——与值、姿态和 Keyframes 的行为一致,并且不请求帧。视图对播放头本身的操作仍然有效,拖动也仍能移动 Draggable 元素:Seek 和 Progress 把它放到拖动条要求的位置,Paused 将其停在那里。DisableAnimations 不会改变时间线——它和 Keyframes 一样自行播放;要跳转请用 Seek。时间线只在播放期间绘制帧,由 c.Now() 计时。
滚动
InView() 报告上一帧中元素在其周围的滚动容器内是否有部分可见,可用于在元素滚入视野时让它入场。ScrollState(见滚动)给出其容器滚动的距离,供跟随滚动的效果使用:
go
card := ui.Box(c).Key(it.ID).Height(120)
shown := float32(0)
if card.InView() {
shown = 1
}
card.Opacity(card.AnimateTo("shown", shown, ui.Smooth))
header.Opacity(1 - min(app.scroll.Y/80, 1)) // 随页面滚动淡出ScrollState.ScrollTo(x, y, animation) 平滑滚动容器(见滚动)。
变化的内容
ContentTransition 在元素内容变化时为其做动画:Text 的文本、Icon 的图标、Image 的图片。默认旧内容淡出的同时新内容淡入,旧图标缩小的同时新图标放大。Numeric 让一行文本中发生变化的字符滚动起来,就像计数器的数字,不变的字符保持不动:向上滚动,或配合 Down 向下滚动,正如 SwiftUI 的 numericText:
go
ui.Text(c, strconv.Itoa(app.count)).FontSize(32).Bold().
ContentTransition(ui.ContentTransition{Numeric: true, Down: app.decreased})
ui.Icon(c, playOrPause).ContentTransition(ui.ContentTransition{})内容过渡在只重绘的帧中运行。当桌面要求减少动态效果时,内容只淡入淡出而不移动。
循环与自定义动态效果
Loop 返回每个周期重新开始播放的动画的进度,适用于加载指示器和脉冲效果(见绘制与动画)。要用其他方式做动画,请基于 c.Now() 计算,并在每个有移动的帧调用 c.AnimationFrame()。
减少动态效果与帧
当桌面要求减少动态效果时(c.Preferences().ReduceMotion),值和姿态会立即到达目标,序列直接停在最后一个值。不移动的变化仍然平滑:颜色(AnimateColor)、姿态的 Fade、内容的交叉淡入淡出,以及过渡的颜色与淡入淡出。Loop 会继续运行,就像系统的加载指示器那样。
动画只在有东西移动时按显示屏刷新率绘制帧,窗口不可见时(见下文)完全不绘制。移动视图所读取值的帧会运行视图;姿态、内容过渡以及从 Draw 驱动动画的绘制只重绘。
不可见时
当窗口的任何部分都不可见时(macOS:隐藏、最小化、在另一个 Space 上、或被其他窗口完全遮挡;浏览器:后台标签页),MyGo 不会为其绘制帧,就像浏览器不为不可见的页面绘制一样,这也是 Apple 对 Mac 应用的要求。视图不运行,Draw 不被调用,After 和 Painter.After 请求的帧也会等待。窗口一旦有部分可见,就立即绘制一帧,包含其间发生的所有变化;在此之前屏幕保持显示它的最后一帧,大约持续一次刷新的时间。以下情况仍会构建帧:有输入之后(向被其他应用窗口遮挡的键窗口键入按键)、辅助技术开启时,以及 Window.CapturePage,以确保截图显示窗口的当前状态。Windows 和 Linux 不会告知 MyGo 窗口何时不可见,因此在这些系统上照常绘制。
这对动态效果意味着:
- MyGo 管理的动画会按时间推进到它应在的位置。 窗口不可见时仍在运行的弹簧、途中的
Animate、Loop、关键帧和时间线,在窗口重新可见时已经前进(或已经完成)。 - 不可见期间改变的目标会在窗口重新可见时开始动画。 视图直到那时才第一次看到新目标,因此值会从其原来的位置移动过去,就像 CSS 过渡处理隐藏标签页中发生的变化一样。
- 自定义的动态效果要基于时间计算,而不是基于帧计数。 如果起始时间取自第一个有绘制的帧,或者用帧数累加,那么窗口重新可见时动画会从头开始,把其间发生的事当作刚刚发生一样播放。请在改变状态的代码中、状态改变的那一刻记录时间,这样视图会把不可见期间发生的变化直接显示为已完成:
go
// counter 按时钟在 400 ms 内从一个值缓动到下一个。
type counter struct {
from, to float32
changed time.Time
}
const ease = 400 * time.Millisecond
// at 返回 t 时刻应显示的值。
func (n counter) at(t time.Time) float32 {
p := min(float32(t.Sub(n.changed))/float32(ease), 1)
return n.from + (n.to-n.from)*ui.EaseOut(p)
}
// 计数变化的地方,在视图之外:
now := time.Now()
app.count = counter{from: app.count.at(now), to: float32(n), changed: now}
win.Update(func() {})
// 在视图中:
value := app.count.at(c.Now())
if c.Now().Sub(app.count.changed) < ease {
c.AnimationFrame()
}- 把活儿移出视图。 从视图、从
After或从Draw中做轮询、保存或启动任务,在窗口不可见期间都会停摆。这类工作应由应用本身运行(goroutine 或time.Ticker),并用Window.Update展示其结果。