Flutter - Anti-occlusion of form in ScrollView 🗒

LinXunFeng
5 min readNov 5, 2023

--

Overview

An optimization point that I encountered recently is that there is a form view for submitting feedback in a ScrollView. Here is a simple simulation. This is the effect after clicking TextField.

When the designer check it, she felt that there was a point that needed to be optimized, that is, the form view was not large and the submit button needed to be fully displayed. It would be quite troublesome to manually calculate the offset, but if combined with my package (flutter_scrollview_observer) , it will not be a problem for us at all. The optimized effect is as follows:

Demo link: https://github.com/fluttercandies/flutter_scrollview_observer/blob/main/example/lib/features/scene/scrollview_form_demo/scrollview_form_demo_page.dart

Layout

The layout is relatively easy, just go through it briefly

ScrollView

We use ListView to build the ScrollView.

ScrollController scrollController = ScrollController();

Widget _buildScrollView() {
Widget resultWidget = ListView.builder(
controller: scrollController,
itemBuilder: (context, index) {
if (formIndex == index) {
return _buildForm();
}
return _buildImage();
},
itemCount: 10,
);
...
return resultWidget;
}

Form view

FocusNode formFocusNode = FocusNode();

Widget _buildForm() {
Widget resultWidget = Form(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'Feedback',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
// TextField
TextField(
focusNode: formFocusNode,
),
// Submit button
Container(
width: double.infinity,
color: Colors.white,
alignment: Alignment.center,
margin: const EdgeInsets.only(top: 10.0),
child: TextButton(
child: const Text('Submit'),
onPressed: () {
// Make TextField lose focus
formFocusNode.unfocus();
},
),
),
],
),
);
...
return resultWidget;
}

Practical part

Since the ScrollView is implemented using ListView, we use the corresponding ListViewObserver to wrap it for observation.

// Pass the ScrollView's ScrollController to the ListObserverController
ListObserverController observerController = ListObserverController(controller: scrollController);

resultWidget = ListViewObserver(
controller: observerController,
// Turn off automatic triggering
autoTriggerObserveTypes: const [],
child: _buildScrollView(),
// Do not implement onObserve callback
// onObserve: (resultModel) {}
);

Note: Please pay attention to the commented out onObserve callback. Relevant version update instructions will be provided below.

// The index of form view.
final int formIndex = 3;

// Listening the focus state of TextField.
formFocusNode.addListener(handleFormFocus);

...

handleFormFocus() async {
// We only handle cases where focus is obtained.
if (!formFocusNode.hasFocus) return;
// After getting focus, wait for the keyboard to be fully displayed.
await Future.delayed(const Duration(milliseconds: 600));
// Trigger observation for ScrollView.
final result = await observerController.dispatchOnceObserve(
// Force to observate without comparing the last observation result
isForce: true,
// Does not rely on the implementation of the onObserve callback,
// so that the observation result can be returned normally.
isDependObserveCallback: false,
);
// If the observation is unsuccessful, there is no need to continue.
if (!result.isSuccess) return;

// Find the data corresponding to the form from the observation result
// based on the index
final formResultModel =
result.observeResult?.displayingChildModelList.firstWhere((element) {
return element.index == formIndex;
});
if (formResultModel == null) return;

// Perform offset scrolling so that the bottom of the form view appears
// against the top of the keyboard.
observerController.controller?.animateTo(
// The logic of subtraction will be explained below.
formResultModel.scrollOffset - formResultModel.trailingMarginToViewport,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
}

Some additional notes:

  • The length of time to wait for the keyboard to be fully displayed (600 ms) is a value tested that applies to both iOS and Android. If you have a better way, you can leave a message to share it 😁
  • Before flutter_scrollview_observer 1.16.0, if the corresponding observation callback (such as onObserve) is not implemented, the internal observation logic cannot proceed and ends early. After version 1.16.0, the dispatchOnceObserve method has been enhanced. You can avoid this logic by setting isDependObserveCallback to false, and the return value has also been transformed into Future<ListViewOnceObserveNotificationResult>, so you can get the observation results directly, which is quite convenient!
  • The scrollOffset is the offset of the current ScrollView.
  • The trailingMarginToViewport refers to the distance from the bottom of the current item to the viewport, that is, the distance from the bottom of the form view to the bottom of the ScrollView’s viewport.
  • Since the above function relies on the feature of the view height changing when the keyboard pops up, the size of the viewport is squeezed and reduced when the keyboard pops up, so please do not set the resizeToAvoidBottomInset property here in Scaffold to false. If false is set, the scroll offset needs to be manually added to the keyboard height~
return Scaffold(
resizeToAvoidBottomInset: true,
body: ...,
);

After understanding the above content, let’s look at the logic of the final offset calculation.

observerController.controller?.animateTo(
formResultModel.scrollOffset - formResultModel.trailingMarginToViewport,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);

As shown in the figure above, when the keyboard is fully displayed, the ScrollView’s viewport becomes smaller, changing from the red area to the blue area. At this time, the form view is also fully displayed, its distance from the bottom to the viewport is trailingMarginToViewport, which is a positive number, but we need to make it close to the top of keyboard, so the offset of the ScrollView is too much at this time, and we need to subtract the trailingMarginToViewport. This is the logic of the final offset calculation.

--

--