import 'dart:collection'; import 'package:flutter/widgets.dart'; import 'package:flutter/rendering.dart'; /// Helps [child] stay visible by resizing it to avoid the given [areaToAvoid]. /// /// Wraps the [child] in a [AnimatedContainer] that adjusts its bottom [padding] to accommodate the given area. /// /// If [autoScroll] is true and the [child] contains a focused widget such as a [TextField], /// automatically scrolls so that it is just visible above the keyboard, plus any additional [overscroll]. class BottomAreaAvoider extends StatefulWidget { static const Duration defaultDuration = Duration(milliseconds: 100); static const Curve defaultCurve = Curves.easeIn; static const double defaultOverscroll = 12.0; static const bool defaultAutoScroll = false; /// The child to embed. /// /// If the [child] is not a [ScrollView], it is automatically embedded in a [SingleChildScrollView]. /// If the [child] is a [ScrollView], it must have a [ScrollController]. final Widget child; /// Amount of bottom area to avoid. For example, the height of the currently-showing system keyboard, or /// any custom bottom overlays. final double areaToAvoid; /// Whether to auto-scroll to the focused widget after the keyboard appears. Defaults to false. /// Could be expensive because it searches all the child objects in this widget's render tree. final bool autoScroll; /// Extra amount to scroll past the focused widget. Defaults to [defaultOverscroll]. /// Useful in case the focused widget is inside a parent widget that you also want to be visible. final double overscroll; /// Duration of the resize animation. Defaults to [defaultDuration]. To disable, set to [Duration.zero]. final Duration duration; /// Animation curve. Defaults to [defaultCurve] final Curve curve; final ScrollController scrollTo; BottomAreaAvoider({ Key key, @required this.child, @required this.areaToAvoid, this.autoScroll = false, this.duration = defaultDuration, this.curve = defaultCurve, this.scrollTo, this.overscroll = defaultOverscroll, }) : //assert(child is ScrollView ? child.controller != null : true), assert(areaToAvoid >= 0, 'Cannot avoid a negative area'), super(key: key); BottomAreaAvoiderState createState() => BottomAreaAvoiderState(); } class BottomAreaAvoiderState extends State { final _animationKey = new GlobalKey(); Function(AnimationStatus) _animationListener; ScrollController _scrollController; double _previousAreaToAvoid; scroll() { _scrollController.position.moveTo( 200, duration: Duration(milliseconds: 100), curve: widget.curve, ); } @override void initState() { super.initState(); } @override void didUpdateWidget(BottomAreaAvoider oldWidget) { _previousAreaToAvoid = oldWidget.areaToAvoid; super.didUpdateWidget(oldWidget); } @override void dispose() { _animationKey.currentState?.animation ?.removeStatusListener(_animationListener); super.dispose(); } @override Widget build(BuildContext context) { // Add a status listener to the animation after the initial build. // Wait a frame so that _animationKey.currentState is not null. if (_animationListener == null) { WidgetsBinding.instance.addPostFrameCallback((_) { _animationListener = _paddingAnimationStatusChanged; _animationKey.currentState.animation .addStatusListener(_animationListener); }); } // If [child] is a [ScrollView], get its [ScrollController] // and embed the [child] directly in an [AnimatedContainer]. if (widget.child is ScrollView) { var scrollView = widget.child as ScrollView; _scrollController = scrollView.controller ?? PrimaryScrollController.of(context); return _buildAnimatedContainer(widget.child); } // If [child] is not a [ScrollView], and [autoScroll] is true, // embed the [child] in a [SingleChildScrollView] to make // it possible to scroll to the focused widget. if (widget.autoScroll) { _scrollController = widget.scrollTo; // widget.scrollTo =_scrollController; return _buildAnimatedContainer( LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( controller: _scrollController, child: ConstrainedBox( constraints: BoxConstraints( minHeight: constraints.maxHeight, ), child: widget.child, ), ); }, ), ); } // Just embed the [child] directly in an [AnimatedContainer]. return _buildAnimatedContainer(widget.child); } Widget _buildAnimatedContainer(Widget child) { return AnimatedContainer( key: _animationKey, padding: EdgeInsets.only(bottom: widget.areaToAvoid), duration: widget.duration, curve: widget.curve, child: child, ); } /// Called whenever the status of our padding animation changes. /// /// If the animation has completed, we added overlap, and scroll is on, scroll to that. void _paddingAnimationStatusChanged(AnimationStatus status) { if (status != AnimationStatus.completed) { return; // Only check when the animation is finishing } if (!widget.autoScroll) { return; // auto scroll is not enabled, do nothing } if (widget.areaToAvoid <= _previousAreaToAvoid) { return; // decreased-- do nothing. We only scroll when area to avoid is added (keyboard shown). } // Need to wait a frame to get the new size (todo: is this still needed? we dont use mediaquery anymore) WidgetsBinding.instance.addPostFrameCallback((_) { if (context == null || !mounted) { return; // context is no longer valid } final focused = findFocusedObject(context.findRenderObject()); if (focused == null) { return; // no focused object found } scrollToObject(focused, _scrollController, widget.duration, widget.curve, widget.overscroll); }); } } /// Utility helper methods /// Finds the first focused focused child of [root] using a breadth-first search. RenderObject findFocusedObject(RenderObject root) { final q = Queue(); q.add(root); while (q.isNotEmpty) { final RenderObject node = q.removeFirst(); final config = SemanticsConfiguration(); node.describeSemanticsConfiguration(config); if (config.isFocused) { return node; } node.visitChildrenForSemantics((child) { q.add(child); }); } return null; } /// Scroll to the given [object], which must be inside [scrollController]s viewport. scrollToObject(RenderObject object, ScrollController scrollController, Duration duration, Curve curve, double overscroll) { // 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(object); final offset = viewport.getOffsetToReveal(object, 1.0).offset + overscroll; // If the object is covered by the keyboard, scroll to reveal it, // and add [focusPadding] between it and top of the keyboard. if (offset > scrollController.position.pixels) { scrollController.position.moveTo( offset, duration: duration, curve: curve, ); } }