diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 5acd362f45fae..dbab89d673ae0 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -c757fc74512fe9039a31e194906bf3700b4c1319 +5fcfb995bbce72b5f1ee807121f51a3c0280c8b4 diff --git a/bin/internal/update_dart_sdk.ps1 b/bin/internal/update_dart_sdk.ps1 index 1f16cb6ebb685..43a10566b1be8 100644 --- a/bin/internal/update_dart_sdk.ps1 +++ b/bin/internal/update_dart_sdk.ps1 @@ -40,8 +40,13 @@ if (Test-Path $dartSdkPath) { } New-Item $dartSdkPath -force -type directory | Out-Null $dartSdkZip = "$cachePath\dart-sdk.zip" -Import-Module BitsTransfer -Start-BitsTransfer -Source $dartSdkUrl -Destination $dartSdkZip +# TODO(goderbauer): remove (slow and backwards-incompatible) appveyor work around +if (Test-Path Env:\APPVEYOR) { + curl $dartSdkUrl -OutFile $dartSdkZip +} else { + Import-Module BitsTransfer + Start-BitsTransfer -Source $dartSdkUrl -Destination $dartSdkZip +} Write-Host "Unzipping Dart SDK..." If (Get-Command 7z -errorAction SilentlyContinue) { diff --git a/dev/devicelab/bin/tasks/commands_test.dart b/dev/devicelab/bin/tasks/commands_test.dart new file mode 100644 index 0000000000000..01641289dbeca --- /dev/null +++ b/dev/devicelab/bin/tasks/commands_test.dart @@ -0,0 +1,93 @@ +// Copyright (c) 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:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as path; + +import 'package:flutter_devicelab/framework/adb.dart'; +import 'package:flutter_devicelab/framework/framework.dart'; +import 'package:flutter_devicelab/framework/utils.dart'; + +void main() { + task(() async { + final Device device = await devices.workingDevice; + await device.unlock(); + final Directory appDir = dir(path.join(flutterDirectory.path, 'dev/integration_tests/ui')); + await inDirectory(appDir, () async { + final Completer ready = new Completer(); + bool ok; + print('run: starting...'); + final Process run = await startProcess( + path.join(flutterDirectory.path, 'bin', 'flutter'), + ['run', '--verbose', '--observatory-port=8888', '-d', device.deviceId, 'lib/commands.dart'], + ); + run.stdout + .transform(UTF8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + print('run:stdout: $line'); + if (line.contains(new RegExp(r'^\[\s+\] For a more detailed help message, press "h"\. To quit, press "q"\.'))) { + print('run: ready!'); + ready.complete(); + ok ??= true; + } + }); + run.stderr + .transform(UTF8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + stderr.writeln('run:stderr: $line'); + }); + run.exitCode.then((int exitCode) { ok = false; }); + await Future.any(>[ ready.future, run.exitCode ]); + if (!ok) + throw 'Failed to run test app.'; + await drive('none'); + print('test: pressing "p" to enable debugPaintSize...'); + run.stdin.write('p'); + await drive('debug_paint'); + print('test: pressing "p" again...'); + run.stdin.write('p'); + await drive('none'); + print('test: pressing "P" to enable performance overlay...'); + run.stdin.write('P'); + await drive('performance_overlay'); + print('test: pressing "P" again...'); + run.stdin.write('P'); + await drive('none'); + run.stdin.write('q'); + final int result = await run.exitCode; + if (result != 0) + throw 'Received unexpected exit code $result from run process.'; + }); + return new TaskResult.success(null); + }); +} + +Future drive(String name) async { + print('drive: running commands_$name check...'); + final Process drive = await startProcess( + path.join(flutterDirectory.path, 'bin', 'flutter'), + ['drive', '--use-existing-app', 'http://127.0.0.1:8888/', '--keep-app-running', '--driver', 'test_driver/commands_${name}_test.dart'], + ); + drive.stdout + .transform(UTF8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + print('drive:stdout: $line'); + }); + drive.stderr + .transform(UTF8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + stderr.writeln('drive:stderr: $line'); + }); + final int result = await drive.exitCode; + if (result != 0) + throw 'Failed to drive test app (exit code $result).'; + print('drive: finished commands_$name check successfully.'); +} \ No newline at end of file diff --git a/dev/devicelab/lib/framework/framework.dart b/dev/devicelab/lib/framework/framework.dart index f86124eeeec7c..be9d26d0c0ee3 100644 --- a/dev/devicelab/lib/framework/framework.dart +++ b/dev/devicelab/lib/framework/framework.dart @@ -134,7 +134,8 @@ class _TaskRunner { // are catching errors coming from arbitrary (and untrustworthy) task // code. Our goal is to convert the failure into a readable message. // Propagating it further is not useful. - completer.complete(new TaskResult.failure(message)); + if (!completer.isCompleted) + completer.complete(new TaskResult.failure(message)); }); return completer.future; } diff --git a/dev/devicelab/manifest.yaml b/dev/devicelab/manifest.yaml index c93ae2b233edb..2b46fe19ba030 100644 --- a/dev/devicelab/manifest.yaml +++ b/dev/devicelab/manifest.yaml @@ -114,6 +114,13 @@ tasks: stage: devicelab required_agent_capabilities: ["has-android-device"] + commands_test: + description: > + Runs tests of flutter run commands. + stage: devicelab + required_agent_capabilities: ["has-android-device"] + flaky: true + android_sample_catalog_generator: description: > Builds sample catalog markdown pages and Android screenshots @@ -131,7 +138,6 @@ tasks: Verifies that `flutter drive --route` still works. No performance numbers. stage: devicelab required_agent_capabilities: ["linux/android"] - flaky: true flutter_gallery_instrumentation_test: description: > @@ -140,7 +146,6 @@ tasks: test can run on off-the-shelf infrastructures, such as Firebase Test Lab. stage: devicelab required_agent_capabilities: ["linux/android"] - flaky: true # iOS on-device tests @@ -149,14 +154,12 @@ tasks: Checks that platform channels work on iOS. stage: devicelab_ios required_agent_capabilities: ["has-ios-device"] - flaky: true platform_channel_sample_test_ios: description: > Runs a driver test on the Platform Channel sample app on iOS. stage: devicelab_ios required_agent_capabilities: ["has-ios-device"] - flaky: true complex_layout_scroll_perf_ios__timeline_summary: description: > @@ -164,7 +167,6 @@ tasks: iOS. stage: devicelab_ios required_agent_capabilities: ["has-ios-device"] - flaky: true # flutter_gallery_ios__start_up: # description: > @@ -186,14 +188,12 @@ tasks: iOS. stage: devicelab_ios required_agent_capabilities: ["has-ios-device"] - flaky: true basic_material_app_ios__size: description: > Measures the IPA size of a basic material app. stage: devicelab_ios required_agent_capabilities: ["has-ios-device"] - flaky: true # microbenchmarks_ios: # description: > @@ -215,14 +215,12 @@ tasks: Runs end-to-end Flutter tests on iOS. stage: devicelab_ios required_agent_capabilities: ["has-ios-device"] - flaky: true ios_sample_catalog_generator: description: > Builds sample catalog markdown pages and iOS screenshots stage: devicelab_ios required_agent_capabilities: ["has-ios-device"] - flaky: true # Tests running on Windows host diff --git a/dev/integration_tests/ui/lib/commands.dart b/dev/integration_tests/ui/lib/commands.dart new file mode 100644 index 0000000000000..2e066408b71af --- /dev/null +++ b/dev/integration_tests/ui/lib/commands.dart @@ -0,0 +1,43 @@ +// 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 'package:flutter_driver/driver_extension.dart'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +String log = ''; + +void main() { + enableFlutterDriverExtension(handler: (String message) async { + log = 'log:'; + await WidgetsBinding.instance.reassembleApplication(); + return log; + }); + runApp(new MaterialApp(home: const Test())); +} + +class Test extends SingleChildRenderObjectWidget { + const Test({ Key key }) : super(key: key); + + @override + RenderTest createRenderObject(BuildContext context) { + return new RenderTest(); + } +} + +class RenderTest extends RenderProxyBox { + RenderTest({ RenderBox child }) : super(child); + + @override + void debugPaintSize(PaintingContext context, Offset offset) { + super.debugPaintSize(context, offset); + log += ' debugPaintSize'; + } + + @override + void paint(PaintingContext context, Offset offset) { + log += ' paint'; + } +} diff --git a/dev/integration_tests/ui/test_driver/commands_debug_paint_test.dart b/dev/integration_tests/ui/test_driver/commands_debug_paint_test.dart new file mode 100644 index 0000000000000..d7046c98b5377 --- /dev/null +++ b/dev/integration_tests/ui/test_driver/commands_debug_paint_test.dart @@ -0,0 +1,22 @@ +// 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 'package:flutter_driver/flutter_driver.dart'; +import 'package:test/test.dart'; + +void main() { + FlutterDriver driver; + + setUpAll(() async { + driver = await FlutterDriver.connect(); + }); + + tearDownAll(() async { + driver?.close(); + }); + + test('check that we are painting in debugPaintSize mode', () async { + expect(await driver.requestData('status'), 'log: paint debugPaintSize'); + }); +} diff --git a/dev/integration_tests/ui/test_driver/commands_none_test.dart b/dev/integration_tests/ui/test_driver/commands_none_test.dart new file mode 100644 index 0000000000000..f14815504aaaf --- /dev/null +++ b/dev/integration_tests/ui/test_driver/commands_none_test.dart @@ -0,0 +1,23 @@ +// 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 'package:flutter_driver/flutter_driver.dart'; +import 'package:test/test.dart'; + +void main() { + FlutterDriver driver; + + setUpAll(() async { + driver = await FlutterDriver.connect(); + }); + + tearDownAll(() async { + driver?.close(); + }); + + test('check that we are in normal mode', () async { + expect(await driver.requestData('status'), 'log: paint'); + await driver.waitForAbsent(find.byType('PerformanceOverlay'), timeout: Duration.ZERO); + }); +} diff --git a/dev/integration_tests/ui/test_driver/commands_performance_overlay_test.dart b/dev/integration_tests/ui/test_driver/commands_performance_overlay_test.dart new file mode 100644 index 0000000000000..91140e1223cf9 --- /dev/null +++ b/dev/integration_tests/ui/test_driver/commands_performance_overlay_test.dart @@ -0,0 +1,23 @@ +// 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 'package:flutter_driver/flutter_driver.dart'; +import 'package:test/test.dart'; + +void main() { + FlutterDriver driver; + + setUpAll(() async { + driver = await FlutterDriver.connect(); + }); + + tearDownAll(() async { + driver?.close(); + }); + + test('check that we are showing the performance overlay', () async { + await driver.requestData('status'); // force a reassemble + await driver.waitFor(find.byType('PerformanceOverlay'), timeout: Duration.ZERO); + }); +} diff --git a/dev/integration_tests/ui/test_driver/keyboard_resize_test.dart b/dev/integration_tests/ui/test_driver/keyboard_resize_test.dart index 3efb7fc930bd1..f10dff665f057 100644 --- a/dev/integration_tests/ui/test_driver/keyboard_resize_test.dart +++ b/dev/integration_tests/ui/test_driver/keyboard_resize_test.dart @@ -1,3 +1,7 @@ +// 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:async'; import 'package:integration_ui/keys.dart' as keys; diff --git a/dev/integration_tests/ui/test_driver/route_test.dart b/dev/integration_tests/ui/test_driver/route_test.dart index fa06384398e41..f7ec81e37f27d 100644 --- a/dev/integration_tests/ui/test_driver/route_test.dart +++ b/dev/integration_tests/ui/test_driver/route_test.dart @@ -1,3 +1,7 @@ +// 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 'package:flutter_driver/flutter_driver.dart'; import 'package:test/test.dart'; diff --git a/packages/flutter/lib/src/material/slider.dart b/packages/flutter/lib/src/material/slider.dart index 213884cdd7bff..5cc935716c630 100644 --- a/packages/flutter/lib/src/material/slider.dart +++ b/packages/flutter/lib/src/material/slider.dart @@ -64,6 +64,7 @@ class Slider extends StatefulWidget { this.divisions, this.label, this.activeColor, + this.inactiveColor, this.thumbOpenAtMin: false, }) : assert(value != null), assert(min != null), @@ -138,6 +139,11 @@ class Slider extends StatefulWidget { /// Defaults to accent color of the current [Theme]. final Color activeColor; + /// The color for the unselected portion of the slider. + /// + /// Defaults to the unselected widget color of the current [Theme]. + final Color inactiveColor; + /// Whether the thumb should be an open circle when the slider is at its minimum position. /// /// When this property is false, the thumb does not change when it the slider @@ -178,6 +184,7 @@ class _SliderState extends State with TickerProviderStateMixin { divisions: widget.divisions, label: widget.label, activeColor: widget.activeColor ?? theme.accentColor, + inactiveColor: widget.inactiveColor ?? theme.unselectedWidgetColor, thumbOpenAtMin: widget.thumbOpenAtMin, textTheme: theme.accentTextTheme, onChanged: (widget.onChanged != null) && (widget.max > widget.min) ? _handleChanged : null, @@ -193,6 +200,7 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget { this.divisions, this.label, this.activeColor, + this.inactiveColor, this.thumbOpenAtMin, this.textTheme, this.onChanged, @@ -203,6 +211,7 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget { final int divisions; final String label; final Color activeColor; + final Color inactiveColor; final bool thumbOpenAtMin; final TextTheme textTheme; final ValueChanged onChanged; @@ -215,6 +224,7 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget { divisions: divisions, label: label, activeColor: activeColor, + inactiveColor: inactiveColor, thumbOpenAtMin: thumbOpenAtMin, textTheme: textTheme, onChanged: onChanged, @@ -229,6 +239,7 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget { ..divisions = divisions ..label = label ..activeColor = activeColor + ..inactiveColor = inactiveColor ..thumbOpenAtMin = thumbOpenAtMin ..textTheme = textTheme ..onChanged = onChanged; @@ -246,11 +257,9 @@ const double _kMinimumTrackWidth = _kActiveThumbRadius; // biggest of the thumb const double _kPreferredTotalWidth = _kPreferredTrackWidth + 2 * _kReactionRadius; const double _kMinimumTotalWidth = _kMinimumTrackWidth + 2 * _kReactionRadius; -final Color _kInactiveTrackColor = Colors.grey.shade400; final Color _kActiveTrackColor = Colors.grey; final Tween _kReactionRadiusTween = new Tween(begin: _kThumbRadius, end: _kReactionRadius); final Tween _kThumbRadiusTween = new Tween(begin: _kThumbRadius, end: _kActiveThumbRadius); -final ColorTween _kTrackColorTween = new ColorTween(begin: _kInactiveTrackColor, end: _kActiveTrackColor); final ColorTween _kTickColorTween = new ColorTween(begin: Colors.transparent, end: Colors.black54); final Duration _kDiscreteTransitionDuration = const Duration(milliseconds: 500); @@ -276,6 +285,7 @@ class _RenderSlider extends RenderBox implements SemanticsActionHandler { int divisions, String label, Color activeColor, + Color inactiveColor, bool thumbOpenAtMin, TextTheme textTheme, this.onChanged, @@ -284,6 +294,7 @@ class _RenderSlider extends RenderBox implements SemanticsActionHandler { _value = value, _divisions = divisions, _activeColor = activeColor, + _inactiveColor = inactiveColor, _thumbOpenAtMin = thumbOpenAtMin, _textTheme = textTheme { this.label = label; @@ -363,6 +374,15 @@ class _RenderSlider extends RenderBox implements SemanticsActionHandler { markNeedsPaint(); } + Color get inactiveColor => _inactiveColor; + Color _inactiveColor; + set inactiveColor(Color value) { + if (value == _inactiveColor) + return; + _inactiveColor = value; + markNeedsPaint(); + } + bool get thumbOpenAtMin => _thumbOpenAtMin; bool _thumbOpenAtMin; set thumbOpenAtMin(bool value) { @@ -451,7 +471,6 @@ class _RenderSlider extends RenderBox implements SemanticsActionHandler { } } - @override double computeMinIntrinsicWidth(double height) { return _kMinimumTotalWidth; @@ -501,8 +520,8 @@ class _RenderSlider extends RenderBox implements SemanticsActionHandler { final double trackRight = trackLeft + trackLength; final double trackActive = trackLeft + trackLength * value; - final Paint primaryPaint = new Paint()..color = enabled ? _activeColor : _kInactiveTrackColor; - final Paint trackPaint = new Paint()..color = _kTrackColorTween.evaluate(_reaction); + final Paint primaryPaint = new Paint()..color = enabled ? _activeColor : _inactiveColor; + final Paint trackPaint = new Paint()..color = _inactiveColor; final Offset thumbCenter = new Offset(trackActive, trackCenter); final double thumbRadius = enabled ? _kThumbRadiusTween.evaluate(_reaction) : _kDisabledThumbRadius; diff --git a/packages/flutter/lib/src/painting/basic_types.dart b/packages/flutter/lib/src/painting/basic_types.dart index 65247d731a21d..f4c026b57b057 100644 --- a/packages/flutter/lib/src/painting/basic_types.dart +++ b/packages/flutter/lib/src/painting/basic_types.dart @@ -52,3 +52,41 @@ export 'dart:ui' show // - window, WindowPadding // These are generally wrapped by other APIs so we always refer to them directly // as ui.* to avoid making them seem like high-level APIs. + +/// The description of the difference between two objects, in the context of how +/// it will affect the rendering. +/// +/// Used by [TextSpan.compareTo] and [TextStyle.compareTo]. +/// +/// The values in this enum are ordered such that they are in increasing order +/// of cost. A value with index N implies all the values with index less than N. +/// For example, [layout] (index 3) implies [paint] (2). +enum RenderComparison { + /// The two objects are identical (meaning deeply equal, not necessarily + /// [identical]). + identical, + + /// The two objects are identical for the purpose of layout, but may be different + /// in other ways. + /// + /// For example, maybe some event handlers changed. + metadata, + + /// The two objects are different but only in ways that affect paint, not layout. + /// + /// For example, only the color is changed. + /// + /// [RenderObject.markNeedsPaint] would be necessary to handle this kind of + /// change in a render object. + paint, + + /// The two objects are different in ways that affect layout (and therefore paint). + /// + /// For example, the size is changed. + /// + /// This is the most drastic level of change possible. + /// + /// [RenderObject.markNeedsLayout] would be necessary to handle this kind of + /// change in a render object. + layout, +} diff --git a/packages/flutter/lib/src/painting/text_span.dart b/packages/flutter/lib/src/painting/text_span.dart index a7f068d7d809e..feb610e4a2e7b 100644 --- a/packages/flutter/lib/src/painting/text_span.dart +++ b/packages/flutter/lib/src/painting/text_span.dart @@ -317,6 +317,39 @@ class TextSpan { return true; } + /// Describe the difference between this text span and another, in terms of + /// how much damage it will make to the rendering. The comparison is deep. + /// + /// See also: + /// + /// * [TextStyle.compareTo], which does the same thing for [TextStyle]s. + RenderComparison compareTo(TextSpan other) { + if (identical(this, other)) + return RenderComparison.identical; + if (other.text != text || + children?.length != other.children?.length || + (style == null) != (other.style == null)) + return RenderComparison.layout; + RenderComparison result = recognizer == other.recognizer ? RenderComparison.identical : RenderComparison.metadata; + if (style != null) { + final RenderComparison candidate = style.compareTo(other.style); + if (candidate.index > result.index) + result = candidate; + if (result == RenderComparison.layout) + return result; + } + if (children != null) { + for (int index = 0; index < children.length; index += 1) { + final RenderComparison candidate = children[index].compareTo(other.children[index]); + if (candidate.index > result.index) + result = candidate; + if (result == RenderComparison.layout) + return result; + } + } + return result; + } + @override bool operator ==(dynamic other) { if (identical(this, other)) diff --git a/packages/flutter/lib/src/painting/text_style.dart b/packages/flutter/lib/src/painting/text_style.dart index c7595b8fc1552..f2605523726ec 100644 --- a/packages/flutter/lib/src/painting/text_style.dart +++ b/packages/flutter/lib/src/painting/text_style.dart @@ -387,6 +387,33 @@ class TextStyle { ); } + /// Describe the difference between this style and another, in terms of how + /// much damage it will make to the rendering. + /// + /// See also: + /// + /// * [TextSpan.compareTo], which does the same thing for entire [TextSpan]s. + RenderComparison compareTo(TextStyle other) { + if (identical(this, other)) + return RenderComparison.identical; + if (inherit != other.inherit || + fontFamily != other.fontFamily || + fontSize != other.fontSize || + fontWeight != other.fontWeight || + fontStyle != other.fontStyle || + letterSpacing != other.letterSpacing || + wordSpacing != other.wordSpacing || + textBaseline != other.textBaseline || + height != other.height) + return RenderComparison.layout; + if (color != other.color || + decoration != other.decoration || + decorationColor != other.decorationColor || + decorationStyle != other.decorationStyle) + return RenderComparison.paint; + return RenderComparison.identical; + } + @override bool operator ==(dynamic other) { if (identical(this, other)) diff --git a/packages/flutter/lib/src/rendering/animated_size.dart b/packages/flutter/lib/src/rendering/animated_size.dart index 788404dddca07..28f34a2231cb4 100644 --- a/packages/flutter/lib/src/rendering/animated_size.dart +++ b/packages/flutter/lib/src/rendering/animated_size.dart @@ -10,6 +10,41 @@ import 'box.dart'; import 'object.dart'; import 'shifted_box.dart'; +/// A [RenderAnimatedSize] can be in exactly one of these states. +@visibleForTesting +enum RenderAnimatedSizeState { + /// The initial state, when we do not yet know what the starting and target + /// sizes are to animate. + /// + /// Next possible state is [stable]. + start, + + /// At this state the child's size is assumed to be stable and we are either + /// animating, or waiting for the child's size to change. + /// + /// Next possible state is [changed]. + stable, + + /// At this state we know that the child has changed once after being assumed + /// [stable]. + /// + /// Next possible states are: + /// + /// - [stable] - if the child's size stabilized immediately, this is a signal + /// for us to begin animating the size towards the child's new size. + /// - [unstable] - if the child's size continues to change, we assume it is + /// not stable and enter the [unstable] state. + changed, + + /// At this state the child's size is assumed to be unstable. + /// + /// Instead of chasing the child's size in this state we tightly track the + /// child's size until it stabilizes. + /// + /// Next possible state is [stable]. + unstable, +} + /// A render object that animates its size to its child's size over a given /// [duration] and with a given [curve]. If the child's size itself animates /// (i.e. if it changes size two frames in a row, as opposed to abruptly @@ -60,10 +95,16 @@ class RenderAnimatedSize extends RenderAligningShiftedBox { AnimationController _controller; CurvedAnimation _animation; final SizeTween _sizeTween = new SizeTween(); - bool _didChangeTargetSizeLastFrame = false; bool _hasVisualOverflow; double _lastValue; + /// The state this size animation is in. + /// + /// See [RenderAnimatedSizeState] for possible states. + @visibleForTesting + RenderAnimatedSizeState get state => _state; + RenderAnimatedSizeState _state = RenderAnimatedSizeState.start; + /// The duration of the animation. Duration get duration => _controller.duration; set duration(Duration value) { @@ -82,6 +123,12 @@ class RenderAnimatedSize extends RenderAligningShiftedBox { _animation.curve = value; } + /// Whether the size is being currently animated towards the child's size. + /// + /// See [RenderAnimatedSizeState] for situations when we may not be animating + /// the size. + bool get isAnimating => _controller.isAnimating; + /// The [TickerProvider] for the [AnimationController] that runs the animation. TickerProvider get vsync => _vsync; TickerProvider _vsync; @@ -93,16 +140,10 @@ class RenderAnimatedSize extends RenderAligningShiftedBox { _controller.resync(vsync); } - @override - void attach(PipelineOwner owner) { - super.attach(owner); - if (_animatedSize != _sizeTween.end && !_controller.isAnimating) - _controller.forward(); - } - @override void detach() { _controller.stop(); + _state = RenderAnimatedSizeState.start; super.detach(); } @@ -121,29 +162,25 @@ class RenderAnimatedSize extends RenderAligningShiftedBox { } child.layout(constraints, parentUsesSize: true); - if (_sizeTween.end != child.size) { - _sizeTween.begin = _animatedSize ?? child.size; - _sizeTween.end = child.size; - - if (_didChangeTargetSizeLastFrame) { - size = child.size; - _controller.stop(); - } else { - // Don't register first change as a last-frame change. - if (_sizeTween.end != _sizeTween.begin) - _didChangeTargetSizeLastFrame = true; - - _lastValue = 0.0; - _controller.forward(from: 0.0); - - size = constraints.constrain(_animatedSize); - } - } else { - _didChangeTargetSizeLastFrame = false; - size = constraints.constrain(_animatedSize); + switch(_state) { + case RenderAnimatedSizeState.start: + _layoutStart(); + break; + case RenderAnimatedSizeState.stable: + _layoutStable(); + break; + case RenderAnimatedSizeState.changed: + _layoutChanged(); + break; + case RenderAnimatedSizeState.unstable: + _layoutUnstable(); + break; + default: + throw new StateError('$runtimeType is in an invalid state $_state'); } + size = constraints.constrain(_animatedSize); alignChild(); if (size.width < _sizeTween.end.width || @@ -151,6 +188,69 @@ class RenderAnimatedSize extends RenderAligningShiftedBox { _hasVisualOverflow = true; } + void _restartAnimation() { + _lastValue = 0.0; + _controller.forward(from: 0.0); + } + + /// Laying out the child for the first time. + /// + /// We have the initial size to animate from, but we do not have the target + /// size to animate to, so we set both ends to child's size. + void _layoutStart() { + _sizeTween.begin = _sizeTween.end = child.size; + _state = RenderAnimatedSizeState.stable; + } + + /// At this state we're assuming the child size is stable and letting the + /// animation run its course. + /// + /// If during animation the size of the child changes we restart the + /// animation. + void _layoutStable() { + if (_sizeTween.end != child.size) { + _sizeTween.end = child.size; + _restartAnimation(); + _state = RenderAnimatedSizeState.changed; + } else if (_controller.value == _controller.upperBound) { + // Animation finished. Reset target sizes. + _sizeTween.begin = _sizeTween.end = child.size; + } + } + + /// This state indicates that the size of the child changed once after being + /// considered stable. + /// + /// If the child stabilizes immediately, we go back to stable state. If it + /// changes again, we match the child's size, restart animation and go to + /// unstable state. + void _layoutChanged() { + if (_sizeTween.end != child.size) { + // Child size changed again. Match the child's size and restart animation. + _sizeTween.begin = _sizeTween.end = child.size; + _restartAnimation(); + _state = RenderAnimatedSizeState.unstable; + } else { + // Child size stabilized. + _state = RenderAnimatedSizeState.stable; + } + } + + /// The child's size is not stable. + /// + /// Continue tracking the child's size until is stabilizes. + void _layoutUnstable() { + if (_sizeTween.end != child.size) { + // Still unstable. Continue tracking the child. + _sizeTween.begin = _sizeTween.end = child.size; + _restartAnimation(); + } else { + // Child size stabilized. + _controller.stop(); + _state = RenderAnimatedSizeState.stable; + } + } + @override void paint(PaintingContext context, Offset offset) { if (child != null && _hasVisualOverflow) { diff --git a/packages/flutter/lib/src/rendering/binding.dart b/packages/flutter/lib/src/rendering/binding.dart index 6faf2d72f52f8..655507669dfc4 100644 --- a/packages/flutter/lib/src/rendering/binding.dart +++ b/packages/flutter/lib/src/rendering/binding.dart @@ -309,12 +309,12 @@ abstract class RendererBinding extends BindingBase with SchedulerBinding, Servic /// Prints a textual representation of the entire render tree. void debugDumpRenderTree() { - debugPrint(RendererBinding.instance?.renderView?.toStringDeep()); + debugPrint(RendererBinding.instance?.renderView?.toStringDeep() ?? 'Render tree unavailable.'); } /// Prints a textual representation of the entire layer tree. void debugDumpLayerTree() { - debugPrint(RendererBinding.instance?.renderView?.debugLayer?.toStringDeep()); + debugPrint(RendererBinding.instance?.renderView?.debugLayer?.toStringDeep() ?? 'Layer tree unavailable.'); } /// Prints a textual representation of the entire semantics tree. diff --git a/packages/flutter/lib/src/rendering/debug.dart b/packages/flutter/lib/src/rendering/debug.dart index a4ad85c03f2ec..d0302b7b936e8 100644 --- a/packages/flutter/lib/src/rendering/debug.dart +++ b/packages/flutter/lib/src/rendering/debug.dart @@ -95,9 +95,6 @@ HSVColor debugCurrentRepaintColor = _kDebugCurrentRepaintColor; /// The amount to increment the hue of the current repaint color. double debugRepaintRainbowHueIncrement = _kDebugRepaintRainbowHueIncrement; -/// Log the call stacks that mark render objects as needing paint. -bool debugPrintMarkNeedsPaintStacks = false; - /// Log the call stacks that mark render objects as needing layout. /// /// For sanity, this only logs the stack traces of cases where an object is @@ -106,6 +103,29 @@ bool debugPrintMarkNeedsPaintStacks = false; /// up the tree. bool debugPrintMarkNeedsLayoutStacks = false; +/// Log the call stacks that mark render objects as needing paint. +bool debugPrintMarkNeedsPaintStacks = false; + +/// Log the dirty render objects that are laid out each frame. +/// +/// Combined with [debugPrintBeginFrameBanner], this allows you to distinguish +/// layouts triggered by the initial mounting of a render tree (e.g. in a call +/// to [runApp]) from the regular layouts triggered by the pipeline. +/// +/// Combined with [debugPrintMarkNeedsLayoutStacks], this lets you watch a +/// render object's dirty/clean lifecycle. +/// +/// See also: +/// +/// * [debugProfilePaintsEnabled], which does something similar for +/// painting but using the timeline view. +/// +/// * [debugPrintRebuildDirtyWidgets], which does something similar for widgets +/// being rebuilt. +/// +/// * The discussion at [RendererBinding.drawFrame]. +bool debugPrintLayouts = false; + /// Check the intrinsic sizes of each [RenderBox] during layout. /// /// By default this is turned off since these checks are expensive, but it is @@ -121,6 +141,16 @@ bool debugCheckIntrinsicSizes = false; /// For details on how to use [dart:developer.Timeline] events in the Dart /// Observatory to optimize your app, see: /// +/// +/// See also: +/// +/// * [debugPrintLayouts], which does something similar for layout but using +/// console output. +/// +/// * [debugPrintRebuildDirtyWidgets], which does something similar for widgets +/// being rebuilt. +/// +/// * The discussion at [RendererBinding.drawFrame]. bool debugProfilePaintsEnabled = false; @@ -184,8 +214,9 @@ bool debugAssertAllRenderVarsUnset(String reason, { bool debugCheckIntrinsicSize debugPaintPointersEnabled || debugRepaintRainbowEnabled || debugRepaintTextRainbowEnabled || - debugPrintMarkNeedsPaintStacks || debugPrintMarkNeedsLayoutStacks || + debugPrintMarkNeedsPaintStacks || + debugPrintLayouts || debugCheckIntrinsicSizes != debugCheckIntrinsicSizesOverride || debugProfilePaintsEnabled || debugPaintSizeColor != _kDebugPaintSizeColor || diff --git a/packages/flutter/lib/src/rendering/layer.dart b/packages/flutter/lib/src/rendering/layer.dart index 453fe509e1640..b47b2e86c9836 100644 --- a/packages/flutter/lib/src/rendering/layer.dart +++ b/packages/flutter/lib/src/rendering/layer.dart @@ -754,6 +754,8 @@ class PhysicalModelLayer extends ContainerLayer { void debugFillDescription(List description) { super.debugFillDescription(description); description.add('clipRRect: $clipRRect'); + description.add('elevation: $elevation'); + description.add('color: $color'); } } diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index 67d0d47a643f7..d84ca71e431ff 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -739,7 +739,8 @@ class _RootSemanticsFragment extends _InterestingSemanticsFragment { assert(parentSemantics == null); renderObjectOwner._semantics ??= new SemanticsNode.root( handler: renderObjectOwner is SemanticsActionHandler ? renderObjectOwner as dynamic : null, - owner: renderObjectOwner.owner.semanticsOwner + owner: renderObjectOwner.owner.semanticsOwner, + showOnScreen: renderObjectOwner.showOnScreen, ); final SemanticsNode node = renderObjectOwner._semantics; assert(MatrixUtils.matrixEquals(node.transform, new Matrix4.identity())); @@ -768,7 +769,8 @@ class _ConcreteSemanticsFragment extends _InterestingSemanticsFragment { @override SemanticsNode establishSemanticsNode(_SemanticsGeometry geometry, SemanticsNode currentSemantics, SemanticsNode parentSemantics) { renderObjectOwner._semantics ??= new SemanticsNode( - handler: renderObjectOwner is SemanticsActionHandler ? renderObjectOwner as dynamic : null + handler: renderObjectOwner is SemanticsActionHandler ? renderObjectOwner as dynamic : null, + showOnScreen: renderObjectOwner.showOnScreen, ); final SemanticsNode node = renderObjectOwner._semantics; if (geometry != null) { @@ -812,7 +814,8 @@ class _ImplicitSemanticsFragment extends _InterestingSemanticsFragment { _haveConcreteNode = currentSemantics == null && annotator != null; if (haveConcreteNode) { renderObjectOwner._semantics ??= new SemanticsNode( - handler: renderObjectOwner is SemanticsActionHandler ? renderObjectOwner as dynamic : null + handler: renderObjectOwner is SemanticsActionHandler ? renderObjectOwner as dynamic : null, + showOnScreen: renderObjectOwner.showOnScreen, ); node = renderObjectOwner._semantics; } else { @@ -1556,6 +1559,8 @@ abstract class RenderObject extends AbstractNode implements HitTestTarget { /// to condition their runtime behavior on whether they are dirty or not, /// since they should only be marked dirty immediately prior to being laid /// out and painted. + /// + /// It is intended to be used by tests and asserts. bool get debugNeedsLayout { bool result; assert(() { @@ -1740,6 +1745,8 @@ abstract class RenderObject extends AbstractNode implements HitTestTarget { _debugDoingThisLayout = true; debugPreviousActiveLayout = _debugActiveLayout; _debugActiveLayout = this; + if (debugPrintLayouts) + debugPrint('Laying out (without resize) $this'); return true; }); try { @@ -1846,6 +1853,8 @@ abstract class RenderObject extends AbstractNode implements HitTestTarget { assert(!_doingThisLayoutWithCallback); assert(() { _debugMutationsLocked = true; + if (debugPrintLayouts) + debugPrint('Laying out (${sizedByParent ? "with separate resize" : "with resize allowed"}) $this'); return true; }); if (sizedByParent) { @@ -2126,6 +2135,28 @@ abstract class RenderObject extends AbstractNode implements HitTestTarget { _needsCompositingBitsUpdate = false; } + /// Whether this render object's paint information is dirty. + /// + /// This is only set in debug mode. In general, render objects should not need + /// to condition their runtime behavior on whether they are dirty or not, + /// since they should only be marked dirty immediately prior to being laid + /// out and painted. + /// + /// It is intended to be used by tests and asserts. + /// + /// It is possible (and indeed, quite common) for [debugNeedsPaint] to be + /// false and [debugNeedsLayout] to be true. The render object will still be + /// repainted in the next frame when this is the case, because the + /// [markNeedsPaint] method is implicitly called by the framework after a + /// render object is laid out, prior to the paint phase. + bool get debugNeedsPaint { + bool result; + assert(() { + result = _needsPaint; + return true; + }); + return result; + } bool _needsPaint = true; /// Mark this render object as having changed its visual appearance. @@ -2138,6 +2169,10 @@ abstract class RenderObject extends AbstractNode implements HitTestTarget { /// /// This mechanism batches the painting work so that multiple sequential /// writes are coalesced, removing redundant computation. + /// + /// Once [markNeedsPaint] has been called on a render object, + /// [debugNeedsPaint] returns true for that render object until just after + /// the pipeline owner has called [paint] on the render object. void markNeedsPaint() { assert(owner == null || !owner.debugDoingPaint); if (_needsPaint) @@ -2777,6 +2812,17 @@ abstract class RenderObject extends AbstractNode implements HitTestTarget { @protected String debugDescribeChildren(String prefix) => ''; + + /// Attempt to make this or a descendant RenderObject visible on screen. + /// + /// If [child] is provided, that [RenderObject] is made visible. If [child] is + /// omitted, this [RenderObject] is made visible. + void showOnScreen([RenderObject child]) { + if (parent is RenderObject) { + final RenderObject renderParent = parent; + renderParent.showOnScreen(child ?? this); + } + } } /// Generic mixin for render objects with one child. diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index 51ef993009e9d..667929c55d903 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -64,11 +64,20 @@ class RenderParagraph extends RenderBox { TextSpan get text => _textPainter.text; set text(TextSpan value) { assert(value != null); - if (_textPainter.text == value) - return; - _textPainter.text = value; - _overflowShader = null; - markNeedsLayout(); + switch (_textPainter.text.compareTo(value)) { + case RenderComparison.identical: + case RenderComparison.metadata: + return; + case RenderComparison.paint: + _textPainter.text = value; + markNeedsPaint(); + break; + case RenderComparison.layout: + _textPainter.text = value; + _overflowShader = null; + markNeedsLayout(); + break; + } } /// How the text should be aligned horizontally. diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index 90843e1160bb5..1c36d7d775dad 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/painting.dart'; +import 'package:collection/collection.dart'; import 'package:vector_math/vector_math_64.dart'; import 'box.dart'; @@ -1363,7 +1364,11 @@ class RenderPhysicalModel extends _RenderCustomClip { ); } canvas.drawRRect(offsetClipRRect, new Paint()..color = color); - canvas.saveLayer(offsetBounds, _defaultPaint); + if (offsetClipRRect.isRect) { + canvas.save(); + } else { + canvas.saveLayer(offsetBounds, _defaultPaint); + } canvas.clipRRect(offsetClipRRect); super.paint(context, offset); canvas.restore(); @@ -2731,6 +2736,15 @@ class RenderSemanticsGestureHandler extends RenderProxyBox implements SemanticsA _onVerticalDragUpdate = onVerticalDragUpdate, super(child); + Set get validActions => _validActions; + Set _validActions; + set validActions(Set value) { + if (const SetEquality().equals(value, _validActions)) + return; + _validActions = value; + markNeedsSemanticsUpdate(onlyChanges: true); + } + /// Called when the user taps on the render object. GestureTapCallback get onTap => _onTap; GestureTapCallback _onTap; @@ -2802,14 +2816,25 @@ class RenderSemanticsGestureHandler extends RenderProxyBox implements SemanticsA SemanticsAnnotator get semanticsAnnotator => isSemanticBoundary ? _annotate : null; void _annotate(SemanticsNode node) { + List actions = []; if (onTap != null) - node.addAction(SemanticsAction.tap); + actions.add(SemanticsAction.tap); if (onLongPress != null) - node.addAction(SemanticsAction.longPress); - if (onHorizontalDragUpdate != null) - node.addHorizontalScrollingActions(); - if (onVerticalDragUpdate != null) - node.addVerticalScrollingActions(); + actions.add(SemanticsAction.longPress); + if (onHorizontalDragUpdate != null) { + actions.add(SemanticsAction.scrollRight); + actions.add(SemanticsAction.scrollLeft); + } + if (onVerticalDragUpdate != null) { + actions.add(SemanticsAction.scrollUp); + actions.add(SemanticsAction.scrollDown); + } + + // If a set of validActions has been provided only expose those. + if (validActions != null) + actions = actions.where((SemanticsAction action) => validActions.contains(action)).toList(); + + actions.forEach(node.addAction); } @override diff --git a/packages/flutter/lib/src/rendering/semantics.dart b/packages/flutter/lib/src/rendering/semantics.dart index f2bdd7a9e682c..e0a19c215e1ec 100644 --- a/packages/flutter/lib/src/rendering/semantics.dart +++ b/packages/flutter/lib/src/rendering/semantics.dart @@ -143,8 +143,10 @@ class SemanticsNode extends AbstractNode { /// Each semantic node has a unique identifier that is assigned when the node /// is created. SemanticsNode({ - SemanticsActionHandler handler + SemanticsActionHandler handler, + VoidCallback showOnScreen, }) : id = _generateNewId(), + _showOnScreen = showOnScreen, _actionHandler = handler; /// Creates a semantic node to represent the root of the semantics tree. @@ -152,8 +154,10 @@ class SemanticsNode extends AbstractNode { /// The root node is assigned an identifier of zero. SemanticsNode.root({ SemanticsActionHandler handler, - SemanticsOwner owner + VoidCallback showOnScreen, + SemanticsOwner owner, }) : id = 0, + _showOnScreen = showOnScreen, _actionHandler = handler { attach(owner); } @@ -171,6 +175,7 @@ class SemanticsNode extends AbstractNode { final int id; final SemanticsActionHandler _actionHandler; + final VoidCallback _showOnScreen; // GEOMETRY // These are automatically handled by RenderObject's own logic @@ -734,7 +739,14 @@ class SemanticsOwner extends ChangeNotifier { void performAction(int id, SemanticsAction action) { assert(action != null); final SemanticsActionHandler handler = _getSemanticsActionHandlerForId(id, action); - handler?.performAction(action); + if (handler != null) { + handler.performAction(action); + return; + } + + // Default actions if no [handler] was provided. + if (action == SemanticsAction.showOnScreen && _nodes[id]._showOnScreen != null) + _nodes[id]._showOnScreen(); } SemanticsActionHandler _getSemanticsActionHandlerForPosition(SemanticsNode node, Offset position, SemanticsAction action) { diff --git a/packages/flutter/lib/src/rendering/viewport.dart b/packages/flutter/lib/src/rendering/viewport.dart index 89bc9565d40ef..c8243bae611f0 100644 --- a/packages/flutter/lib/src/rendering/viewport.dart +++ b/packages/flutter/lib/src/rendering/viewport.dart @@ -591,6 +591,24 @@ abstract class RenderViewportBase get childrenInHitTestOrder; + + @override + void showOnScreen([RenderObject child]) { + // Logic duplicated in [_RenderSingleChildViewport.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(); + } } /// A render object that is bigger on the inside. diff --git a/packages/flutter/lib/src/rendering/viewport_offset.dart b/packages/flutter/lib/src/rendering/viewport_offset.dart index 6b1721e2562ba..d5fc99cf84624 100644 --- a/packages/flutter/lib/src/rendering/viewport_offset.dart +++ b/packages/flutter/lib/src/rendering/viewport_offset.dart @@ -152,6 +152,10 @@ abstract class ViewportOffset extends ChangeNotifier { /// being called again, though this should be very rare. void correctBy(double correction); + /// Jumps the scroll position from its current value to the given value, + /// without animation, and without checking if the new value is in range. + void jumpTo(double pixels); + /// The direction in which the user is trying to change [pixels], relative to /// the viewport's [RenderViewport.axisDirection]. /// @@ -208,6 +212,11 @@ class _FixedViewportOffset extends ViewportOffset { _pixels += correction; } + @override + void jumpTo(double pixels) { + // Do nothing, viewport is fixed. + } + @override ScrollDirection get userScrollDirection => ScrollDirection.idle; } diff --git a/packages/flutter/lib/src/scheduler/debug.dart b/packages/flutter/lib/src/scheduler/debug.dart index 3e52836b863eb..4d9c127c1480d 100644 --- a/packages/flutter/lib/src/scheduler/debug.dart +++ b/packages/flutter/lib/src/scheduler/debug.dart @@ -21,7 +21,16 @@ import 'package:flutter/foundation.dart'; /// intra-frame output from inter-frame output, set [debugPrintEndFrameBanner] /// to true as well. /// -/// See [SchedulerBinding.handleBeginFrame]. +/// See also: +/// +/// * [debugProfilePaintsEnabled], which does something similar for +/// painting but using the timeline view. +/// +/// * [debugPrintLayouts], which does something similar for layout but using +/// console output. +/// +/// * The discussions at [WidgetsBinding.drawFrame] and at +/// [SchedulerBinding.handleBeginFrame]. bool debugPrintBeginFrameBanner = false; /// Print a banner at the end of each frame. diff --git a/packages/flutter/lib/src/widgets/animated_cross_fade.dart b/packages/flutter/lib/src/widgets/animated_cross_fade.dart index b64447099f179..307ea068dee09 100644 --- a/packages/flutter/lib/src/widgets/animated_cross_fade.dart +++ b/packages/flutter/lib/src/widgets/animated_cross_fade.dart @@ -46,7 +46,7 @@ enum CrossFadeState { /// ## Sample code /// /// This code fades between two representations of the Flutter logo. It depends -/// on a boolean field `_on`; when `_on` is true, the first logo is shown, +/// on a boolean field `_first`; when `_first` is true, the first logo is shown, /// otherwise the second logo is shown. When the field changes state, the /// [AnimatedCrossFade] widget cross-fades between the two forms of the logo /// over three seconds. @@ -151,15 +151,26 @@ class _AnimatedCrossFadeState extends State with TickerProvid } Animation _initAnimation(Curve curve, bool inverted) { - final CurvedAnimation animation = new CurvedAnimation( + Animation animation = new CurvedAnimation( parent: _controller, curve: curve ); - return inverted ? new Tween( - begin: 1.0, - end: 0.0 - ).animate(animation) : animation; + if (inverted) { + animation = new Tween( + begin: 1.0, + end: 0.0 + ).animate(animation); + } + + animation.addStatusListener((AnimationStatus status) { + setState(() { + // Trigger a rebuild because it depends on _isTransitioning, which + // changes its value together with animation status. + }); + }); + + return animation; } @override @@ -189,49 +200,73 @@ class _AnimatedCrossFadeState extends State with TickerProvid } } - @override - Widget build(BuildContext context) { - List children; + /// Whether we're in the middle of cross-fading this frame. + bool get _isTransitioning => _controller.status == AnimationStatus.forward || _controller.status == AnimationStatus.reverse; - if (_controller.status == AnimationStatus.completed || - _controller.status == AnimationStatus.forward) { - children = [ - new FadeTransition( - opacity: _secondAnimation, - child: widget.secondChild, - ), - new Positioned( + List _buildCrossFadedChildren() { + const Key kFirstChildKey = const ValueKey(CrossFadeState.showFirst); + const Key kSecondChildKey = const ValueKey(CrossFadeState.showSecond); + final bool transitioningForwards = _controller.status == AnimationStatus.completed || _controller.status == AnimationStatus.forward; + + Key topKey; + Widget topChild; + Animation topAnimation; + Key bottomKey; + Widget bottomChild; + Animation bottomAnimation; + if (transitioningForwards) { + topKey = kSecondChildKey; + topChild = widget.secondChild; + topAnimation = _secondAnimation; + bottomKey = kFirstChildKey; + bottomChild = widget.firstChild; + bottomAnimation = _firstAnimation; + } else { + topKey = kFirstChildKey; + topChild = widget.firstChild; + topAnimation = _firstAnimation; + bottomKey = kSecondChildKey; + bottomChild = widget.secondChild; + bottomAnimation = _secondAnimation; + } + + return [ + new TickerMode( + key: bottomKey, + enabled: _isTransitioning, + child: new Positioned( // TODO(dragostis): Add a way to crop from top right for // right-to-left languages. left: 0.0, top: 0.0, right: 0.0, - child: new FadeTransition( - opacity: _firstAnimation, - child: widget.firstChild, + child: new ExcludeSemantics( + excluding: true, // always exclude the semantics of the widget that's fading out + child: new FadeTransition( + opacity: bottomAnimation, + child: bottomChild, + ), ), ), - ]; - } else { - children = [ - new FadeTransition( - opacity: _firstAnimation, - child: widget.firstChild, - ), - new Positioned( - // TODO(dragostis): Add a way to crop from top right for - // right-to-left languages. - left: 0.0, - top: 0.0, - right: 0.0, - child: new FadeTransition( - opacity: _secondAnimation, - child: widget.secondChild, + ), + new TickerMode( + key: topKey, + enabled: true, // top widget always has its animations enabled + child: new Positioned( + child: new ExcludeSemantics( + excluding: false, // always publish semantics for the widget that's fading in + child: new FadeTransition( + opacity: topAnimation, + child: topChild, + ), ), ), - ]; - } + ), + ]; + } + @override + Widget build(BuildContext context) { return new ClipRect( child: new AnimatedSize( key: new ValueKey(widget.key), @@ -241,7 +276,7 @@ class _AnimatedCrossFadeState extends State with TickerProvid vsync: this, child: new Stack( overflow: Overflow.visible, - children: children, + children: _buildCrossFadedChildren(), ), ), ); diff --git a/packages/flutter/lib/src/widgets/gesture_detector.dart b/packages/flutter/lib/src/widgets/gesture_detector.dart index 5c681f35a2015..ad3db0e259621 100644 --- a/packages/flutter/lib/src/widgets/gesture_detector.dart +++ b/packages/flutter/lib/src/widgets/gesture_detector.dart @@ -535,6 +535,25 @@ class RawGestureDetectorState extends State { } } + void replaceSemanticsActions(Set actions) { + assert(() { + if (!context.findRenderObject().owner.debugDoingLayout) { + throw new FlutterError( + 'Unexpected call to replaceSemanticsActions() method of RawGestureDetectorState.\n' + 'The replaceSemanticsActions() method can only be called during the layout phase.' + ); + } + return true; + }); + if (!widget.excludeFromSemantics) { + final RenderSemanticsGestureHandler semanticsGestureHandler = context.findRenderObject(); + context.visitChildElements((Element element) { + final _GestureSemantics widget = element.widget; + widget._updateSemanticsActions(semanticsGestureHandler, actions); + }); + } + } + @override void dispose() { for (GestureRecognizer recognizer in _recognizers.values) @@ -714,6 +733,10 @@ class _GestureSemantics extends SingleChildRenderObjectWidget { recognizers.containsKey(PanGestureRecognizer) ? _handleVerticalDragUpdate : null; } + void _updateSemanticsActions(RenderSemanticsGestureHandler renderObject, Set actions) { + renderObject.validActions = actions; + } + @override void updateRenderObject(BuildContext context, RenderSemanticsGestureHandler renderObject) { _updateHandlers(renderObject, owner._recognizers); diff --git a/packages/flutter/lib/src/widgets/navigator.dart b/packages/flutter/lib/src/widgets/navigator.dart index 6cc8e4499f6f3..d02f720437921 100644 --- a/packages/flutter/lib/src/widgets/navigator.dart +++ b/packages/flutter/lib/src/widgets/navigator.dart @@ -204,6 +204,7 @@ class RouteSettings { /// Creates data used to construct routes. const RouteSettings({ this.name, + this.arguments, this.isInitialRoute: false, }); @@ -211,10 +212,12 @@ class RouteSettings { /// replaced with the new values. RouteSettings copyWith({ String name, + Map arguments, bool isInitialRoute, }) { return new RouteSettings( name: name ?? this.name, + arguments: arguments ?? this.arguments, isInitialRoute: isInitialRoute ?? this.isInitialRoute, ); } @@ -224,6 +227,11 @@ class RouteSettings { /// If null, the route is anonymous. final String name; + /// The arguments map that this route was created with. + /// + /// If null, there were no arguments given for this route. + final Map arguments; + /// Whether this route is the very first route being pushed onto this [Navigator]. /// /// The initial route typically skips any entrance transition to speed startup. @@ -558,8 +566,9 @@ class Navigator extends StatefulWidget { /// ```dart /// Navigator.pushNamed(context, '/nyc/1776'); /// ``` - static Future pushNamed(BuildContext context, String routeName) { - return Navigator.of(context).pushNamed(routeName); + static Future pushNamed(BuildContext context, String routeName, + {Map arguments}) { + return Navigator.of(context).pushNamed(routeName, arguments: arguments); } /// Adds the given route to the history of the navigator that most tightly @@ -862,11 +871,12 @@ class NavigatorState extends State with TickerProviderStateMixin { bool _debugLocked = false; // used to prevent re-entrant calls to push, pop, and friends - Route _routeNamed(String name, { bool allowNull: false }) { + Route _routeNamed(String name, { bool allowNull: false, Map arguments}) { assert(!_debugLocked); assert(name != null); final RouteSettings settings = new RouteSettings( name: name, + arguments: arguments, isInitialRoute: _history.isEmpty, ); Route route = widget.onGenerateRoute(settings); @@ -913,8 +923,8 @@ class NavigatorState extends State with TickerProviderStateMixin { /// ```dart /// Navigator.of(context).pushNamed('/nyc/1776'); /// ``` - Future pushNamed(String name) { - return push(_routeNamed(name)); + Future pushNamed(String name, {Map arguments}) { + return push(_routeNamed(name, arguments: arguments)); } /// Adds the given route to the navigator's history, and transitions to it. @@ -1036,15 +1046,17 @@ class NavigatorState extends State with TickerProviderStateMixin { return newRoute.popped; } - /// Push the route named [name] and dispose the old current route. + /// Push the route named [name] with optional [arguments] and dispose the + /// old current route. /// - /// The route name will be passed to [Navigator.onGenerateRoute]. The returned - /// route will be pushed into the navigator. + /// The new route information will be passed to [Navigator.onGenerateRoute]. + /// The returned route will be pushed into the navigator. /// /// Returns a [Future] that completes to the `result` value passed to [pop] /// when the pushed route is popped off the navigator. - Future pushReplacementNamed(String name, { dynamic result }) { - return pushReplacement(_routeNamed(name), result: result); + Future pushReplacementNamed(String name, + { dynamic result, Map arguments }) { + return pushReplacement(_routeNamed(name, arguments: arguments), result: result); } /// Replaces a route that is not currently visible with a new route. @@ -1135,8 +1147,8 @@ class NavigatorState extends State with TickerProviderStateMixin { return newRoute.popped; } - /// Push the route with the given name and then remove all the previous routes - /// until the `predicate` returns true. + /// Push the route with the given `routeName`, and optional `arguments`, and + /// then remove all the previous routes until the `predicate` returns true. /// /// The predicate may be applied to the same route more than once if /// [Route.willHandlePopInternally] is true. @@ -1146,8 +1158,9 @@ class NavigatorState extends State with TickerProviderStateMixin { /// /// To remove all the routes before the pushed route, use a [RoutePredicate] /// that always returns false. - Future pushNamedAndRemoveUntil(String routeName, RoutePredicate predicate) { - return pushAndRemoveUntil(_routeNamed(routeName), predicate); + Future pushNamedAndRemoveUntil(String routeName, RoutePredicate predicate, + {Map arguments}) { + return pushAndRemoveUntil(_routeNamed(routeName, arguments: arguments), predicate); } /// Tries to pop the current route, first giving the active route the chance diff --git a/packages/flutter/lib/src/widgets/nested_scroll_view.dart b/packages/flutter/lib/src/widgets/nested_scroll_view.dart index 2b6280ec30bd8..bd6a146dea44d 100644 --- a/packages/flutter/lib/src/widgets/nested_scroll_view.dart +++ b/packages/flutter/lib/src/widgets/nested_scroll_view.dart @@ -32,12 +32,10 @@ import 'ticker_provider.dart'; /// content ostensibly below it. typedef List NestedScrollViewHeaderSliversBuilder(BuildContext context, bool innerBoxIsScrolled); -// TODO(abarth): Make this configurable with a controller. -const double _kInitialScrollOffset = 0.0; - class NestedScrollView extends StatefulWidget { const NestedScrollView({ Key key, + this.controller, this.scrollDirection: Axis.vertical, this.reverse: false, this.physics, @@ -49,7 +47,9 @@ class NestedScrollView extends StatefulWidget { assert(body != null), super(key: key); - // TODO(ianh): we should expose a controller so you can call animateTo, etc. + /// An object that can be used to control the position to which the outer + /// scroll view is scrolled. + final ScrollController controller; /// The axis along which the scroll view scrolls. /// @@ -114,7 +114,7 @@ class _NestedScrollViewState extends State { @override void initState() { super.initState(); - _coordinator = new _NestedScrollCoordinator(context, _kInitialScrollOffset); + _coordinator = new _NestedScrollCoordinator(context, widget.controller); } @override @@ -170,12 +170,14 @@ class _NestedScrollMetrics extends FixedScrollMetrics { typedef ScrollActivity _NestedScrollActivityGetter(_NestedScrollPosition position); class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldController { - _NestedScrollCoordinator(this._context, double initialScrollOffset) { + _NestedScrollCoordinator(this._context, this._parent) { + final double initialScrollOffset = _parent?.initialScrollOffset ?? 0.0; _outerController = new _NestedScrollController(this, initialScrollOffset: initialScrollOffset, debugLabel: 'outer'); - _innerController = new _NestedScrollController(this, initialScrollOffset: initialScrollOffset, debugLabel: 'inner'); + _innerController = new _NestedScrollController(this, initialScrollOffset: 0.0, debugLabel: 'inner'); } final BuildContext _context; + final ScrollController _parent; _NestedScrollController _outerController; _NestedScrollController _innerController; @@ -407,7 +409,7 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont Future animateTo(double to, { @required Duration duration, @required Curve curve, - }) { + }) async { final DrivenScrollActivity outerActivity = _outerPosition.createDrivenScrollActivity( nestOffset(to, _outerPosition), duration, @@ -426,7 +428,7 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont return innerActivity; }, ); - return Future.wait(resultFutures); + await Future.wait(resultFutures); } void jumpTo(double to) { @@ -513,7 +515,7 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont } void updateParent() { - _outerPosition?.setParent(PrimaryScrollController.of(_context)); + _outerPosition?.setParent(_parent ?? PrimaryScrollController.of(_context)); } @mustCallSuper @@ -827,7 +829,6 @@ class _NestedOuterBallisticScrollActivity extends BallisticScrollActivity { done = true; } } else if (velocity < 0.0) { - assert(velocity < 0.0); if (value > metrics.maxRange) return true; if (value < metrics.minRange) { diff --git a/packages/flutter/lib/src/widgets/overscroll_indicator.dart b/packages/flutter/lib/src/widgets/overscroll_indicator.dart index 83fee2b526d6c..266cf2123d938 100644 --- a/packages/flutter/lib/src/widgets/overscroll_indicator.dart +++ b/packages/flutter/lib/src/widgets/overscroll_indicator.dart @@ -117,12 +117,14 @@ class GlowingOverscrollIndicator extends StatefulWidget { class _GlowingOverscrollIndicatorState extends State with TickerProviderStateMixin { _GlowController _leadingController; _GlowController _trailingController; + Listenable _leadingAndTrailingListener; @override void initState() { super.initState(); _leadingController = new _GlowController(vsync: this, color: widget.color, axis: widget.axis); _trailingController = new _GlowController(vsync: this, color: widget.color, axis: widget.axis); + _leadingAndTrailingListener = new Listenable.merge([_leadingController, _trailingController]); } @override @@ -210,6 +212,7 @@ class _GlowingOverscrollIndicatorState extends State leadingController: widget.showLeading ? _leadingController : null, trailingController: widget.showTrailing ? _trailingController : null, axisDirection: widget.axisDirection, + repaint: _leadingAndTrailingListener, ), child: new RepaintBoundary( child: widget.child, @@ -444,8 +447,9 @@ class _GlowingOverscrollIndicatorPainter extends CustomPainter { this.leadingController, this.trailingController, this.axisDirection, + Listenable repaint, }) : super( - repaint: new Listenable.merge([leadingController, trailingController]) + repaint: repaint, ); /// The controller for the overscroll glow on the side with negative scroll offsets. diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart index fd75f000e0cd5..80f7cb1ae4a20 100644 --- a/packages/flutter/lib/src/widgets/routes.dart +++ b/packages/flutter/lib/src/widgets/routes.dart @@ -971,11 +971,37 @@ abstract class PopupRoute extends ModalRoute { /// A [Navigator] observer that notifies [RouteAware]s of changes to the /// state of their [Route]. /// +/// [RouteObserver] informs subscribers whenever a route of type `T` is pushed +/// on top of their own route of type `T` or popped from it. This is for example +/// useful to keep track of page transitions, e.i. a `RouteObserver` +/// will inform subscribed [RouteAware]s whenever the user navigates away from +/// the current page route to another page route. +/// +/// If you want to be informed about route changes of any type, you should +/// instantiate a `RouteObserver`. +/// +/// ## Sample code +/// /// To make a [StatefulWidget] aware of its current [Route] state, implement -/// [RouteAware] in its [State] and subscribe it to the [RouteObserver]: +/// [RouteAware] in its [State] and subscribe it to a [RouteObserver]: /// /// ```dart +/// // Register the RouteObserver as a navigation observer. +/// final RouteObserver routeObserver = new RouteObserver(); +/// void main() { +/// runApp(new MaterialApp( +/// home: new Container(), +/// navigatorObservers: [routeObserver], +/// )); +/// } +/// +/// class RouteAwareWidget extends StatefulWidget { +/// State createState() => new RouteAwareWidgetState(); +/// } +/// +/// // Implement RouteAware in a widget's state and subscribe it to the RouteObserver. /// class RouteAwareWidgetState extends State with RouteAware { +/// /// @override /// void didChangeDependencies() { /// super.didChangeDependencies(); @@ -990,19 +1016,28 @@ abstract class PopupRoute extends ModalRoute { /// /// @override /// void didPush() { -/// // Do something +/// // Route was pushed onto navigator and is now topmost route. /// } /// /// @override /// void didPopNext() { -/// // Do something +/// // Covering route was popped off the navigator. /// } /// +/// @override +/// Widget build(BuildContext context) => new Container(); +/// /// } +/// /// ``` class RouteObserver> extends NavigatorObserver { final Map _listeners = {}; + /// Subscribe [routeAware] to be informed about changes to [route]. + /// + /// Going forward, [routeAware] will be informed about qualifying changes + /// to [route], e.g. when [route] is covered by another route or when [route] + /// is popped off the [Navigator] stack. void subscribe(RouteAware routeAware, T route) { assert(routeAware != null); assert(route != null); @@ -1012,6 +1047,9 @@ class RouteObserver> extends NavigatorObserver { } } + /// Unsubscribe [routeAware]. + /// + /// [routeAware] is no longer informed about changes to its route. void unsubscribe(RouteAware routeAware) { assert(routeAware != null); _listeners.remove(routeAware); @@ -1048,4 +1086,4 @@ abstract class RouteAware { /// Called when a new route has been pushed, and the current route is no /// longer visible. void didPushNext() { } -} \ No newline at end of file +} diff --git a/packages/flutter/lib/src/widgets/scroll_context.dart b/packages/flutter/lib/src/widgets/scroll_context.dart index 71fec6d219ddd..ef62f6b7659e8 100644 --- a/packages/flutter/lib/src/widgets/scroll_context.dart +++ b/packages/flutter/lib/src/widgets/scroll_context.dart @@ -56,4 +56,7 @@ abstract class ScrollContext { /// Whether the user can drag the widget, for example to initiate a scroll. void setCanDrag(bool value); + + /// Set the [SemanticsAction]s that should be expose to the semantics tree. + void setSemanticsActions(Set actions); } diff --git a/packages/flutter/lib/src/widgets/scroll_controller.dart b/packages/flutter/lib/src/widgets/scroll_controller.dart index b96c5de4e5373..379ba043400dc 100644 --- a/packages/flutter/lib/src/widgets/scroll_controller.dart +++ b/packages/flutter/lib/src/widgets/scroll_controller.dart @@ -45,11 +45,12 @@ class ScrollController extends ChangeNotifier { /// /// The values of `initialScrollOffset` and `keepScrollOffset` must not be null. ScrollController({ - this.initialScrollOffset: 0.0, + double initialScrollOffset: 0.0, this.keepScrollOffset: true, this.debugLabel, }) : assert(initialScrollOffset != null), - assert(keepScrollOffset != null); + assert(keepScrollOffset != null), + _initialScrollOffset = initialScrollOffset; /// The initial value to use for [offset]. /// @@ -58,7 +59,8 @@ class ScrollController extends ChangeNotifier { /// if [keepScrollOffset] is false or a scroll offset hasn't been saved yet. /// /// Defaults to 0.0. - final double initialScrollOffset; + final double _initialScrollOffset; + double get initialScrollOffset => _initialScrollOffset; /// Each time a scroll completes, save the current scroll [offset] with /// [PageStorage] and restore it if this controller's scrollable is recreated. @@ -266,3 +268,89 @@ class ScrollController extends ChangeNotifier { } } } + +// Examples can assume: +// TrackingScrollController _trackingScrollController; + +/// A [ScrollController] whose `initialScrollOffset` tracks its most recently +/// updated [ScrollPosition]. +/// +/// This class can be used to synchronize the scroll offset of two or more +/// lazily created scroll views that share a single [TrackingScrollController]. +/// It tracks the most recently updated scroll position and reports it as its +/// `initialScrollOffset`. +/// +/// ## Sample code +/// +/// In this example each [PageView] page contains a [ListView] and all three +/// [ListView]'s share a [TrackingController]. The scroll offsets of all three +/// list views will track each other, to the extent that's possible given the +/// different list lengths. +/// +/// ```dart +/// new PageView( +/// children: [ +/// new ListView( +/// controller: _trackingScrollController, +/// children: new List.generate(100, (int i) => new Text('page 0 item $i')).toList(), +/// ), +/// new ListView( +/// controller: _trackingScrollController, +/// children: new List.generate(200, (int i) => new Text('page 1 item $i')).toList(), +/// ), +/// new ListView( +/// controller: _trackingScrollController, +/// children: new List.generate(300, (int i) => new Text('page 2 item $i')).toList(), +/// ), +/// ], +/// ) +/// ``` +/// +/// In this example the `_trackingController` would have been created by the +/// stateful widget that built the widget tree. +class TrackingScrollController extends ScrollController { + TrackingScrollController({ + double initialScrollOffset: 0.0, + bool keepScrollOffset: true, + String debugLabel, + }) : super(initialScrollOffset: initialScrollOffset, + keepScrollOffset: keepScrollOffset, + debugLabel: debugLabel); + + Map _positionToListener = {}; + ScrollPosition _lastUpdated; + + /// The last [ScrollPosition] to change. Returns null if there aren't any + /// attached scroll positions or there hasn't been any scrolling yet. + ScrollPosition get mostRecentlyUpdatedPosition => _lastUpdated; + + /// Returns the scroll offset of the [mostRecentlyUpdatedPosition] or 0.0. + @override + double get initialScrollOffset => _lastUpdated?.pixels ?? super.initialScrollOffset; + + @override + void attach(ScrollPosition position) { + super.attach(position); + assert(!_positionToListener.containsKey(position)); + _positionToListener[position] = () { _lastUpdated = position; }; + position.addListener(_positionToListener[position]); + } + + @override + void detach(ScrollPosition position) { + super.detach(position); + assert(_positionToListener.containsKey(position)); + position.removeListener(_positionToListener[position]); + _positionToListener.remove(position); + } + + @override + void dispose() { + for (ScrollPosition position in positions) { + assert(_positionToListener.containsKey(position)); + position.removeListener(_positionToListener[position]); + } + _positionToListener.clear(); + super.dispose(); + } +} diff --git a/packages/flutter/lib/src/widgets/scroll_position.dart b/packages/flutter/lib/src/widgets/scroll_position.dart index c96ee6ea2fb93..103fb0a0e321e 100644 --- a/packages/flutter/lib/src/widgets/scroll_position.dart +++ b/packages/flutter/lib/src/widgets/scroll_position.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; @@ -367,6 +368,35 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { return true; } + Set _semanticActions; + + void _updateSemanticActions() { + SemanticsAction forward; + SemanticsAction backward; + switch (axis) { + case Axis.vertical: + forward = SemanticsAction.scrollUp; + backward = SemanticsAction.scrollDown; + break; + case Axis.horizontal: + forward = SemanticsAction.scrollLeft; + backward = SemanticsAction.scrollRight; + break; + } + + final Set actions = new Set(); + if (pixels > minScrollExtent) + actions.add(backward); + if (pixels < maxScrollExtent) + actions.add(forward); + + if (const SetEquality().equals(actions, _semanticActions)) + return; + + _semanticActions = actions; + context.setSemanticsActions(_semanticActions); + } + @override bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) { if (_minScrollExtent != minScrollExtent || @@ -378,6 +408,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { applyNewDimensions(); _didChangeViewportDimension = false; } + _updateSemanticActions(); return true; } @@ -479,6 +510,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { /// If this method changes the scroll position, a sequence of start/update/end /// scroll notifications will be dispatched. No overscroll notifications can /// be generated by this method. + @override void jumpTo(double value); /// Deprecated. Use [jumpTo] or a custom [ScrollPosition] instead. diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index 45e06e99cf249..1284b0e8171a7 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -304,6 +304,16 @@ class ScrollableState extends State with TickerProviderStateMixin } + // SEMANTICS ACTIONS + + @override + @protected + void setSemanticsActions(Set actions) { + if (_gestureDetectorKey.currentState != null) + _gestureDetectorKey.currentState.replaceSemanticsActions(actions); + } + + // GESTURE RECOGNITION AND POINTER IGNORING final GlobalKey _gestureDetectorKey = new GlobalKey(); diff --git a/packages/flutter/lib/src/widgets/single_child_scroll_view.dart b/packages/flutter/lib/src/widgets/single_child_scroll_view.dart index e7af006eb435c..9b75768b50f03 100644 --- a/packages/flutter/lib/src/widgets/single_child_scroll_view.dart +++ b/packages/flutter/lib/src/widgets/single_child_scroll_view.dart @@ -418,4 +418,23 @@ class _RenderSingleChildViewport extends RenderBox with RenderObjectWithChildMix 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(); + } } diff --git a/packages/flutter/test/material/slider_test.dart b/packages/flutter/test/material/slider_test.dart index 3942e4039553d..05f876bc2080c 100644 --- a/packages/flutter/test/material/slider_test.dart +++ b/packages/flutter/test/material/slider_test.dart @@ -118,6 +118,72 @@ void main() { log.clear(); }); + testWidgets('Slider has a customizable active color', + (WidgetTester tester) async { + final Color customColor = const Color(0xFF4CD964); + final ThemeData theme = new ThemeData(platform: TargetPlatform.android); + Widget buildApp(Color activeColor) { + return new Material( + child: new Center( + child: new Theme( + data: theme, + child: new Slider( + value: 0.5, + activeColor: activeColor, + onChanged: (double newValue) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp(null)); + + final RenderBox sliderBox = + tester.firstRenderObject(find.byType(Slider)); + + expect(sliderBox, paints..rect(color: theme.accentColor)..rect(color: theme.unselectedWidgetColor)); + expect(sliderBox, paints..circle(color: theme.accentColor)); + expect(sliderBox, isNot(paints..circle(color: customColor))); + expect(sliderBox, isNot(paints..circle(color: theme.unselectedWidgetColor))); + await tester.pumpWidget(buildApp(customColor)); + expect(sliderBox, paints..rect(color: customColor)..rect(color: theme.unselectedWidgetColor)); + expect(sliderBox, paints..circle(color: customColor)); + expect(sliderBox, isNot(paints..circle(color: theme.accentColor))); + expect(sliderBox, isNot(paints..circle(color: theme.unselectedWidgetColor))); + }); + + testWidgets('Slider has a customizable inactive color', + (WidgetTester tester) async { + final Color customColor = const Color(0xFF4CD964); + final ThemeData theme = new ThemeData(platform: TargetPlatform.android); + Widget buildApp(Color inactiveColor) { + return new Material( + child: new Center( + child: new Theme( + data: theme, + child: new Slider( + value: 0.5, + inactiveColor: inactiveColor, + onChanged: (double newValue) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp(null)); + + final RenderBox sliderBox = + tester.firstRenderObject(find.byType(Slider)); + + expect(sliderBox, paints..rect(color: theme.accentColor)..rect(color: theme.unselectedWidgetColor)); + expect(sliderBox, paints..circle(color: theme.accentColor)); + await tester.pumpWidget(buildApp(customColor)); + expect(sliderBox, paints..rect(color: theme.accentColor)..rect(color: customColor)); + expect(sliderBox, paints..circle(color: theme.accentColor)); + }); + testWidgets('Slider can draw an open thumb at min', (WidgetTester tester) async { Widget buildApp(bool thumbOpenAtMin) { diff --git a/packages/flutter/test/rendering/paragraph_test.dart b/packages/flutter/test/rendering/paragraph_test.dart index e90d7f28d5553..ed01b1e7d2343 100644 --- a/packages/flutter/test/rendering/paragraph_test.dart +++ b/packages/flutter/test/rendering/paragraph_test.dart @@ -177,6 +177,36 @@ void main() { expect(paragraph.size.height, 30.0); }); + test('changing color does not do layout', () { + final RenderParagraph paragraph = new RenderParagraph( + const TextSpan( + text: 'Hello', + style: const TextStyle(color: const Color(0xFF000000)), + ), + ); + layout(paragraph, constraints: const BoxConstraints(maxWidth: 100.0), phase: EnginePhase.paint); + expect(paragraph.debugNeedsLayout, isFalse); + expect(paragraph.debugNeedsPaint, isFalse); + paragraph.text = const TextSpan( + text: 'Hello World', + style: const TextStyle(color: const Color(0xFF000000)), + ); + expect(paragraph.debugNeedsLayout, isTrue); + expect(paragraph.debugNeedsPaint, isFalse); + pumpFrame(phase: EnginePhase.paint); + expect(paragraph.debugNeedsLayout, isFalse); + expect(paragraph.debugNeedsPaint, isFalse); + paragraph.text = const TextSpan( + text: 'Hello World', + style: const TextStyle(color: const Color(0xFFFFFFFF)), + ); + expect(paragraph.debugNeedsLayout, isFalse); + expect(paragraph.debugNeedsPaint, isTrue); + pumpFrame(phase: EnginePhase.paint); + expect(paragraph.debugNeedsLayout, isFalse); + expect(paragraph.debugNeedsPaint, isFalse); + }); + test('toStringDeep', () { final RenderParagraph paragraph = new RenderParagraph( const TextSpan(text: _kText), diff --git a/packages/flutter/test/rendering/proxy_box_test.dart b/packages/flutter/test/rendering/proxy_box_test.dart index ff0958d654ed5..99502d600ceff 100644 --- a/packages/flutter/test/rendering/proxy_box_test.dart +++ b/packages/flutter/test/rendering/proxy_box_test.dart @@ -34,7 +34,7 @@ void main() { test('RenderPhysicalModel compositing on Fuchsia', () { debugDefaultTargetPlatformOverride = TargetPlatform.fuchsia; - final root = new RenderPhysicalModel(color: new Color(0xffff00ff)); + final RenderPhysicalModel root = new RenderPhysicalModel(color: const Color(0xffff00ff)); layout(root, phase: EnginePhase.composite); expect(root.needsCompositing, isFalse); @@ -54,7 +54,7 @@ void main() { test('RenderPhysicalModel compositing on non-Fuchsia', () { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - final root = new RenderPhysicalModel(color: new Color(0xffff00ff)); + final RenderPhysicalModel root = new RenderPhysicalModel(color: const Color(0xffff00ff)); layout(root, phase: EnginePhase.composite); expect(root.needsCompositing, isFalse); diff --git a/packages/flutter/test/widgets/animated_cross_fade_test.dart b/packages/flutter/test/widgets/animated_cross_fade_test.dart index d0a9dfc434b62..18c2a2afa8c89 100644 --- a/packages/flutter/test/widgets/animated_cross_fade_test.dart +++ b/packages/flutter/test/widgets/animated_cross_fade_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/scheduler.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; @@ -131,4 +132,81 @@ void main() { expect(box2.localToGlobal(Offset.zero), const Offset(275.0, 175.0)); }); + Widget crossFadeWithWatcher({bool towardsSecond: false}) { + return new AnimatedCrossFade( + firstChild: const _TickerWatchingWidget(), + secondChild: new Container(), + crossFadeState: towardsSecond ? CrossFadeState.showSecond : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 50), + ); + } + + testWidgets('AnimatedCrossFade preserves widget state', (WidgetTester tester) async { + await tester.pumpWidget(crossFadeWithWatcher()); + + _TickerWatchingWidgetState findState() => tester.state(find.byType(_TickerWatchingWidget)); + final _TickerWatchingWidgetState state = findState(); + + await tester.pumpWidget(crossFadeWithWatcher(towardsSecond: true)); + for (int i = 0; i < 3; i += 1) { + await tester.pump(const Duration(milliseconds: 25)); + expect(findState(), same(state)); + } + }); + + testWidgets('AnimatedCrossFade switches off TickerMode and semantics on faded out widget', (WidgetTester tester) async { + ExcludeSemantics findSemantics() { + return tester.widget(find.descendant( + of: find.byKey(const ValueKey(CrossFadeState.showFirst)), + matching: find.byType(ExcludeSemantics), + )); + } + + await tester.pumpWidget(crossFadeWithWatcher()); + + final _TickerWatchingWidgetState state = tester.state(find.byType(_TickerWatchingWidget)); + expect(state.ticker.muted, false); + expect(findSemantics().excluding, false); + + await tester.pumpWidget(crossFadeWithWatcher(towardsSecond: true)); + for (int i = 0; i < 2; i += 1) { + await tester.pump(const Duration(milliseconds: 25)); + // Animations are kept alive in the middle of cross-fade + expect(state.ticker.muted, false); + // Semantics are turned off immediately on the widget that's fading out + expect(findSemantics().excluding, true); + } + + // In the final state both animations and semantics should be off on the + // widget that's faded out. + await tester.pump(const Duration(milliseconds: 25)); + expect(state.ticker.muted, true); + expect(findSemantics().excluding, true); + }); +} + +class _TickerWatchingWidget extends StatefulWidget { + const _TickerWatchingWidget(); + + @override + State createState() => new _TickerWatchingWidgetState(); +} + +class _TickerWatchingWidgetState extends State<_TickerWatchingWidget> with SingleTickerProviderStateMixin { + Ticker ticker; + + @override + void initState() { + super.initState(); + ticker = createTicker((_) {})..start(); + } + + @override + Widget build(BuildContext context) => new Container(); + + @override + void dispose() { + ticker.dispose(); + super.dispose(); + } } diff --git a/packages/flutter/test/widgets/animated_size_test.dart b/packages/flutter/test/widgets/animated_size_test.dart index 2c0b8609f67fa..fd669a2a3cdc3 100644 --- a/packages/flutter/test/widgets/animated_size_test.dart +++ b/packages/flutter/test/widgets/animated_size_test.dart @@ -16,197 +16,268 @@ class TestPaintingContext implements PaintingContext { } void main() { - testWidgets('AnimatedSize test', (WidgetTester tester) async { - await tester.pumpWidget( - new Center( - child: new AnimatedSize( - duration: const Duration(milliseconds: 200), - vsync: tester, - child: const SizedBox( - width: 100.0, - height: 100.0, + group('AnimatedSize', () { + testWidgets('animates forwards then backwards with stable-sized children', (WidgetTester tester) async { + await tester.pumpWidget( + new Center( + child: new AnimatedSize( + duration: const Duration(milliseconds: 200), + vsync: tester, + child: const SizedBox( + width: 100.0, + height: 100.0, + ), ), ), - ), - ); - - RenderBox box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(100.0)); - expect(box.size.height, equals(100.0)); - - await tester.pumpWidget( - new Center( - child: new AnimatedSize( - duration: const Duration(milliseconds: 200), - vsync: tester, - child: const SizedBox( - width: 200.0, - height: 200.0, + ); + + RenderBox box = tester.renderObject(find.byType(AnimatedSize)); + expect(box.size.width, equals(100.0)); + expect(box.size.height, equals(100.0)); + + await tester.pumpWidget( + new Center( + child: new AnimatedSize( + duration: const Duration(milliseconds: 200), + vsync: tester, + child: const SizedBox( + width: 200.0, + height: 200.0, + ), ), ), - ), - ); - - await tester.pump(const Duration(milliseconds: 100)); - box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(150.0)); - expect(box.size.height, equals(150.0)); - - TestPaintingContext context = new TestPaintingContext(); - box.paint(context, Offset.zero); - expect(context.invocations.first.memberName, equals(#pushClipRect)); - - await tester.pump(const Duration(milliseconds: 100)); - box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(200.0)); - expect(box.size.height, equals(200.0)); - - await tester.pumpWidget( - new Center( - child: new AnimatedSize( - duration: const Duration(milliseconds: 200), - vsync: tester, - child: const SizedBox( + ); + + await tester.pump(const Duration(milliseconds: 100)); + box = tester.renderObject(find.byType(AnimatedSize)); + expect(box.size.width, equals(150.0)); + expect(box.size.height, equals(150.0)); + + TestPaintingContext context = new TestPaintingContext(); + box.paint(context, Offset.zero); + expect(context.invocations.first.memberName, equals(#pushClipRect)); + + await tester.pump(const Duration(milliseconds: 100)); + box = tester.renderObject(find.byType(AnimatedSize)); + expect(box.size.width, equals(200.0)); + expect(box.size.height, equals(200.0)); + + await tester.pumpWidget( + new Center( + child: new AnimatedSize( + duration: const Duration(milliseconds: 200), + vsync: tester, + child: const SizedBox( + width: 100.0, + height: 100.0, + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 100)); + box = tester.renderObject(find.byType(AnimatedSize)); + expect(box.size.width, equals(150.0)); + expect(box.size.height, equals(150.0)); + + context = new TestPaintingContext(); + box.paint(context, Offset.zero); + expect(context.invocations.first.memberName, equals(#paintChild)); + + await tester.pump(const Duration(milliseconds: 100)); + box = tester.renderObject(find.byType(AnimatedSize)); + expect(box.size.width, equals(100.0)); + expect(box.size.height, equals(100.0)); + }); + + testWidgets('clamps animated size to constraints', (WidgetTester tester) async { + await tester.pumpWidget( + new Center( + child: new SizedBox ( width: 100.0, height: 100.0, + child: new AnimatedSize( + duration: const Duration(milliseconds: 200), + vsync: tester, + child: const SizedBox( + width: 100.0, + height: 100.0, + ), + ), ), ), - ), - ); - - await tester.pump(const Duration(milliseconds: 100)); - box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(150.0)); - expect(box.size.height, equals(150.0)); - - context = new TestPaintingContext(); - box.paint(context, Offset.zero); - expect(context.invocations.first.memberName, equals(#paintChild)); - - await tester.pump(const Duration(milliseconds: 100)); - box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(100.0)); - expect(box.size.height, equals(100.0)); - }); + ); + + RenderBox box = tester.renderObject(find.byType(AnimatedSize)); + expect(box.size.width, equals(100.0)); + expect(box.size.height, equals(100.0)); + + // Attempt to animate beyond the outer SizedBox. + await tester.pumpWidget( + new Center( + child: new SizedBox ( + width: 100.0, + height: 100.0, + child: new AnimatedSize( + duration: const Duration(milliseconds: 200), + vsync: tester, + child: const SizedBox( + width: 200.0, + height: 200.0, + ), + ), + ), + ), + ); + + // Verify that animated size is the same as the outer SizedBox. + await tester.pump(const Duration(milliseconds: 100)); + box = tester.renderObject(find.byType(AnimatedSize)); + expect(box.size.width, equals(100.0)); + expect(box.size.height, equals(100.0)); + }); - testWidgets('AnimatedSize constrained test', (WidgetTester tester) async { - await tester.pumpWidget( - new Center( - child: new SizedBox ( - width: 100.0, - height: 100.0, + testWidgets('tracks unstable child, then resumes animation when child stabilizes', (WidgetTester tester) async { + Future pumpMillis(int millis) async { + await tester.pump(new Duration(milliseconds: millis)); + } + + void verify({double size, RenderAnimatedSizeState state}) { + assert(size != null || state != null); + final RenderAnimatedSize box = tester.renderObject(find.byType(AnimatedSize)); + if (size != null) { + expect(box.size.width, size); + expect(box.size.height, size); + } + if (state != null) { + expect(box.state, state); + } + } + + await tester.pumpWidget( + new Center( child: new AnimatedSize( duration: const Duration(milliseconds: 200), vsync: tester, - child: const SizedBox( + child: new AnimatedContainer( + duration: const Duration(milliseconds: 100), width: 100.0, height: 100.0, ), ), ), - ), - ); - - RenderBox box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(100.0)); - expect(box.size.height, equals(100.0)); - - await tester.pumpWidget( - new Center( - child: new SizedBox ( - width: 100.0, - height: 100.0, + ); + + verify(size: 100.0, state: RenderAnimatedSizeState.stable); + + // Animate child size from 100 to 200 slowly (100ms). + await tester.pumpWidget( + new Center( child: new AnimatedSize( duration: const Duration(milliseconds: 200), vsync: tester, - child: const SizedBox( + child: new AnimatedContainer( + duration: const Duration(milliseconds: 100), width: 200.0, height: 200.0, ), ), ), - ), - ); + ); - await tester.pump(const Duration(milliseconds: 100)); - box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(100.0)); - expect(box.size.height, equals(100.0)); - }); + // Make sure animation proceeds at child's pace, with AnimatedSize + // tightly tracking the child's size. + verify(state: RenderAnimatedSizeState.stable); + await pumpMillis(1); // register change + verify(state: RenderAnimatedSizeState.changed); + await pumpMillis(49); + verify(size: 150.0, state: RenderAnimatedSizeState.unstable); + await pumpMillis(50); + verify(size: 200.0, state: RenderAnimatedSizeState.unstable); - testWidgets('AnimatedSize with AnimatedContainer', (WidgetTester tester) async { - await tester.pumpWidget( - new Center( - child: new AnimatedSize( - duration: const Duration(milliseconds: 200), - vsync: tester, - child: new AnimatedContainer( - duration: const Duration(milliseconds: 100), - width: 100.0, - height: 100.0, + // Stabilize size + await pumpMillis(50); + verify(size: 200.0, state: RenderAnimatedSizeState.stable); + + // Quickly (in 1ms) change size back to 100 + await tester.pumpWidget( + new Center( + child: new AnimatedSize( + duration: const Duration(milliseconds: 200), + vsync: tester, + child: new AnimatedContainer( + duration: const Duration(milliseconds: 1), + width: 100.0, + height: 100.0, + ), ), ), - ), - ); - - RenderBox box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(100.0)); - expect(box.size.height, equals(100.0)); - - await tester.pumpWidget( - new Center( - child: new AnimatedSize( - duration: const Duration(milliseconds: 200), - vsync: tester, - child: new AnimatedContainer( - duration: const Duration(milliseconds: 100), - width: 200.0, - height: 200.0, + ); + + verify(size: 200.0, state: RenderAnimatedSizeState.stable); + await pumpMillis(1); // register change + verify(state: RenderAnimatedSizeState.changed); + await pumpMillis(100); + verify(size: 150.0, state: RenderAnimatedSizeState.stable); + await pumpMillis(100); + verify(size: 100.0, state: RenderAnimatedSizeState.stable); + }); + + testWidgets('resyncs its animation controller', (WidgetTester tester) async { + await tester.pumpWidget( + const Center( + child: const AnimatedSize( + duration: const Duration(milliseconds: 200), + vsync: const TestVSync(), + child: const SizedBox( + width: 100.0, + height: 100.0, + ), ), ), - ), - ); - - await tester.pump(const Duration(milliseconds: 1)); // register change - await tester.pump(const Duration(milliseconds: 49)); - expect(box.size.width, equals(150.0)); - expect(box.size.height, equals(150.0)); - await tester.pump(const Duration(milliseconds: 50)); - box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(200.0)); - expect(box.size.height, equals(200.0)); - }); + ); - testWidgets('AnimatedSize resync', (WidgetTester tester) async { - await tester.pumpWidget( - const Center( - child: const AnimatedSize( - duration: const Duration(milliseconds: 200), - vsync: const TestVSync(), - child: const SizedBox( - width: 100.0, - height: 100.0, + await tester.pumpWidget( + new Center( + child: new AnimatedSize( + duration: const Duration(milliseconds: 200), + vsync: tester, + child: const SizedBox( + width: 200.0, + height: 100.0, + ), ), ), - ), - ); - - await tester.pumpWidget( - new Center( - child: new AnimatedSize( - duration: const Duration(milliseconds: 200), - vsync: tester, - child: const SizedBox( - width: 200.0, - height: 100.0, + ); + + await tester.pump(const Duration(milliseconds: 100)); + + final RenderBox box = tester.renderObject(find.byType(AnimatedSize)); + expect(box.size.width, equals(150.0)); + }); + + testWidgets('does not run animation unnecessarily', (WidgetTester tester) async { + await tester.pumpWidget( + new Center( + child: new AnimatedSize( + duration: const Duration(milliseconds: 200), + vsync: tester, + child: const SizedBox( + width: 100.0, + height: 100.0, + ), ), ), - ), - ); - - await tester.pump(const Duration(milliseconds: 100)); + ); - final RenderBox box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(150.0)); + for (int i = 0; i < 20; i++) { + final RenderAnimatedSize box = tester.renderObject(find.byType(AnimatedSize)); + expect(box.size.width, 100.0); + expect(box.size.height, 100.0); + expect(box.state, RenderAnimatedSizeState.stable); + expect(box.isAnimating, false); + await tester.pump(const Duration(milliseconds: 10)); + } + }); }); } diff --git a/packages/flutter/test/widgets/navigator_test.dart b/packages/flutter/test/widgets/navigator_test.dart index 3aa80bbc3e391..2254e815450d3 100644 --- a/packages/flutter/test/widgets/navigator_test.dart +++ b/packages/flutter/test/widgets/navigator_test.dart @@ -566,5 +566,47 @@ void main() { navigator.removeRoute(routes['/A']); // stack becomes /, pageValue will not complete }); + testWidgets('push a route with arguments', (WidgetTester tester) async { + Future pageValue; + final Map argumentsA = {"foo": 1, "bar" : "baz"}; + final Map argumentsB = {"foo": 1, "bar" : "baz"}; + final Map pageBuilders = { + '/': (BuildContext context) => new OnTapPage(id: '/', + onTap: () { pageValue = Navigator.pushNamed(context, '/A', arguments: argumentsA); }), + '/A': (BuildContext context) => new OnTapPage(id: 'A', + onTap: () { Navigator.of(context).pushNamed('/B', arguments: argumentsB); }), + '/B': (BuildContext context) => new OnTapPage(id: 'B', onTap: () { Navigator.pop(context, 'B'); }), + }; + final Map> routes = >{}; + + await tester.pumpWidget(new MaterialApp( + onGenerateRoute: (RouteSettings settings) { + if (settings.name == '/A') { + expect(settings.arguments, equals(argumentsA)); + } else if (settings.name == '/B') { + expect(settings.arguments, equals(argumentsB)); + } else { + expect(settings.arguments, isNull); + } + routes[settings.name] = new PageRouteBuilder( + settings: settings, + pageBuilder: (BuildContext context, Animation _, Animation __) { + return pageBuilders[settings.name](context); + }, + ); + return routes[settings.name]; + } + )); + await tester.tap(find.text('/')); // pushNamed('/A'), stack becomes /, /A + await tester.pumpAndSettle(); + + // Navigator.of(context).pushNamed('/B'), stack becomes /, /A, /B + await tester.tap(find.text('A')); + await tester.pumpAndSettle(); + pageValue.then((String value) { assert(false); }); + + final NavigatorState navigator = tester.state(find.byType(Navigator)); + navigator.removeRoute(routes['/B']); // stack becomes /, /A, pageValue will not complete + }); } diff --git a/packages/flutter/test/widgets/nested_scroll_view_test.dart b/packages/flutter/test/widgets/nested_scroll_view_test.dart index 7d8c63f5ab60c..3483a9c32bd1a 100644 --- a/packages/flutter/test/widgets/nested_scroll_view_test.dart +++ b/packages/flutter/test/widgets/nested_scroll_view_test.dart @@ -6,17 +6,18 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -Widget buildTest() { +Widget buildTest({ ScrollController controller, String title: 'TTTTTTTT' }) { return new MediaQuery( data: const MediaQueryData(), child: new Scaffold( body: new DefaultTabController( length: 4, child: new NestedScrollView( + controller: controller, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return [ new SliverAppBar( - title: const Text('TTTTTTTT'), + title: new Text(title), pinned: true, expandedHeight: 200.0, forceElevated: innerBoxIsScrolled, @@ -183,4 +184,108 @@ void main() { expect(find.text('ccc1'), findsOneWidget); expect(tester.renderObject(find.byType(AppBar)).size.height, 200.0); }); -} \ No newline at end of file + + testWidgets('NestedScrollView with a ScrollController', (WidgetTester tester) async { + final ScrollController controller = new ScrollController(initialScrollOffset: 50.0); + + double scrollOffset; + controller.addListener(() { + scrollOffset = controller.offset; + }); + + await tester.pumpWidget(buildTest(controller: controller)); + expect(controller.position.minScrollExtent, 0.0); + expect(controller.position.pixels, 50.0); + expect(controller.position.maxScrollExtent, 200.0); + + // The appbar's expandedHeight - initialScrollOffset = 150. + expect(tester.renderObject(find.byType(AppBar)).size.height, 150.0); + + // Fully expand the appbar by scrolling (no animation) to 0.0. + controller.jumpTo(0.0); + await(tester.pumpAndSettle()); + expect(scrollOffset, 0.0); + expect(tester.renderObject(find.byType(AppBar)).size.height, 200.0); + + // Scroll back to 50.0 animating over 100ms. + controller.animateTo(50.0, duration: const Duration(milliseconds: 100), curve: Curves.linear); + await tester.pump(); + await tester.pump(); + expect(scrollOffset, 0.0); + expect(tester.renderObject(find.byType(AppBar)).size.height, 200.0); + await tester.pump(const Duration(milliseconds: 50)); // 50ms - halfway to scroll offset = 50.0. + expect(scrollOffset, 25.0); + expect(tester.renderObject(find.byType(AppBar)).size.height, 175.0); + await tester.pump(const Duration(milliseconds: 50)); // 100ms - all the way to scroll offset = 50.0. + expect(scrollOffset, 50.0); + expect(tester.renderObject(find.byType(AppBar)).size.height, 150.0); + + // Scroll to the end, (we're not scrolling to the end of the list that contains aaa1, + // just to the end of the outer scrollview). Verify that the first item in each tab + // is still visible. + controller.jumpTo(controller.position.maxScrollExtent); + await tester.pumpAndSettle(); + expect(scrollOffset, 200.0); + expect(find.text('aaa1'), findsOneWidget); + + await tester.tap(find.text('BB')); + await tester.pumpAndSettle(); + expect(find.text('bbb1'), findsOneWidget); + + await tester.tap(find.text('CC')); + await tester.pumpAndSettle(); + expect(find.text('ccc1'), findsOneWidget); + + await tester.tap(find.text('DD')); + await tester.pumpAndSettle(); + expect(find.text('ddd1'), findsOneWidget); + }); + + testWidgets('Three NestedScrollViews with one ScrollController', (WidgetTester tester) async { + final TrackingScrollController controller = new TrackingScrollController(); + expect(controller.mostRecentlyUpdatedPosition, isNull); + expect(controller.initialScrollOffset, 0.0); + + await tester.pumpWidget( + new PageView( + children: [ + buildTest(controller: controller, title: 'Page0'), + buildTest(controller: controller, title: 'Page1'), + buildTest(controller: controller, title: 'Page2'), + ], + ), + ); + + // Initially Page0 is visible and Page0's appbar is fully expanded (height = 200.0). + expect(find.text('Page0'), findsOneWidget); + expect(find.text('Page1'), findsNothing); + expect(find.text('Page2'), findsNothing); + expect(tester.renderObject(find.byType(AppBar)).size.height, 200.0); + + // A scroll collapses Page0's appbar to 150.0. + controller.jumpTo(50.0); + await(tester.pumpAndSettle()); + expect(tester.renderObject(find.byType(AppBar)).size.height, 150.0); + + // Fling to Page1. Page1's appbar height is the same as the appbar for Page0. + await tester.fling(find.text('Page0'), const Offset(-100.0, 0.0), 10000.0); + await(tester.pumpAndSettle()); + expect(find.text('Page0'), findsNothing); + expect(find.text('Page1'), findsOneWidget); + expect(find.text('Page2'), findsNothing); + expect(tester.renderObject(find.byType(AppBar)).size.height, 150.0); + + // Expand Page1's appbar and then fling to Page2. Page2's appbar appears + // fully expanded. + controller.jumpTo(0.0); + await(tester.pumpAndSettle()); + expect(tester.renderObject(find.byType(AppBar)).size.height, 200.0); + await tester.fling(find.text('Page1'), const Offset(-100.0, 0.0), 10000.0); + await(tester.pumpAndSettle()); + expect(find.text('Page0'), findsNothing); + expect(find.text('Page1'), findsNothing); + expect(find.text('Page2'), findsOneWidget); + expect(tester.renderObject(find.byType(AppBar)).size.height, 200.0); + }); + +} diff --git a/packages/flutter/test/widgets/scrollable_semantics_test.dart b/packages/flutter/test/widgets/scrollable_semantics_test.dart new file mode 100644 index 0000000000000..d59adc53f5697 --- /dev/null +++ b/packages/flutter/test/widgets/scrollable_semantics_test.dart @@ -0,0 +1,81 @@ +// 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 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'semantics_tester.dart'; + +void main() { + testWidgets('scrollable exposes the correct semantic actions', (WidgetTester tester) async { + final SemanticsTester semantics = new SemanticsTester(tester); + + final List textWidgets = []; + for (int i = 0; i < 80; i++) + textWidgets.add(new Text('$i')); + await tester.pumpWidget(new ListView(children: textWidgets)); + + expect(semantics,includesNodeWith(actions: [SemanticsAction.scrollUp])); + + await flingUp(tester); + expect(semantics, includesNodeWith(actions: [SemanticsAction.scrollUp, SemanticsAction.scrollDown])); + + await flingDown(tester, repetitions: 2); + expect(semantics, includesNodeWith(actions: [SemanticsAction.scrollUp])); + + await flingUp(tester, repetitions: 5); + expect(semantics, includesNodeWith(actions: [SemanticsAction.scrollDown])); + + await flingDown(tester); + expect(semantics, includesNodeWith(actions: [SemanticsAction.scrollUp, SemanticsAction.scrollDown])); + }); + + testWidgets('showOnScreen works in scrollable', (WidgetTester tester) async { + new SemanticsTester(tester); // enables semantics tree generation + + const double kItemHeight = 40.0; + + final List containers = []; + for (int i = 0; i < 80; i++) + containers.add(new MergeSemantics(child: new Container( + height: kItemHeight, + child: new Text('container $i'), + ))); + + final ScrollController scrollController = new ScrollController( + initialScrollOffset: kItemHeight / 2, + ); + + await tester.pumpWidget(new ListView( + controller: scrollController, + children: containers + )); + + expect(scrollController.offset, kItemHeight / 2); + + final int firstContainerId = tester.renderObject(find.byWidget(containers.first)).debugSemantics.id; + tester.binding.pipelineOwner.semanticsOwner.performAction(firstContainerId, SemanticsAction.showOnScreen); + await tester.pump(); + await tester.pump(const Duration(seconds: 5)); + + expect(scrollController.offset, 0.0); + }); +} + +Future flingUp(WidgetTester tester, { int repetitions: 1 }) async { + while (repetitions-- > 0) { + await tester.fling(find.byType(ListView), const Offset(0.0, -200.0), 1000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 5)); + } +} + +Future flingDown(WidgetTester tester, { int repetitions: 1 }) async { + while (repetitions-- > 0) { + await tester.fling(find.byType(ListView), const Offset(0.0, 200.0), 1000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 5)); + } +} \ No newline at end of file diff --git a/packages/flutter_driver/lib/src/driver.dart b/packages/flutter_driver/lib/src/driver.dart index c3ed994873d17..9b2eec4c86466 100644 --- a/packages/flutter_driver/lib/src/driver.dart +++ b/packages/flutter_driver/lib/src/driver.dart @@ -348,6 +348,12 @@ class FlutterDriver { return null; } + /// Waits until [finder] can no longer locate the target. + Future waitForAbsent(SerializableFinder finder, {Duration timeout}) async { + await _sendCommand(new WaitForAbsent(finder, timeout: timeout)); + return null; + } + /// Waits until there are no more transient callbacks in the queue. /// /// Use this method when you need to wait for the moment when the application @@ -597,9 +603,12 @@ class CommonFinders { /// Finds [Text] widgets containing string equal to [text]. SerializableFinder text(String text) => new ByText(text); - /// Finds widgets by [key]. + /// Finds widgets by [key]. Only [String] and [int] values can be used. SerializableFinder byValueKey(dynamic key) => new ByValueKey(key); /// Finds widgets with a tooltip with the given [message]. SerializableFinder byTooltip(String message) => new ByTooltipMessage(message); + + /// Finds widgets whose class name matches the given string. + SerializableFinder byType(String type) => new ByType(type); } diff --git a/packages/flutter_driver/lib/src/extension.dart b/packages/flutter_driver/lib/src/extension.dart index 6a617cc57b4df..992c698ab9700 100644 --- a/packages/flutter_driver/lib/src/extension.dart +++ b/packages/flutter_driver/lib/src/extension.dart @@ -86,6 +86,7 @@ class FlutterDriverExtension { 'set_semantics': _setSemantics, 'tap': _tap, 'waitFor': _waitFor, + 'waitForAbsent': _waitForAbsent, 'waitUntilNoTransientCallbacks': _waitUntilNoTransientCallbacks, }); @@ -100,6 +101,7 @@ class FlutterDriverExtension { 'set_semantics': (Map params) => new SetSemantics.deserialize(params), 'tap': (Map params) => new Tap.deserialize(params), 'waitFor': (Map params) => new WaitFor.deserialize(params), + 'waitForAbsent': (Map params) => new WaitForAbsent.deserialize(params), 'waitUntilNoTransientCallbacks': (Map params) => new WaitUntilNoTransientCallbacks.deserialize(params), }); @@ -107,6 +109,7 @@ class FlutterDriverExtension { 'ByText': _createByTextFinder, 'ByTooltipMessage': _createByTooltipMessageFinder, 'ByValueKey': _createByValueKeyFinder, + 'ByType': _createByTypeFinder, }); } @@ -195,6 +198,19 @@ class FlutterDriverExtension { return finder; } + /// Runs `finder` repeatedly until it finds zero [Element]s. + Future _waitForAbsentElement(Finder finder) async { + if (_frameSync) + await _waitUntilFrame(() => SchedulerBinding.instance.transientCallbackCount == 0); + + await _waitUntilFrame(() => !finder.precache()); + + if (_frameSync) + await _waitUntilFrame(() => SchedulerBinding.instance.transientCallbackCount == 0); + + return finder; + } + Finder _createByTextFinder(ByText arguments) { return find.text(arguments.text); } @@ -219,6 +235,12 @@ class FlutterDriverExtension { } } + Finder _createByTypeFinder(ByType arguments) { + return find.byElementPredicate((Element element) { + return element.widget.runtimeType.toString() == arguments.type; + }, description: 'widget with runtimeType "${arguments.type}"'); + } + Finder _createFinder(SerializableFinder finder) { final FinderConstructor constructor = _finders[finder.finderType]; @@ -242,6 +264,12 @@ class FlutterDriverExtension { return null; } + Future _waitForAbsent(Command command) async { + final WaitForAbsent waitForAbsentCommand = command; + await _waitForAbsentElement(_createFinder(waitForAbsentCommand.finder)); + return new WaitForAbsentResult(); + } + Future _waitUntilNoTransientCallbacks(Command command) async { if (SchedulerBinding.instance.transientCallbackCount != 0) await _waitUntilFrame(() => SchedulerBinding.instance.transientCallbackCount == 0); diff --git a/packages/flutter_driver/lib/src/find.dart b/packages/flutter_driver/lib/src/find.dart index a45fed0a5b4cd..6fd1ceda52e16 100644 --- a/packages/flutter_driver/lib/src/find.dart +++ b/packages/flutter_driver/lib/src/find.dart @@ -62,6 +62,22 @@ class WaitFor extends CommandWithTarget { WaitFor.deserialize(Map json) : super.deserialize(json); } +/// Waits until [finder] can no longer locate the target. +class WaitForAbsent extends CommandWithTarget { + @override + final String kind = 'waitForAbsent'; + + /// Creates a command that waits for the widget identified by [finder] to + /// disappear within the [timeout] amount of time. + /// + /// If [timeout] is not specified the command times out after 5 seconds. + WaitForAbsent(SerializableFinder finder, {Duration timeout}) + : super(finder, timeout: timeout); + + /// Deserializes the command from JSON generated by [serialize]. + WaitForAbsent.deserialize(Map json) : super.deserialize(json); +} + /// Waits until there are no more transient callbacks in the queue. class WaitUntilNoTransientCallbacks extends Command { @override @@ -85,6 +101,17 @@ class WaitForResult extends Result { Map toJson() => {}; } +/// The result of a [WaitForAbsent] command. +class WaitForAbsentResult extends Result { + /// Deserializes the result from JSON. + static WaitForAbsentResult fromJson(Map json) { + return new WaitForAbsentResult(); + } + + @override + Map toJson() => {}; +} + /// Describes how to the driver should search for elements. abstract class SerializableFinder { /// Identifies the type of finder to be used by the driver extension. @@ -94,6 +121,7 @@ abstract class SerializableFinder { static SerializableFinder deserialize(Map json) { final String finderType = json['finderType']; switch(finderType) { + case 'ByType': return ByType.deserialize(json); case 'ByValueKey': return ByValueKey.deserialize(json); case 'ByTooltipMessage': return ByTooltipMessage.deserialize(json); case 'ByText': return ByText.deserialize(json); @@ -200,6 +228,28 @@ class ByValueKey extends SerializableFinder { } } +/// Finds widgets by their [runtimeType]. +class ByType extends SerializableFinder { + @override + final String finderType = 'ByType'; + + /// Creates a finder that given the runtime type in string form. + ByType(this.type); + + /// The widget's [runtimeType], in string form. + final String type; + + @override + Map serialize() => super.serialize()..addAll({ + 'type': type, + }); + + /// Deserializes the finder from JSON generated by [serialize]. + static ByType deserialize(Map json) { + return new ByType(json['type']); + } +} + /// Command to read the text from a given element. class GetText extends CommandWithTarget { @override diff --git a/packages/flutter_tools/lib/src/android/android_sdk.dart b/packages/flutter_tools/lib/src/android/android_sdk.dart index 4f23574e6b1ce..0ea844b465bc5 100644 --- a/packages/flutter_tools/lib/src/android/android_sdk.dart +++ b/packages/flutter_tools/lib/src/android/android_sdk.dart @@ -67,7 +67,10 @@ class AndroidSdk { static AndroidSdk locateAndroidSdk() { String androidHomeDir; - if (platform.environment.containsKey(kAndroidHome)) { + + if (config.containsKey('android-sdk')) { + androidHomeDir = config.getValue('android-sdk'); + } else if (platform.environment.containsKey(kAndroidHome)) { androidHomeDir = platform.environment[kAndroidHome]; } else if (platform.isLinux) { if (homeDirPath != null) diff --git a/packages/flutter_tools/lib/src/base/config.dart b/packages/flutter_tools/lib/src/base/config.dart index 832280f8a580c..373173f3c5891 100644 --- a/packages/flutter_tools/lib/src/base/config.dart +++ b/packages/flutter_tools/lib/src/base/config.dart @@ -24,6 +24,8 @@ class Config { Iterable get keys => _values.keys; + bool containsKey(String key) => _values.containsKey(key); + dynamic getValue(String key) => _values[key]; void setValue(String key, String value) { diff --git a/packages/flutter_tools/lib/src/commands/config.dart b/packages/flutter_tools/lib/src/commands/config.dart index 25225e1809c50..f05980a37e0f5 100644 --- a/packages/flutter_tools/lib/src/commands/config.dart +++ b/packages/flutter_tools/lib/src/commands/config.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:convert'; +import '../android/android_sdk.dart'; import '../android/android_studio.dart'; import '../globals.dart'; import '../runner/flutter_command.dart'; @@ -19,6 +20,7 @@ class ConfigCommand extends FlutterCommand { negatable: false, help: 'Clear the saved development certificate choice used to sign apps for iOS device deployment.'); argParser.addOption('gradle-dir', help: 'The gradle install directory.'); + argParser.addOption('android-sdk', help: 'The Android SDK directory.'); argParser.addOption('android-studio-dir', help: 'The Android Studio install directory.'); argParser.addFlag('machine', negatable: false, @@ -38,6 +40,9 @@ class ConfigCommand extends FlutterCommand { @override final List aliases = ['configure']; + @override + bool get shouldUpdateCache => false; + @override String get usageFooter { // List all config settings. @@ -69,6 +74,9 @@ class ConfigCommand extends FlutterCommand { if (argResults.wasParsed('gradle-dir')) _updateConfig('gradle-dir', argResults['gradle-dir']); + if (argResults.wasParsed('android-sdk')) + _updateConfig('android-sdk', argResults['android-sdk']); + if (argResults.wasParsed('android-studio-dir')) _updateConfig('android-studio-dir', argResults['android-studio-dir']); @@ -90,8 +98,11 @@ class ConfigCommand extends FlutterCommand { if (results['android-studio-dir'] == null && androidStudio != null) { results['android-studio-dir'] = androidStudio.directory; } + if (results['android-sdk'] == null && androidSdk != null) { + results['android-sdk'] = androidSdk.directory; + } - printStatus(JSON.encode(results)); + printStatus(const JsonEncoder.withIndent(' ').convert(results)); } void _updateConfig(String keyName, String keyValue) { diff --git a/packages/flutter_tools/lib/src/commands/drive.dart b/packages/flutter_tools/lib/src/commands/drive.dart index 91f65f7a48059..e9ad4176ff4f4 100644 --- a/packages/flutter_tools/lib/src/commands/drive.dart +++ b/packages/flutter_tools/lib/src/commands/drive.dart @@ -60,6 +60,18 @@ class DriveCommand extends RunCommandBase { valueHelp: 'url' ); + + argParser.addOption( + 'driver', + help: + 'The test file to run on the host (as opposed to the target file to run on\n' + 'the device). By default, this file has the same base name as the target\n' + 'file, but in the "test_driver/" directory instead, and with "_test" inserted\n' + 'just before the extension, so e.g. if the target is "lib/main.dart", the\n' + 'driver will be "test_driver/main_test.dart".', + valueHelp: + 'path' + ); } @override @@ -139,6 +151,11 @@ class DriveCommand extends RunCommandBase { } String _getTestFile() { + if (argResults['driver'] != null) + return argResults['driver']; + + // If the --driver argument wasn't provided, then derive the value from + // the target file. String appFile = fs.path.normalize(targetFile); // This command extends `flutter run` and therefore CWD == package dir diff --git a/packages/flutter_tools/lib/src/resident_runner.dart b/packages/flutter_tools/lib/src/resident_runner.dart index 91de539be1739..f03822d9f95a1 100644 --- a/packages/flutter_tools/lib/src/resident_runner.dart +++ b/packages/flutter_tools/lib/src/resident_runner.dart @@ -171,6 +171,11 @@ class FlutterDevice { await view.uiIsolate.flutterToggleDebugPaintSizeEnabled(); } + Future debugTogglePerformanceOverlayOverride() async { + for (FlutterView view in views) + await view.uiIsolate.flutterTogglePerformanceOverlayOverride(); + } + Future togglePlatform({ String from }) async { String to; switch (from) { @@ -451,6 +456,12 @@ abstract class ResidentRunner { await device.toggleDebugPaintSizeEnabled(); } + Future _debugTogglePerformanceOverlayOverride() async { + await refreshViews(); + for (FlutterDevice device in flutterDevices) + await device.debugTogglePerformanceOverlayOverride(); + } + Future _screenshot(FlutterDevice device) async { final Status status = logger.startProgress('Taking screenshot for ${device.device.name}...'); final File outputFile = getUniqueFile(fs.currentDirectory, 'flutter', 'png'); @@ -606,11 +617,16 @@ abstract class ResidentRunner { await _debugDumpSemanticsTree(); return true; } - } else if (lower == 'p') { + } else if (character == 'p') { if (supportsServiceProtocol && isRunningDebug) { await _debugToggleDebugPaintSizeEnabled(); return true; } + } else if (character == 'P') { + if (supportsServiceProtocol) { + await _debugTogglePerformanceOverlayOverride(); + return true; + } } else if (character == 's') { for (FlutterDevice device in flutterDevices) { if (device.device.supportsScreenshot) @@ -726,11 +742,14 @@ abstract class ResidentRunner { if (supportsServiceProtocol) { printStatus('You can dump the widget hierarchy of the app (debugDumpApp) by pressing "w".'); printStatus('To dump the rendering tree of the app (debugDumpRenderTree), press "t".'); - printStatus('For layers (debugDumpLayerTree), use "L"; accessibility (debugDumpSemantics), "S".'); if (isRunningDebug) { + printStatus('For layers (debugDumpLayerTree), use "L"; accessibility (debugDumpSemantics), "S".'); printStatus('To toggle the display of construction lines (debugPaintSizeEnabled), press "p".'); printStatus('To simulate different operating systems, (defaultTargetPlatform), press "o".'); + } else { + printStatus('To dump the accessibility tree (debugDumpSemantics), press "S".'); } + printStatus('To display the performance overlay (WidgetsApp.showPerformanceOverlay), press "P".'); } if (flutterDevices.any((FlutterDevice d) => d.device.supportsScreenshot)) printStatus('To save a screenshot to flutter.png, press "s".'); diff --git a/packages/flutter_tools/lib/src/run_hot.dart b/packages/flutter_tools/lib/src/run_hot.dart index 51300a601f694..0689738824aab 100644 --- a/packages/flutter_tools/lib/src/run_hot.dart +++ b/packages/flutter_tools/lib/src/run_hot.dart @@ -89,7 +89,7 @@ class HotRunner extends ResidentRunner { { bool force: false, bool pause: false }) async { // TODO(cbernaschina): check that isolateId is the id of the UI isolate. final OperationResult result = await restart(pauseAfterRestart: pause); - if (result != OperationResult.ok) { + if (!result.isOk) { throw new rpc.RpcException( rpc_error_code.INTERNAL_ERROR, 'Unable to reload sources', @@ -153,7 +153,6 @@ class HotRunner extends ResidentRunner { // Measure time to perform a hot restart. printStatus('Benchmarking hot restart'); await restart(fullRestart: true); - await refreshViews(); // TODO(johnmccutchan): Modify script entry point. printStatus('Benchmarking hot reload'); // Measure time to perform a hot reload. @@ -313,6 +312,11 @@ class HotRunner extends ResidentRunner { deviceEntryUri, devicePackagesUri, deviceAssetsDirectoryUri); + if (benchmarkMode) { + for (FlutterDevice device in flutterDevices) + for (FlutterView view in device.views) + await view.flushUIThreadTasks(); + } } } diff --git a/packages/flutter_tools/lib/src/runner/flutter_command.dart b/packages/flutter_tools/lib/src/runner/flutter_command.dart index ddbdfeba10906..56d2bd92f012e 100644 --- a/packages/flutter_tools/lib/src/runner/flutter_command.dart +++ b/packages/flutter_tools/lib/src/runner/flutter_command.dart @@ -79,7 +79,10 @@ abstract class FlutterCommand extends Command { argParser.addOption('target', abbr: 't', defaultsTo: flx.defaultMainPath, - help: 'Target app path / main entry-point file.'); + help: 'The main entry-point file of the application, as run on the device.\n' + 'If the --target option is omitted, but a file name is provided on\n' + 'the command line, then that is used instead.', + valueHelp: 'path'); _usesTargetOption = true; } diff --git a/packages/flutter_tools/lib/src/vmservice.dart b/packages/flutter_tools/lib/src/vmservice.dart index 2b5f41bb6e1aa..9b255ced90102 100644 --- a/packages/flutter_tools/lib/src/vmservice.dart +++ b/packages/flutter_tools/lib/src/vmservice.dart @@ -1069,11 +1069,11 @@ class Isolate extends ServiceObjectOwner { return invokeFlutterExtensionRpcRaw('ext.flutter.debugDumpSemanticsTree', timeout: kLongRequestTimeout); } - Future> flutterToggleDebugPaintSizeEnabled() async { - Map state = await invokeFlutterExtensionRpcRaw('ext.flutter.debugPaint'); + Future> _flutterToggle(String name) async { + Map state = await invokeFlutterExtensionRpcRaw('ext.flutter.$name'); if (state != null && state.containsKey('enabled') && state['enabled'] is String) { state = await invokeFlutterExtensionRpcRaw( - 'ext.flutter.debugPaint', + 'ext.flutter.$name', params: { 'enabled': state['enabled'] == 'true' ? 'false' : 'true' }, timeout: const Duration(milliseconds: 150), timeoutFatal: false, @@ -1082,6 +1082,10 @@ class Isolate extends ServiceObjectOwner { return state; } + Future> flutterToggleDebugPaintSizeEnabled() => _flutterToggle('debugPaint'); + + Future> flutterTogglePerformanceOverlayOverride() => _flutterToggle('showPerformanceOverlay'); + Future flutterDebugAllowBanner(bool show) async { await invokeFlutterExtensionRpcRaw( 'ext.flutter.debugAllowBanner', @@ -1226,6 +1230,10 @@ class FlutterView extends ServiceObject { bool get hasIsolate => _uiIsolate != null; + Future flushUIThreadTasks() async { + await owner.vm.invokeRpcRaw('_flutter.flushUIThreadTasks'); + } + @override String toString() => id; } diff --git a/packages/flutter_tools/test/commands/config_test.dart b/packages/flutter_tools/test/commands/config_test.dart new file mode 100644 index 0000000000000..c096e3e0e96c7 --- /dev/null +++ b/packages/flutter_tools/test/commands/config_test.dart @@ -0,0 +1,56 @@ +// Copyright 2016 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:convert'; + +import 'package:flutter_tools/src/android/android_sdk.dart'; +import 'package:flutter_tools/src/android/android_studio.dart'; +import 'package:flutter_tools/src/base/context.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/commands/config.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import '../src/context.dart'; + +void main() { + MockAndroidStudio mockAndroidStudio; + MockAndroidSdk mockAndroidSdk; + + setUp(() { + mockAndroidStudio = new MockAndroidStudio(); + mockAndroidSdk = new MockAndroidSdk(); + }); + + group('config', () { + testUsingContext('machine flag', () async { + final BufferLogger logger = context[Logger]; + final ConfigCommand command = new ConfigCommand(); + await command.handleMachine(); + + expect(logger.statusText, isNotEmpty); + final dynamic json = JSON.decode(logger.statusText); + expect(json, isMap); + + expect(json.containsKey('android-studio-dir'), true); + expect(json['android-studio-dir'], isNotNull); + + expect(json.containsKey('android-sdk'), true); + expect(json['android-sdk'], isNotNull); + }, overrides: { + AndroidStudio: () => mockAndroidStudio, + AndroidSdk: () => mockAndroidSdk, + }); + }); +} + +class MockAndroidStudio extends Mock implements AndroidStudio, Comparable { + @override + String get directory => 'path/to/android/stdio'; +} + +class MockAndroidSdk extends Mock implements AndroidSdk { + @override + String get directory => 'path/to/android/sdk'; +} diff --git a/packages/flutter_tools/test/config_test.dart b/packages/flutter_tools/test/config_test.dart index 88f7a0d97525b..a46d15ee7c941 100644 --- a/packages/flutter_tools/test/config_test.dart +++ b/packages/flutter_tools/test/config_test.dart @@ -2,28 +2,17 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:convert'; - -import 'package:flutter_tools/src/android/android_studio.dart'; import 'package:flutter_tools/src/base/config.dart'; -import 'package:flutter_tools/src/base/context.dart'; import 'package:flutter_tools/src/base/file_system.dart'; -import 'package:flutter_tools/src/base/logger.dart'; -import 'package:flutter_tools/src/commands/config.dart'; -import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; -import 'src/context.dart'; - void main() { Config config; - MockAndroidStudio mockAndroidStudio; setUp(() { final Directory tempDirectory = fs.systemTempDirectory.createTempSync('flutter_test'); final File file = fs.file(fs.path.join(tempDirectory.path, '.settings')); config = new Config(file); - mockAndroidStudio = new MockAndroidStudio(); }); group('config', () { @@ -34,6 +23,12 @@ void main() { expect(config.keys, contains('foo')); }); + test('containsKey', () async { + expect(config.containsKey('foo'), false); + config.setValue('foo', 'bar'); + expect(config.containsKey('foo'), true); + }); + test('removeValue', () async { expect(config.getValue('foo'), null); config.setValue('foo', 'bar'); @@ -43,24 +38,5 @@ void main() { expect(config.getValue('foo'), null); expect(config.keys, isNot(contains('foo'))); }); - - testUsingContext('machine flag', () async { - final BufferLogger logger = context[Logger]; - final ConfigCommand command = new ConfigCommand(); - await command.handleMachine(); - - expect(logger.statusText, isNotEmpty); - final dynamic json = JSON.decode(logger.statusText); - expect(json, isMap); - expect(json.containsKey('android-studio-dir'), true); - expect(json['android-studio-dir'], isNotNull); - }, overrides: { - AndroidStudio: () => mockAndroidStudio, - }); }); } - -class MockAndroidStudio extends Mock implements AndroidStudio, Comparable { - @override - String get directory => 'path/to/android/stdio'; -}