mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 11:43:10 +02:00
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:
parent
72100793c6
commit
ca93791bf9
@ -78,6 +78,12 @@ class _NoteEditorState extends State<NoteEditor> {
|
||||
/// One controller per structured section (keyed by section key).
|
||||
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
|
||||
/// mode of a structured template.
|
||||
final TextEditingController _body = TextEditingController();
|
||||
@ -130,13 +136,38 @@ class _NoteEditorState extends State<NoteEditor> {
|
||||
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) {
|
||||
for (final s in template.sections) {
|
||||
_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) {
|
||||
_ensureControllers(_template);
|
||||
for (final s in _template.sections) {
|
||||
@ -322,12 +353,12 @@ class _NoteEditorState extends State<NoteEditor> {
|
||||
child: Stepper(
|
||||
currentStep: _currentStep.clamp(0, sections.length - 1),
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
onStepTapped: (i) => setState(() => _currentStep = i),
|
||||
onStepTapped: _goToStep,
|
||||
onStepContinue: _currentStep < sections.length - 1
|
||||
? () => setState(() => _currentStep++)
|
||||
? () => _goToStep(_currentStep + 1)
|
||||
: null,
|
||||
onStepCancel: _currentStep > 0
|
||||
? () => setState(() => _currentStep--)
|
||||
? () => _goToStep(_currentStep - 1)
|
||||
: null,
|
||||
controlsBuilder: (context, details) {
|
||||
return Padding(
|
||||
@ -362,7 +393,15 @@ class _NoteEditorState extends State<NoteEditor> {
|
||||
final controller = _section[section.key]!;
|
||||
final hasValue = controller.text.trim().isNotEmpty;
|
||||
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
|
||||
? StepState.complete
|
||||
: (index == _currentStep ? StepState.editing : StepState.indexed),
|
||||
@ -390,7 +429,7 @@ class _NoteEditorState extends State<NoteEditor> {
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
// Rebuild so the step's completion tick reflects the new value.
|
||||
// Rebuild so the step's completion tick reflects the value.
|
||||
onChanged: (_) {
|
||||
setState(() {});
|
||||
_emit();
|
||||
|
||||
@ -64,9 +64,11 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Back').hitTestable()); // and back
|
||||
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.pump();
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
expect(find.byType(Stepper), findsOneWidget);
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user