-
-
Save collinjackson/50172e3547e959cba77e2938f2fe5ff5 to your computer and use it in GitHub Desktop.
// Copyright 2017, the Flutter project authors. Please see the AUTHORS file | |
// for details. 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:meta/meta.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/rendering.dart'; | |
/// A widget that ensures it is always visible when focused. | |
class EnsureVisibleWhenFocused extends StatefulWidget { | |
const EnsureVisibleWhenFocused({ | |
Key key, | |
@required this.child, | |
@required this.focusNode, | |
this.curve: Curves.ease, | |
this.duration: const Duration(milliseconds: 100), | |
}) : super(key: key); | |
/// The node we will monitor to determine if the child is focused | |
final FocusNode focusNode; | |
/// The child widget that we are wrapping | |
final Widget child; | |
/// The curve we will use to scroll ourselves into view. | |
/// | |
/// Defaults to Curves.ease. | |
final Curve curve; | |
/// The duration we will use to scroll ourselves into view | |
/// | |
/// Defaults to 100 milliseconds. | |
final Duration duration; | |
EnsureVisibleWhenFocusedState createState() => new EnsureVisibleWhenFocusedState(); | |
} | |
class EnsureVisibleWhenFocusedState extends State<EnsureVisibleWhenFocused> { | |
@override | |
void initState() { | |
super.initState(); | |
widget.focusNode.addListener(_ensureVisible); | |
} | |
@override | |
void dispose() { | |
super.dispose(); | |
widget.focusNode.removeListener(_ensureVisible); | |
} | |
Future<Null> _ensureVisible() async { | |
// Wait for the keyboard to come into view | |
// TODO: position doesn't seem to notify listeners when metrics change, | |
// perhaps a NotificationListener around the scrollable could avoid | |
// the need insert a delay here. | |
await new Future.delayed(const Duration(milliseconds: 300)); | |
if (!widget.focusNode.hasFocus) | |
return; | |
final RenderObject object = context.findRenderObject(); | |
final RenderAbstractViewport viewport = RenderAbstractViewport.of(object); | |
assert(viewport != null); | |
ScrollableState scrollableState = Scrollable.of(context); | |
assert(scrollableState != null); | |
ScrollPosition position = scrollableState.position; | |
double alignment; | |
if (position.pixels > viewport.getOffsetToReveal(object, 0.0)) { | |
// Move down to the top of the viewport | |
alignment = 0.0; | |
} else if (position.pixels < viewport.getOffsetToReveal(object, 1.0)) { | |
// Move up to the bottom of the viewport | |
alignment = 1.0; | |
} else { | |
// No scrolling is necessary to reveal the child | |
return; | |
} | |
position.ensureVisible( | |
object, | |
alignment: alignment, | |
duration: widget.duration, | |
curve: widget.curve, | |
); | |
} | |
Widget build(BuildContext context) => widget.child; | |
} | |
class MyHomePage extends StatefulWidget { | |
MyHomePage({Key key}) : super(key: key); | |
@override | |
_MyHomePageState createState() => new _MyHomePageState(); | |
} | |
class _MyHomePageState extends State<MyHomePage> { | |
FocusNode _focusNode = new FocusNode(); | |
@override | |
Widget build(BuildContext context) { | |
return new Scaffold( | |
appBar: new AppBar( | |
title: new Text("Focus Example"), | |
), | |
body: new Center( | |
child: new ListView( | |
padding: new EdgeInsets.all(20.0), | |
children: <Widget>[ | |
new Container(height: 800.0, color: Colors.blue.shade200), | |
new EnsureVisibleWhenFocused( | |
focusNode: _focusNode, | |
child: new TextFormField( | |
focusNode: _focusNode, | |
decoration: new InputDecoration( | |
hintText: 'Focus me!', | |
), | |
), | |
), | |
new Container(height: 800.0, color: Colors.blue.shade200), | |
], | |
), | |
), | |
); | |
} | |
} | |
void main() { | |
runApp(new MyApp()); | |
} | |
class MyApp extends StatelessWidget { | |
@override | |
Widget build(BuildContext context) { | |
return new MaterialApp( | |
title: 'Flutter Demo', | |
home: new MyHomePage(), | |
); | |
} | |
} |
You may use the WidgetsBindingObserver and in particular the method didChangeMetrics to solve the issue.
Here are the modifications to be applied to the solution provided earlier by Collin:
///
/// We implement the WidgetsBindingObserver to be notified of any change to the window metrics
///
class _EnsureVisibleWhenFocusedState extends State with WidgetsBindingObserver {@OverRide
void initState(){
super.initState();
widget.focusNode.addListener(_ensureVisible);
WidgetsBinding.instance.addObserver(this);
}@OverRide
void dispose(){
WidgetsBinding.instance.removeObserver(this);
widget.focusNode.removeListener(_ensureVisible);
super.dispose();
}///
/// This routine is invoked when the window metrics have changed.
/// This happens when the keyboard is open or dismissed, among others.
/// It is the opportunity to check if the field has the focus
/// and to ensure it is fully visible in the viewport when
/// the keyboard is displayed
///
@OverRide
void didChangeMetrics(){
if (widget.focusNode.hasFocus){
_ensureVisible();
}
}
...
I wrote an article to fully cover the topic: link
Great solution, Collin !
Thanks @collinjackson and @boeledi. You are awesome!
This class breaks my tests with error:
A Timer is still pending even after the widget tree was disposed.
@collinjackson how would you suggest implementing this for textfields inside a nested pageview? For instance, I have a horizontally scrolling pageview nested within a larger scaffold tree...so the viewport and scrollable determined by this code aren't sufficient.
Update
Found Scrollable.ensureVisible; this ALMOST works out of the box...but it gets messy if the alignment parameter needs to be set differently for each Scrollable - like in the case of a textfield on PageView #2.
We found
The argument type 'RevealedOffset' can't be assigned to the parameter type 'num'.
if (position.pixels > viewport.getOffsetToReveal(object, 0.0)) {
// Move down to the top of the viewport
alignment = 0.0;
} else if (position.pixels < viewport.getOffsetToReveal(object, 1.0)) {
// Move up to the bottom of the viewport
alignment = 1.0;
if (position.pixels > viewport.getOffsetToReveal(object, 0.0).offset) {
// Move down to the top of the viewport
alignment = 0.0;
} else if (position.pixels < viewport.getOffsetToReveal(object, 1.0).offset) {
// Move up to the bottom of the viewport
alignment = 1.0;
That was helpful 🙇♂️ Thank you
Two calls on viewport.getOffsetToReveal(object, alignment)
should be updated to
viewport.getOffsetToReveal(object, alignment).offset
for Flutter v1.12.13+hotfix.5.
"Hello, I just used this, but it didn't work
@liumengchun A lot has changed over the last couple of years. You can easily track focus states using the Focus widget now.
Flutter 3.3.x
This is simply possible with this trick:
scrollController.animateTo( // or simpler with the jumpTo method
focusNode.offset.dy,
duration: const Duration(milliseconds: 100),
curve: Curves.ease,
);
Thanks for this!
I did notice, on Android, that when the
TextFormField
has focus and the keyboard is dismissed, clicking on theTextFormField
will cause the keyboard to appear again, but it doesn't get scrolled into view.