Asuka
Motion and gestures

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:

  • Duration is in seconds; 0 means 0.25.
  • Curve is ui.EaseInOut (the default), ui.Linear, ui.EaseIn, ui.EaseOut or ui.Spring. A spring is a damped spring that settles by the end of Duration: UIKit's spring animation on iOS, the same spring's interpolator on Android, Windows, Linux and the web.
  • Damping is a spring's damping ratio: 1 settles without passing its end, less overshoots and comes back (0 means 0.85, a little). Velocity is its speed at the start, as a fraction of the change per second (UIKit's initialSpringVelocity), to carry on a fling.
  • Delay waits that many seconds before it starts.
  • OnDone is called once the animation is logically complete (after Delay and Duration, 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.
  • Repeat runs it again this many times more; ui.Forever repeats it until the props change again. With Autoreverses, 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, ScaleY set 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.Fade fades in and out.
  • ui.Pop fades and grows in from 85% (a popover, a badge).
  • ui.SlideUp fades in from 24 points below (a toast, a new row).
  • ui.Transition{...} is your own: Fade, Scale (0: none) and an offset X, Y in 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:

  • OnPress and OnLongPress: a tap, and a touch held.
  • OnPan: a drag.
  • OnPinch: two fingers spreading or closing.
  • OnRotate: two fingers turning.
  • OnSwipe: a quick flick, with its ui.SwipeDirection: ui.SwipeLeft, ui.SwipeRight, ui.SwipeUp or ui.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, then ui.GestureEnded, or ui.GestureCancelled if 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, so X - TranslationX is 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:

  1. Variants, for the view's own look. Style.Pressed, Hovered, Dragging and DragOver are laid over the rest of the style while the view is in that state. They are CSS's :active and :hover, or Tailwind's active: and hover:.
  2. An Interaction, for other views and anything else that follows the state. Give the view one, then read it.Pressed() or it.Hovered() wherever you need them, such as Style.When, a StyleFunc, a Text or a Show. This is how a child follows its parent (a group's hover) and a sibling follows its neighbor (a peer's).
  3. Handlers, for what isn't a style, such as starting a recording: OnPressedChange and 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: 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.
    • OnPress fires 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 Drop takes is over it.

How variants and When combine:

  • A variant sets only the fields it names. The rest stay as the style has them.
  • Font is merged field by field, so Font{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.
  • When calls 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 Pressed variant and no reader of Pressed doesn'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.
  • MIME is the item's type, which targets accept it by. Texts, links and files get theirs filled in. Give a Value a 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:

  • Items runs in an effect, so what it reads keeps it up to date. A drag carries what the items were when it began.
  • OnPhase reports ui.DragBegan as the view lifts. Then it reports either ui.DragDropped, when a target took it (for a move, remove what was dragged), or ui.DragCancelled. For the dragged look, use the Dragging variant or Interaction.Dragging.

The drop target:

  • Accept lists 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.
  • OnOverChange reports a drag coming over the view and leaving it, the same state as the DragOver variant.
  • OnMove reports where the drag is over the view, as an insertion point.
  • OnDrop gets the items it takes and where they were dropped. Drop.Local says 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.Copied is true. Move them where the app keeps them, or remove them when done.
  • On macOS, Windows and Linux, Path is 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.Copied is 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.