diff --git a/packages/core/lib/src/internal/ops/anchor.dart b/packages/core/lib/src/internal/ops/anchor.dart index 9d5b8f10f..8d13c3e14 100644 --- a/packages/core/lib/src/internal/ops/anchor.dart +++ b/packages/core/lib/src/internal/ops/anchor.dart @@ -28,24 +28,26 @@ class AnchorRegistry { final completer = Completer(); _ensureVisible( id, - bodyItemIndecesFromPreviousRun: const [], completer: completer, curve: curve, duration: duration, jumpCurve: jumpCurve, jumpDuration: jumpDuration, + prevMax: null, + prevMin: null, ); return completer.future; } Future _ensureVisible( String id, { - required List bodyItemIndecesFromPreviousRun, required Completer completer, required Curve curve, required Duration duration, required Curve jumpCurve, required Duration jumpDuration, + required int? prevMax, + required int? prevMin, }) async { final anchor = _anchorById[id]; if (anchor == null) return completer.complete(false); @@ -59,46 +61,48 @@ class AnchorRegistry { )); } - final abii = _indexByAnchor[anchor]; - if (abii == null) return completer.complete(false); - if (_bodyItemIndeces.isEmpty) return completer.complete(false); final current = _bodyItemIndeces.toList(growable: false); - if (listEquals(current, bodyItemIndecesFromPreviousRun)) { - debugPrint('Stopped looking for #$id (possible infinite loop)'); - return completer.complete(false); - } final currentMin = current.reduce(min); final currentMax = current.reduce(max); + if (currentMin == prevMin && currentMax == prevMax) { + return completer.complete(false); + } + final effectiveMin = min(prevMin ?? currentMin, currentMin); + final effectiveMax = max(prevMax ?? currentMax, currentMax); + + final abii = _indexByAnchor[anchor]; + final anchorMin = abii?.min ?? effectiveMin; + final anchorMax = abii?.max ?? effectiveMax; - if (abii.min < currentMin) { - debugPrint('Going up to index=$currentMin looking for #$id'); - final wentUp = await _ensureVisibleContext( + var movedOk = false; + if (anchorMin < effectiveMin) { + movedOk = await _ensureVisibleContext( _bodyItemKeys[currentMin].currentContext, alignment: 0.0, curve: jumpCurve, duration: jumpDuration, ); - if (!wentUp) return completer.complete(false); - } else if (abii.max > currentMax) { - debugPrint('Going down to index=$currentMax looking for #$id'); - final wentDown = await _ensureVisibleContext( + } else if (anchorMax > effectiveMax) { + movedOk = await _ensureVisibleContext( _bodyItemKeys[currentMax].currentContext, alignment: 1.0, curve: jumpCurve, duration: jumpDuration, ); - if (!wentDown) return completer.complete(false); } + if (!movedOk) return completer.complete(false); + WidgetsBinding.instance?.addPostFrameCallback((_) => _ensureVisible( id, - bodyItemIndecesFromPreviousRun: current, completer: completer, curve: curve, duration: duration, jumpCurve: jumpCurve, jumpDuration: jumpDuration, + prevMax: effectiveMax, + prevMin: effectiveMin, )); } @@ -142,7 +146,7 @@ class AnchorRegistry { if (_indexByAnchor[anchor] != null) continue; int? prevMax; - for (var prevIndex = j - 1; prevIndex > 0; prevIndex--) { + for (var prevIndex = j - 1; prevIndex >= 0; prevIndex--) { final prevAnchor = _anchors[prevIndex]; final prevAbii = _indexByAnchor[prevAnchor]; prevMax = prevAbii?.isExact == true ? prevAbii?.max : null; diff --git a/packages/core/test/anchor_test.dart b/packages/core/test/anchor_test.dart index ec1579b3b..f479a6556 100644 --- a/packages/core/test/anchor_test.dart +++ b/packages/core/test/anchor_test.dart @@ -8,10 +8,12 @@ import 'package:golden_toolkit/golden_toolkit.dart'; import '_.dart'; +final _onTapAnchorResults = {}; + void main() async { await loadAppFonts(); - group('build test', () { + group('build tests', () { testWidgets('renders A[name]', (WidgetTester tester) async { final html = 'Foo'; final explained = await explain(tester, html); @@ -76,13 +78,178 @@ void main() async { }); }); + group('tap tests', () { + setUp(() { + _onTapAnchorResults.clear(); + }); + + testWidgets('skips unknown id', (WidgetTester tester) async { + await tester.pumpWidgetBuilder( + _ColumnTestApp(html: 'Tap me'), + ); + + expect(await tapText(tester, 'Tap me'), equals(1)); + await tester.pumpAndSettle(); + expect(_onTapAnchorResults, equals({'foo': false})); + }); + + group('scrolls', () { + testWidgets('Column', (WidgetTester tester) async { + await tester.pumpWidgetBuilder( + _ColumnTestApp(), + surfaceSize: Size(200, 200), + ); + + expect(await tapText(tester, 'Scroll down'), equals(1)); + await tester.pumpAndSettle(); + expect(_onTapAnchorResults, equals({'target': true})); + }); + + testWidgets('ListView', (WidgetTester tester) async { + await tester.pumpWidgetBuilder( + _ListViewTestApp(), + surfaceSize: Size(200, 200), + ); + + expect(await tapText(tester, 'Scroll down'), equals(1)); + await tester.pumpAndSettle(); + expect(_onTapAnchorResults, equals({'target': true})); + }); + + testWidgets('SliverList', (WidgetTester tester) async { + final keyBottom = GlobalKey(); + await tester.pumpWidgetBuilder( + _SliverListTestApp(keyBottom: keyBottom), + surfaceSize: Size(200, 200), + ); + + await tester.scrollUntilVisible(find.byKey(keyBottom), 100); + + expect(await tapText(tester, 'Scroll up'), equals(1)); + await tester.pumpAndSettle(); + expect(_onTapAnchorResults, equals({'target': true})); + }); + }); + + group('ListView', () { + testWidgets('scrolls to DIV', (WidgetTester tester) async { + await tester.pumpWidgetBuilder( + _ListViewTestApp( + html: 'Tap me' + '${htmlAsc * 3}' + '
Foo
', + ), + surfaceSize: Size(200, 200), + ); + + expect(await tapText(tester, 'Tap me'), equals(1)); + await tester.pumpAndSettle(); + expect(_onTapAnchorResults, equals({'div': true})); + }); + + testWidgets('scrolls to SPAN', (WidgetTester tester) async { + await tester.pumpWidgetBuilder( + _ListViewTestApp( + html: 'Tap me' + '${htmlAsc * 10}' + 'Foo', + ), + surfaceSize: Size(200, 200), + ); + + expect(await tapText(tester, 'Tap me'), equals(1)); + await tester.pumpAndSettle(); + expect(_onTapAnchorResults, equals({'span': true})); + }); + + testWidgets('scrolls to SPAN after DIV', (WidgetTester tester) async { + await tester.pumpWidgetBuilder( + _ListViewTestApp( + html: 'Tap me' + '${htmlAsc * 3}' + '
YOLO
' + '${htmlAsc * 3}' + 'Foo', + ), + surfaceSize: Size(200, 200), + ); + + expect(await tapText(tester, 'Tap me'), equals(1)); + await tester.pumpAndSettle(); + expect(_onTapAnchorResults, equals({'span': true})); + }); + + testWidgets('scrolls to SPAN before DIV', (WidgetTester tester) async { + await tester.pumpWidgetBuilder( + _ListViewTestApp( + html: 'Tap me' + '${htmlAsc * 3}' + 'Foo' + '${htmlAsc * 3}' + '
YOLO
' + '${htmlAsc * 3}', + ), + surfaceSize: Size(200, 200), + ); + + expect(await tapText(tester, 'Tap me'), equals(1)); + await tester.pumpAndSettle(); + expect(_onTapAnchorResults, equals({'span': true})); + }); + + testWidgets('scrolls to SPAN between DIVs', (WidgetTester tester) async { + await tester.pumpWidgetBuilder( + _ListViewTestApp( + html: 'Tap me' + '${htmlAsc * 3}' + '
YOLO
' + '${htmlAsc * 3}' + 'Foo' + '${htmlAsc * 3}' + '
YOLO
' + '${htmlAsc * 3}', + ), + surfaceSize: Size(200, 200), + ); + + expect(await tapText(tester, 'Tap me'), equals(1)); + await tester.pumpAndSettle(); + expect(_onTapAnchorResults, equals({'span': true})); + }); + + testWidgets('scrolls up then down', (WidgetTester tester) async { + await tester.pumpWidgetBuilder( + _ListViewTestApp( + html: 'Tap me 1' + '${htmlAsc * 10}' + '
YOLO
' + '${htmlAsc * 10}' + 'Foo' + '

Tap me 2

' + '${htmlAsc * 10}' + 'Foo', + ), + surfaceSize: Size(200, 200), + ); + + expect(await tapText(tester, 'Tap me 1'), equals(1)); + await tester.pumpAndSettle(); + expect(_onTapAnchorResults, equals({'span1': true})); + + expect(await tapText(tester, 'Tap me 2'), equals(1)); + await tester.pumpAndSettle(); + expect(_onTapAnchorResults, equals({'span1': true, 'span2': true})); + }); + }); + }); + final goldenSkip = Platform.isLinux ? null : 'Linux only'; GoldenToolkit.runWithConfiguration( () { group('tap test', () { testGoldens('scrolls down', (WidgetTester tester) async { await tester.pumpWidgetBuilder( - _AnchorTestApp(), + _ColumnTestApp(), wrapper: materialAppWrapper(theme: ThemeData.light()), surfaceSize: Size(200, 200), ); @@ -96,7 +263,7 @@ void main() async { testGoldens('scrolls up', (WidgetTester tester) async { final keyBottom = GlobalKey(); await tester.pumpWidgetBuilder( - _AnchorTestApp(keyBottom: keyBottom), + _ColumnTestApp(keyBottom: keyBottom), wrapper: materialAppWrapper(theme: ThemeData.light()), surfaceSize: Size(200, 200), ); @@ -149,7 +316,7 @@ void main() async { ); } -final _html1 = '''

1

+final htmlAsc = '''

1

12

123

1234

@@ -160,7 +327,7 @@ final _html1 = '''

1

123456789

1234567890

'''; -final _html2 = '''

1234567890

+final htmlDesc = '''

1234567890

123456789

12345678

1234567

@@ -171,25 +338,29 @@ final _html2 = '''

1234567890

12

1

'''; -final html = ''' +final htmlDefault = ''' Scroll down -${_html1 * 3} +${htmlAsc * 3}

--> TARGET <--

-${_html2 * 3} +${htmlDesc * 3} Scroll up '''; -class _AnchorTestApp extends StatelessWidget { +class _ColumnTestApp extends StatelessWidget { + final String? html; final Key? keyBottom; - _AnchorTestApp({Key? key, this.keyBottom}) : super(key: key); + _ColumnTestApp({this.html, Key? key, this.keyBottom}) : super(key: key); @override Widget build(BuildContext _) => Scaffold( body: SingleChildScrollView( child: Column( children: [ - HtmlWidget(html), + HtmlWidget( + html ?? htmlDefault, + factoryBuilder: () => _WidgetFactory(), + ), SizedBox.shrink(key: keyBottom), ], ), @@ -198,26 +369,46 @@ class _AnchorTestApp extends StatelessWidget { } class _ListViewTestApp extends StatelessWidget { - _ListViewTestApp({Key? key}) : super(key: key); + final String? html; + + _ListViewTestApp({this.html, Key? key}) : super(key: key); @override Widget build(BuildContext _) => Scaffold( - body: HtmlWidget(html, renderMode: RenderMode.ListView), + body: HtmlWidget( + html ?? htmlDefault, + factoryBuilder: () => _WidgetFactory(), + renderMode: RenderMode.ListView, + ), ); } class _SliverListTestApp extends StatelessWidget { - final Key keyBottom; + final String? html; + final Key? keyBottom; - _SliverListTestApp({Key? key, required this.keyBottom}) : super(key: key); + _SliverListTestApp({this.html, Key? key, this.keyBottom}) : super(key: key); @override Widget build(BuildContext _) => Scaffold( body: CustomScrollView( slivers: [ - HtmlWidget(html, renderMode: RenderMode.SliverList), + HtmlWidget( + html ?? htmlDefault, + factoryBuilder: () => _WidgetFactory(), + renderMode: RenderMode.SliverList, + ), SliverToBoxAdapter(child: Container(height: 1, key: keyBottom)), ], ), ); } + +class _WidgetFactory extends WidgetFactory { + @override + Future onTapAnchor(String id, EnsureVisible scrollTo) async { + final result = await super.onTapAnchor(id, scrollTo); + _onTapAnchorResults[id] = result; + return result; + } +}