Guides
Motion and gestures
Animations, transitions, shared elements, gestures and haptics.
RyUI animates with each platform's own machinery: UIView animations on
iOS, animators on Android. 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, 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 settles with a little overshoot: UIKit's spring animation on iOS, an overshoot interpolator on Android. It still takesDuration.Repeatruns it again this many times more;ui.Foreverrepeats it until the props change again. WithAutoreverses, each run goes back before the next.
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 repeating animation stops when its props are set again without an animation. On Android, only opacity and transforms repeat; on iOS, everything that animates does.
What animates
| Change | iOS | Android |
|---|---|---|
| Frames (position and size, from layout) | yes | yes |
Opacity |
yes | yes |
Transform |
yes | yes |
Background (a color) |
yes | yes |
CornerRadius |
yes | yes |
A Shape's TrimStart and TrimEnd |
yes | yes |
A Shape's path |
yes | 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"}))
})
}- 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: "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.
- 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.
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 it began.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: "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.
A swipe or a pan can't be done with a screen reader on. Offer what it does as an accessibility action too.
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: RyUI 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.
Reduce Motion
ui.UseEnvironment().Get().ReduceMotion is true when the user asked for
less motion: iOS's Reduce Motion, or Android's animations turned off
(Developer options, or Accessibility, Remove animations). It changes as
the user changes the setting.
What RyUI 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, RyUI's and the platform's, ends at once.
What's left to you is motion RyUI 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.