Adds animateToItem to the CarouselController (#162694)
Closes #161368 This PR adds the `animateToItem` method to the `CarouselController`, enabling smooth, index-based navigation for carousels with fixed or dynamically-sized items (via `flexWeights`). https://github.com/user-attachments/assets/c0fe375a-5495-4d56-8b2d-0bd6d0bd0639 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
parent
fef2adca24
commit
40e4c5eed1
@ -1547,6 +1547,63 @@ class CarouselController extends ScrollController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Animates the controlled carousel to the given item index.
|
||||||
|
///
|
||||||
|
/// For [CarouselView], this will scroll the carousel so the item at [index] becomes
|
||||||
|
/// the leading item.
|
||||||
|
///
|
||||||
|
/// If the [index] is less than 0, the carousel will scroll to the first item.
|
||||||
|
/// If the [index] is greater than the number of items, the carousel will scroll
|
||||||
|
/// to the last item.
|
||||||
|
///
|
||||||
|
/// For [CarouselView.weighted], animates to make the item at [index] occupy the primary,
|
||||||
|
/// most prominent position determined by the largest weight in `flexWeights`.
|
||||||
|
///
|
||||||
|
/// The animation uses the provided [Duration] and [Curve]. The returned [Future]
|
||||||
|
/// completes when the animation finishes.
|
||||||
|
///
|
||||||
|
/// The [Duration] defaults to 300 milliseconds and [Curve] defaults to [Curves.ease].
|
||||||
|
///
|
||||||
|
/// Does nothing if the carousel is not attached to this controller.
|
||||||
|
Future<void> animateToItem(
|
||||||
|
int index, {
|
||||||
|
Duration duration = const Duration(milliseconds: 300),
|
||||||
|
Curve curve = Curves.ease,
|
||||||
|
}) async {
|
||||||
|
if (!hasClients || _carouselState == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool hasFlexWeights = _carouselState!._flexWeights?.isNotEmpty ?? false;
|
||||||
|
index = index.clamp(0, _carouselState!.widget.children.length - 1);
|
||||||
|
|
||||||
|
await Future.wait<void>(<Future<void>>[
|
||||||
|
for (final _CarouselPosition position in positions.cast<_CarouselPosition>())
|
||||||
|
position.animateTo(
|
||||||
|
_getTargetOffset(position, index, hasFlexWeights),
|
||||||
|
duration: duration,
|
||||||
|
curve: curve,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _getTargetOffset(_CarouselPosition position, int index, bool hasFlexWeights) {
|
||||||
|
if (!hasFlexWeights) {
|
||||||
|
return index * _carouselState!._itemExtent!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final _CarouselViewState carouselState = _carouselState!;
|
||||||
|
final List<int> weights = carouselState._flexWeights!;
|
||||||
|
final int totalWeight = weights.reduce((int a, int b) => a + b);
|
||||||
|
final double dimension = position.viewportDimension;
|
||||||
|
|
||||||
|
final int maxWeightIndex = weights.indexOf(weights.max);
|
||||||
|
int leadingIndex = carouselState._consumeMaxWeight ? index : index - maxWeightIndex;
|
||||||
|
leadingIndex = leadingIndex.clamp(0, _carouselState!.widget.children.length - 1);
|
||||||
|
|
||||||
|
return dimension * (weights.first / totalWeight) * leadingIndex;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ScrollPosition createScrollPosition(
|
ScrollPosition createScrollPosition(
|
||||||
ScrollPhysics physics,
|
ScrollPhysics physics,
|
||||||
|
@ -1432,6 +1432,201 @@ void main() {
|
|||||||
|
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('CarouselController.animateToItem', () {
|
||||||
|
testWidgets('CarouselView.weighted horizontal, not reversed, flexWeights [7,1]', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
await runCarouselTest(
|
||||||
|
tester: tester,
|
||||||
|
flexWeights: <int>[7, 1],
|
||||||
|
numberOfChildren: 20,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
reverse: false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('CarouselView.weighted horizontal, reversed, flexWeights [7,1]', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
await runCarouselTest(
|
||||||
|
tester: tester,
|
||||||
|
flexWeights: <int>[7, 1],
|
||||||
|
numberOfChildren: 20,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
reverse: true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('CarouselView.weighted vertical, not reversed, flexWeights [7,1]', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
await runCarouselTest(
|
||||||
|
tester: tester,
|
||||||
|
flexWeights: <int>[7, 1],
|
||||||
|
numberOfChildren: 20,
|
||||||
|
scrollDirection: Axis.vertical,
|
||||||
|
reverse: false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('CarouselView.weighted vertical, reversed, flexWeights [7,1]', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
await runCarouselTest(
|
||||||
|
tester: tester,
|
||||||
|
flexWeights: <int>[7, 1],
|
||||||
|
numberOfChildren: 20,
|
||||||
|
scrollDirection: Axis.vertical,
|
||||||
|
reverse: true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'CarouselView.weighted horizontal, not reversed, flexWeights [1,7] and consumeMaxWeight false',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
await runCarouselTest(
|
||||||
|
tester: tester,
|
||||||
|
flexWeights: <int>[1, 7],
|
||||||
|
numberOfChildren: 20,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
reverse: false,
|
||||||
|
consumeMaxWeight: false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('CarouselView.weighted horizontal, reversed, flexWeights [1,7]', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
await runCarouselTest(
|
||||||
|
tester: tester,
|
||||||
|
flexWeights: <int>[1, 7],
|
||||||
|
numberOfChildren: 20,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
reverse: true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'CarouselView.weighted vertical, not reversed, flexWeights [1,7] and consumeMaxWeight false',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
await runCarouselTest(
|
||||||
|
tester: tester,
|
||||||
|
flexWeights: <int>[1, 7],
|
||||||
|
numberOfChildren: 20,
|
||||||
|
scrollDirection: Axis.vertical,
|
||||||
|
consumeMaxWeight: false,
|
||||||
|
reverse: false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'CarouselView.weighted vertical, reversed, flexWeights [1,7] and consumeMaxWeight false',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
await runCarouselTest(
|
||||||
|
tester: tester,
|
||||||
|
flexWeights: <int>[1, 7],
|
||||||
|
numberOfChildren: 20,
|
||||||
|
scrollDirection: Axis.vertical,
|
||||||
|
consumeMaxWeight: false,
|
||||||
|
reverse: true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'CarouselView.weighted vertical, reversed, flexWeights [1,7] and consumeMaxWeight',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
await runCarouselTest(
|
||||||
|
tester: tester,
|
||||||
|
flexWeights: <int>[1, 7],
|
||||||
|
numberOfChildren: 20,
|
||||||
|
scrollDirection: Axis.vertical,
|
||||||
|
reverse: true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('CarouselView horizontal, not reversed', (WidgetTester tester) async {
|
||||||
|
await runCarouselTest(
|
||||||
|
tester: tester,
|
||||||
|
numberOfChildren: 20,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
reverse: false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('CarouselView horizontal, reversed', (WidgetTester tester) async {
|
||||||
|
await runCarouselTest(
|
||||||
|
tester: tester,
|
||||||
|
numberOfChildren: 10,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
reverse: true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('CarouselView vertical, not reversed', (WidgetTester tester) async {
|
||||||
|
await runCarouselTest(
|
||||||
|
tester: tester,
|
||||||
|
numberOfChildren: 10,
|
||||||
|
scrollDirection: Axis.vertical,
|
||||||
|
reverse: false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('CarouselView vertical, reversed', (WidgetTester tester) async {
|
||||||
|
await runCarouselTest(
|
||||||
|
tester: tester,
|
||||||
|
numberOfChildren: 10,
|
||||||
|
scrollDirection: Axis.vertical,
|
||||||
|
reverse: true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('CarouselView positions items correctly', (WidgetTester tester) async {
|
||||||
|
const int numberOfChildren = 5;
|
||||||
|
final CarouselController controller = CarouselController();
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: CarouselView.weighted(
|
||||||
|
flexWeights: const <int>[2, 3, 1],
|
||||||
|
controller: controller,
|
||||||
|
itemSnapping: true,
|
||||||
|
children: List<Widget>.generate(numberOfChildren, (int index) {
|
||||||
|
return Center(child: Text('Item $index'));
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Get the RenderBox of the CarouselView to determine its position and boundaries.
|
||||||
|
final RenderBox carouselBox = tester.renderObject(find.byType(CarouselView));
|
||||||
|
final Offset carouselPos = carouselBox.localToGlobal(Offset.zero);
|
||||||
|
final double carouselLeft = carouselPos.dx;
|
||||||
|
final double carouselRight = carouselLeft + carouselBox.size.width;
|
||||||
|
|
||||||
|
for (int i = 0; i < numberOfChildren; i++) {
|
||||||
|
controller.animateToItem(i, curve: Curves.easeInOut);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Item $i'), findsOneWidget);
|
||||||
|
|
||||||
|
// Get the item's RenderBox and determine its position.
|
||||||
|
final RenderBox itemBox = tester.renderObject(find.text('Item $i'));
|
||||||
|
final Rect itemRect = itemBox.localToGlobal(Offset.zero) & itemBox.size;
|
||||||
|
|
||||||
|
// Validate that the item is positioned within the CarouselView boundaries.
|
||||||
|
expect(itemRect.left, greaterThanOrEqualTo(carouselLeft));
|
||||||
|
expect(itemRect.right, lessThanOrEqualTo(carouselRight));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Finder getItem(int index) {
|
Finder getItem(int index) {
|
||||||
@ -1450,3 +1645,77 @@ Future<TestGesture> hoverPointerOverCarouselItem(WidgetTester tester, Key key) a
|
|||||||
await gesture.moveTo(center);
|
await gesture.moveTo(center);
|
||||||
return gesture;
|
return gesture;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> runCarouselTest({
|
||||||
|
required WidgetTester tester,
|
||||||
|
List<int> flexWeights = const <int>[],
|
||||||
|
bool consumeMaxWeight = true,
|
||||||
|
required int numberOfChildren,
|
||||||
|
required Axis scrollDirection,
|
||||||
|
required bool reverse,
|
||||||
|
}) async {
|
||||||
|
final CarouselController controller = CarouselController();
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body:
|
||||||
|
(flexWeights.isEmpty)
|
||||||
|
? CarouselView(
|
||||||
|
scrollDirection: scrollDirection,
|
||||||
|
reverse: reverse,
|
||||||
|
controller: controller,
|
||||||
|
itemSnapping: true,
|
||||||
|
itemExtent: 300,
|
||||||
|
children: List<Widget>.generate(numberOfChildren, (int index) {
|
||||||
|
return Center(child: Text('Item $index'));
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: CarouselView.weighted(
|
||||||
|
flexWeights: flexWeights,
|
||||||
|
scrollDirection: scrollDirection,
|
||||||
|
reverse: reverse,
|
||||||
|
controller: controller,
|
||||||
|
itemSnapping: true,
|
||||||
|
consumeMaxWeight: consumeMaxWeight,
|
||||||
|
children: List<Widget>.generate(numberOfChildren, (int index) {
|
||||||
|
return Center(child: Text('Item $index'));
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
double realOffset() {
|
||||||
|
return tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the index of the middle item.
|
||||||
|
// The calculation depends on the scroll direction (normal or reverse).
|
||||||
|
// For reverse scrolling, the middle item is calculated taking into account the end of the list,
|
||||||
|
// reversing the calculation so that the item that appears in the middle when scrolling is the correct one.
|
||||||
|
// For normal scrolling, we simply get the middle item.
|
||||||
|
final int middleIndex =
|
||||||
|
reverse
|
||||||
|
? (numberOfChildren - 1 - (numberOfChildren / 2).round())
|
||||||
|
: (numberOfChildren / 2).round();
|
||||||
|
|
||||||
|
controller.animateToItem(
|
||||||
|
middleIndex,
|
||||||
|
duration: const Duration(milliseconds: 100),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify that the middle item is visible.
|
||||||
|
expect(find.text('Item $middleIndex'), findsOneWidget);
|
||||||
|
expect(realOffset(), controller.offset);
|
||||||
|
|
||||||
|
// Scroll to the first item.
|
||||||
|
controller.animateToItem(0, duration: const Duration(milliseconds: 100), curve: Curves.easeInOut);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify that the first item is visible.
|
||||||
|
expect(find.text('Item 0'), findsOneWidget);
|
||||||
|
expect(realOffset(), controller.offset);
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user