|
| 1 | +import 'dart:math' as math; |
| 2 | + |
| 3 | +import 'package:checks/checks.dart'; |
| 4 | +import 'package:flutter/widgets.dart'; |
| 5 | +import 'package:flutter_test/flutter_test.dart'; |
| 6 | +import 'package:zulip/widgets/sticky_header.dart'; |
| 7 | + |
| 8 | +void main() { |
| 9 | + testWidgets('sticky headers: scroll up, headers bounded by items, semi-explicit version', (tester) async { |
| 10 | + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, |
| 11 | + child: StickyHeaderListView( |
| 12 | + reverse: true, |
| 13 | + children: List.generate(100, (i) => StickyHeaderItem( |
| 14 | + header: _Header(i, height: 20), |
| 15 | + content: _Item(i, height: 80)))))); |
| 16 | + |
| 17 | + void checkState(int index, {required double item, required double header}) => |
| 18 | + _checkHeader(tester, index, first: false, |
| 19 | + item: Offset(0, item), header: Offset(0, header)); |
| 20 | + |
| 21 | + checkState(5, item: 20, header: 0); |
| 22 | + |
| 23 | + await _drag(tester, const Offset(0, 5)); |
| 24 | + checkState(6, item: -75, header: -15); |
| 25 | + |
| 26 | + await _drag(tester, const Offset(0, 75)); |
| 27 | + checkState(6, item: 0, header: 0); |
| 28 | + |
| 29 | + await _drag(tester, const Offset(0, 20)); |
| 30 | + checkState(6, item: 20, header: 0); |
| 31 | + }); |
| 32 | + |
| 33 | + for (final reverse in [true, false]) { |
| 34 | + for (final reverseHeader in [true, false]) { |
| 35 | + final name = 'sticky headers: ' |
| 36 | + 'scroll ${reverse ? 'up' : 'down'}, ' |
| 37 | + 'header at ${reverseHeader ? 'bottom' : 'top'}'; |
| 38 | + testWidgets(name, (tester) => |
| 39 | + _checkSequence(tester, |
| 40 | + Axis.vertical, |
| 41 | + reverse: reverse, |
| 42 | + reverseHeader: reverseHeader, |
| 43 | + )); |
| 44 | + } |
| 45 | + } |
| 46 | + |
| 47 | + for (final reverse in [true, false]) { |
| 48 | + for (final reverseHeader in [true, false]) { |
| 49 | + for (final textDirection in TextDirection.values) { |
| 50 | + final name = 'sticky headers: ' |
| 51 | + '${textDirection.name.toUpperCase()} ' |
| 52 | + 'scroll ${reverse ? 'backward' : 'forward'}, ' |
| 53 | + 'header at ${reverseHeader ? 'end' : 'start'}'; |
| 54 | + testWidgets(name, (tester) => |
| 55 | + _checkSequence(tester, |
| 56 | + Axis.horizontal, textDirection: textDirection, |
| 57 | + reverse: reverse, |
| 58 | + reverseHeader: reverseHeader, |
| 59 | + )); |
| 60 | + } |
| 61 | + } |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +Future<void> _checkSequence( |
| 66 | + WidgetTester tester, |
| 67 | + Axis axis, { |
| 68 | + TextDirection? textDirection, |
| 69 | + bool reverse = false, |
| 70 | + bool reverseHeader = false, |
| 71 | +}) async { |
| 72 | + assert(textDirection != null || axis == Axis.vertical); |
| 73 | + final headerAtCoordinateEnd = switch (axis) { |
| 74 | + Axis.horizontal => reverseHeader ^ (textDirection == TextDirection.rtl), |
| 75 | + Axis.vertical => reverseHeader, |
| 76 | + }; |
| 77 | + |
| 78 | + final controller = ScrollController(); |
| 79 | + await tester.pumpWidget(Directionality( |
| 80 | + textDirection: textDirection ?? TextDirection.rtl, |
| 81 | + child: StickyHeaderListView( |
| 82 | + controller: controller, |
| 83 | + scrollDirection: axis, |
| 84 | + reverse: reverse, |
| 85 | + children: List.generate(100, (i) => StickyHeaderItem( |
| 86 | + direction: switch ((axis, headerAtCoordinateEnd)) { |
| 87 | + (Axis.horizontal, true ) => AxisDirection.left, |
| 88 | + (Axis.horizontal, false) => AxisDirection.right, |
| 89 | + (Axis.vertical, true ) => AxisDirection.up, |
| 90 | + (Axis.vertical, false) => AxisDirection.down, |
| 91 | + }, |
| 92 | + header: _Header(i, height: 20), |
| 93 | + content: _Item(i, height: 80)))))); |
| 94 | + |
| 95 | + final extent = tester.getSize(find.byType(StickyHeaderListView)).onAxis(axis); |
| 96 | + assert(extent % 100 == 0); |
| 97 | + |
| 98 | + final first = !(reverse ^ reverseHeader); |
| 99 | + |
| 100 | + final itemFinder = first ? find.byType(_Item).first : find.byType(_Item).last; |
| 101 | + final headerFinder = first ? find.byType(_Header).first : find.byType(_Header).last; |
| 102 | + |
| 103 | + double insetExtent(Finder finder) { |
| 104 | + return headerAtCoordinateEnd |
| 105 | + ? extent - tester.getTopLeft(finder).inDirection(axis.coordinateDirection) |
| 106 | + : tester.getBottomRight(finder).inDirection(axis.coordinateDirection); |
| 107 | + } |
| 108 | + |
| 109 | + void checkState() { |
| 110 | + final scrollOffset = controller.position.pixels; |
| 111 | + final expectedHeaderIndex = first |
| 112 | + ? (scrollOffset / 100).floor() |
| 113 | + : (extent ~/ 100 - 1) + (scrollOffset / 100).ceil(); |
| 114 | + check(tester.widget<_Item>(itemFinder).index).equals(expectedHeaderIndex); |
| 115 | + check(tester.widget<_Header>(headerFinder).index).equals(expectedHeaderIndex); |
| 116 | + |
| 117 | + final expectedItemInsetExtent = |
| 118 | + 100 - (first ? scrollOffset % 100 : (-scrollOffset) % 100); |
| 119 | + check(insetExtent(itemFinder)).equals(expectedItemInsetExtent); |
| 120 | + check(insetExtent(headerFinder)).equals( |
| 121 | + math.min(20, expectedItemInsetExtent)); |
| 122 | + } |
| 123 | + |
| 124 | + Future<void> jumpAndCheck(double position) async { |
| 125 | + controller.jumpTo(position); |
| 126 | + await tester.pump(); |
| 127 | + checkState(); |
| 128 | + } |
| 129 | + |
| 130 | + checkState(); |
| 131 | + await jumpAndCheck(5); |
| 132 | + await jumpAndCheck(10); |
| 133 | + await jumpAndCheck(20); |
| 134 | + await jumpAndCheck(50); |
| 135 | + await jumpAndCheck(80); |
| 136 | + await jumpAndCheck(90); |
| 137 | + await jumpAndCheck(95); |
| 138 | + await jumpAndCheck(100); |
| 139 | +} |
| 140 | + |
| 141 | +Future<void> _drag(WidgetTester tester, Offset offset) async { |
| 142 | + await tester.drag(find.byType(StickyHeaderListView), offset); |
| 143 | + await tester.pump(); |
| 144 | +} |
| 145 | + |
| 146 | +void _checkHeader( |
| 147 | + WidgetTester tester, |
| 148 | + int index, { |
| 149 | + required bool first, |
| 150 | + required Offset item, |
| 151 | + required Offset header, |
| 152 | +}) { |
| 153 | + final itemFinder = first ? find.byType(_Item).first : find.byType(_Item).last; |
| 154 | + final headerFinder = first ? find.byType(_Header).first : find.byType(_Header).last; |
| 155 | + check(tester.widget<_Item>(itemFinder).index).equals(index); |
| 156 | + check(tester.widget<_Header>(headerFinder).index).equals(index); |
| 157 | + check(tester.getTopLeft(itemFinder)).equals(item); |
| 158 | + check(tester.getTopLeft(headerFinder)).equals(header); |
| 159 | +} |
| 160 | + |
| 161 | +class _Header extends StatelessWidget { |
| 162 | + const _Header(this.index, {required this.height}); |
| 163 | + |
| 164 | + final int index; |
| 165 | + final double height; |
| 166 | + |
| 167 | + @override |
| 168 | + Widget build(BuildContext context) { |
| 169 | + return SizedBox( |
| 170 | + height: height, |
| 171 | + width: height, // TODO clean up |
| 172 | + child: Text("Header $index")); |
| 173 | + } |
| 174 | +} |
| 175 | + |
| 176 | +class _Item extends StatelessWidget { |
| 177 | + const _Item(this.index, {required this.height}); |
| 178 | + |
| 179 | + final int index; |
| 180 | + final double height; |
| 181 | + |
| 182 | + @override |
| 183 | + Widget build(BuildContext context) { |
| 184 | + return SizedBox( |
| 185 | + height: height, |
| 186 | + width: height, |
| 187 | + child: Text("Item $index")); |
| 188 | + } |
| 189 | +} |
0 commit comments