My textfield has a lot of text and is scrollable. I want to scroll to a specific substring or index (marked with red) when I click on a button. How can I achieve this?
I have tried following solution:
void scrollToWord(String word) {
final text = _controller.text;
// Find the position of the word in the text
int index = text.indexOf(word);
if (index == -1) {
// Word not found, return early
return;
}
// Move the cursor to the start of the word
_controller.selection = TextSelection.fromPosition(TextPosition(offset: index));
// Calculate the scroll position (find position of word's start)
final textPainter = TextPainter(
text: TextSpan(text: text),
textDirection: TextDirection.ltr,
);
textPainter.layout(maxWidth: MediaQuery.of(context).size.width);
// Calculate the word's start position on screen
final wordStartOffset = textPainter.getOffsetForCaret(TextPosition(offset: index));
final wordHeight = textPainter.size.height;
// Scroll to the word's position
_scrollController.animateTo(
wordStartOffset.dy, // Scroll position
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
But this does not work, as the word startOffset
is way off somehow.
My textfield has a lot of text and is scrollable. I want to scroll to a specific substring or index (marked with red) when I click on a button. How can I achieve this?
I have tried following solution:
void scrollToWord(String word) {
final text = _controller.text;
// Find the position of the word in the text
int index = text.indexOf(word);
if (index == -1) {
// Word not found, return early
return;
}
// Move the cursor to the start of the word
_controller.selection = TextSelection.fromPosition(TextPosition(offset: index));
// Calculate the scroll position (find position of word's start)
final textPainter = TextPainter(
text: TextSpan(text: text),
textDirection: TextDirection.ltr,
);
textPainter.layout(maxWidth: MediaQuery.of(context).size.width);
// Calculate the word's start position on screen
final wordStartOffset = textPainter.getOffsetForCaret(TextPosition(offset: index));
final wordHeight = textPainter.size.height;
// Scroll to the word's position
_scrollController.animateTo(
wordStartOffset.dy, // Scroll position
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
But this does not work, as the word startOffset
is way off somehow.
- How you rendered your text string ? using segments as List<String> or just a single large text? – Aks Commented Feb 7 at 7:17
- Its just a single large String – Lukas Commented Feb 7 at 20:34
1 Answer
Reset to default 0After some research, I found a solution to this problem.
Let's divide it into parts to be clear:
Problem Statement:
You want to:
- Take a word as input from the user (or from any other resource).
- Find this word inside the text stored in a
TextField
'scontroller.text
. - Get the position (
offset
) of this word within theTextField
. - Scroll to that position (
offset
) so the word becomes in visible scope.
To achieve this, you need two main controllers:
ScrollController
: To control the scrolling behavior of the TextField
.
TextEditingController
: To manage the text and cursor position inside the TextField
.
1. The Programmatical Part
Defining and initializing the Controllers:
// Controller that will catch the target Word from the User
// Also will be used to control the cursor position
late final TextEditingController _searchController;
// The Target contrller that we will scroll from it
late final TextEditingController _targetController;
// The ScrollController of the TextField Widget
late final ScrollController _scrollController;
// Initialize the Controllers in the initState
@override
void initState() {
_searchController = TextEditingController();
_targetController = TextEditingController();
_scrollController = ScrollController();
super.initState();
}
@override
void dispose() {
_searchController.dispose();
_targetController.dispose();
_scrollController.dispose();
super.dispose();
}
Then You Create the method that will control the Scroll:
void get _scrollToSearchedText {
log("Start to Scroll .....");
final String contentText = _targetController.text;
final String searchText = _searchController.text;
final int indexOfTextinContent = contentText.indexOf(searchText);
if (indexOfTextinContent != -1 || contentText.isNotEmpty) {
log("trying ....");
// ensure that all frames has been build and there are no Widgets need to bind
WidgetsBinding.instance.addPostFrameCallback(
(_) {
final textPainter = TextPainter(
text: TextSpan(
text: contentText.substring(0, indexOfTextinContent),
style: const TextStyle(fontSize: 16),
),
textDirection: TextDirection.ltr,
);
// Layout the text to calculate the size
// Computes the visual position of the glyphs for painting the text.
textPainter.layout();
// Calculate the scroll offset based on the text width and height
final double scrollOffset = textPainter.size.height *
(textPainter.size.width /
_scrollController.position.viewportDimension);
// Scroll to the calculated offset
_scrollController.jumpTo(scrollOffset.clamp(
0.0, _scrollController.position.maxScrollExtent));
// Move the cursor to the start of the searched word
_targetController.selection = TextSelection.collapsed(
offset: indexOfTextinContent,
);
},
);
}
}
2. The UI Part
- Create the TextFielWidget Component
class CustomTextFielWidget extends StatelessWidget {
const CustomTextFielWidget({
super.key,
required this.controller,
required this.hintText,
this.isTargetField = false,
this.scrollController,
});
final TextEditingController controller;
final String hintText;
final bool isTargetField;
final ScrollController? scrollController;
@override
Widget build(BuildContext context) {
return Container(
height: isTargetField ? context.screenHeight * .5 : null, <---- Height is must
padding: const EdgeInsets.all(10),
child: TextField(
controller: controller,
scrollController: scrollController,
scrollPhysics: const AlwaysScrollableScrollPhysics(),
maxLines: isTargetField ? null : 1,
style: TextStyle(
fontSize: isTargetField ? 20 : 18,
fontWeight: FontWeight.bold,
fontFamily: isTargetField ? FontFamily.verlaFont : null,
),
decoration: InputDecoration(
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.6),
width: 1.3,
),
),
hintText: hintText,
fillColor: Colors.grey.withOpacity(0.3),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.6),
width: 2.0,
),
),
),
),
);
}
}
You must define a height to a the Field or give it a Constrains to prevent it from Expansion
- Create the Button Widget Component
class ScrollButtonWidget extends StatelessWidget {
const ScrollButtonWidget({
super.key,
required this.onScrollTap,
});
final void Function() onScrollTap;
@override
Widget build(BuildContext context) {
return SizedBox(
width: context.screenWidth * .7,
height: context.screenHeight * .07,
child: MaterialButton(
onPressed: onScrollTap,
color: Colors.blue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: const Text("Scroll To Target Word"),
),
);
}
}
Finally this is the Full Code
import 'dart:developer';
import 'package:flutter/material.dart';
class ScrollToTextWidget extends StatefulWidget {
const ScrollToTextWidget({super.key});
@override
State<ScrollToTextWidget> createState() => _ScrollToTextWidgetState();
}
class _ScrollToTextWidgetState extends State<ScrollToTextWidget> {
// Controller that will catch the target Word from the User
late final TextEditingController _searchController;
// The Target contrller that we will scroll from it
late final TextEditingController _targetController;
// The ScrollController of the TextField Widget
late final ScrollController _scrollController;
@override
void initState() {
_searchController = TextEditingController();
_targetController = TextEditingController();
_scrollController = ScrollController();
super.initState();
}
@override
void dispose() {
_searchController.dispose();
_targetController.dispose();
_scrollController.dispose();
super.dispose();
}
void get _scrollToSearchedText {
log("Start to Scroll .....");
final String contentText = _targetController.text;
final String searchText = _searchController.text;
final int indexOfTextinContent = contentText.indexOf(searchText);
if (indexOfTextinContent != -1 || contentText.isNotEmpty) {
log("trying ....");
WidgetsBinding.instance.addPostFrameCallback(
(_) {
final TextPainter textPainter = TextPainter(
text: TextSpan(
text: contentText.substring(0, indexOfTextinContent),
style: const TextStyle(fontSize: 16),
),
textDirection: TextDirection.ltr,
);
// Layout the text to calculate the size
// Computes the visual position of the glyphs for painting the text.
textPainter.layout();
// Calculate the scroll offset based on the text width and height
final double scrollOffset = textPainter.size.height *
(textPainter.size.width /
_scrollController.position.viewportDimension);
// Scroll to the calculated offset
_scrollController.jumpTo(scrollOffset.clamp(
0.0, _scrollController.position.maxScrollExtent));
// Move the cursor to the start of the searched word
_targetController.selection = TextSelection.collapsed(
offset: indexOfTextinContent,
);
},
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Scroll To Text"),
automaticallyImplyLeading: false,
backgroundColor: Colors.green,
),
body: Column(
children: <Widget>[
Expanded(
child: SingleChildScrollView(
child: Column(
children: <Widget>[
gapH1,
CustomTextFielWidget(
controller: _searchController,
hintText: "Enter Target Text",
),
gapH1,
CustomTextFielWidget(
controller: _targetController,
hintText: "Content Text",
scrollController: _scrollController,
isTargetField: true,
),
gapH2,
],
),
),
),
ScrollButtonWidget(
onScrollTap: () {
_scrollToSearchedText;
},
)
],
),
);
}
}
class CustomTextFielWidget extends StatelessWidget {
const CustomTextFielWidget({
super.key,
required this.controller,
required this.hintText,
this.isTargetField = false,
this.scrollController,
});
final TextEditingController controller;
final String hintText;
final bool isTargetField;
final ScrollController? scrollController;
@override
Widget build(BuildContext context) {
return Container(
height: isTargetField ? context.screenHeight * .5 : null,
padding: const EdgeInsets.all(10),
child: TextField(
controller: controller,
scrollController: scrollController,
scrollPhysics: const AlwaysScrollableScrollPhysics(),
maxLines: isTargetField ? null : 1,
style: TextStyle(
fontSize: isTargetField ? 20 : 18,
fontWeight: FontWeight.bold,
fontFamily: isTargetField ? FontFamily.verlaFont : null,
),
decoration: InputDecoration(
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.6),
width: 1.3,
),
),
hintText: hintText,
fillColor: Colors.grey.withOpacity(0.3),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.6),
width: 2.0,
),
),
),
),
);
}
}
class ScrollButtonWidget extends StatelessWidget {
const ScrollButtonWidget({
super.key,
required this.onScrollTap,
});
final void Function() onScrollTap;
@override
Widget build(BuildContext context) {
return SizedBox(
width: context.screenWidth * .7,
height: context.screenHeight * .07,
child: MaterialButton(
onPressed: onScrollTap,
color: Colors.blue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: const Text("Scroll To Target Word"),
),
);
}
}
You can ignore some part of code like
gapH1
,screenWidth
orscreenHeight
it's a properties I created
Also....
there is some note about the cursor , if you want the cursor be at the beginning of the word you the target textfield must be focus , you can handle this simply by wrapping the
TextField
withFocusScope
Widget and control thefocus
form it when the user click on the button