Last active
August 26, 2020 21:25
-
-
Save s0nerik/b75438df6bf57039b40f77fdbf6a908e to your computer and use it in GitHub Desktop.
A widget that mimics Android's `adjustPan` windowSoftInputMode
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:async'; | |
import 'package:flutter/rendering.dart'; | |
import 'package:flutter/widgets.dart'; | |
typedef AdjustPanChildBuilder = Widget Function(EdgeInsetsGeometry padding); | |
/// A widget that forces the same behavior as | |
/// Android's `adjustPan` windowSoftInputMode. | |
/// | |
/// !!!IMPORTANT!!! | |
/// If used within [Scaffold] - `resizeToAvoidBottomInset: false` must be set. | |
class AdjustPan extends StatefulWidget { | |
const AdjustPan({ | |
Key key, | |
@required this.builder, | |
this.additionalPadding = 24, | |
this.duration = const Duration(milliseconds: 200), | |
this.curve = Curves.easeInOut, | |
}) : assert(builder != null), | |
assert(additionalPadding != null), | |
super(key: key); | |
/// Child widget builder. | |
/// Make sure to use the provided padding for scrollable containers! | |
final AdjustPanChildBuilder builder; | |
/// Padding above the bottom inset and below the [FocusNode] | |
final double additionalPadding; | |
/// Scroll animation duration | |
final Duration duration; | |
/// Scroll animation curve | |
final Curve curve; | |
@override | |
_AdjustPanState createState() => _AdjustPanState(); | |
} | |
class _AdjustPanState extends State<AdjustPan> { | |
var _bottomInsetHeight = 0.0; | |
bool _hasFocus; | |
FocusNode _primaryFocus; | |
final _adjustScrollStreamCtrl = StreamController<int>(); | |
StreamSubscription _adjustScrollStreamSub; | |
Timer _scrollTimer; | |
@override | |
void initState() { | |
super.initState(); | |
FocusManager.instance.addListener(_onFocusChange); | |
_adjustScrollStreamSub = | |
_adjustScrollStreamCtrl.stream.listen(_adjustScroll); | |
} | |
@override | |
void dispose() { | |
_scrollTimer?.cancel(); | |
FocusManager.instance.removeListener(_onFocusChange); | |
_adjustScrollStreamSub?.cancel(); | |
_adjustScrollStreamCtrl.close(); | |
super.dispose(); | |
} | |
void _onFocusChange() { | |
final primaryFocus = FocusManager.instance.primaryFocus; | |
if (primaryFocus != _primaryFocus) { | |
_primaryFocus = primaryFocus; | |
_scheduleScrollAdjustment(); | |
} | |
} | |
@override | |
void didChangeDependencies() { | |
super.didChangeDependencies(); | |
final bottomInsetHeight = MediaQuery.of(context).viewInsets.bottom; | |
if (bottomInsetHeight != _bottomInsetHeight) { | |
_bottomInsetHeight = bottomInsetHeight; | |
_scheduleScrollAdjustment(); | |
} | |
} | |
void _scheduleScrollAdjustment() { | |
if (_hasFocus != true) { | |
return; | |
} | |
final t = DateTime.now().millisecondsSinceEpoch ~/ 100; | |
_adjustScrollStreamCtrl.add(t); | |
} | |
void _adjustScroll(_) { | |
if (_hasFocus != true) { | |
return; | |
} | |
final bottomInsetHeight = MediaQuery.of(context).viewInsets.bottom; | |
if (bottomInsetHeight > 0) { | |
final screenHeight = MediaQuery.of(context).size.height; | |
final focusNode = FocusManager.instance.primaryFocus; | |
_scrollToFocusNode(focusNode, bottomInsetHeight, screenHeight); | |
} | |
} | |
@override | |
Widget build(BuildContext context) { | |
return FocusScope( | |
onFocusChange: (focused) { | |
_hasFocus = focused; | |
}, | |
child: LayoutBuilder( | |
builder: (context, constraints) { | |
final child = widget.builder( | |
EdgeInsets.only(bottom: _bottomInsetHeight), | |
); | |
if (child is ScrollView || child is SingleChildScrollView) { | |
return child; | |
} | |
return SingleChildScrollView( | |
physics: const NeverScrollableScrollPhysics(), | |
padding: EdgeInsets.only( | |
bottom: _bottomInsetHeight, | |
), | |
child: SizedBox( | |
height: constraints.maxHeight, | |
child: child, | |
), | |
); | |
}, | |
), | |
); | |
} | |
void _scrollToFocusNode( | |
FocusNode focusNode, | |
double bottomInsetHeight, | |
double screenHeight, | |
) { | |
final RenderBox focusNodeRenderBox = focusNode?.context?.findRenderObject(); | |
if (focusNodeRenderBox == null) { | |
return; | |
} | |
// Calculate the offset needed to show the object in the [ScrollView] | |
// so that its bottom touches the top of the keyboard. | |
final viewport = RenderAbstractViewport.of(focusNodeRenderBox); | |
final offset = viewport?.getOffsetToReveal(focusNodeRenderBox, 1)?.offset; | |
if (offset == null) { | |
return; | |
} | |
final pos = Scrollable.of(focusNode.context)?.position; | |
if (pos == null) { | |
return; | |
} | |
final target = | |
(offset + bottomInsetHeight + widget.additionalPadding).clamp( | |
pos.minScrollExtent, | |
pos.maxScrollExtent, | |
); | |
pos.animateTo( | |
target, | |
duration: widget.duration, | |
curve: widget.curve, | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment