Unverified Commit e867d1c6 authored by Kate Lovett's avatar Kate Lovett Committed by GitHub

Add optional axis specifier to static scrollable methods (#124894)

This is motivated by part of the 2D scrolling proposal: [flutter.dev/go/2D-Foundation](https://flutter.dev/go/2D-Foundation)

This is one of the last little PRs to prep for the 2D scrolling foundation. 
This adds an optional `axis` parameter to the static Scrollable methods `[of, maybeOf, recommendDeferredLoadingForContext]`. This allows developers that are nesting scrollables (or one day using 2D scrolling) to look them up instead by a particular axis.

In general, even outside the context of 2D, I think this is helpful. I am often asked how to get the outer scrollable when nesting. Now it can be done.

There is also a small semantic refactor here in ScrollableState.build, this just creates a private method (_buildChrome) that will be overridden in 2D later. It is easier to add now than in the really big PR that will be.
parent e99eb402
...@@ -300,16 +300,35 @@ class Scrollable extends StatefulWidget { ...@@ -300,16 +300,35 @@ class Scrollable extends StatefulWidget {
/// ScrollableState? scrollable = Scrollable.maybeOf(context); /// ScrollableState? scrollable = Scrollable.maybeOf(context);
/// ``` /// ```
/// ///
/// Calling this method will create a dependency on the closest [Scrollable] /// Calling this method will create a dependency on the [ScrollableState]
/// in the [context], if there is one. /// that is returned, if there is one. This is typically the closest
/// [Scrollable], but may be a more distant ancestor if [axis] is used to
/// target a specific [Scrollable].
///
/// Using the optional [Axis] is useful when Scrollables are nested and the
/// target [Scrollable] is not the closest instance. When [axis] is provided,
/// the nearest enclosing [ScrollableState] in that [Axis] is returned, or
/// null if there is none.
/// ///
/// See also: /// See also:
/// ///
/// * [Scrollable.of], which is similar to this method, but asserts /// * [Scrollable.of], which is similar to this method, but asserts
/// if no [Scrollable] ancestor is found. /// if no [Scrollable] ancestor is found.
static ScrollableState? maybeOf(BuildContext context) { static ScrollableState? maybeOf(BuildContext context, { Axis? axis }) {
final _ScrollableScope? widget = context.dependOnInheritedWidgetOfExactType<_ScrollableScope>(); // This is the context that will need to establish the dependency.
return widget?.scrollable; final BuildContext originalContext = context;
InheritedElement? element = context.getElementForInheritedWidgetOfExactType<_ScrollableScope>();
while (element != null) {
final ScrollableState scrollable = (element.widget as _ScrollableScope).scrollable;
if (axis == null || axisDirectionToAxis(scrollable.axisDirection) == axis) {
// Establish the dependency on the correct context.
originalContext.dependOnInheritedElement(element);
return scrollable;
}
context = scrollable.context;
element = context.getElementForInheritedWidgetOfExactType<_ScrollableScope>();
}
return null;
} }
/// The state from the closest instance of this class that encloses the given /// The state from the closest instance of this class that encloses the given
...@@ -321,8 +340,14 @@ class Scrollable extends StatefulWidget { ...@@ -321,8 +340,14 @@ class Scrollable extends StatefulWidget {
/// ScrollableState scrollable = Scrollable.of(context); /// ScrollableState scrollable = Scrollable.of(context);
/// ``` /// ```
/// ///
/// Calling this method will create a dependency on the closest [Scrollable] /// Calling this method will create a dependency on the [ScrollableState]
/// in the [context]. /// that is returned, if there is one. This is typically the closest
/// [Scrollable], but may be a more distant ancestor if [axis] is used to
/// target a specific [Scrollable].
///
/// Using the optional [Axis] is useful when Scrollables are nested and the
/// target [Scrollable] is not the closest instance. When [axis] is provided,
/// the nearest enclosing [ScrollableState] in that [Axis] is returned.
/// ///
/// If no [Scrollable] ancestor is found, then this method will assert in /// If no [Scrollable] ancestor is found, then this method will assert in
/// debug mode, and throw an exception in release mode. /// debug mode, and throw an exception in release mode.
...@@ -331,20 +356,29 @@ class Scrollable extends StatefulWidget { ...@@ -331,20 +356,29 @@ class Scrollable extends StatefulWidget {
/// ///
/// * [Scrollable.maybeOf], which is similar to this method, but returns null /// * [Scrollable.maybeOf], which is similar to this method, but returns null
/// if no [Scrollable] ancestor is found. /// if no [Scrollable] ancestor is found.
static ScrollableState of(BuildContext context) { static ScrollableState of(BuildContext context, { Axis? axis }) {
final ScrollableState? scrollableState = maybeOf(context); final ScrollableState? scrollableState = maybeOf(context, axis: axis);
assert(() { assert(() {
if (scrollableState == null) { if (scrollableState == null) {
throw FlutterError( throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'Scrollable.of() was called with a context that does not contain a ' 'Scrollable.of() was called with a context that does not contain a '
'Scrollable widget.\n' 'Scrollable widget.',
'No Scrollable widget ancestor could be found starting from the ' ),
'context that was passed to Scrollable.of(). This can happen ' ErrorDescription(
'because you are using a widget that looks for a Scrollable ' 'No Scrollable widget ancestor could be found '
'${axis == null ? '' : 'for the provided Axis: $axis '}'
'starting from the context that was passed to Scrollable.of(). This '
'can happen because you are using a widget that looks for a Scrollable '
'ancestor, but no such ancestor exists.\n' 'ancestor, but no such ancestor exists.\n'
'The context used was:\n' 'The context used was:\n'
' $context', ' $context',
); ),
if (axis != null) ErrorHint(
'When specifying an axis, this method will only look for a Scrollable '
'that matches the given Axis.',
),
]);
} }
return true; return true;
}()); }());
...@@ -362,15 +396,24 @@ class Scrollable extends StatefulWidget { ...@@ -362,15 +396,24 @@ class Scrollable extends StatefulWidget {
/// via [ScrollPhysics.recommendDeferredLoading]. That method is called with /// via [ScrollPhysics.recommendDeferredLoading]. That method is called with
/// the current [ScrollPosition.activity]'s [ScrollActivity.velocity]. /// the current [ScrollPosition.activity]'s [ScrollActivity.velocity].
/// ///
/// The optional [Axis] allows targeting of a specific [Scrollable] of that
/// axis, useful when Scrollables are nested. When [axis] is provided,
/// [ScrollPosition.recommendDeferredLoading] is called for the nearest
/// [Scrollable] in that [Axis].
///
/// If there is no [Scrollable] in the widget tree above the [context], this /// If there is no [Scrollable] in the widget tree above the [context], this
/// method returns false. /// method returns false.
static bool recommendDeferredLoadingForContext(BuildContext context) { static bool recommendDeferredLoadingForContext(BuildContext context, { Axis? axis }) {
final _ScrollableScope? widget = context.getInheritedWidgetOfExactType<_ScrollableScope>(); _ScrollableScope? widget = context.getInheritedWidgetOfExactType<_ScrollableScope>();
if (widget == null) { while (widget != null) {
return false; if (axis == null || axisDirectionToAxis(widget.scrollable.axisDirection) == axis) {
}
return widget.position.recommendDeferredLoading(context); return widget.position.recommendDeferredLoading(context);
} }
context = widget.scrollable.context;
widget = context.getInheritedWidgetOfExactType<_ScrollableScope>();
}
return false;
}
/// Scrolls the scrollables that enclose the given context so as to make the /// Scrolls the scrollables that enclose the given context so as to make the
/// given context visible. /// given context visible.
...@@ -855,6 +898,20 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, R ...@@ -855,6 +898,20 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, R
return false; return false;
} }
Widget _buildChrome(BuildContext context, Widget child) {
final ScrollableDetails details = ScrollableDetails(
direction: widget.axisDirection,
controller: _effectiveScrollController,
decorationClipBehavior: widget.clipBehavior,
);
return _configuration.buildScrollbar(
context,
_configuration.buildOverscrollIndicator(context, child, details),
details,
);
}
// DESCRIPTION // DESCRIPTION
@override @override
...@@ -904,17 +961,7 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, R ...@@ -904,17 +961,7 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, R
); );
} }
final ScrollableDetails details = ScrollableDetails( result = _buildChrome(context, result);
direction: widget.axisDirection,
controller: _effectiveScrollController,
decorationClipBehavior: widget.clipBehavior,
);
result = _configuration.buildScrollbar(
context,
_configuration.buildOverscrollIndicator(context, result, details),
details,
);
// Selection is only enabled when there is a parent registrar. // Selection is only enabled when there is a parent registrar.
final SelectionRegistrar? registrar = SelectionContainer.maybeOf(context); final SelectionRegistrar? registrar = SelectionContainer.maybeOf(context);
......
...@@ -39,7 +39,87 @@ class _ScrollPositionListenerState extends State<ScrollPositionListener> { ...@@ -39,7 +39,87 @@ class _ScrollPositionListenerState extends State<ScrollPositionListener> {
void listener() { void listener() {
widget.log('listener ${_position?.pixels.toStringAsFixed(1)}'); widget.log('listener ${_position?.pixels.toStringAsFixed(1)}');
} }
}
class TestScrollController extends ScrollController {
TestScrollController({ required this.deferLoading });
final bool deferLoading;
@override
ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) {
return TestScrollPosition(
physics: physics,
context: context,
oldPosition: oldPosition,
deferLoading: deferLoading,
);
}
}
class TestScrollPosition extends ScrollPositionWithSingleContext {
TestScrollPosition({
required super.physics,
required super.context,
super.oldPosition,
required this.deferLoading,
});
final bool deferLoading;
@override
bool recommendDeferredLoading(BuildContext context) => deferLoading;
}
class TestScrollable extends StatefulWidget {
const TestScrollable({ super.key, required this.child });
final Widget child;
@override
State<StatefulWidget> createState() => TestScrollableState();
}
class TestScrollableState extends State<TestScrollable> {
int dependenciesChanged = 0;
@override
void didChangeDependencies() {
dependenciesChanged += 1;
super.didChangeDependencies();
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}
class TestChild extends StatefulWidget {
const TestChild({ super.key });
@override
State<TestChild> createState() => TestChildState();
}
class TestChildState extends State<TestChild> {
int dependenciesChanged = 0;
late ScrollableState scrollable;
@override
void didChangeDependencies() {
dependenciesChanged += 1;
scrollable = Scrollable.of(context, axis: Axis.horizontal);
super.didChangeDependencies();
}
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: 1000,
child: Text(scrollable.axisDirection.toString()),
);
}
} }
void main() { void main() {
...@@ -102,4 +182,75 @@ void main() { ...@@ -102,4 +182,75 @@ void main() {
final StatefulElement scrollableElement = find.byType(Scrollable).evaluate().first as StatefulElement; final StatefulElement scrollableElement = find.byType(Scrollable).evaluate().first as StatefulElement;
expect(Scrollable.of(notification.context!), equals(scrollableElement.state)); expect(Scrollable.of(notification.context!), equals(scrollableElement.state));
}); });
testWidgets('Static Scrollable methods can target a specific axis', (WidgetTester tester) async {
final TestScrollController horizontalController = TestScrollController(deferLoading: true);
final TestScrollController verticalController = TestScrollController(deferLoading: false);
late final AxisDirection foundAxisDirection;
late final bool foundRecommendation;
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: horizontalController,
child: SingleChildScrollView(
controller: verticalController,
child: Builder(
builder: (BuildContext context) {
foundAxisDirection = Scrollable.of(
context,
axis: Axis.horizontal,
).axisDirection;
foundRecommendation = Scrollable.recommendDeferredLoadingForContext(
context,
axis: Axis.horizontal,
);
return const SizedBox(height: 1200.0, width: 1200.0);
}
),
),
),
));
await tester.pumpAndSettle();
expect(foundAxisDirection, AxisDirection.right);
expect(foundRecommendation, isTrue);
});
testWidgets('Axis targeting scrollables establishes the correct dependencies', (WidgetTester tester) async {
final GlobalKey<TestScrollableState> verticalKey = GlobalKey<TestScrollableState>();
final GlobalKey<TestChildState> childKey = GlobalKey<TestChildState>();
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: TestScrollable(
key: verticalKey,
child: TestChild(key: childKey),
),
),
));
await tester.pumpAndSettle();
expect(verticalKey.currentState!.dependenciesChanged, 1);
expect(childKey.currentState!.dependenciesChanged, 1);
// Change the horizontal ScrollView, adding a controller
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: ScrollController(),
child: TestScrollable(
key: verticalKey,
child: TestChild(key: childKey),
),
),
));
await tester.pumpAndSettle();
expect(verticalKey.currentState!.dependenciesChanged, 1);
expect(childKey.currentState!.dependenciesChanged, 2);
});
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment