Fix guided-stepper scroll overshoot by waiting out the collapse animation

Scrolling on the next post-frame callback raced the Stepper's own
200ms expand/collapse transition, so ensureVisible measured a layout
that hadn't settled yet — the tapped step ended up hidden under the
fixed Priority/Status/Template bar. Delaying past kThemeAnimationDuration
before scrolling fixes it; verified on-device via ADB screenshots.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-19 11:48:23 +02:00
parent 72100793c6
commit ca93791bf9
2 changed files with 49 additions and 8 deletions

View File

@ -78,6 +78,12 @@ class _NoteEditorState extends State<NoteEditor> {
/// One controller per structured section (keyed by section key). /// One controller per structured section (keyed by section key).
final Map<String, TextEditingController> _section = {}; final Map<String, TextEditingController> _section = {};
/// One key per structured section, used to scroll the newly-active step's
/// content into view after a step change the Stepper reflows height
/// (other steps collapse/expand) without moving the scroll offset, so
/// without this the active step's input can end up hidden off-screen.
final Map<String, GlobalKey> _stepKeys = {};
/// Single field used for the freeform [NoteTemplate.blank] body and for raw /// Single field used for the freeform [NoteTemplate.blank] body and for raw
/// mode of a structured template. /// mode of a structured template.
final TextEditingController _body = TextEditingController(); final TextEditingController _body = TextEditingController();
@ -130,13 +136,38 @@ class _NoteEditorState extends State<NoteEditor> {
return desired; return desired;
} }
/// Ensures a controller exists for every section of [template]. /// Ensures a controller and scroll-into-view key exist for every section
/// of [template].
void _ensureControllers(NoteTemplate template) { void _ensureControllers(NoteTemplate template) {
for (final s in template.sections) { for (final s in template.sections) {
_section.putIfAbsent(s.key, () => TextEditingController()); _section.putIfAbsent(s.key, () => TextEditingController());
_stepKeys.putIfAbsent(s.key, () => GlobalKey());
} }
} }
/// Moves to step [index] and scrolls that step's content into view once the
/// Stepper's own expand/collapse transition has settled. The Stepper
/// animates each step's content height over [kThemeAnimationDuration], so
/// scrolling on the next post-frame callback (before that animation
/// finishes) measures a layout that's still mid-collapse — the scroll lands
/// on a stale position and the active step ends up hidden under the fixed
/// app bar above. Waiting for the animation to finish before measuring
/// fixes that.
Future<void> _goToStep(int index) async {
setState(() => _currentStep = index);
await Future<void>.delayed(kThemeAnimationDuration);
if (!mounted) return;
final key = _stepKeys[_template.sections[index].key];
final stepContext = key?.currentContext;
if (stepContext == null || !stepContext.mounted) return;
await Scrollable.ensureVisible(
stepContext,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
alignment: 0.0,
);
}
void _fillSections(Map<String, String> values) { void _fillSections(Map<String, String> values) {
_ensureControllers(_template); _ensureControllers(_template);
for (final s in _template.sections) { for (final s in _template.sections) {
@ -322,12 +353,12 @@ class _NoteEditorState extends State<NoteEditor> {
child: Stepper( child: Stepper(
currentStep: _currentStep.clamp(0, sections.length - 1), currentStep: _currentStep.clamp(0, sections.length - 1),
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
onStepTapped: (i) => setState(() => _currentStep = i), onStepTapped: _goToStep,
onStepContinue: _currentStep < sections.length - 1 onStepContinue: _currentStep < sections.length - 1
? () => setState(() => _currentStep++) ? () => _goToStep(_currentStep + 1)
: null, : null,
onStepCancel: _currentStep > 0 onStepCancel: _currentStep > 0
? () => setState(() => _currentStep--) ? () => _goToStep(_currentStep - 1)
: null, : null,
controlsBuilder: (context, details) { controlsBuilder: (context, details) {
return Padding( return Padding(
@ -362,7 +393,15 @@ class _NoteEditorState extends State<NoteEditor> {
final controller = _section[section.key]!; final controller = _section[section.key]!;
final hasValue = controller.text.trim().isNotEmpty; final hasValue = controller.text.trim().isNotEmpty;
return Step( return Step(
title: Text(section.isTitle ? 'title' : section.label), // Keyed on the title (not content): ensureVisible aligns this widget's
// top to the viewport's top, so keying the title — which Stepper
// renders *above* content as the tappable heading is what keeps the
// step's own heading on-screen. Keying content alone scrolled the
// heading off the top of the viewport.
title: KeyedSubtree(
key: _stepKeys[section.key],
child: Text(section.isTitle ? 'title' : section.label),
),
state: hasValue state: hasValue
? StepState.complete ? StepState.complete
: (index == _currentStep ? StepState.editing : StepState.indexed), : (index == _currentStep ? StepState.editing : StepState.indexed),
@ -390,7 +429,7 @@ class _NoteEditorState extends State<NoteEditor> {
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
isDense: true, isDense: true,
), ),
// Rebuild so the step's completion tick reflects the new value. // Rebuild so the step's completion tick reflects the value.
onChanged: (_) { onChanged: (_) {
setState(() {}); setState(() {});
_emit(); _emit();

View File

@ -64,9 +64,11 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Back').hitTestable()); // and back await tester.tap(find.text('Back').hitTestable()); // and back
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Jump directly to a step by tapping its header. // Jump directly to a step by tapping its header. _goToStep delays past
// the Stepper's own collapse/expand animation before scrolling, so drain
// that timer rather than a bare pump see CLAUDE.md's Timer pitfall.
await tester.tap(find.text('done')); await tester.tap(find.text('done'));
await tester.pump(); await tester.pump(const Duration(milliseconds: 250));
expect(find.byType(Stepper), findsOneWidget); expect(find.byType(Stepper), findsOneWidget);
}); });