single_child_scroll_view.dart 13.9 KB
Newer Older
1 2 3 4 5 6
// 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;

7
import 'package:flutter/foundation.dart';
8 9 10 11
import 'package:flutter/rendering.dart';

import 'basic.dart';
import 'framework.dart';
12
import 'primary_scroll_controller.dart';
13 14
import 'scroll_controller.dart';
import 'scroll_physics.dart';
15 16
import 'scrollable.dart';

17 18 19 20 21 22 23 24 25 26
/// 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]
27
/// with a [ListBody] child.
28 29 30 31
///
/// 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
32
/// that a [SingleChildScrollView] containing a [ListBody] or [Column] with
33 34 35 36 37 38 39
/// 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.
Adam Barth's avatar
Adam Barth committed
40
/// * [Scrollable], which handles arbitrary scrolling effects.
41
class SingleChildScrollView extends StatelessWidget {
42
  /// Creates a box in which a single widget can be scrolled.
43 44 45
  SingleChildScrollView({
    Key key,
    this.scrollDirection: Axis.vertical,
46
    this.reverse: false,
47
    this.padding,
48
    bool primary,
49 50
    this.physics,
    this.controller,
51
    this.child,
52 53 54 55 56 57 58
  }) : 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);
59

60 61 62
  /// The axis along which the scroll view scrolls.
  ///
  /// Defaults to [Axis.vertical].
63 64
  final Axis scrollDirection;

65 66 67 68 69 70 71 72 73 74 75 76
  /// 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 scroll view
  /// scrolls from top to bottom when [reverse] is false and from bottom to top
  /// when [reverse] is true.
  ///
  /// Defaults to false.
77 78
  final bool reverse;

79
  /// The amount of space by which to inset the child.
80
  final EdgeInsetsGeometry padding;
81

82 83 84 85
  /// An object that can be used to control the position to which this scroll
  /// view is scrolled.
  ///
  /// Must be null if [primary] is true.
86 87 88 89 90 91 92 93
  ///
  /// 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]).
94 95
  final ScrollController controller;

96 97 98 99 100 101
  /// 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.
  ///
102
  /// Defaults to true when [scrollDirection] is vertical and [controller] is
103
  /// not specified.
104 105
  final bool primary;

106 107 108 109 110 111
  /// 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.
112 113
  final ScrollPhysics physics;

114
  /// The widget that scrolls.
115 116 117 118 119
  final Widget child;

  AxisDirection _getDirection(BuildContext context) {
    switch (scrollDirection) {
      case Axis.horizontal:
120 121
        final TextDirection textDirection = Directionality.of(context);
        assert(textDirection != null);
122 123
        final AxisDirection axisDirection = textDirectionToAxisDirection(textDirection);
        return reverse ? flipAxisDirection(axisDirection) : axisDirection;
124
      case Axis.vertical:
125
        return reverse ? AxisDirection.up : AxisDirection.down;
126 127 128 129 130 131
    }
    return null;
  }

  @override
  Widget build(BuildContext context) {
132
    final AxisDirection axisDirection = _getDirection(context);
133 134 135
    Widget contents = child;
    if (padding != null)
      contents = new Padding(padding: padding, child: contents);
136
    final ScrollController scrollController = primary
137 138
        ? PrimaryScrollController.of(context)
        : controller;
139
    final Scrollable scrollable = new Scrollable(
140
      axisDirection: axisDirection,
141
      controller: scrollController,
142
      physics: physics,
143 144 145 146
      viewportBuilder: (BuildContext context, ViewportOffset offset) {
        return new _SingleChildViewport(
          axisDirection: axisDirection,
          offset: offset,
147
          child: contents,
148 149
        );
      },
150
    );
151 152 153
    return primary && scrollController != null
      ? new PrimaryScrollController.none(child: scrollable)
      : scrollable;
154 155 156 157
  }
}

class _SingleChildViewport extends SingleChildRenderObjectWidget {
158
  const _SingleChildViewport({
159 160 161 162
    Key key,
    this.axisDirection: AxisDirection.down,
    this.offset,
    Widget child,
163 164
  }) : assert(axisDirection != null),
       super(key: key, child: child);
165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185

  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;
  }
}

186
class _RenderSingleChildViewport extends RenderBox with RenderObjectWithChildMixin<RenderBox> implements RenderAbstractViewport {
187 188
  _RenderSingleChildViewport({
    AxisDirection axisDirection: AxisDirection.down,
189
    @required ViewportOffset offset,
190
    RenderBox child,
191 192 193
  }) : assert(axisDirection != null),
       assert(offset != null),
       _axisDirection = axisDirection,
194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216
       _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)
217
      _offset.removeListener(markNeedsPaint);
218 219
    _offset = value;
    if (attached)
220 221
      _offset.addListener(markNeedsPaint);
    markNeedsLayout();
222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246
  }

  @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(markNeedsPaint);
  }

  @override
  void detach() {
    _offset.removeListener(markNeedsPaint);
    super.detach();
  }

  @override
  bool get isRepaintBoundary => true;

247
  double get _viewportExtent {
248 249 250 251
    assert(hasSize);
    switch (axis) {
      case Axis.horizontal:
        return size.width;
252 253
      case Axis.vertical:
        return size.height;
254 255 256 257 258 259 260 261 262 263 264 265 266
    }
    return null;
  }

  double get _minScrollExtent {
    assert(hasSize);
    return 0.0;
  }

  double get _maxScrollExtent {
    assert(hasSize);
    if (child == null)
      return 0.0;
267 268 269 270 271 272 273
    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;
274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327
  }

  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);
    }

328
    offset.applyViewportDimension(_viewportExtent);
329 330 331
    offset.applyContentDimensions(_minScrollExtent, _maxScrollExtent);
  }

332
  Offset get _paintOffset {
333 334 335
    assert(axisDirection != null);
    switch (axisDirection) {
      case AxisDirection.up:
336
        return new Offset(0.0, _offset.pixels - child.size.height + size.height);
337 338 339
      case AxisDirection.down:
        return new Offset(0.0, -_offset.pixels);
      case AxisDirection.left:
340
        return new Offset(_offset.pixels - child.size.width + size.width, 0.0);
341 342 343 344 345 346 347 348 349 350 351 352 353 354
      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) {
355
      final Offset paintOffset = _paintOffset;
356 357 358 359 360 361

      void paintContents(PaintingContext context, Offset offset) {
        context.paintChild(child, offset + paintOffset);
      }

      if (_shouldClipAtPaintOffset(paintOffset)) {
362
        context.pushClipRect(needsCompositing, offset, Offset.zero & size, paintContents);
363 364 365 366 367 368 369 370
      } else {
        paintContents(context, offset);
      }
    }
  }

  @override
  void applyPaintTransform(RenderBox child, Matrix4 transform) {
371
    final Offset paintOffset = _paintOffset;
372 373 374 375 376
    transform.translate(paintOffset.dx, paintOffset.dy);
  }

  @override
  Rect describeApproximatePaintClip(RenderObject child) {
377
    if (child != null && _shouldClipAtPaintOffset(_paintOffset))
378
      return Offset.zero & size;
379 380 381 382
    return null;
  }

  @override
383
  bool hitTestChildren(HitTestResult result, { Offset position }) {
384
    if (child != null) {
385
      final Offset transformed = position + -_paintOffset;
386 387 388 389
      return child.hitTest(result, position: transformed);
    }
    return false;
  }
390 391

  @override
392 393
  double getOffsetToReveal(RenderObject target, double alignment) {
    if (target is! RenderBox)
394 395
      return offset.pixels;

396 397 398
    final RenderBox targetBox = target;
    final Matrix4 transform = targetBox.getTransformTo(this);
    final Rect bounds = MatrixUtils.transformRect(transform, targetBox.paintBounds);
399 400
    final Size contentSize = child.size;

401 402 403
    double leadingScrollOffset;
    double targetMainAxisExtent;
    double mainAxisExtent;
404 405 406 407

    assert(axisDirection != null);
    switch (axisDirection) {
      case AxisDirection.up:
408 409 410
        mainAxisExtent = size.height;
        leadingScrollOffset = contentSize.height - bounds.bottom;
        targetMainAxisExtent = bounds.height;
411 412
        break;
      case AxisDirection.right:
413 414 415
        mainAxisExtent = size.width;
        leadingScrollOffset = bounds.left;
        targetMainAxisExtent = bounds.width;
416 417
        break;
      case AxisDirection.down:
418 419 420
        mainAxisExtent = size.height;
        leadingScrollOffset = bounds.top;
        targetMainAxisExtent = bounds.height;
421 422
        break;
      case AxisDirection.left:
423 424 425
        mainAxisExtent = size.width;
        leadingScrollOffset = contentSize.width - bounds.right;
        targetMainAxisExtent = bounds.width;
426 427 428
        break;
    }

429
    return leadingScrollOffset - (mainAxisExtent - targetMainAxisExtent) * alignment;
430
  }
431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449

  @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();
  }
450
}