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" + ); +}