Guides
Motion and gestures
Animations, transitions, shared elements, gestures and haptics.
Asuka animates with each platform's own machinery where it has one:
UIView animations on iOS, animators on Android, AppKit's implicit
animations and Core Animation on macOS, the Web Animations API in a
browser. Windows and GTK have no animations of a view's frame, so the
Windows and Linux hosts step them themselves, each frame, with the same
curves. You change state inside ui.Animate, and what that change moves
on screen animates. This page covers animating changes, transitions in
and out, shared elements, frame-driven animation, gestures with touch,
mouse and trackpad, haptics and Reduce Motion.
Animate a change
Wrap the write in ui.Animate. Whatever the next flush changes then
animates: frames that layout moves, and the visual props listed below.
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: ui.SecondaryBackground}
})}),
)
}The box grows, and everything below it slides down with it: layout runs once, and each frame that changed animates to its new place. You don't animate positions yourself.
The same write without ui.Animate is applied at once. ui.Animate
runs on the main thread, as every signal write does; from a goroutine,
call it inside ui.Post.
Duration, curve and repeat
ui.AnimateWith takes a ui.Animation:
Durationis in seconds; 0 means 0.25.Curveisui.EaseInOut(the default),ui.Linear,ui.EaseIn,ui.EaseOutorui.Spring. A spring is a damped spring that settles by the end ofDuration: UIKit's spring animation on iOS, the same spring's interpolator on Android, Windows, Linux and the web.Dampingis a spring's damping ratio: 1 settles without passing its end, less overshoots and comes back (0 means 0.85, a little).Velocityis its speed at the start, as a fraction of the change per second (UIKit'sinitialSpringVelocity), to carry on a fling.Delaywaits that many seconds before it starts.OnDoneis called once the animation is logically complete (afterDelayandDuration, each repeat): chain the next step from it.- On macOS, AppKit's implicit animations have neither springs nor delays: a bouncy spring overshoots on a curve, and a delay is skipped.
Repeatruns it again this many times more;ui.Foreverrepeats it until the props change again. WithAutoreverses, each run goes back before the next. macOS and Windows don't repeat: the animation runs once.
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: ui.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) } // set without an animation: the pulse stops
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}),
)
}A gesture's end often hands over to a spring. Its velocity, divided by
the distance left to go, is the spring's Velocity, so the view keeps
the speed the finger gave it:
OnPan: func(g ui.Gesture) {
if g.State != ui.GestureEnded {
drag.Set(g) // follows the finger
return
}
d := math.Hypot(float64(g.TranslationX), float64(g.TranslationY))
v := 0.0
if d > 1 { // the speed toward home, along the way back
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{}) })
},Steps that follow each other chain through OnDone; a toast that comes
in, stays, and goes:
ui.AnimateWith(ui.Animation{OnDone: func() {
ui.AnimateWith(ui.Animation{Delay: 1.5}, func() { shown.Set(false) })
}}, func() { shown.Set(true) })A repeating animation stops when its props are set again without an animation. On Android, only opacity and transforms repeat; on iOS, Linux and the web, everything that animates does; macOS and Windows run it once.
What animates
| Change | iOS | Android | macOS | Windows | Linux | Web |
|---|---|---|---|---|---|---|
| Frames (position and size, from layout) | yes | yes | yes | yes | yes | yes |
Opacity |
yes | yes | yes | no | yes | yes |
Transform |
yes | yes | yes | yes | yes | yes |
Background (a color) |
yes | yes | no | no | no | yes |
CornerRadius |
yes | yes | yes | no | no | no |
A Shape's TrimStart and TrimEnd |
yes | yes | yes | no | yes | no |
A Shape's path |
yes | no | yes | no | no | no |
Other props (BorderColor, Shadow, Gradient, text color and fonts)
change at once. A view only animates if it's on screen; views that
appear during ui.Animate don't animate in, unless they have a
transition.
Opacity 0 is the zero value and means opaque, so a view can't animate
to fully transparent by its Opacity. To fade a view out, remove it
with a Fade transition.
Transforms
Style.Transform moves, scales and rotates a view around its center as
it's drawn. Layout doesn't see it: its neighbors stay where they are.
TranslateX,TranslateY: an offset, in points.Scale: both axes (0 means 1);ScaleX,ScaleYset one axis.Rotate: degrees, clockwise.
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: ui.Accent, Transform: t}
}),
})
}Enter and exit
Style.Transition is the state a view comes in from when it's added
during ui.Animate (a Show turning true, an item added to a For),
and goes out to when it's removed during one. A removed view stays on
screen until it has gone out, and takes no touches meanwhile.
ui.Fadefades in and out.ui.Popfades and grows in from 85% (a popover, a badge).ui.SlideUpfades in from 24 points below (a toast, a new row).ui.Transition{...}is your own:Fade,Scale(0: none) and an offsetX,Yin points. They combine: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: ui.SecondaryBackground, CornerRadius: 16, Padding: ui.Symmetric(14, 18),
Transition: ui.SlideUp}},
ui.StaticText("Saved"))
}),
)
}Transitions run only inside ui.Animate, with its duration and curve.
The same shown.Set(true) without it shows the toast at once. A view
only transitions in if its parent is already on screen.
Shared elements
Style.Match is a key a view shares with another, as SwiftUI's
matchedGeometryEffect. When, during ui.Animate, a view with the key
goes and another with it comes, the new one grows from the old one's
place and size into its own, while the old one moves along with it and
fades. Here a row opens into a card, its artwork and title moving with
it (from 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: ui.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: ui.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: ui.Headline, Match: "album-title"}))
})
}- Every platform runs it, desktops and the web included.
- Both views must change in the same
ui.Animate: one removed, one added. - Keys belong to the root: one view at a time has each.
- A view that goes with a key and has no partner fades out.
Zoom into a pushed screen
ScreenOptions.ZoomFrom names the Match key of a view on the screen
below. The pushed screen grows out of that view, and shrinks back into
it when it's popped. It works for a List's cells, as in this grid of
photos:
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 and later use UIKit's zoom transition; the interactive pop gesture shrinks the screen back into the view. Earlier versions push as usual.
- Android runs Material's container transform: the screen grows from the view into its bounds, rounded, the two cross-fading near the view. A predictive back gesture shrinks it back.
- macOS, Windows, Linux and the web push as usual.
- If no view with the key is on the screen below, it pushes as usual.
You don't need ui.Animate for this: pushes animate on their own. See
navigation.
Shapes that draw themselves
A Shape's TrimStart and TrimEnd are the part of its outline
drawn, from 0 to 1. Change them inside ui.Animate and the stroke
draws itself: a progress ring, a check mark.
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: ui.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) })
}}),
)
}Frame by frame
When the motion is computed rather than declared (physics, a game, a
gauge following a value), ui.OnFrame calls a function at each frame
the display draws, 60 or 120 times a second, with the frame's time.
What it writes shows in that frame. It runs until you call the stop
it returns, or until the component's owner is disposed. While no
callback is registered, the host asks for no frames.
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) // half a turn a second
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: ui.LargeTitle, Color: ui.Accent}))
}Base the motion on the time between frames, not on the count of frames: the rate differs between devices.
Gestures
A View takes gestures, recognized by the platform's recognizers:
OnPressandOnLongPress: a tap, and a touch held.OnPan: a drag.OnPinch: two fingers spreading or closing.OnRotate: two fingers turning.OnSwipe: a quick flick, with itsui.SwipeDirection:ui.SwipeLeft,ui.SwipeRight,ui.SwipeUporui.SwipeDown.
With a mouse or a trackpad, the same handlers get what the desktop does for each:
| Gesture | macOS | Windows | Linux | Web |
|---|---|---|---|---|
OnPress |
a click | a click, or Enter or Space on a focused view | a click, or Enter or Space on a focused view | a click, or Enter or Space on a focused view |
OnLongPress |
a secondary click, or the button held | a right-click, or the button held | a secondary click, or the button held | a right-click, or the button or a touch held |
OnPan |
a drag with the button down | a drag with the button down | a drag with the button down, a touch or the touchpad | a drag with the pointer down |
OnSwipe |
a drag that ends fast; a trackpad's two-finger flick; a three-finger swipe, where the system leaves it to apps | a drag that ends fast | a drag that ends fast; a touchscreen's flick | a drag that ends fast |
OnPinch |
a trackpad's pinch | a touchpad's pinch, Ctrl and the wheel, or two fingers on a touch screen | a touchpad's or touch screen's pinch, or Ctrl and the wheel | a trackpad's pinch, Ctrl and the wheel, or two fingers on a touch screen |
OnRotate |
a trackpad's rotation | two fingers turning on a touch screen | a touchpad's or touch screen's rotation | two fingers turning on a touch screen |
Few people hold a mouse button down to see what it does, so give what
OnLongPress does another way in too where it matters: a menu item, a
button. On Windows and Linux, Ctrl and the wheel pinch 10% a notch, and
the pinch ends once the wheel rests. A two-finger flick on a Mac's
trackpad still scrolls what's around the view: it swipes up or down only
where nothing around it scrolls that way.
Pan, pinch and rotate call their function with a ui.Gesture as it goes:
State:ui.GestureBegan,ui.GestureChanged, thenui.GestureEnded, orui.GestureCancelledif the system took the touch away.X,Y: where the touch is, in the view, in points.TranslationX,TranslationY: how far a pan has moved since the finger came down, soX - TranslationXis where it came down.VelocityX,VelocityY: a pan's speed, in points per second.Scale: a pinch's scale since it began (1 for a pan).Rotation: a rotation's angle since it began, in radians, clockwise.
A dragged view follows the finger through its transform, and springs back when it's let go:
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: ui.Accent,
Transform: ui.Transform{TranslateX: g.TranslationX, TranslateY: g.TranslationY}}
}),
})
}Scale and rotation are since the gesture began, so keep where the last one left off to add the next to it. Gestures on the same view work together: pinch and turn at once.
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: ui.Purple,
Transform: ui.Transform{Rotate: angle.Get(), Scale: scale.Get()}}
}),
})Once a pan, pinch or rotation has begun, it has the touch: a scroll view around it doesn't take it over, and a pressable view inside it gets no press, as a drag that starts on a button doesn't press it.
A swipe or a pan can't be done with a screen reader on. Offer what it does as an accessibility action too.
Pressed and hover states
A View reports its interaction states as they change, and a style
can follow them in three ways. Start with the first one that does the
job:
- Variants, for the view's own look.
Style.Pressed,Hovered,DraggingandDragOverare laid over the rest of the style while the view is in that state. They are CSS's:activeand:hover, or Tailwind'sactive:andhover:. - An
Interaction, for other views and anything else that follows the state. Give the view one, then readit.Pressed()orit.Hovered()wherever you need them, such asStyle.When, aStyleFunc, aTextor aShow. This is how a child follows its parent (a group's hover) and a sibling follows its neighbor (a peer's). - Handlers, for what isn't a style, such as starting a recording:
OnPressedChangeandOnHoverChange.
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: ui.SecondaryGroupedBackground,
Hovered: &ui.Style{Background: ui.SecondaryBackground},
Pressed: &ui.Style{Background: ui.Separator, Transform: ui.Transform{Scale: 0.97}}},
},
// The card's hover, followed by its title: a group's hover.
ui.StaticText(title, ui.Style{Grow: 1}.When(card.Hovered, ui.Style{Color: ui.Accent})),
)
}The states:
- Pressed is true while a finger, or the mouse's primary button, is
down on the view.
- In a scrolling container it comes on a moment late, so a scroll doesn't flash it.
- It goes off when the finger lifts, moves off the view (moving back on presses it again), or a scroll, a long press or another gesture takes over the touch.
OnPressfires only for a release while pressed.
- Hovered is true while a pointer is over the view or its children: a mouse, a trackpad, an iPad's pointer, or a hovering Apple Pencil or stylus. A touch screen alone never hovers.
- Dragging is true while the view is being dragged (its
Drag). - DragOver is true while a drag that its
Droptakes is over it.
How variants and When combine:
- A variant sets only the fields it names. The rest stay as the style has them.
Fontis merged field by field, soFont{Weight: ui.Bold}keeps the text style. Every other field is replaced whole.- Variants apply in the order Hovered, Pressed, Dragging, DragOver, so pressed wins over hovered.
Whencalls chain, and later ones win.- A zero field means "not set", so a state can't clear a field. To show
something only in a state, set the field in the other state with
Unless:
row := ui.NewInteraction()
ui.View(ui.ViewProps{Interaction: row},
ui.Text(name),
// The delete button shows while the row is hovered.
ui.View(ui.ViewProps{OnPress: remove, Style: ui.Style{}.Unless(row.Hovered, ui.Style{Hidden: true})},
ui.Symbol(ui.IconTrash)),
)When and Unless take any func() bool, so a style can follow any
signal too: base.When(selected.Get, ui.Style{Background: ui.Accent}).
A view tracks only the states that something reads: its variants, or
reads of its Interaction.
- A view with no
Pressedvariant and no reader ofPresseddoesn't report its pressed state, and keeps the platform's highlight. - Once a view reports its pressed state, it draws its own. The platform's highlight (Android's ripple, Windows' hover and pressed fills) is left off.
Each state is reported once per change. A view that goes away while in
a state leaves it, so nothing that follows it stays stuck. Give the same
Interaction to several views and a state is true while any of them is
in it.
Without OnPress, a press held is a hold, as in hold to record. The
recording is a side effect, so it goes in the handler:
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: ui.Red}},
}, ...)Drag and drop
ViewProps.Drag makes a view draggable. On a touch screen, a long
press then a move lifts it, and that long press doesn't also fire
OnLongPress. With a mouse, a move with the button held drags it. The
view's image follows the finger or pointer, within the app and into
other apps. ViewProps.Drop makes a view take drops.
A ui.DragItem is a text, a link (Kind: ui.SharedLink), a file
(Path), or a value of the app's (Value):
- Inside the app, a drop gets each item as it is, Go value and all.
- Other apps get the item's text, link or file. An item that's only a value goes nowhere else.
MIMEis the item's type, which targets accept it by. Texts, links and files get theirs filled in. Give aValuea type of the app's own, so only its targets take it.
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}}
},
},
// Faded while it's dragged.
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)
}
}
},
},
// Outlined while a task is dragged over it.
Style: ui.Style{Gap: 8, Padding: ui.All(8), BorderWidth: 2, BorderColor: ui.Clear,
DragOver: &ui.Style{BorderColor: ui.Accent}},
}, cards...)
}The drag source:
Itemsruns in an effect, so what it reads keeps it up to date. A drag carries what the items were when it began.OnPhasereportsui.DragBeganas the view lifts. Then it reports eitherui.DragDropped, when a target took it (for a move, remove what was dragged), orui.DragCancelled. For the dragged look, use theDraggingvariant orInteraction.Dragging.
The drop target:
Acceptlists MIME types: exact ("text/plain"), a family ("image/*") or anything ("*/*").- A drag with no item of those types never comes over the view.
- Of nested targets, the innermost one that takes a drag gets it.
OnOverChangereports a drag coming over the view and leaving it, the same state as theDragOvervariant.OnMovereports where the drag is over the view, as an insertion point.OnDropgets the items it takes and where they were dropped.Drop.Localsays the drag came from the app.
Drops from other apps (files from Finder or Files, a photo from Photos, a link from a browser) come once their items are loaded:
- On iOS and Android, files are copied into the app's caches first and
Drop.Copiedis true. Move them where the app keeps them, or remove them when done. - On macOS, Windows and Linux,
Pathis the user's file itself. Don't remove it. - In a browser, a dropped file is copied into the app's files (the
page's, in memory) and
Drop.Copiedis true.
Haptics
ui.Haptic plays feedback: ui.HapticSelection (a value ticked over),
ui.HapticLight, ui.HapticMedium and ui.HapticHeavy (impacts), and
ui.HapticSuccess, ui.HapticWarning and ui.HapticError (an
outcome).
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) },On iOS these are UIFeedbackGenerator's. Android has no exact
equivalents: Asuka plays the closest of performHapticFeedback's
constants, and success, warning and error are confirm and reject from
Android 11; before that, a key press or a long press.
On a Mac with a Force Touch trackpad, ui.HapticSelection is
NSHapticFeedbackManager's alignment and the others its level change;
the trackpad plays them while a finger is on it. Windows and Linux have
no haptics: ui.Haptic does nothing there. In a browser it's a short
vibration where the browser has navigator.vibrate (Chrome on
Android), and nothing elsewhere.
Reduce Motion
ui.UseEnvironment().Get().ReduceMotion is true when the user asked for
less motion. It changes as the user changes the setting:
- iOS: Settings, Accessibility, Motion, Reduce Motion.
- Android: animations turned off (Developer options, or Accessibility, Remove animations).
- macOS: System Settings, Accessibility, Display, Reduce motion.
- Windows: Settings, Accessibility, Visual effects, Animation effects off.
- Linux: GNOME's animations off (Settings, Accessibility, Reduce
Animation; GTK's
gtk-enable-animations). - The web: the browser's
prefers-reduced-motion, which follows the system's setting.
What Asuka does about it on its own:
- iOS: transitions dissolve in place instead of moving or scaling,
as UIKit's own do then, and shared elements (
Match) don't fly: the views come and go with their transitions. Other animations run. - Android: the system's animator scale is 0, so every animation, Asuka's and the platform's, ends at once.
- macOS, Windows, Linux and the web:
ui.Animate's changes apply at once, transitions and shared elements included. A stack pushes and pops without sliding on macOS, cross-fades in a browser, and on Linux does as libadwaita's navigation does with animations off.
What's left to you is motion Asuka can't tell is decorative: an OnFrame
loop (as the spinner above stops), a repeating pulse, an animation that
moves something far. Read ReduceMotion and do less.