// Copyright 2017 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'basic.dart'; import 'framework.dart'; import 'primary_scroll_controller.dart'; import 'scroll_controller.dart'; import 'scroll_physics.dart'; import 'scrollable.dart'; /// A box in which a single widget can be scrolled. /// /// This widget is useful when you have a single box that will normally be /// entirely visible, for example a clock face in a time picker, but you need to /// make sure it can be scrolled if the container gets too small in one axis /// (the scroll direction). /// /// It is also useful if you need to shrink-wrap in both axes (the main /// scrolling direction as well as the cross axis), as one might see in a dialog /// or pop-up menu. In that case, you might pair the [SingleChildScrollView] /// with a [ListBody] child. /// /// When you have a list of children and do not require cross-axis /// shrink-wrapping behavior, for example a scrolling list that is always the /// width of the screen, consider [ListView], which is vastly more efficient /// that a [SingleChildScrollView] containing a [ListBody] or [Column] with /// many children. /// /// See also: /// /// * [ListView], which handles multiple children in a scrolling list. /// * [GridView], which handles multiple children in a scrolling grid. /// * [PageView], for a scrollable that works page by page. /// * [Scrollable], which handles arbitrary scrolling effects. class SingleChildScrollView extends StatelessWidget { /// Creates a box in which a single widget can be scrolled. SingleChildScrollView({ Key key, this.scrollDirection: Axis.vertical, this.reverse: false, this.padding, bool primary, this.physics, this.controller, this.child, }) : assert(scrollDirection != null), assert(!(controller != null && primary == true), 'Primary ScrollViews obtain their ScrollController via inheritance from a PrimaryScrollController widget. ' 'You cannot both set primary to true and pass an explicit controller.' ), primary = primary ?? controller == null && scrollDirection == Axis.vertical, super(key: key); /// The axis along which the scroll view scrolls. /// /// Defaults to [Axis.vertical]. final Axis scrollDirection; /// Whether the scroll view scrolls in the reading direction. /// /// For example, if the reading direction is left-to-right and /// [scrollDirection] is [Axis.horizontal], then the scroll view scrolls from /// left to right when [reverse] is false and from right to left when /// [reverse] is true. /// /// Similarly, if [scrollDirection] is [Axis.vertical], then the scroll view /// scrolls from top to bottom when [reverse] is false and from bottom to top /// when [reverse] is true. /// /// Defaults to false. final bool reverse; /// The amount of space by which to inset the child. final EdgeInsetsGeometry padding; /// An object that can be used to control the position to which this scroll /// view is scrolled. /// /// Must be null if [primary] is true. /// /// A [ScrollController] serves several purposes. It can be used to control /// the initial scroll position (see [ScrollController.initialScrollOffset]). /// It can be used to control whether the scroll view should automatically /// save and restore its scroll position in the [PageStorage] (see /// [ScrollController.keepScrollOffset]). It can be used to read the current /// scroll position (see [ScrollController.offset]), or change it (see /// [ScrollController.animateTo]). final ScrollController controller; /// Whether this is the primary scroll view associated with the parent /// [PrimaryScrollController]. /// /// On iOS, this identifies the scroll view that will scroll to top in /// response to a tap in the status bar. /// /// Defaults to true when [scrollDirection] is vertical and [controller] is /// not specified. final bool primary; /// How the scroll view should respond to user input. /// /// For example, determines how the scroll view continues to animate after the /// user stops dragging the scroll view. /// /// Defaults to matching platform conventions. final ScrollPhysics physics; /// The widget that scrolls. /// /// {@macro flutter.widgets.child} final Widget child; AxisDirection _getDirection(BuildContext context) { return getAxisDirectionFromAxisReverseAndDirectionality(context, scrollDirection, reverse); } @override Widget build(BuildContext context) { final AxisDirection axisDirection = _getDirection(context); Widget contents = child; if (padding != null) contents = new Padding(padding: padding, child: contents); final ScrollController scrollController = primary ? PrimaryScrollController.of(context) : controller; final Scrollable scrollable = new Scrollable( axisDirection: axisDirection, controller: scrollController, physics: physics, viewportBuilder: (BuildContext context, ViewportOffset offset) { return new _SingleChildViewport( axisDirection: axisDirection, offset: offset, child: contents, ); }, ); return primary && scrollController != null ? new PrimaryScrollController.none(child: scrollable) : scrollable; } } class _SingleChildViewport extends SingleChildRenderObjectWidget { const _SingleChildViewport({ Key key, this.axisDirection: AxisDirection.down, this.offset, Widget child, }) : assert(axisDirection != null), super(key: key, child: child); final AxisDirection axisDirection; final ViewportOffset offset; @override _RenderSingleChildViewport createRenderObject(BuildContext context) { return new _RenderSingleChildViewport( axisDirection: axisDirection, offset: offset, ); } @override void updateRenderObject(BuildContext context, _RenderSingleChildViewport renderObject) { // Order dependency: The offset setter reads the axis direction. renderObject ..axisDirection = axisDirection ..offset = offset; } } class _RenderSingleChildViewport extends RenderBox with RenderObjectWithChildMixin<RenderBox> implements RenderAbstractViewport { _RenderSingleChildViewport({ AxisDirection axisDirection: AxisDirection.down, @required ViewportOffset offset, RenderBox child, }) : assert(axisDirection != null), assert(offset != null), _axisDirection = axisDirection, _offset = offset { this.child = child; } AxisDirection get axisDirection => _axisDirection; AxisDirection _axisDirection; set axisDirection(AxisDirection value) { assert(value != null); if (value == _axisDirection) return; _axisDirection = value; markNeedsLayout(); } Axis get axis => axisDirectionToAxis(axisDirection); ViewportOffset get offset => _offset; ViewportOffset _offset; set offset(ViewportOffset value) { assert(value != null); if (value == _offset) return; if (attached) _offset.removeListener(_hasScrolled); _offset = value; if (attached) _offset.addListener(_hasScrolled); markNeedsLayout(); } void _hasScrolled() { markNeedsPaint(); markNeedsSemanticsUpdate(); } @override void setupParentData(RenderObject child) { // We don't actually use the offset argument in BoxParentData, so let's // avoid allocating it at all. if (child.parentData is! ParentData) child.parentData = new ParentData(); } @override void attach(PipelineOwner owner) { super.attach(owner); _offset.addListener(_hasScrolled); } @override void detach() { _offset.removeListener(_hasScrolled); super.detach(); } @override bool get isRepaintBoundary => true; double get _viewportExtent { assert(hasSize); switch (axis) { case Axis.horizontal: return size.width; case Axis.vertical: return size.height; } return null; } double get _minScrollExtent { assert(hasSize); return 0.0; } double get _maxScrollExtent { assert(hasSize); if (child == null) return 0.0; switch (axis) { case Axis.horizontal: return math.max(0.0, child.size.width - size.width); case Axis.vertical: return math.max(0.0, child.size.height - size.height); } return null; } BoxConstraints _getInnerConstraints(BoxConstraints constraints) { switch (axis) { case Axis.horizontal: return constraints.heightConstraints(); case Axis.vertical: return constraints.widthConstraints(); } return null; } @override double computeMinIntrinsicWidth(double height) { if (child != null) return child.getMinIntrinsicWidth(height); return 0.0; } @override double computeMaxIntrinsicWidth(double height) { if (child != null) return child.getMaxIntrinsicWidth(height); return 0.0; } @override double computeMinIntrinsicHeight(double width) { if (child != null) return child.getMinIntrinsicHeight(width); return 0.0; } @override double computeMaxIntrinsicHeight(double width) { if (child != null) return child.getMaxIntrinsicHeight(width); return 0.0; } // We don't override computeDistanceToActualBaseline(), because we // want the default behavior (returning null). Otherwise, as you // scroll, it would shift in its parent if the parent was baseline-aligned, // which makes no sense. @override void performLayout() { if (child == null) { size = constraints.smallest; } else { child.layout(_getInnerConstraints(constraints), parentUsesSize: true); size = constraints.constrain(child.size); } offset.applyViewportDimension(_viewportExtent); offset.applyContentDimensions(_minScrollExtent, _maxScrollExtent); } Offset get _paintOffset { assert(axisDirection != null); switch (axisDirection) { case AxisDirection.up: return new Offset(0.0, _offset.pixels - child.size.height + size.height); case AxisDirection.down: return new Offset(0.0, -_offset.pixels); case AxisDirection.left: return new Offset(_offset.pixels - child.size.width + size.width, 0.0); case AxisDirection.right: return new Offset(-_offset.pixels, 0.0); } return null; } bool _shouldClipAtPaintOffset(Offset paintOffset) { assert(child != null); return paintOffset < Offset.zero || !(Offset.zero & size).contains((paintOffset & child.size).bottomRight); } @override void paint(PaintingContext context, Offset offset) { if (child != null) { final Offset paintOffset = _paintOffset; void paintContents(PaintingContext context, Offset offset) { context.paintChild(child, offset + paintOffset); } if (_shouldClipAtPaintOffset(paintOffset)) { context.pushClipRect(needsCompositing, offset, Offset.zero & size, paintContents); } else { paintContents(context, offset); } } } @override void applyPaintTransform(RenderBox child, Matrix4 transform) { final Offset paintOffset = _paintOffset; transform.translate(paintOffset.dx, paintOffset.dy); } @override Rect describeApproximatePaintClip(RenderObject child) { if (child != null && _shouldClipAtPaintOffset(_paintOffset)) return Offset.zero & size; return null; } @override bool hitTestChildren(HitTestResult result, { Offset position }) { if (child != null) { final Offset transformed = position + -_paintOffset; return child.hitTest(result, position: transformed); } return false; } @override double getOffsetToReveal(RenderObject target, double alignment) { if (target is! RenderBox) return offset.pixels; final RenderBox targetBox = target; final Matrix4 transform = targetBox.getTransformTo(this); final Rect bounds = MatrixUtils.transformRect(transform, targetBox.paintBounds); final Size contentSize = child.size; double leadingScrollOffset; double targetMainAxisExtent; double mainAxisExtent; assert(axisDirection != null); switch (axisDirection) { case AxisDirection.up: mainAxisExtent = size.height; leadingScrollOffset = contentSize.height - bounds.bottom; targetMainAxisExtent = bounds.height; break; case AxisDirection.right: mainAxisExtent = size.width; leadingScrollOffset = bounds.left; targetMainAxisExtent = bounds.width; break; case AxisDirection.down: mainAxisExtent = size.height; leadingScrollOffset = bounds.top; targetMainAxisExtent = bounds.height; break; case AxisDirection.left: mainAxisExtent = size.width; leadingScrollOffset = contentSize.width - bounds.right; targetMainAxisExtent = bounds.width; break; } return leadingScrollOffset - (mainAxisExtent - targetMainAxisExtent) * alignment; } @override void showOnScreen([RenderObject child]) { // Logic duplicated in [RenderViewportBase.showOnScreen]. if (child != null) { // Move viewport the smallest distance to bring [child] on screen. final double leadingEdgeOffset = getOffsetToReveal(child, 0.0); final double trailingEdgeOffset = getOffsetToReveal(child, 1.0); final double currentOffset = offset.pixels; if ((currentOffset - leadingEdgeOffset).abs() < (currentOffset - trailingEdgeOffset).abs()) { offset.jumpTo(leadingEdgeOffset); } else { offset.jumpTo(trailingEdgeOffset); } } // Make sure the viewport itself is on screen. super.showOnScreen(); } }