iris: drive animation from painter frame time

This commit is contained in:
iris committed 2026-09-12 21:37:47 -04:00
1 parent 5f4975285d
commit eb30a01f2d
14 files changed
+148 -116

No files matched your search

+12 -26
View File
@@ -26,7 +26,6 @@ pub struct UiData {
pub text: Rc<RefCell<TextResources>>,
pub masks: TrackedArena<Mask, u32>,
pub move_offsets: TrackedArena<MoveOffset, u32>,
animating: Vec<WidgetId>,
}
#[derive(Clone)]
@@ -169,40 +168,27 @@ impl DerefMut for Ui {
}
}
impl UiData {
/// Ask for `id`'s [`crate::Widget::tick`] to run every frame until it
/// says it is done. Idempotent -- registering an already-animating
/// widget is the ordinary case (a second fling before the first
/// settled) and must not tick it twice per frame.
pub fn animate(&mut self, id: WidgetId) {
if !self.animating.contains(&id) {
self.animating.push(id);
}
}
pub fn tick_animations(&mut self, now: std::time::Instant) -> bool {
let mut registered = std::mem::take(&mut self.animating);
registered.retain(|&id| match self.widgets.get_dyn_mut(id) {
Some(widget) => widget.tick(now),
None => false,
});
for id in registered {
self.animate(id);
}
!self.animating.is_empty()
}
}
pub trait UiRsc {
fn ui(&self) -> &Ui;
fn ui_mut(&mut self) -> &mut Ui;
fn draw<'a>(&mut self, root: impl Into<Option<&'a crate::StrongWidget>>)
where
Self: Sized,
{
self.draw_at(root, std::time::Instant::now());
}
fn draw_at<'a>(
&mut self,
root: impl Into<Option<&'a crate::StrongWidget>>,
frame_time: std::time::Instant,
) -> bool
where
Self: Sized,
{
let render_state = self.ui().render_state.clone();
render_state.get_mut().update(root, self);
render_state.get_mut().update_at(root, self, frame_time)
}
#[allow(unused_variables)]
+16 -1
View File
@@ -9,7 +9,7 @@ use crate::{
ui::render_state::Retained,
util::Vec2,
};
use std::{cell::RefCell, rc::Rc};
use std::{cell::RefCell, rc::Rc, time::Instant};
pub struct Painter<'a> {
pub(super) render_state: &'a mut UiRenderState,
@@ -52,6 +52,21 @@ impl DrawResult<'_, '_> {
}
impl<'a> Painter<'a> {
/// The presentation time of this frame, supplied by the platform. On a
/// backend with a display clock this is the vsync timestamp, not the time
/// at which this widget happened to be drawn.
pub fn frame_time(&self) -> Instant {
self.render_state.frame_time
}
/// Redraw this widget on the following frame and ask the platform to
/// produce that frame. The invalidation is staged until the current
/// retained redraw has finished, so it cannot be consumed again in this
/// frame.
pub fn request_next_frame(&mut self) {
self.render_state.next_frame.insert(self.id);
}
pub fn set_size(&mut self, size: Size) {
assert!(
self.size.replace(size).is_none(),
+26
View File
@@ -45,6 +45,11 @@ pub struct UiRenderState {
/// guard could never fire and the set grew by one entry per widget
/// ever drawn and was never emptied.
draw_started: HashSet<WidgetId>,
/// Widgets which asked from inside `draw` to be drawn on the following
/// frame. Kept separate from `Widgets::needs_redraw` until `update_at`
/// finishes because `redraw_updates` drains that set to completion.
pub(super) next_frame: HashSet<WidgetId>,
pub(super) frame_time: Instant,
/// `Widget::draw` calls and `Primitives::region_mut` rewrites since the
/// last `take_counters`. LAYOUT.md section 8's pass conditions are
@@ -111,6 +116,8 @@ impl UiRenderState {
old_root: None,
resized: false,
draw_started: Default::default(),
next_frame: Default::default(),
frame_time: Instant::now(),
draw_count: 0,
region_mut_count: 0,
mov_count: 0,
@@ -225,6 +232,17 @@ impl UiRenderState {
}
pub fn update<'a>(&mut self, root: impl Into<Option<&'a StrongWidget>>, rsc: &mut dyn UiRsc) {
self.update_at(root, rsc, Instant::now());
}
/// Draw a frame dated on the platform's presentation clock. Returns
/// whether a widget requested another frame.
pub fn update_at<'a>(
&mut self,
root: impl Into<Option<&'a StrongWidget>>,
rsc: &mut dyn UiRsc,
frame_time: Instant,
) -> bool {
// safety mechanism for memory leaks; might wanna return a result instead so user can
// decide whether to panic or not
if !rsc.widgets().waiting.is_empty() {
@@ -241,6 +259,7 @@ impl UiRenderState {
weak widgets: {all:#?}"
);
}
self.frame_time = frame_time;
let root = root.into();
debug_assert!(
self.draw_started.is_empty(),
@@ -270,6 +289,13 @@ impl UiRenderState {
self.apply_free();
#[cfg(debug_assertions)]
debug_assert!(self.primitive_counts_agree(), "{}", self.orphan_report(rsc),);
let next: Vec<_> = std::mem::take(&mut self.next_frame)
.into_iter()
.filter(|id| self.active.contains_key(id))
.collect();
let requested = !next.is_empty();
rsc.widgets_mut().needs_redraw.extend(next);
requested
}
pub fn epoch(&self) -> Instant {
-5
View File
@@ -47,11 +47,6 @@ pub trait Widget: Any {
fn child_order(&self) -> ChildOrder {
ChildOrder::Draw
}
#[allow(unused_variables)]
fn tick(&mut self, now: std::time::Instant) -> bool {
false
}
}
impl Widget for () {
+4 -3
View File
@@ -356,13 +356,14 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
);
}
let frame_start = Instant::now();
let animating = self.rsc.ui_mut().tick_animations(now);
let ui_state = self.state.android_state_mut();
let animating = self.rsc.draw_at(&ui_state.root, now);
// Ask before renderer update and swapchain acquisition: the latter
// may block until near the next presentation boundary.
if animating {
ctx.view.post_frame_callback(&mut ctx.env);
}
let ui_state = self.state.android_state_mut();
self.rsc.draw(&ui_state.root);
let ui_state = self.state.android_state_mut();
let Some(renderer) = &mut ui_state.renderer else {
return;
};
+3 -3
View File
@@ -255,14 +255,14 @@ impl<State: DesktopAppState> AppState for DesktopApp<State> {
match &event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::RedrawRequested => {
// Advance animations before drawing and keep requesting frames while active.
let frame_start = std::time::Instant::now();
let animating = rsc.ui_mut().tick_animations(frame_start);
let ui_state = state.desktop_state_mut();
let animating = rsc.draw_at(&ui_state.root, frame_start);
// Ask before renderer update and swapchain acquisition: the latter
// may block until near the next presentation boundary.
if animating {
ui_state.window.request_redraw();
}
rsc.draw(&ui_state.root);
ui_state.renderer.update(&mut rsc.ui);
let mut parts = ui_state.renderer.draw();
parts.total = frame_start.elapsed();
+3 -4
View File
@@ -198,8 +198,8 @@ impl Harness {
}
/// The `Instant` this harness means by `t_ms`. Public because a
/// caller driving `ScrollController::tick` or `DragGesture` by hand needs
/// to date those calls on the same clock the touch samples use.
/// caller driving a `DragGesture` by hand needs to date those calls on the
/// same clock the touch samples and frame painter use.
pub fn at(&self, t_ms: u64) -> Instant {
self.base + Duration::from_millis(t_ms)
}
@@ -218,8 +218,7 @@ impl Harness {
}
let now = self.at(t_ms);
let at = Instant::now();
let animating = self.rsc.ui.tick_animations(now);
self.rsc.draw(&self.state.root);
let animating = self.rsc.draw_at(&self.state.root, now);
// No GPU here, so there is nothing to acquire and nothing to
// submit: the frame is all `build`, which is honest rather than
// zero-filled (`FrameParts::whole`). `layout`/`redraw`/
+43
View File
@@ -69,6 +69,23 @@ struct CountedLeaf {
draws: Rc<Cell<u32>>,
}
struct FrameRequester {
draws: Rc<Cell<u32>>,
seen_time: Rc<Cell<Option<std::time::Instant>>>,
request: bool,
}
impl Widget for FrameRequester {
fn draw(&mut self, painter: &mut Painter) {
self.draws.set(self.draws.get() + 1);
self.seen_time.set(Some(painter.frame_time()));
if self.request {
painter.request_next_frame();
}
painter.set_size(Size::ZERO);
}
}
impl Widget for CountedLeaf {
fn draw(&mut self, painter: &mut Painter) {
self.draws.set(self.draws.get() + 1);
@@ -93,6 +110,32 @@ impl Widget for TracedParent {
}
}
#[test]
fn a_next_frame_request_is_staged_after_the_current_redraw() {
let mut rsc = TestRsc { ui: Ui::default() };
let draws = Rc::new(Cell::new(0));
let seen_time = Rc::new(Cell::new(None));
let widget = rsc.ui.widgets.add_strong(FrameRequester {
draws: draws.clone(),
seen_time: seen_time.clone(),
request: true,
});
let weak = widget.weak();
let root = widget.any();
let mut render = UiRenderState::new();
let frame_time = std::time::Instant::now();
assert!(render.update_at(&root, &mut rsc, frame_time));
assert_eq!(draws.get(), 1, "the request was consumed in the same frame");
assert_eq!(seen_time.get(), Some(frame_time));
assert!(rsc.ui.widgets.needs_redraw.contains(&weak.id()));
rsc.ui.widgets.get_mut(&weak).unwrap().request = false;
assert!(!render.update_at(&root, &mut rsc, frame_time));
assert_eq!(draws.get(), 2);
assert!(!rsc.ui.widgets.needs_redraw.contains(&weak.id()));
}
#[test]
fn a_widget_retains_its_entry_layer_not_its_child_cursor() {
let mut rsc = TestRsc { ui: Ui::default() };
+8 -10
View File
@@ -1344,8 +1344,8 @@ impl FlingCalculator {
}
/// The signed distance covered by `elapsed` into a fling of this
/// `velocity` -- what a per-frame ticker (`ScrollController::tick`) calls to
/// find how far to have scrolled by now. Clamped to the full
/// `velocity` -- what a scrolling widget uses to find how far it should
/// have moved by this frame. Clamped to the full
/// `distance()` once `elapsed` reaches `duration()`, so a caller need
/// not special-case "past the end."
pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 {
@@ -1373,7 +1373,7 @@ impl FlingCalculator {
/// go, are the caller's -- a `LazySpan` scrolls its anchor one way and a
/// `ScrollArea` moves its `amt` the other, and a `Flinger` that tried to know
/// which would have to be told, which is the same thing as not knowing.
/// So a caller applies [`Self::tick`]'s delta in its own convention and
/// So a caller applies [`Self::advance`]'s delta in its own convention and
/// calls [`Self::stop`] when it runs out of content.
pub struct Flinger {
fling: Option<InFlight>,
@@ -1382,7 +1382,7 @@ pub struct Flinger {
struct InFlight {
calc: FlingCalculator,
velocity: f32,
/// When the curve begins -- **the first [`Flinger::tick`], not the
/// When the curve begins -- **the first [`Flinger::advance`], not the
/// release**. Set there so the only clock this reads is the one its
/// driver hands it: a caller running frames on an explicit clock
/// (`iris::harness`, `bench_client.rs`'s scripted phases) would
@@ -1406,15 +1406,13 @@ impl Flinger {
}
/// Start a fling at `velocity_px_per_s`, in whatever pixel space the
/// caller applies [`Self::tick`]'s delta in. `density` is physical
/// caller applies [`Self::advance`]'s delta in. `density` is physical
/// pixels per dp, from the painter -- it does **not** cancel out of
/// the spline (see [`FlingCalculator`]), and a hardcoded 1.0 against a
/// 2.55-density screen made a one-second coast run for 45.
///
/// Answers whether a fling actually started, which is a caller's cue
/// to register for frames (`UiData::animate`): registering a widget
/// that is not animating only asks the next frame to find that out.
/// Cancels any fling already in progress.
/// Cancels any fling already in progress. A widget which owns one advances
/// it during `draw` and asks its painter for the following frame.
///
/// Compose's two thresholds at a release, and **only** those two. The
/// maximum is `ViewConfiguration.getScaledMaximumFlingVelocity()`
@@ -1476,7 +1474,7 @@ impl Flinger {
/// itself on the spline's own schedule, after which
/// [`Self::is_flinging`] is false and the caller stops asking for
/// frames.
pub fn tick(&mut self, now: Instant) -> f32 {
pub fn advance(&mut self, now: Instant) -> f32 {
let Some(f) = &mut self.fling else {
return 0.0;
};
+4 -6
View File
@@ -616,8 +616,9 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
let mut c = cursor_at((50.0, y).into());
c.buttons.left = button;
c.time = base + std::time::Duration::from_millis(at_ms);
let frame_time = c.time;
render.run_sensors(rsc, state, c, win);
render.update(&root, rsc);
render.update_at(&root, rsc, frame_time);
};
sample(
&mut render,
@@ -645,8 +646,6 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
"the flick must leave the top area coasting -- the press below is \
only dangerous while something is still moving",
);
let flung_to = rsc.ui.widgets.get(&top_w).unwrap().amt();
t += 8;
sample(
&mut render,
@@ -672,9 +671,8 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
"the area actually under the finger should have panned by the 40px \
past the slop, got {moved}"
);
assert_eq!(
rsc.ui.widgets.get(&top_w).unwrap().amt(),
flung_to,
assert!(
rsc.ui.widgets.get(&top_w).unwrap().is_scrolling(),
"the area the pointer had left must not have seen the press at all -- \
a catch would have stopped its fling on the touch-down"
);
+13 -23
View File
@@ -1,7 +1,6 @@
use crate::prelude::*;
use iris_core::util::HashMap;
use std::collections::VecDeque;
use std::time::Instant;
pub type RowKey = u64;
@@ -745,15 +744,13 @@ impl Widget for LazySpan {
ChildOrder::Axis(self.dir.axis)
}
/// A lazy span animates exactly one thing, its fling -- and it drives
/// its own rather than being handed deltas by a `ScrollArea` around
/// it, since which rows exist at all is a function of where it is
/// scrolled to and a moved lump would never update them.
fn tick(&mut self, now: Instant) -> bool {
self.tick_fling(now)
}
fn draw(&mut self, painter: &mut Painter) {
// A lazy span drives its own fling because which rows exist is a
// function of where it is scrolled to; it cannot be moved as one
// retained lump by a `ScrollArea` around it.
if self.ctl.advance(painter.frame_time()) {
painter.request_next_frame();
}
let axis = self.dir.axis;
let output_len = painter.output_size().axis(axis);
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
@@ -1529,14 +1526,11 @@ mod tests {
fn fling_frame(
rsc: &mut TestRsc,
scroll: &WeakWidget<LazySpan>,
root: &StrongWidget,
render: &mut UiRenderState,
now: Instant,
) -> bool {
let still = rsc.ui.widgets.get_mut(scroll).unwrap().tick(now);
render.update(root, rsc);
still
render.update_at(root, rsc, now)
}
#[test]
@@ -1552,7 +1546,7 @@ mod tests {
let mut still = true;
for step in 0..600 {
let now = start + std::time::Duration::from_millis(step * 16);
still = fling_frame(&mut rsc, &scroll, &root, &mut render, now);
still = fling_frame(&mut rsc, &root, &mut render, now);
if !still {
break;
}
@@ -1566,24 +1560,20 @@ mod tests {
}
#[test]
fn a_registered_fling_is_driven_by_tick_animations_and_then_unregisters() {
fn a_fling_requests_frames_until_it_settles() {
let mut rsc = TestRsc { ui: Ui::default() };
let (scroll, root, mut render) = build_flingable_list(&mut rsc);
let list_weak = scroll;
let before = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
if rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0) {
let id = scroll.id();
rsc.ui.animate(id);
}
assert!(rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0));
let start = Instant::now();
let mut steps = 0;
let mut animating = true;
while animating && steps < 600 {
let now = start + std::time::Duration::from_millis(steps * 16);
animating = rsc.ui.tick_animations(now);
render.update(&root, &mut rsc);
animating = render.update_at(&root, &mut rsc, now);
steps += 1;
}
assert!(!animating, "the driver never stopped within 600 frames");
@@ -1595,7 +1585,7 @@ mod tests {
);
let now = start + std::time::Duration::from_millis(steps * 16);
assert!(!rsc.ui.tick_animations(now));
assert!(!render.update_at(&root, &mut rsc, now));
}
#[test]
@@ -1650,7 +1640,7 @@ mod tests {
let start = Instant::now();
for step in 0..2000 {
let now = start + std::time::Duration::from_millis(step * 16);
if !fling_frame(&mut rsc, &scroll, &root, &mut render, now) {
if !fling_frame(&mut rsc, &root, &mut render, now) {
break;
}
}
+6 -14
View File
@@ -1,5 +1,4 @@
use crate::prelude::*;
use std::time::Instant;
pub struct ScrollArea {
inner: StrongWidget,
@@ -19,11 +18,10 @@ impl Scrollable for ScrollArea {
}
impl Widget for ScrollArea {
fn tick(&mut self, now: Instant) -> bool {
self.tick_fling(now)
}
fn draw(&mut self, painter: &mut Painter) {
if self.ctl.advance(painter.frame_time()) {
painter.request_next_frame();
}
let axis = self.ctl.axis();
let container_len = painter.px_size().axis(axis);
self.container_len = container_len;
@@ -87,7 +85,7 @@ mod tests {
use super::*;
use crate::layout_tests::TestRsc;
use crate::sense::{CursorButton, DRAG_SLOP, PointerRequests};
use std::time::Duration;
use std::time::{Duration, Instant};
fn area() -> (Fixture, WidgetId) {
area_on(Axis::Y)
@@ -146,9 +144,7 @@ mod tests {
}
fn fling_frame(&mut self, now: Instant) -> bool {
let still = self.get().tick(now);
self.draw();
still
self.render.update_at(&self.root, &mut self.rsc, now)
}
}
@@ -158,11 +154,7 @@ mod tests {
fn drag(f: &mut Fixture, id: WidgetId, sense: CursorSense, pos: Vec2, t: Instant) {
let pointer = PointerRequests::default();
let flung = f.get().drag(&pointer, id, sense, pos, t);
if flung {
let id = f.area.id();
f.rsc.ui.animate(id);
}
f.get().drag(&pointer, id, sense, pos, t);
f.draw();
}
+8 -17
View File
@@ -159,9 +159,9 @@ impl ScrollController {
}
/// Start a fling at `velocity`, in [`Self::scroll`]'s direction
/// convention. Answers whether one actually started, which is the
/// caller's cue to register the widget for frames (`UiData::animate`).
/// Cancels any fling already in progress.
/// convention, cancelling any fling already in progress. Answers whether
/// one actually started. The owning widget advances it from `draw` and
/// requests another frame while it remains active.
pub fn fling(&mut self, velocity: f32) -> bool {
self.fling.start(velocity, self.density)
}
@@ -189,8 +189,8 @@ impl ScrollController {
self.fling.velocity()
}
pub fn tick(&mut self, now: Instant) -> bool {
let delta = self.fling.tick(now);
pub(crate) fn advance(&mut self, now: Instant) -> bool {
let delta = self.fling.advance(now);
self.scroll(delta);
self.fling.is_flinging()
}
@@ -210,9 +210,8 @@ impl ScrollController {
/// this takes the gesture over. Android's own `EditText` behaves the
/// same way -- a vertical drag scrolls, and only a long press selects.
///
/// Answers whether this frame *started a fling*, which is the caller's
/// cue to register the widget for frames (`UiData::animate`) -- see
/// [`Self::fling`].
/// Answers whether this frame started a fling, for callers that need to
/// distinguish a release which began coasting from one which did not.
pub fn drag(
&mut self,
pointer: &PointerRequests,
@@ -306,10 +305,6 @@ pub trait Scrollable {
fn set_pinned_to_end(&mut self, pinned: bool) {
self.controller_mut().set_pinned_to_end(pinned);
}
fn tick_fling(&mut self, now: Instant) -> bool {
self.controller_mut().tick(now)
}
}
pub fn scroll_senses<Rsc, Tag, W, WL>(w: WL, axis: Axis) -> impl WidgetIdFn<Rsc, W>
@@ -325,11 +320,7 @@ where
.on(CursorSense::drag(axis), |ctx, rsc: &mut Rsc| {
let id = ctx.widget.id();
let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos);
let flung = ctx
.widget(rsc)
ctx.widget(rsc)
.drag(ctx.data.pointer, id, sense, pos, ctx.data.cursor.time);
if flung {
rsc.ui_mut().animate(id);
}
})
}
+2 -4
View File
@@ -432,10 +432,8 @@ impl SelectionController {
SelectionInput::Handled
}
GestureOutcome::Released(Some(velocity)) => {
if let Some(scroll) = self.scroll
&& scroll(rsc).fling(velocity)
{
rsc.ui_mut().animate(scroll.id());
if let Some(scroll) = self.scroll {
scroll(rsc).fling(velocity);
}
SelectionInput::Handled
}