From 872587836c408d92ce75be9817cea642c133d527 Mon Sep 17 00:00:00 2001 From: Ammar Abou Zor <35040531+AmmarAbouZor@users.noreply.github.com> Date: Tue, 26 May 2026 16:01:55 +0200 Subject: [PATCH] Fix `ScrollArea::scroll_to_*` calls when `stick_to_bottom` is Active (#8033) * Closes #8032 * [x] I have followed the instructions in the PR template It includes: * Fix for `ScrollArea` when `scroll_to_*` could be ignored when `stick_to_bottom(true)` was active and the viewport was already stuck to the bottom. * The fix is by making explicit per-axis scroll movement take priority over sticky-end snapping for that frame, and avoid immediately re-marking animated scrolls as still stuck. * I've also added a regression test for this issue to ensure it will be caught on further code changes. The code snippets form the original issue can be used for testing here as well Co-authored-by: Emil Ernerfeldt --- crates/egui/src/containers/scroll_area.rs | 29 +++++++++++---- tests/egui_tests/tests/regression_tests.rs | 41 ++++++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index b99bcf5da..66d77615f 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -1062,6 +1062,8 @@ impl Prepared { .ctx() .pass_state_mut(|state| std::mem::take(&mut state.scroll_delta)); + let mut had_explicit_scroll_adjustment = Vec2b::FALSE; + for d in 0..2 { // PassState::scroll_delta is inverted from the way we apply the delta, so we need to negate it. let mut delta = -scroll_delta.0[d]; @@ -1133,6 +1135,10 @@ impl Prepared { ui.request_repaint(); } } + + if delta != 0.0 { + had_explicit_scroll_adjustment[d] = true; + } } // Restore scroll target meant for ScrollAreas up the stack (if any) @@ -1234,8 +1240,10 @@ impl Prepared { // Paint the bars: let scroll_bar_rect = scroll_bar_rect.unwrap_or(inner_rect); for d in 0..2 { - // maybe force increase in offset to keep scroll stuck to end position - if stick_to_end[d] && state.scroll_stuck_to_end[d] { + // maybe force increase in offset to keep scroll stuck to end position, + // unless this axis had an explicit scroll adjustment. + if stick_to_end[d] && state.scroll_stuck_to_end[d] && !had_explicit_scroll_adjustment[d] + { state.offset[d] = content_size[d] - inner_rect.size()[d]; } @@ -1487,16 +1495,25 @@ impl Prepared { state.offset = state.offset.min(available_offset); state.offset = state.offset.max(Vec2::ZERO); + let suppress_stuck_recompute = Vec2b::new( + had_explicit_scroll_adjustment[0] && state.offset_target[0].is_some(), + had_explicit_scroll_adjustment[1] && state.offset_target[1].is_some(), + ); + // Is scroll handle at end of content, or is there no scrollbar // yet (not enough content), but sticking is requested? If so, enter sticky mode. // Only has an effect if stick_to_end is enabled but we save in // state anyway so that entering sticky mode at an arbitrary time // has appropriate effect. + // Keep explicit target requests from being reclassified as "still stuck" in the same + // frame, otherwise animated scroll-to requests never get a chance to pull away from the end. state.scroll_stuck_to_end = Vec2b::new( - (state.offset[0] == available_offset[0]) - || (self.stick_to_end[0] && available_offset[0] < 0.0), - (state.offset[1] == available_offset[1]) - || (self.stick_to_end[1] && available_offset[1] < 0.0), + !suppress_stuck_recompute[0] + && ((state.offset[0] == available_offset[0]) + || (stick_to_end[0] && available_offset[0] < 0.0)), + !suppress_stuck_recompute[1] + && ((state.offset[1] == available_offset[1]) + || (stick_to_end[1] && available_offset[1] < 0.0)), ); state.show_scroll = show_scroll_this_frame; diff --git a/tests/egui_tests/tests/regression_tests.rs b/tests/egui_tests/tests/regression_tests.rs index f32ff7ff3..4b19768f2 100644 --- a/tests/egui_tests/tests/regression_tests.rs +++ b/tests/egui_tests/tests/regression_tests.rs @@ -407,3 +407,44 @@ fn horizontal_wrapped_multiline_row_height_reference() { harness.snapshot("horizontal_wrapped_multiline_row_height_reference"); } + +#[test] +fn animated_scroll_beats_sticky_bottom() { + let mut harness = Harness::builder() + .with_size((200.0, 120.0)) + .with_max_steps(8) + .build_ui_state( + |ui, state: &mut (bool, f32, f32)| { + ui.style_mut().scroll_animation = ScrollAnimation::duration(0.5); + + let output = ScrollArea::vertical() + .max_height(60.0) + .stick_to_bottom(true) + .animated(true) + .show(ui, |ui| { + for row in 0..40 { + let response = ui.label(format!("Row {row}")); + if state.0 && row == 0 { + response.scroll_to_me(Some(Align::TOP)); + state.0 = false; + } + } + }); + + state.1 = output.state.offset.y; + state.2 = (output.content_size.y - output.inner_rect.height()).max(0.0); + }, + (false, 0.0, 0.0), + ); + + assert!((harness.state().1 - harness.state().2).abs() <= 1.0); + + harness.state_mut().0 = true; + harness.step(); + harness.run(); + + assert!( + harness.state().1 + 1.0 < harness.state().2, + "animated explicit scroll should leave the sticky bottom" + ); +}