From dd5df79e7bb4b8084e2ecb8907504298952e6f3e Mon Sep 17 00:00:00 2001 From: Adam Barth Date: Thu, 21 Jan 2016 16:13:28 -0800 Subject: [PATCH] Scroll focused input widgets into view When opening the keyboard or focusing an input widget, we should scroll the widget into view so that the user can see what they're typing. --- .../src/animation/animation_controller.dart | 17 ++++++-- .../flutter/lib/src/material/scaffold.dart | 2 +- packages/flutter/lib/src/widgets/focus.dart | 35 +++++++++++++++- .../flutter/lib/src/widgets/scrollable.dart | 41 +++++++++++++++---- 4 files changed, 79 insertions(+), 16 deletions(-) diff --git a/packages/flutter/lib/src/animation/animation_controller.dart b/packages/flutter/lib/src/animation/animation_controller.dart index c2c7d62504..bccb784d78 100644 --- a/packages/flutter/lib/src/animation/animation_controller.dart +++ b/packages/flutter/lib/src/animation/animation_controller.dart @@ -145,13 +145,22 @@ class AnimationController extends Animation } Future animateTo(double target, { Duration duration, Curve curve: Curves.linear }) { - Duration remainingDuration = (duration ?? this.duration) * (target - _value).abs(); + Duration simulationDuration = duration; + if (simulationDuration == null) { + double range = upperBound - lowerBound; + if (range.isFinite) { + double remainingFraction = (target - _value).abs() / range; + simulationDuration = this.duration * remainingFraction; + } + } stop(); - if (remainingDuration == Duration.ZERO) + if (simulationDuration == Duration.ZERO) { + assert(value == target); return new Future.value(); - assert(remainingDuration > Duration.ZERO); + } + assert(simulationDuration > Duration.ZERO); assert(!isAnimating); - return _startSimulation(new _TweenSimulation(_value, target, remainingDuration, curve)); + return _startSimulation(new _TweenSimulation(_value, target, simulationDuration, curve)); } Future _startSimulation(Simulation simulation) { diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index 1e1d2cff1d..9292045733 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -366,7 +366,7 @@ class ScaffoldState extends State { } Widget build(BuildContext context) { - EdgeDims padding = MediaQuery.of(context).padding; + EdgeDims padding = MediaQuery.of(context)?.padding ?? EdgeDims.zero; if (_snackBars.length > 0) { ModalRoute route = ModalRoute.of(context); diff --git a/packages/flutter/lib/src/widgets/focus.dart b/packages/flutter/lib/src/widgets/focus.dart index 15e12240ec..2a1e05cb41 100644 --- a/packages/flutter/lib/src/widgets/focus.dart +++ b/packages/flutter/lib/src/widgets/focus.dart @@ -2,7 +2,12 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + +import 'basic.dart'; import 'framework.dart'; +import 'media_query.dart'; +import 'scrollable.dart'; // _noFocusedScope is used by Focus to track the case where none of the Focus // component's subscopes (e.g. dialogs) are focused. This is distinct from the @@ -130,10 +135,13 @@ class Focus extends StatefulComponent { /// Don't call moveTo() from your build() functions, it's intended to be /// called from event listeners, e.g. in response to a finger tap or tab key. static void moveTo(GlobalKey key) { - assert(key.currentContext != null); + BuildContext focusedContext = key.currentContext; + assert(focusedContext != null); _FocusScope focusScope = key.currentContext.ancestorWidgetOfExactType(_FocusScope); - if (focusScope != null) + if (focusScope != null) { focusScope.focusState._setFocusedWidget(key); + Scrollable.ensureVisible(focusedContext); + } } /// Focuses a particular focus scope, identified by its GlobalKey. The widget @@ -239,7 +247,30 @@ class FocusState extends State { super.dispose(); } + Size _mediaSize; + EdgeDims _mediaPadding; + + void _ensureVisibleIfFocused() { + if (!Focus._atScope(context)) + return; + BuildContext focusedContext = _focusedWidget?.currentContext; + if (focusedContext == null) + return; + Scrollable.ensureVisible(focusedContext); + } + Widget build(BuildContext context) { + MediaQueryData data = MediaQuery.of(context); + if (data != null) { + Size newMediaSize = data.size; + EdgeDims newMediaPadding = data.padding; + if (newMediaSize != _mediaSize || newMediaPadding != _mediaPadding) { + _mediaSize = newMediaSize; + _mediaPadding = newMediaPadding; + scheduleMicrotask(_ensureVisibleIfFocused); + } + } + return new _FocusScope( focusState: this, scopeFocused: Focus._atScope(context), diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index cecd3f4fa6..3774c37760 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -64,7 +64,7 @@ abstract class Scrollable extends StatefulComponent { } /// Scrolls the closest enclosing scrollable to make the given context visible. - static Future ensureVisible(BuildContext context, { Duration duration, Curve curve }) { + static Future ensureVisible(BuildContext context, { Duration duration, Curve curve: Curves.ease }) { assert(context.findRenderObject() is RenderBox); // TODO(abarth): This function doesn't handle nested scrollable widgets. @@ -80,20 +80,43 @@ abstract class Scrollable extends StatefulComponent { assert(scrollableBox.attached); Size scrollableSize = scrollableBox.size; - double scrollOffsetDelta; + double targetMin; + double targetMax; + double scrollableMin; + double scrollableMax; + switch (scrollable.config.scrollDirection) { case Axis.vertical: - Point targetCenter = targetBox.localToGlobal(new Point(0.0, targetSize.height / 2.0)); - Point scrollableCenter = scrollableBox.localToGlobal(new Point(0.0, scrollableSize.height / 2.0)); - scrollOffsetDelta = targetCenter.y - scrollableCenter.y; + targetMin = targetBox.localToGlobal(Point.origin).y; + targetMax = targetBox.localToGlobal(new Point(0.0, targetSize.height)).y; + scrollableMin = scrollableBox.localToGlobal(Point.origin).y; + scrollableMax = scrollableBox.localToGlobal(new Point(0.0, scrollableSize.height)).y; break; case Axis.horizontal: - Point targetCenter = targetBox.localToGlobal(new Point(targetSize.width / 2.0, 0.0)); - Point scrollableCenter = scrollableBox.localToGlobal(new Point(scrollableSize.width / 2.0, 0.0)); - scrollOffsetDelta = targetCenter.x - scrollableCenter.x; + targetMin = targetBox.localToGlobal(Point.origin).x; + targetMax = targetBox.localToGlobal(new Point(targetSize.width, 0.0)).x; + scrollableMin = scrollableBox.localToGlobal(Point.origin).x; + scrollableMax = scrollableBox.localToGlobal(new Point(scrollableSize.width, 0.0)).x; break; } + double scrollOffsetDelta; + if (targetMin < scrollableMin) { + if (targetMax > scrollableMax) { + // The target is to big to fit inside the scrollable. The best we can do + // is to center the target. + double targetCenter = (targetMin + targetMax) / 2.0; + double scrollableCenter = (scrollableMin + scrollableMax) / 2.0; + scrollOffsetDelta = targetCenter - scrollableCenter; + } else { + scrollOffsetDelta = targetMin - scrollableMin; + } + } else if (targetMax > scrollableMax) { + scrollOffsetDelta = targetMax - scrollableMax; + } else { + return new Future.value(); + } + ExtentScrollBehavior scrollBehavior = scrollable.scrollBehavior; double scrollOffset = (scrollable.scrollOffset + scrollOffsetDelta) .clamp(scrollBehavior.minScrollOffset, scrollBehavior.maxScrollOffset); @@ -281,7 +304,7 @@ abstract class ScrollableState extends State { return _animateTo(newScrollOffset, duration, curve); } - Future scrollBy(double scrollDelta, { Duration duration, Curve curve }) { + Future scrollBy(double scrollDelta, { Duration duration, Curve curve: Curves.ease }) { double newScrollOffset = scrollBehavior.applyCurve(_scrollOffset, scrollDelta); return scrollTo(newScrollOffset, duration: duration, curve: curve); }