improved quick tab switcher & general tab design/logic;

This commit is contained in:
Fabian Freund
2026-06-12 03:47:20 +02:00
parent 83c78b53d3
commit 3c5f472bdf
35 changed files with 2715 additions and 768 deletions
@@ -0,0 +1,145 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
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<K>({
required ScrollController controller,
required GlobalKey activeChipKey,
required K? activeId,
required List<K> 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]);
}
@@ -19,6 +19,7 @@
*/
import 'package:flutter/material.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
@@ -28,6 +29,7 @@ class UrlListTile extends StatelessWidget {
final Widget? leading;
final Widget? trailing;
final Color? containerColor;
final IconData? containerIcon;
final bool useCustomColor;
final bool showHttpScheme;
final VoidCallback? onTap;
@@ -39,12 +41,14 @@ class UrlListTile extends StatelessWidget {
this.leading,
this.trailing,
this.containerColor,
this.containerIcon,
this.useCustomColor = false,
this.showHttpScheme = true,
this.onTap,
});
static const iconSize = 32.0;
static const _badgeWidth = 56.0;
static const _borderRadius = BorderRadius.all(Radius.circular(12.0));
@override
@@ -75,51 +79,91 @@ class UrlListTile extends StatelessWidget {
child: InkWell(
borderRadius: _borderRadius,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(
left: 12.0,
top: 10.0,
bottom: 10.0,
right: 12.0,
),
child: Row(
children: [
leading ??
RepaintBoundary(child: UrlIcon([uri], iconSize: iconSize)),
const SizedBox(width: 14.0),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3.0),
UriBreadcrumb(
uri: uri,
showHttpScheme: showHttpScheme,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
child: Stack(
children: [
Padding(
padding: EdgeInsets.only(
left: 12.0,
top: 10.0,
bottom: 10.0,
// Reserve room for the trailing badge so content never
// slides underneath it.
right: containerPalette != null ? _badgeWidth + 12.0 : 12.0,
),
child: Row(
children: [
leading ??
RepaintBoundary(
child: UrlIcon([uri], iconSize: iconSize),
),
const SizedBox(width: 14.0),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3.0),
UriBreadcrumb(
uri: uri,
showHttpScheme: showHttpScheme,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (trailing != null) ...[
const SizedBox(width: 8.0),
trailing!,
],
],
),
),
// Stretches to the card height set by the content above without
// the extra layout pass an IntrinsicHeight Row would cost.
if (containerPalette != null)
Positioned(
top: 0.0,
bottom: 0.0,
right: 0.0,
width: _badgeWidth,
child: _ContainerBadge(
palette: containerPalette,
icon: resolveContainerIcon(containerIcon),
),
),
if (trailing != null) ...[
const SizedBox(width: 8.0),
trailing!,
],
],
),
],
),
),
),
);
}
}
/// Trailing accent-coloured strip flush against the card's right edge that
/// surfaces the owning container's identity (its colour and icon).
class _ContainerBadge extends StatelessWidget {
final ContainerColorPalette palette;
final IconData icon;
const _ContainerBadge({required this.palette, required this.icon});
@override
Widget build(BuildContext context) {
return Container(
width: UrlListTile._badgeWidth,
alignment: Alignment.center,
color: palette.accentColor,
child: Icon(icon, size: 22.0, color: palette.onAccentColor),
);
}
}