第陸話
动效与手势
动画、过渡、共享元素、手势、按键和触感反馈。
只要平台自带动画机制,Asuka 就直接用它:iOS 上是 UIView 动画,Android 上是 animator,macOS 上是 AppKit 的隐式动画和 Core Animation,浏览器里是 Web Animations API。Windows 和 GTK 没有针对视图 frame(位置与尺寸)的动画,所以 Windows 和 Linux 宿主会自己逐帧推进,用的也是同样的曲线。你只要在 ui.Animate 里修改状态,这次修改在屏幕上引起的移动就会以动画呈现。本页依次介绍:为变化添加动画、进入与退出过渡、共享元素、逐帧动画、触摸/鼠标/触控板手势、触感反馈,以及减弱动态效果。
为变化添加动画
把写入操作包进 ui.Animate,下一次刷新(flush)带来的变化就会以动画呈现:包括布局移动的 frame,以及下文列出的那些视觉属性。
func Details() ui.Node {
expanded := signal.New(false)
return ui.Column(ui.Style{Gap: 12, Padding: ui.All(20)},
ui.Button(ui.ButtonProps{Title: ui.Val("More"), OnPress: func() {
ui.Animate(func() { expanded.Set(!expanded.Peek()) })
}}),
ui.View(ui.ViewProps{Style: ui.StyleFunc(func() ui.Style {
h := float32(80)
if expanded.Get() {
h = 240
}
return ui.Style{Height: ui.Pt(h), CornerRadius: 16, Background: colors.SecondaryBackground}
})}),
)
}方块变高时,它下面的内容也会跟着往下滑:布局只运行一次,每个变了的 frame 都会以动画移到新位置。你不需要自己去给位置做动画。
同样的写入如果不放在 ui.Animate 里,就会立刻生效。和所有 signal 写入一样,ui.Animate 也在主线程上运行;如果在 goroutine 里,请在 ui.Post 中调用它。
时长、曲线与重复
ui.AnimateWith 接受一个 ui.Animation:
Duration是时长,单位为秒;0 表示 0.25。Curve可以是ui.EaseInOut(默认)、ui.Linear、ui.EaseIn、ui.EaseOut或ui.Spring。弹簧动画是一个带阻尼的弹簧,会在Duration结束时停稳:iOS 上用的是 UIKit 的弹簧动画,Android、Windows、Linux 和 Web 上则用同一个弹簧的插值器。Damping是弹簧的阻尼比:为 1 时不越过终点就停下,小于 1 时会冲过头再弹回来(0 表示 0.85,稍有回弹)。Velocity是弹簧的初速度,以“每秒走完整段变化的几分之几”来表示(即 UIKit 的initialSpringVelocity),用来延续一次快速滑动(fling)的速度。Delay是开始前等待的秒数。OnDone会在动画逻辑上完成时调用(即Delay加上每一次重复的Duration都过去之后),可以在这里接上下一步。- macOS 上 AppKit 的隐式动画既没有弹簧也没有延迟:回弹明显的弹簧动画会改用一条会冲过头的曲线,延迟则直接跳过。
Repeat表示再额外重复多少次;ui.Forever会一直重复,直到这些属性再次改变。设置了Autoreverses时,每一轮都会先倒放回去,再开始下一轮。macOS 和 Windows 不支持重复:动画只运行一次。
func Pulse() ui.Node {
on := signal.New(false)
dot := ui.View(ui.ViewProps{Style: ui.StyleFunc(func() ui.Style {
s := ui.Style{Width: ui.Pt(24), Height: ui.Pt(24), CornerRadius: 12, Background: colors.Red}
if on.Get() {
s.Transform = ui.Transform{Scale: 1.6}
s.Opacity = 0.35
}
return s
})})
start := func() {
ui.AnimateWith(ui.Animation{Duration: 0.8, Curve: ui.EaseInOut, Repeat: ui.Forever, Autoreverses: true},
func() { on.Set(true) })
}
stop := func() { on.Set(false) } // 不带动画地设置:脉冲随即停止
return ui.Row(ui.Style{Gap: 16},
dot,
ui.Button(ui.ButtonProps{Title: ui.Val("Pulse"), OnPress: start}),
ui.Button(ui.ButtonProps{Title: ui.Val("Stop"), OnPress: stop}),
)
}手势结束时,往往接着播放一个弹簧动画。把手势的速度除以剩下的距离,就得到弹簧的 Velocity,这样视图就能保持手指甩出去的速度:
OnPan: func(g ui.Gesture) {
if g.State != ui.GestureEnded {
drag.Set(g) // 跟随手指
return
}
d := math.Hypot(float64(g.TranslationX), float64(g.TranslationY))
v := 0.0
if d > 1 { // 沿返回路径、朝向原位的速度
v = -(float64(g.VelocityX*g.TranslationX) + float64(g.VelocityY*g.TranslationY)) / d / d
}
ui.AnimateWith(ui.Animation{Curve: ui.Spring, Duration: 0.6, Damping: 0.55, Velocity: float32(v)},
func() { drag.Set(ui.Gesture{}) })
},前后相接的几步动画可以通过 OnDone 串起来。比如一个 toast 提示:滑入、停留一会儿、再消失:
ui.AnimateWith(ui.Animation{OnDone: func() {
ui.AnimateWith(ui.Animation{Delay: 1.5}, func() { shown.Set(false) })
}}, func() { shown.Set(true) })要停止一个重复动画,只需不带动画地再次设置它的属性。Android 上只有透明度和变换能重复;iOS、Linux 和 Web 上所有能动画的属性都能重复;macOS 和 Windows 则只运行一次。
哪些变化会动画
| 变化 | iOS | Android | macOS | Windows | Linux | Web |
|---|---|---|---|---|---|---|
| frame(位置和尺寸,来自布局) | 是 | 是 | 是 | 是 | 是 | 是 |
Opacity |
是 | 是 | 是 | 否 | 是 | 是 |
Transform |
是 | 是 | 是 | 是 | 是 | 是 |
Background(纯色) |
是 | 是 | 否 | 否 | 否 | 是 |
CornerRadius |
是 | 是 | 是 | 否 | 否 | 否 |
Shape 的 TrimStart 和 TrimEnd |
是 | 是 | 是 | 否 | 是 | 否 |
Shape 的路径 |
是 | 否 | 是 | 否 | 否 | 否 |
其他属性(BorderColor、Shadow、Gradient、文字颜色和字体)都会立即改变。只有已经在屏幕上的视图才会动画;在 ui.Animate 期间新出现的视图不会以动画进入,除非它设置了过渡。
Opacity 为 0 是零值,表示不透明,所以无法通过 Opacity 把视图动画到完全透明。想让一个视图淡出,就用 Fade 过渡把它移除。
变换
Style.Transform 会在绘制时以视图中心为原点,对它进行平移、缩放和旋转。布局看不到变换,相邻的视图都待在原处不动。
TranslateX、TranslateY:偏移量,单位为点。Scale:同时缩放两个轴(0 表示 1);ScaleX、ScaleY只缩放其中一个轴。Rotate:角度,顺时针方向。
func Flip() ui.Node {
flipped := signal.New(false)
return ui.View(ui.ViewProps{
OnPress: func() {
ui.AnimateWith(ui.Animation{Curve: ui.Spring, Duration: 0.5}, func() { flipped.Set(!flipped.Peek()) })
},
Style: ui.StyleFunc(func() ui.Style {
t := ui.Transform{}
if flipped.Get() {
t = ui.Transform{Rotate: 6, Scale: 1.04}
}
return ui.Style{Height: ui.Pt(120), CornerRadius: 20, Background: colors.Accent, Transform: t}
}),
})
}进入与退出
Style.Transition 描述的是一个状态:视图在 ui.Animate 期间被添加时(比如 Show 变为 true,或 For 里新增了一项),会从这个状态进入;在 ui.Animate 期间被移除时,会退出到这个状态。被移除的视图会一直留在屏幕上,直到退出动画结束,在此期间它不响应触摸。
ui.TransitionFade:淡入淡出。ui.TransitionPop:淡入的同时从 85% 放大进入(适合弹出框、角标)。ui.TransitionSlideUp:从下方 24 点处淡入(适合 toast、新增的行)。ui.Transition{...}:自定义过渡,包括Fade、Scale(0 表示不缩放),以及以点为单位的偏移X、Y。它们可以组合使用:ui.Transition{Fade: true, Scale: 0.5, Y: -8}。
func Toast() ui.Node {
shown := signal.New(false)
save := func() {
ui.Animate(func() { shown.Set(true) })
ui.Announce("Saved")
go func() {
time.Sleep(1500 * time.Millisecond)
ui.Post(func() { ui.Animate(func() { shown.Set(false) }) })
}()
}
return ui.View(ui.ViewProps{Style: ui.Style{Grow: 1}},
ui.Button(ui.ButtonProps{Title: ui.Val("Save"), OnPress: save}),
ui.Show(shown, func() ui.Node {
return ui.View(ui.ViewProps{Style: ui.Style{Absolute: true, Left: ui.Pt(20), Right: ui.Pt(20), Bottom: ui.Pt(20),
Background: colors.SecondaryBackground, CornerRadius: 16, Padding: ui.Symmetric(14, 18),
Transition: ui.TransitionSlideUp}},
ui.StaticText("Saved"))
}),
)
}过渡只在 ui.Animate 内部运行,并沿用它的时长和曲线。同样的 shown.Set(true) 如果不在 ui.Animate 里,toast 会直接出现。另外,只有父视图已经在屏幕上时,子视图才会以过渡进入。
共享元素
Style.Match 是一个键,让两个视图彼此对应,作用类似 SwiftUI 的 matchedGeometryEffect。在 ui.Animate 期间,如果一个带这个键的视图离开、另一个带同样键的视图出现,新视图就会从旧视图的位置和大小长成自己的样子,旧视图则跟着一起移动并淡出。下面的例子里,一行展开成一张卡片,封面和标题随之移动(取自 examples/gallery):
func Matched() ui.Node {
open := signal.New(false)
toggle := func() {
ui.AnimateWith(ui.Animation{Curve: ui.Spring, Duration: 0.5}, func() { open.Set(!open.Peek()) })
}
art := func(size float32, radius float32) ui.Style {
return ui.Style{Width: ui.Pt(size), Height: ui.Pt(size), CornerRadius: radius, Background: colors.Orange, Match: "album"}
}
return ui.Show(open, func() ui.Node {
return ui.View(ui.ViewProps{OnPress: toggle, Style: ui.Style{Gap: 12, Align: ui.AlignCenter}},
ui.View(ui.ViewProps{Style: art(240, 24)}),
ui.StaticText("Night Drive", ui.Style{Font: fonts.Title2, Match: "album-title"}))
}, func() ui.Node {
return ui.View(ui.ViewProps{OnPress: toggle, Style: ui.Style{Direction: ui.RowDir, Gap: 12, Align: ui.AlignCenter}},
ui.View(ui.ViewProps{Style: art(56, 10)}),
ui.StaticText("Night Drive", ui.Style{Font: fonts.Headline, Match: "album-title"}))
})
}- 所有平台都支持,包括桌面端和 Web。
- 两个视图必须在同一次
ui.Animate中变化:一个被移除,一个被添加。 - 键属于整个根节点:同一时刻,每个键只能有一个视图持有。
- 带键离开却找不到对应视图的视图,会直接淡出。
缩放进入新页面
ScreenOptions.ZoomFrom 指定下层页面上某个视图的 Match 键。新页面推入时会从这个视图中放大展开,弹出时再缩回去。List 的单元格也可以用,比如下面这个照片网格:
func Photos() ui.Node {
ids := []int{10, 11, 12, 13, 14, 15}
return ui.List(ui.ListProps{Columns: 3, Gap: 2}, ui.Val(ids), func(id int) int { return id },
func(id signal.Accessor[int], _ signal.Accessor[int]) ui.Node {
nav := ui.UseNavigator()
return ui.View(ui.ViewProps{
Label: ui.Val("Photo"),
OnPress: func() {
i := signal.Untrack(id.Get)
nav.Push(func() ui.Node { return Photo(i) },
ui.ScreenOptions{Title: ui.Val("Photo"), ZoomFrom: fmt.Sprintf("photo-%d", i)})
},
Style: ui.StyleFunc(func() ui.Style {
return ui.Style{AspectRatio: 1, Match: fmt.Sprintf("photo-%d", id.Get())}
}),
}, ui.Image(ui.ImageProps{
Source: signal.Func(func() string { return fmt.Sprintf("https://picsum.photos/id/%d/300/300", id.Get()) }),
Mode: ui.ImageFill, Style: ui.Style{Grow: 1},
}))
})
}- iOS 18 及以上使用 UIKit 的缩放过渡,交互式返回手势会把页面缩回到那个视图里。更早的版本按普通方式推入。
- Android 使用 Material 的容器变换(container transform):页面从视图处长到自己的边界,带圆角,两者在靠近视图的地方交叉淡化。预测性返回手势会把它缩回去。
- macOS、Windows、Linux 和 Web 按普通方式推入。
- 如果下层页面上没有带这个键的视图,也按普通方式推入。
这里不需要 ui.Animate:推入本身就带动画。详见导航。
自己画出来的图形
Shape 的 TrimStart 和 TrimEnd 决定轮廓绘制出来的那一段,取值从 0 到 1。在 ui.Animate 里修改它们,描边就会一点点画出来,适合做进度环、对勾之类的效果。
func ring(w, h float32) *ui.Path {
r := min(w, h)/2 - 4
return new(ui.Path).Arc(w/2, h/2, r, -math.Pi/2, 3*math.Pi/2, true)
}
func Ring() ui.Node {
done := signal.New(float32(0.3))
return ui.Column(ui.Style{Gap: 12},
ui.Shape(ui.ShapeProps{Path: ring, Stroke: colors.Accent, LineWidth: 8, LineCap: ui.CapRound, TrimEnd: done,
Style: ui.Style{Width: ui.Pt(72), Height: ui.Pt(72)}}),
ui.Button(ui.ButtonProps{Title: ui.Val("Finish"), OnPress: func() {
ui.AnimateWith(ui.Animation{Duration: 0.8}, func() { done.Set(1) })
}}),
)
}逐帧动画
如果运动是算出来的而不是声明出来的(物理模拟、游戏、跟随数值变化的仪表盘),可以用 ui.OnFrame:显示器每绘制一帧(每秒 60 或 120 次),它就调用一次你的函数,并传入这一帧的时间。函数里写入的内容会在这一帧显示出来。它会一直运行,直到你调用它返回的 stop,或者组件的 owner 被销毁。没有注册任何回调时,宿主不会请求帧。
func Spinner() ui.Node {
env := ui.UseEnvironment()
angle := signal.New(float32(0))
var last time.Duration
ui.OnFrame(func(t time.Duration) {
if last != 0 && !env.Get().ReduceMotion {
a := angle.Peek() + float32((t-last).Seconds()*180) // 每秒转半圈
angle.Set(float32(math.Mod(float64(a), 360)))
}
last = t
})
return ui.View(ui.ViewProps{Decorative: true, Style: ui.StyleFunc(func() ui.Style {
return ui.Style{Transform: ui.Transform{Rotate: angle.Get()}}
})}, ui.Symbol(ui.IconSettings, ui.Style{Font: fonts.LargeTitle, Color: colors.Accent}))
}运动要根据两帧之间的时间来计算,而不是根据帧数:不同设备的帧率并不相同。
手势
View 可以接收手势,识别工作交给平台自己的手势识别器:
OnPress和OnLongPress:轻点,以及按住不放。OnPan:拖动。OnPinch:双指张开或捏合。OnRotate:双指旋转。OnSwipe:快速轻扫,并带上方向ui.SwipeDirection:ui.SwipeLeft、ui.SwipeRight、ui.SwipeUp或ui.SwipeDown。
使用鼠标或触控板时,同样这些处理函数会收到桌面端对应的操作:
| 手势 | macOS | Windows | Linux | Web |
|---|---|---|---|---|
OnPress |
单击 | 单击;或在获得焦点的视图上按 Enter、空格 | 单击;或在获得焦点的视图上按 Enter、空格 | 单击;或在获得焦点的视图上按 Enter、空格 |
OnLongPress |
辅助点按,或按住鼠标键不放 | 右键单击,或按住鼠标键不放 | 辅助点击,或按住鼠标键不放 | 右键单击,或按住鼠标键、手指按住不放 |
OnPan |
按住鼠标键拖动 | 按住鼠标键拖动 | 按住鼠标键拖动、在触摸屏或触控板上拖动 | 按下指针拖动 |
OnSwipe |
结束时速度很快的拖动;触控板上的双指轻扫;系统留给应用处理时的三指轻扫 | 结束时速度很快的拖动 | 结束时速度很快的拖动;触摸屏上的轻扫 | 结束时速度很快的拖动 |
OnPinch |
触控板捏合 | 触控板捏合、Ctrl 加滚轮,或触摸屏上的双指操作 | 触控板或触摸屏捏合,或 Ctrl 加滚轮 | 触控板捏合、Ctrl 加滚轮,或触摸屏上的双指操作 |
OnRotate |
触控板旋转 | 触摸屏上的双指旋转 | 触控板或触摸屏旋转 | 触摸屏上的双指旋转 |
很少有人会按住鼠标键不放来看看会发生什么,所以在重要的地方,OnLongPress 的功能也要提供另一个入口,比如一个菜单项或一个按钮。在 Windows 和 Linux 上,Ctrl 加滚轮每滚一格捏合 10%,滚轮停下后捏合就结束。在 Mac 触控板上双指轻扫时,视图外围能滚动的内容照样会滚动:只有外围在那个方向上没有可滚动的内容时,才会触发向上或向下的轻扫。
拖动、捏合和旋转在进行过程中,会不断用一个 ui.Gesture 调用对应的函数:
State:依次为ui.GestureBegan、ui.GestureChanged,最后是ui.GestureEnded;如果触摸被系统接管,则是ui.GestureCancelled。X、Y:触摸点在视图中的位置,单位为点。TranslationX、TranslationY:拖动从手指按下以来移动的距离,因此X - TranslationX就是按下的位置。VelocityX、VelocityY:拖动的速度,单位为点每秒。Scale:捏合开始以来的缩放比例(拖动时为 1)。Rotation:旋转开始以来转过的角度,单位为弧度,顺时针方向。
被拖动的视图通过变换跟随手指,松手后弹回原位:
func Puck() ui.Node {
drag := signal.New(ui.Gesture{})
return ui.View(ui.ViewProps{
OnPan: func(g ui.Gesture) {
if g.State == ui.GestureEnded || g.State == ui.GestureCancelled {
ui.AnimateWith(ui.Animation{Curve: ui.Spring, Duration: 0.45}, func() { drag.Set(ui.Gesture{}) })
return
}
drag.Set(g)
},
Style: ui.StyleFunc(func() ui.Style {
g := drag.Get()
return ui.Style{Width: ui.Pt(64), Height: ui.Pt(64), CornerRadius: 32, Background: colors.Accent,
Transform: ui.Transform{TranslateX: g.TranslationX, TranslateY: g.TranslationY}}
}),
})
}缩放和旋转都是从这次手势开始时算起的,所以要记住上一次手势结束时的值,下一次在它的基础上累加。同一个视图上的多个手势可以同时生效:一边捏合一边旋转。
angle, base := signal.New(float32(0)), float32(0)
scale, baseScale := signal.New(float32(1)), float32(1)
return ui.View(ui.ViewProps{
Label: ui.Val("A square to turn"),
OnRotate: func(g ui.Gesture) {
angle.Set(base + g.Rotation*180/math.Pi)
if g.State == ui.GestureEnded || g.State == ui.GestureCancelled {
base = angle.Peek()
}
},
OnPinch: func(g ui.Gesture) {
scale.Set(min(max(baseScale*g.Scale, 0.5), 2))
if g.State == ui.GestureEnded || g.State == ui.GestureCancelled {
baseScale = scale.Peek()
}
},
Style: ui.StyleFunc(func() ui.Style {
return ui.Style{Width: ui.Pt(160), Height: ui.Pt(160), CornerRadius: 24, Background: colors.Purple,
Transform: ui.Transform{Rotate: angle.Get(), Scale: scale.Get()}}
}),
})拖动、捏合或旋转一旦开始,就独占了这次触摸:外层的滚动视图不会把它抢走,里面可按压的视图也不会收到按压,就像从按钮上开始的拖动不会触发按钮一样。
开着屏幕阅读器时,用户无法完成轻扫或拖动。所以请把它们的功能也作为无障碍操作提供出来。
按下与悬停状态
View 会在交互状态变化时报告出来,样式可以通过三种方式跟随这些状态。从第一种开始,能满足需求就用它:
- 变体(variant),用于视图自身的外观。
Style.Pressed、Hovered、Dragging和DragOver会在视图处于对应状态时叠加在其余样式之上。它们相当于 CSS 的:active和:hover,或者 Tailwind 的active:和hover:。 Interaction,用于其他视图,以及其他任何需要跟随状态的东西。给视图设置一个Interaction,然后就可以在任何需要的地方读取it.Pressed()或it.Hovered(),比如Style.When、StyleFunc、Text或Show。子视图跟随父视图(相当于 group 的 hover)、兄弟视图跟随相邻视图(相当于 peer 的 hover),都是这么实现的。- 处理函数,用于样式之外的事情,比如开始录音:
OnPressedChange和OnHoverChange。
func Card(title string, open func()) ui.Node {
card := ui.NewInteraction()
return ui.View(ui.ViewProps{
OnPress: open,
Interaction: card,
Style: ui.Style{Direction: ui.RowDir, Padding: ui.All(16), CornerRadius: 12,
Background: colors.SecondaryGroupedBackground,
Hovered: &ui.Style{Background: colors.SecondaryBackground},
Pressed: &ui.Style{Background: colors.Separator, Transform: ui.Transform{Scale: 0.97}}},
},
// 标题跟随卡片的悬停状态:相当于 group 的 hover。
ui.StaticText(title, ui.Style{Grow: 1}.When(card.Hovered, ui.Style{Color: colors.Accent})),
)
}各个状态的含义:
- Pressed:手指或鼠标主键在视图上按下时为 true。
- 在可滚动的容器里,它会稍晚一些才变为 true,免得一滚动就闪一下。
- 手指抬起、移出视图(移回来会再次按下),或者触摸被滚动、长按或其他手势接管时,它就会变回 false。
- 只有在按下状态中松开,才会触发
OnPress。
- Hovered:指针位于视图或其子视图上方时为 true。指针可以是鼠标、触控板、iPad 的指针,或者悬停的 Apple Pencil、手写笔。单纯的触摸屏永远不会产生悬停。
- Dragging:视图正在被拖动(通过它的
Drag)时为 true。 - DragOver:有一个会被它的
Drop接收的拖动经过它上方时为 true。
变体和 When 的组合规则:
- 变体只设置它写明的字段,其余字段保持样式原有的值。
Font按字段逐个合并,所以Font{Weight: fonts.Bold}会保留原来的文字样式。其他字段都是整体替换。- 变体按 Hovered、Pressed、Dragging、DragOver 的顺序应用,所以按下状态优先于悬停状态。
When可以链式调用,后面的优先。- 零值字段表示“未设置”,所以某个状态无法清除一个字段。如果想让某样东西只在某个状态下显示,就用
Unless在另一种状态下设置这个字段:
row := ui.NewInteraction()
ui.View(ui.ViewProps{Interaction: row},
ui.Text(name),
// 鼠标悬停在这一行上时,才显示删除按钮。
ui.View(ui.ViewProps{OnPress: remove, Style: ui.Style{}.Unless(row.Hovered, ui.Style{Hidden: true})},
ui.Symbol(ui.IconTrash)),
)When 和 Unless 接受任意 func() bool,所以样式也可以跟随任何 signal:base.When(selected.Get, ui.Style{Background: colors.Accent})。
视图只追踪有人读取的状态,也就是它的变体用到的,或者从它的 Interaction 读取的状态。
- 如果一个视图既没有
Pressed变体,也没有人读取Pressed,它就不会报告按下状态,并保留平台自带的高亮效果。 - 视图一旦报告按下状态,就由它自己负责绘制,平台自带的高亮(Android 的水波纹、Windows 的悬停和按下填充)会被关掉。
每个状态每变化一次只报告一次。视图在某个状态中消失时会退出该状态,因此跟随它的东西不会卡在那里。把同一个 Interaction 交给多个视图时,只要其中任何一个处于某个状态,该状态就为 true。
没有 OnPress 时,按下不放就只是单纯的按住,比如“按住录音”。录音属于副作用,所以要放在处理函数里:
ui.View(ui.ViewProps{
OnPressedChange: func(on bool) {
if on {
recorder.Start()
} else {
recorder.Stop()
}
},
Label: ui.Val("Hold to record"),
Style: ui.Style{Padding: ui.All(16), Pressed: &ui.Style{Background: colors.Red}},
}, ...)拖放
ViewProps.Drag 让视图可以被拖动。在触摸屏上,长按后再移动就能把它拿起来,而且这次长按不会再触发 OnLongPress。用鼠标时,按住鼠标键移动就能拖动它。视图的图像会跟着手指或指针移动,既可以在应用内拖动,也可以拖到其他应用里。ViewProps.Drop 则让视图可以接收拖放。
一个 ui.DragItem 可以是一段文本、一个链接(Kind: ui.SharedLink)、一个文件(Path),或者应用自己的一个值(Value):
- 在应用内部,放下时拿到的就是原样的条目,连同 Go 值一起。
- 其他应用拿到的是条目的文本、链接或文件。只带有值的条目不会传到其他应用。
MIME是条目的类型,拖放目标根据它来决定是否接收。文本、链接和文件会自动填好类型。请给Value设置一个应用专属的类型,这样只有应用自己的目标会接收它。
type Task struct{ Title string; Done bool }
func TaskCard(t *Task) ui.Node {
return ui.View(ui.ViewProps{
Drag: &ui.DragSource{
Items: func() []ui.DragItem {
return []ui.DragItem{{Value: t, MIME: "application/x-myapp-task", Text: t.Title}}
},
},
// 拖动时变淡。
Style: ui.Style{Padding: ui.All(12), Dragging: &ui.Style{Opacity: 0.4}},
}, ui.StaticText(t.Title))
}
func DoneColumn(move func(*Task), cards ...ui.Node) ui.Node {
return ui.View(ui.ViewProps{
Drop: &ui.DropTarget{
Accept: []string{"application/x-myapp-task"},
OnDrop: func(d ui.Drop) {
for _, it := range d.Items {
if t, ok := it.Value.(*Task); ok {
move(t)
}
}
},
},
// 有任务拖到上方时显示描边。
Style: ui.Style{Gap: 8, Padding: ui.All(8), BorderWidth: 2, BorderColor: colors.Clear,
DragOver: &ui.Style{BorderColor: colors.Accent}},
}, cards...)
}拖动源:
Items在一个 effect 里运行,所以它读取的内容变化时会自动更新。一次拖动携带的是开始拖动那一刻的条目。OnPhase在视图被拿起时报告ui.DragBegan。之后要么报告ui.DragDropped,表示有目标接收了它(如果是移动操作,就把被拖动的东西删掉);要么报告ui.DragCancelled。拖动中的外观请用Dragging变体或Interaction.Dragging。
拖放目标:
Accept列出 MIME 类型:可以是精确类型("text/plain")、一类类型("image/*")或任意类型("*/*")。- 如果一次拖动里没有这些类型的条目,它就根本不会进入这个视图。
- 目标嵌套时,由最内层愿意接收的那个目标获得这次拖动。
OnOverChange报告拖动进入和离开视图,与DragOver变体是同一个状态。OnMove报告拖动在视图上的位置,可以当作插入点。OnDrop拿到它接收的条目以及放下的位置。Drop.Local表示这次拖动来自应用自身。
从其他应用拖进来的内容(访达或“文件”App 里的文件、“照片”里的照片、浏览器里的链接),会等条目加载完成后再送达:
- 在 iOS 和 Android 上,文件会先被复制到应用的缓存目录,此时
Drop.Copied为 true。请把文件移到应用存放它们的地方,或者用完后删掉。 - 在 macOS、Windows 和 Linux 上,
Path就是用户的原文件,不要删除它。 - 在浏览器里,放下的文件会被复制到应用的文件中(属于这个页面,存放在内存里),此时
Drop.Copied为 true。
按键
键盘焦点位于某个视图(它本身或它里面的视图)时,这个视图可以通过 Keys 接收按键:Keys 是一个 KeyMap,把 Shortcut 映射到要执行的操作。按键会从获得焦点的视图开始向外查找,交给最近的那个处理它的视图,而且优先于获得焦点的控件和菜单栏。这样,搜索框的上下方向键就可以在下面的结果中移动:
sel := signal.New(0)
move := func(by int) func() {
return func() {
n := len(signal.Untrack(results.Get))
sel.Set(max(0, min(sel.Peek()+by, n-1)))
}
}
ui.TextField(ui.TextFieldProps{
Value: query,
Keys: ui.KeyMap{{Key: "down"}: move(1), {Key: "up"}: move(-1)},
OnSubmit: func() { open(signal.Untrack(results.Get)[sel.Peek()]) },
})一个按键可以是一个字符,也可以是一个具名按键(enter、escape、tab、space、delete、backspace、up、down、left、right、home、end、pageup、pagedown、f1 到 f12),修饰键必须与给定的完全一致:ui.Cmd("d")、{Key: "up", Mods: ui.Alt}。按住不放会重复触发。不带修饰键的字符会截走视图内输入框的输入,所以请给它加上修饰键,或者把它放在没有输入框的地方。
ui.UseKeys(ui.KeyMap{...}) 给窗口设置它自己的按键:按键最后才会到达这里;而在没有任何东西获得焦点时,则最先到达这里,比如游戏的方向键或查看器的空格键。它们的生命周期跟随调用 ui.UseKeys 的那个组件。
按键来自硬件键盘,也就是桌面电脑、iPad 或 Chromebook 上的键盘,以及浏览器收到的键盘输入。不要让按键成为完成某件事的唯一方式,因为手机通常没有键盘。输入法正在组字时(比如日文或中文输入法),按键归输入法处理。examples/launcher 就用上下方向键在搜索结果中移动。
触感反馈
ui.Haptic 播放触感反馈:ui.HapticSelection(数值切换了一格),ui.HapticLight、ui.HapticMedium 和 ui.HapticHeavy(碰撞),以及 ui.HapticSuccess、ui.HapticWarning 和 ui.HapticError(操作结果)。
OnSwipe: func(d ui.SwipeDirection) {
switch d {
case ui.SwipeLeft:
page.Set(page.Peek() + 1)
case ui.SwipeRight:
page.Set(max(page.Peek()-1, 0))
}
ui.Haptic(ui.HapticSelection)
},
OnLongPress: func() { ui.Haptic(ui.HapticMedium) },在 iOS 上,它们对应 UIFeedbackGenerator 的各种反馈。Android 没有完全对应的反馈:Asuka 会从 performHapticFeedback 的常量里挑最接近的来播放。成功、警告和错误在 Android 11 及以上对应 confirm 和 reject,更早的版本则用按键或长按的反馈代替。
在配有 Force Touch 触控板的 Mac 上,ui.HapticSelection 对应 NSHapticFeedbackManager 的 alignment,其他几种对应它的 level change;只有手指放在触控板上时才能感受到。Windows 和 Linux 没有触感反馈,ui.Haptic 在那里什么也不做。在浏览器里,如果浏览器支持 navigator.vibrate(Android 上的 Chrome),它会短促地振动一下,其他浏览器则什么也不做。
减弱动态效果
用户要求减少动态效果时,ui.UseEnvironment().Get().ReduceMotion 为 true。用户更改设置时,它也会随之变化:
- iOS:设置 > 辅助功能 > 动态效果 > 减弱动态效果。
- Android:关闭动画(在开发者选项中,或在无障碍 > 移除动画中)。
- macOS:系统设置 > 辅助功能 > 显示 > 减弱动态效果。
- Windows:设置 > 辅助功能 > 视觉效果,关闭“动画效果”。
- Linux:关闭 GNOME 的动画(设置 > 无障碍 > 减少动画(Reduce Animation);即 GTK 的
gtk-enable-animations)。 - Web:浏览器的
prefers-reduced-motion,它跟随系统设置。
Asuka 自己会做的处理:
- iOS:过渡改为原地溶解,不再移动或缩放,和 UIKit 自己的过渡在这种设置下的表现一致;共享元素(
Match)也不再飞过去,视图按各自的过渡出现和消失。其他动画照常运行。 - Android:系统的动画时长缩放为 0,因此所有动画,无论是 Asuka 的还是平台的,都会立即结束。
- macOS、Windows、Linux 和 Web:
ui.Animate里的变化会立即生效,过渡和共享元素也一样。导航栈的推入和弹出在 macOS 上不再滑动,在浏览器里改为交叉淡化,在 Linux 上则和关闭动画后的 libadwaita 导航表现一致。
剩下需要你自己处理的,是 Asuka 无法判断是否只起装饰作用的动效:OnFrame 循环(就像上面的加载图标那样停下来)、反复播放的脉冲、让某样东西移动很远的动画。读取 ReduceMotion,相应地减少这些动效。