1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:math' as math;
6
7import 'package:flutter/gestures.dart' show DragStartBehavior;
8import 'package:flutter/rendering.dart';
9
10import 'basic.dart';
11import 'focus_manager.dart';
12import 'focus_scope.dart';
13import 'framework.dart';
14import 'notification_listener.dart';
15import 'primary_scroll_controller.dart';
16import 'scroll_controller.dart';
17import 'scroll_notification.dart';
18import 'scroll_physics.dart';
19import 'scroll_view.dart';
20import 'scrollable.dart';
21
22/// A box in which a single widget can be scrolled.
23///
24/// This widget is useful when you have a single box that will normally be
25/// entirely visible, for example a clock face in a time picker, but you need to
26/// make sure it can be scrolled if the container gets too small in one axis
27/// (the scroll direction).
28///
29/// It is also useful if you need to shrink-wrap in both axes (the main
30/// scrolling direction as well as the cross axis), as one might see in a dialog
31/// or pop-up menu. In that case, you might pair the [SingleChildScrollView]
32/// with a [ListBody] child.
33///
34/// When you have a list of children and do not require cross-axis
35/// shrink-wrapping behavior, for example a scrolling list that is always the
36/// width of the screen, consider [ListView], which is vastly more efficient
37/// than a [SingleChildScrollView] containing a [ListBody] or [Column] with
38/// many children.
39///
40/// ## Sample code: Using [SingleChildScrollView] with a [Column]
41///
42/// Sometimes a layout is designed around the flexible properties of a
43/// [Column], but there is the concern that in some cases, there might not
44/// be enough room to see the entire contents. This could be because some
45/// devices have unusually small screens, or because the application can
46/// be used in landscape mode where the aspect ratio isn't what was
47/// originally envisioned, or because the application is being shown in a
48/// small window in split-screen mode. In any case, as a result, it might
49/// make sense to wrap the layout in a [SingleChildScrollView].
50///
51/// Doing so, however, usually results in a conflict between the [Column],
52/// which typically tries to grow as big as it can, and the [SingleChildScrollView],
53/// which provides its children with an infinite amount of space.
54///
55/// To resolve this apparent conflict, there are a couple of techniques, as
56/// discussed below. These techniques should only be used when the content is
57/// normally expected to fit on the screen, so that the lazy instantiation of a
58/// sliver-based [ListView] or [CustomScrollView] is not expected to provide any
59/// performance benefit. If the viewport is expected to usually contain content
60/// beyond the dimensions of the screen, then [SingleChildScrollView] would be
61/// very expensive (in which case [ListView] may be a better choice than
62/// [Column]).
63///
64/// ### Centering, spacing, or aligning fixed-height content
65///
66/// If the content has fixed (or intrinsic) dimensions but needs to be spaced out,
67/// centered, or otherwise positioned using the [Flex] layout model of a [Column],
68/// the following technique can be used to provide the [Column] with a minimum
69/// dimension while allowing it to shrink-wrap the contents when there isn't enough
70/// room to apply these spacing or alignment needs.
71///
72/// A [LayoutBuilder] is used to obtain the size of the viewport (implicitly via
73/// the constraints that the [SingleChildScrollView] sees, since viewports
74/// typically grow to fit their maximum height constraint). Then, inside the
75/// scroll view, a [ConstrainedBox] is used to set the minimum height of the
76/// [Column].
77///
78/// The [Column] has no [Expanded] children, so rather than take on the infinite
79/// height from its [BoxConstraints.maxHeight], (the viewport provides no maximum height
80/// constraint), it automatically tries to shrink to fit its children. It cannot
81/// be smaller than its [BoxConstraints.minHeight], though, and It therefore
82/// becomes the bigger of the minimum height provided by the
83/// [ConstrainedBox] and the sum of the heights of the children.
84///
85/// If the children aren't enough to fit that minimum size, the [Column] ends up
86/// with some remaining space to allocate as specified by its
87/// [Column.mainAxisAlignment] argument.
88///
89/// {@tool dartpad}
90/// In this example, the children are spaced out equally, unless there's no more
91/// room, in which case they stack vertically and scroll.
92///
93/// When using this technique, [Expanded] and [Flexible] are not useful, because
94/// in both cases the "available space" is infinite (since this is in a viewport).
95/// The next section describes a technique for providing a maximum height constraint.
96///
97/// ** See code in examples/api/lib/widgets/single_child_scroll_view/single_child_scroll_view.0.dart **
98/// {@end-tool}
99///
100/// ### Expanding content to fit the viewport
101///
102/// The following example builds on the previous one. In addition to providing a
103/// minimum dimension for the child [Column], an [IntrinsicHeight] widget is used
104/// to force the column to be exactly as big as its contents. This constraint
105/// combines with the [ConstrainedBox] constraints discussed previously to ensure
106/// that the column becomes either as big as viewport, or as big as the contents,
107/// whichever is biggest.
108///
109/// Both constraints must be used to get the desired effect. If only the
110/// [IntrinsicHeight] was specified, then the column would not grow to fit the
111/// entire viewport when its children were smaller than the whole screen. If only
112/// the size of the viewport was used, then the [Column] would overflow if the
113/// children were bigger than the viewport.
114///
115/// The widget that is to grow to fit the remaining space so provided is wrapped
116/// in an [Expanded] widget.
117///
118/// This technique is quite expensive, as it more or less requires that the contents
119/// of the viewport be laid out twice (once to find their intrinsic dimensions, and
120/// once to actually lay them out). The number of widgets within the column should
121/// therefore be kept small. Alternatively, subsets of the children that have known
122/// dimensions can be wrapped in a [SizedBox] that has tight vertical constraints,
123/// so that the intrinsic sizing algorithm can short-circuit the computation when it
124/// reaches those parts of the subtree.
125///
126/// {@tool dartpad}
127/// In this example, the column becomes either as big as viewport, or as big as
128/// the contents, whichever is biggest.
129///
130/// ** See code in examples/api/lib/widgets/single_child_scroll_view/single_child_scroll_view.1.dart **
131/// {@end-tool}
132///
133/// {@macro flutter.widgets.ScrollView.PageStorage}
134///
135/// See also:
136///
137/// * [ListView], which handles multiple children in a scrolling list.
138/// * [GridView], which handles multiple children in a scrolling grid.
139/// * [PageView], for a scrollable that works page by page.
140/// * [Scrollable], which handles arbitrary scrolling effects.
141class SingleChildScrollView extends StatelessWidget {
142 /// Creates a box in which a single widget can be scrolled.
143 const SingleChildScrollView({
144 super.key,
145 this.scrollDirection = Axis.vertical,
146 this.reverse = false,
147 this.padding,
148 this.primary,
149 this.physics,
150 this.controller,
151 this.child,
152 this.dragStartBehavior = DragStartBehavior.start,
153 this.clipBehavior = Clip.hardEdge,
154 this.restorationId,
155 this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
156 }) : assert(
157 !(controller != null && (primary ?? false)),
158 'Primary ScrollViews obtain their ScrollController via inheritance '
159 'from a PrimaryScrollController widget. You cannot both set primary to '
160 'true and pass an explicit controller.',
161 );
162
163 /// {@macro flutter.widgets.scroll_view.scrollDirection}
164 final Axis scrollDirection;
165
166 /// Whether the scroll view scrolls in the reading direction.
167 ///
168 /// For example, if the reading direction is left-to-right and
169 /// [scrollDirection] is [Axis.horizontal], then the scroll view scrolls from
170 /// left to right when [reverse] is false and from right to left when
171 /// [reverse] is true.
172 ///
173 /// Similarly, if [scrollDirection] is [Axis.vertical], then the scroll view
174 /// scrolls from top to bottom when [reverse] is false and from bottom to top
175 /// when [reverse] is true.
176 ///
177 /// Defaults to false.
178 final bool reverse;
179
180 /// The amount of space by which to inset the child.
181 final EdgeInsetsGeometry? padding;
182
183 /// An object that can be used to control the position to which this scroll
184 /// view is scrolled.
185 ///
186 /// Must be null if [primary] is true.
187 ///
188 /// A [ScrollController] serves several purposes. It can be used to control
189 /// the initial scroll position (see [ScrollController.initialScrollOffset]).
190 /// It can be used to control whether the scroll view should automatically
191 /// save and restore its scroll position in the [PageStorage] (see
192 /// [ScrollController.keepScrollOffset]). It can be used to read the current
193 /// scroll position (see [ScrollController.offset]), or change it (see
194 /// [ScrollController.animateTo]).
195 final ScrollController? controller;
196
197 /// {@macro flutter.widgets.scroll_view.primary}
198 final bool? primary;
199
200 /// How the scroll view should respond to user input.
201 ///
202 /// For example, determines how the scroll view continues to animate after the
203 /// user stops dragging the scroll view.
204 ///
205 /// Defaults to matching platform conventions.
206 final ScrollPhysics? physics;
207
208 /// The widget that scrolls.
209 ///
210 /// {@macro flutter.widgets.ProxyWidget.child}
211 final Widget? child;
212
213 /// {@macro flutter.widgets.scrollable.dragStartBehavior}
214 final DragStartBehavior dragStartBehavior;
215
216 /// {@macro flutter.material.Material.clipBehavior}
217 ///
218 /// Defaults to [Clip.hardEdge].
219 final Clip clipBehavior;
220
221 /// {@macro flutter.widgets.scrollable.restorationId}
222 final String? restorationId;
223
224 /// {@macro flutter.widgets.scroll_view.keyboardDismissBehavior}
225 final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior;
226
227 AxisDirection _getDirection(BuildContext context) {
228 return getAxisDirectionFromAxisReverseAndDirectionality(context, scrollDirection, reverse);
229 }
230
231 @override
232 Widget build(BuildContext context) {
233 final AxisDirection axisDirection = _getDirection(context);
234 Widget? contents = child;
235 if (padding != null) {
236 contents = Padding(padding: padding!, child: contents);
237 }
238 final bool effectivePrimary = primary
239 ?? controller == null && PrimaryScrollController.shouldInherit(context, scrollDirection);
240
241 final ScrollController? scrollController = effectivePrimary
242 ? PrimaryScrollController.maybeOf(context)
243 : controller;
244
245 Widget scrollable = Scrollable(
246 dragStartBehavior: dragStartBehavior,
247 axisDirection: axisDirection,
248 controller: scrollController,
249 physics: physics,
250 restorationId: restorationId,
251 clipBehavior: clipBehavior,
252 viewportBuilder: (BuildContext context, ViewportOffset offset) {
253 return _SingleChildViewport(
254 axisDirection: axisDirection,
255 offset: offset,
256 clipBehavior: clipBehavior,
257 child: contents,
258 );
259 },
260 );
261
262 if (keyboardDismissBehavior == ScrollViewKeyboardDismissBehavior.onDrag) {
263 scrollable = NotificationListener<ScrollUpdateNotification>(
264 child: scrollable,
265 onNotification: (ScrollUpdateNotification notification) {
266 final FocusScopeNode focusNode = FocusScope.of(context);
267 if (notification.dragDetails != null && focusNode.hasFocus) {
268 focusNode.unfocus();
269 }
270 return false;
271 },
272 );
273 }
274
275 return effectivePrimary && scrollController != null
276 // Further descendant ScrollViews will not inherit the same
277 // PrimaryScrollController
278 ? PrimaryScrollController.none(child: scrollable)
279 : scrollable;
280 }
281}
282
283class _SingleChildViewport extends SingleChildRenderObjectWidget {
284 const _SingleChildViewport({
285 this.axisDirection = AxisDirection.down,
286 required this.offset,
287 super.child,
288 required this.clipBehavior,
289 });
290
291 final AxisDirection axisDirection;
292 final ViewportOffset offset;
293 final Clip clipBehavior;
294
295 @override
296 _RenderSingleChildViewport createRenderObject(BuildContext context) {
297 return _RenderSingleChildViewport(
298 axisDirection: axisDirection,
299 offset: offset,
300 clipBehavior: clipBehavior,
301 );
302 }
303
304 @override
305 void updateRenderObject(BuildContext context, _RenderSingleChildViewport renderObject) {
306 // Order dependency: The offset setter reads the axis direction.
307 renderObject
308 ..axisDirection = axisDirection
309 ..offset = offset
310 ..clipBehavior = clipBehavior;
311 }
312
313 @override
314 SingleChildRenderObjectElement createElement() {
315 return _SingleChildViewportElement(this);
316 }
317}
318
319class _SingleChildViewportElement extends SingleChildRenderObjectElement with NotifiableElementMixin, ViewportElementMixin {
320 _SingleChildViewportElement(_SingleChildViewport super.widget);
321}
322
323class _RenderSingleChildViewport extends RenderBox with RenderObjectWithChildMixin<RenderBox> implements RenderAbstractViewport {
324 _RenderSingleChildViewport({
325 AxisDirection axisDirection = AxisDirection.down,
326 required ViewportOffset offset,
327 RenderBox? child,
328 required Clip clipBehavior,
329 }) : _axisDirection = axisDirection,
330 _offset = offset,
331 _clipBehavior = clipBehavior {
332 this.child = child;
333 }
334
335 AxisDirection get axisDirection => _axisDirection;
336 AxisDirection _axisDirection;
337 set axisDirection(AxisDirection value) {
338 if (value == _axisDirection) {
339 return;
340 }
341 _axisDirection = value;
342 markNeedsLayout();
343 }
344
345 Axis get axis => axisDirectionToAxis(axisDirection);
346
347 ViewportOffset get offset => _offset;
348 ViewportOffset _offset;
349 set offset(ViewportOffset value) {
350 if (value == _offset) {
351 return;
352 }
353 if (attached) {
354 _offset.removeListener(_hasScrolled);
355 }
356 _offset = value;
357 if (attached) {
358 _offset.addListener(_hasScrolled);
359 }
360 markNeedsLayout();
361 }
362
363 /// {@macro flutter.material.Material.clipBehavior}
364 ///
365 /// Defaults to [Clip.none].
366 Clip get clipBehavior => _clipBehavior;
367 Clip _clipBehavior = Clip.none;
368 set clipBehavior(Clip value) {
369 if (value != _clipBehavior) {
370 _clipBehavior = value;
371 markNeedsPaint();
372 markNeedsSemanticsUpdate();
373 }
374 }
375
376 void _hasScrolled() {
377 markNeedsPaint();
378 markNeedsSemanticsUpdate();
379 }
380
381 @override
382 void setupParentData(RenderObject child) {
383 // We don't actually use the offset argument in BoxParentData, so let's
384 // avoid allocating it at all.
385 if (child.parentData is! ParentData) {
386 child.parentData = ParentData();
387 }
388 }
389
390 @override
391 void attach(PipelineOwner owner) {
392 super.attach(owner);
393 _offset.addListener(_hasScrolled);
394 }
395
396 @override
397 void detach() {
398 _offset.removeListener(_hasScrolled);
399 super.detach();
400 }
401
402 @override
403 bool get isRepaintBoundary => true;
404
405 double get _viewportExtent {
406 assert(hasSize);
407 switch (axis) {
408 case Axis.horizontal:
409 return size.width;
410 case Axis.vertical:
411 return size.height;
412 }
413 }
414
415 double get _minScrollExtent {
416 assert(hasSize);
417 return 0.0;
418 }
419
420 double get _maxScrollExtent {
421 assert(hasSize);
422 if (child == null) {
423 return 0.0;
424 }
425 switch (axis) {
426 case Axis.horizontal:
427 return math.max(0.0, child!.size.width - size.width);
428 case Axis.vertical:
429 return math.max(0.0, child!.size.height - size.height);
430 }
431 }
432
433 BoxConstraints _getInnerConstraints(BoxConstraints constraints) {
434 switch (axis) {
435 case Axis.horizontal:
436 return constraints.heightConstraints();
437 case Axis.vertical:
438 return constraints.widthConstraints();
439 }
440 }
441
442 @override
443 double computeMinIntrinsicWidth(double height) {
444 if (child != null) {
445 return child!.getMinIntrinsicWidth(height);
446 }
447 return 0.0;
448 }
449
450 @override
451 double computeMaxIntrinsicWidth(double height) {
452 if (child != null) {
453 return child!.getMaxIntrinsicWidth(height);
454 }
455 return 0.0;
456 }
457
458 @override
459 double computeMinIntrinsicHeight(double width) {
460 if (child != null) {
461 return child!.getMinIntrinsicHeight(width);
462 }
463 return 0.0;
464 }
465
466 @override
467 double computeMaxIntrinsicHeight(double width) {
468 if (child != null) {
469 return child!.getMaxIntrinsicHeight(width);
470 }
471 return 0.0;
472 }
473
474 // We don't override computeDistanceToActualBaseline(), because we
475 // want the default behavior (returning null). Otherwise, as you
476 // scroll, it would shift in its parent if the parent was baseline-aligned,
477 // which makes no sense.
478
479 @override
480 Size computeDryLayout(BoxConstraints constraints) {
481 if (child == null) {
482 return constraints.smallest;
483 }
484 final Size childSize = child!.getDryLayout(_getInnerConstraints(constraints));
485 return constraints.constrain(childSize);
486 }
487
488 @override
489 void performLayout() {
490 final BoxConstraints constraints = this.constraints;
491 if (child == null) {
492 size = constraints.smallest;
493 } else {
494 child!.layout(_getInnerConstraints(constraints), parentUsesSize: true);
495 size = constraints.constrain(child!.size);
496 }
497
498 if (offset.hasPixels) {
499 if (offset.pixels > _maxScrollExtent) {
500 offset.correctBy(_maxScrollExtent - offset.pixels);
501 } else if (offset.pixels < _minScrollExtent) {
502 offset.correctBy(_minScrollExtent - offset.pixels);
503 }
504 }
505
506 offset.applyViewportDimension(_viewportExtent);
507 offset.applyContentDimensions(_minScrollExtent, _maxScrollExtent);
508 }
509
510 Offset get _paintOffset => _paintOffsetForPosition(offset.pixels);
511
512 Offset _paintOffsetForPosition(double position) {
513 switch (axisDirection) {
514 case AxisDirection.up:
515 return Offset(0.0, position - child!.size.height + size.height);
516 case AxisDirection.down:
517 return Offset(0.0, -position);
518 case AxisDirection.left:
519 return Offset(position - child!.size.width + size.width, 0.0);
520 case AxisDirection.right:
521 return Offset(-position, 0.0);
522 }
523 }
524
525 bool _shouldClipAtPaintOffset(Offset paintOffset) {
526 assert(child != null);
527 switch (clipBehavior) {
528 case Clip.none:
529 return false;
530 case Clip.hardEdge:
531 case Clip.antiAlias:
532 case Clip.antiAliasWithSaveLayer:
533 return paintOffset.dx < 0 ||
534 paintOffset.dy < 0 ||
535 paintOffset.dx + child!.size.width > size.width ||
536 paintOffset.dy + child!.size.height > size.height;
537 }
538 }
539
540 @override
541 void paint(PaintingContext context, Offset offset) {
542 if (child != null) {
543 final Offset paintOffset = _paintOffset;
544
545 void paintContents(PaintingContext context, Offset offset) {
546 context.paintChild(child!, offset + paintOffset);
547 }
548
549 if (_shouldClipAtPaintOffset(paintOffset)) {
550 _clipRectLayer.layer = context.pushClipRect(
551 needsCompositing,
552 offset,
553 Offset.zero & size,
554 paintContents,
555 clipBehavior: clipBehavior,
556 oldLayer: _clipRectLayer.layer,
557 );
558 } else {
559 _clipRectLayer.layer = null;
560 paintContents(context, offset);
561 }
562 }
563 }
564
565 final LayerHandle<ClipRectLayer> _clipRectLayer = LayerHandle<ClipRectLayer>();
566
567 @override
568 void dispose() {
569 _clipRectLayer.layer = null;
570 super.dispose();
571 }
572
573 @override
574 void applyPaintTransform(RenderBox child, Matrix4 transform) {
575 final Offset paintOffset = _paintOffset;
576 transform.translate(paintOffset.dx, paintOffset.dy);
577 }
578
579 @override
580 Rect? describeApproximatePaintClip(RenderObject? child) {
581 if (child != null && _shouldClipAtPaintOffset(_paintOffset)) {
582 return Offset.zero & size;
583 }
584 return null;
585 }
586
587 @override
588 bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
589 if (child != null) {
590 return result.addWithPaintOffset(
591 offset: _paintOffset,
592 position: position,
593 hitTest: (BoxHitTestResult result, Offset transformed) {
594 assert(transformed == position + -_paintOffset);
595 return child!.hitTest(result, position: transformed);
596 },
597 );
598 }
599 return false;
600 }
601
602 @override
603 RevealedOffset getOffsetToReveal(
604 RenderObject target,
605 double alignment, {
606 Rect? rect,
607 Axis? axis,
608 }) {
609 // One dimensional viewport has only one axis, override if it was
610 // provided/may be mismatched.
611 axis = this.axis;
612
613 rect ??= target.paintBounds;
614 if (target is! RenderBox) {
615 return RevealedOffset(offset: offset.pixels, rect: rect);
616 }
617
618 final RenderBox targetBox = target;
619 final Matrix4 transform = targetBox.getTransformTo(child);
620 final Rect bounds = MatrixUtils.transformRect(transform, rect);
621 final Size contentSize = child!.size;
622
623 final double leadingScrollOffset;
624 final double targetMainAxisExtent;
625 final double mainAxisExtent;
626
627 switch (axisDirection) {
628 case AxisDirection.up:
629 mainAxisExtent = size.height;
630 leadingScrollOffset = contentSize.height - bounds.bottom;
631 targetMainAxisExtent = bounds.height;
632 case AxisDirection.right:
633 mainAxisExtent = size.width;
634 leadingScrollOffset = bounds.left;
635 targetMainAxisExtent = bounds.width;
636 case AxisDirection.down:
637 mainAxisExtent = size.height;
638 leadingScrollOffset = bounds.top;
639 targetMainAxisExtent = bounds.height;
640 case AxisDirection.left:
641 mainAxisExtent = size.width;
642 leadingScrollOffset = contentSize.width - bounds.right;
643 targetMainAxisExtent = bounds.width;
644 }
645
646 final double targetOffset = leadingScrollOffset - (mainAxisExtent - targetMainAxisExtent) * alignment;
647 final Rect targetRect = bounds.shift(_paintOffsetForPosition(targetOffset));
648 return RevealedOffset(offset: targetOffset, rect: targetRect);
649 }
650
651 @override
652 void showOnScreen({
653 RenderObject? descendant,
654 Rect? rect,
655 Duration duration = Duration.zero,
656 Curve curve = Curves.ease,
657 }) {
658 if (!offset.allowImplicitScrolling) {
659 return super.showOnScreen(
660 descendant: descendant,
661 rect: rect,
662 duration: duration,
663 curve: curve,
664 );
665 }
666
667 final Rect? newRect = RenderViewportBase.showInViewport(
668 descendant: descendant,
669 viewport: this,
670 offset: offset,
671 rect: rect,
672 duration: duration,
673 curve: curve,
674 );
675 super.showOnScreen(
676 rect: newRect,
677 duration: duration,
678 curve: curve,
679 );
680 }
681
682 @override
683 void debugFillProperties(DiagnosticPropertiesBuilder properties) {
684 super.debugFillProperties(properties);
685 properties.add(DiagnosticsProperty<Offset>('offset', _paintOffset));
686 }
687
688 @override
689 Rect describeSemanticsClip(RenderObject child) {
690 final double remainingOffset = _maxScrollExtent - offset.pixels;
691 switch (axisDirection) {
692 case AxisDirection.up:
693 return Rect.fromLTRB(
694 semanticBounds.left,
695 semanticBounds.top - remainingOffset,
696 semanticBounds.right,
697 semanticBounds.bottom + offset.pixels,
698 );
699 case AxisDirection.right:
700 return Rect.fromLTRB(
701 semanticBounds.left - offset.pixels,
702 semanticBounds.top,
703 semanticBounds.right + remainingOffset,
704 semanticBounds.bottom,
705 );
706 case AxisDirection.down:
707 return Rect.fromLTRB(
708 semanticBounds.left,
709 semanticBounds.top - offset.pixels,
710 semanticBounds.right,
711 semanticBounds.bottom + remainingOffset,
712 );
713 case AxisDirection.left:
714 return Rect.fromLTRB(
715 semanticBounds.left - remainingOffset,
716 semanticBounds.top,
717 semanticBounds.right + offset.pixels,
718 semanticBounds.bottom,
719 );
720 }
721 }
722}
723