/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// Keeps the "active" chip of a horizontally-scrolling chip row centered in
/// its viewport, reliably even when that chip is far outside the currently
/// built (lazy) range.
///
/// A plain [Scrollable.ensureVisible] is not enough for these rows: the chips
/// live in a horizontal `ListView.builder`, so an active chip outside the
/// build/cache range has no `BuildContext` and there is nothing to scroll to.
/// Their widths also vary (titles, badges, hierarchy glyphs), so a single
/// index-proportional jump usually lands the target close but not exactly.
///
/// This hook runs a short, bounded converge loop instead. Each pass either:
/// * centers the chip precisely with [Scrollable.ensureVisible] (when the
/// chip is built and therefore has a context), finishing the loop; or
/// * jumps to an index-proportional estimate of the chip's centered offset
/// to pull it into the build/cache range, then retries on the next frame.
///
/// When the estimate is too far off for the chip to build (most likely in the
/// accordion, whose rows mix wide container headers with narrow tab chips),
/// later passes fan out from the estimate in alternating viewport-sized steps
/// so the search window sweeps across the error instead of stalling. The
/// target enters the cache range within a few passes and the final
/// `ensureVisible` snaps it to the exact center — handling the "calculation is
/// a bit off" (variable widths), "doesn't work at all" (metrics not ready on
/// the first frame, e.g. two stacked rows), and far-off-estimate cases.
///
/// The routine re-runs whenever [activeId] changes or its position within
/// [orderedIds] changes (reorders, insertions, container switches), and is
/// suppressed while [isUserScrolling] returns true so it never fights a manual
/// scroll.
void useScrollToActiveChip({
required ScrollController controller,
required GlobalKey activeChipKey,
required K? activeId,
required List orderedIds,
required bool Function() isUserScrolling,
Duration animationDuration = const Duration(milliseconds: 200),
int maxPasses = 8,
}) {
// Index of the active chip drives both the estimate and the effect's
// re-run trigger: a selection change, a reorder, or an insertion that
// shifts the active chip all change this value.
final activeIndex = activeId == null ? -1 : orderedIds.indexOf(activeId);
final totalCount = orderedIds.length;
useEffect(() {
if (activeIndex < 0) return null;
if (isUserScrolling()) return null;
var cancelled = false;
void runPass(int pass) {
if (cancelled || isUserScrolling()) return;
void scheduleNext() {
if (pass >= maxPasses) return;
WidgetsBinding.instance.addPostFrameCallback((_) => runPass(pass + 1));
}
if (!controller.hasClients) {
// Viewport not attached yet (common on the first frame, and worse for
// two stacked rows); wait for it to come up.
scheduleNext();
return;
}
final chipContext = activeChipKey.currentContext;
if (chipContext != null) {
// The chip is built: snap it to the exact center and stop. Any late
// layout shift (image/badge resize) is small enough to ignore.
unawaited(
Scrollable.ensureVisible(
chipContext,
alignment: 0.5,
duration: animationDuration,
curve: Curves.easeInOut,
),
);
return;
}
// The chip is outside the build/cache range, so there is no context to
// center yet. Jump to an estimate of its offset to pull it into range,
// then refine on the next pass once it has been built.
final position = controller.position;
final maxExtent = position.maxScrollExtent;
final viewport = position.viewportDimension;
if (maxExtent <= 0 || totalCount <= 0) {
// Metrics not ready (or everything fits): retry until they settle.
scheduleNext();
return;
}
// Index-proportional estimate of the chip's centered offset, assuming
// roughly uniform widths. Total content spans [0, maxExtent + viewport].
final contentExtent = maxExtent + viewport;
final estimatedCenter = (activeIndex + 0.5) / totalCount * contentExtent;
final estimate = estimatedCenter - viewport / 2;
// Uniform widths are only an approximation — worst for the accordion,
// whose rows mix wide container headers with tab chips. When the first
// estimate doesn't surface the chip, fan out from it in alternating
// directions (pass 2: +1 viewport, pass 3: -1, pass 4: +2, ...) so the
// search window sweeps across the mis-estimate instead of stalling on a
// repeated identical jump. Each step overlaps the lazy cache, so the
// chip is guaranteed to build within a few passes.
final step = pass ~/ 2;
final direction = pass.isOdd ? 1 : -1;
final target = (estimate + direction * step * viewport).clamp(
0.0,
maxExtent,
);
controller.jumpTo(target);
scheduleNext();
}
WidgetsBinding.instance.addPostFrameCallback((_) => runPass(1));
return () => cancelled = true;
}, [activeId, activeIndex, totalCount]);
}