How I built Floating Text Around Images In Flutter
My app fetches and parses the RSS feeds that I am showing in a ListView.builder(); each feed item contains metadata like title…
My app fetches and parses the RSS feeds that I am showing in a ListView.builder(); each feed item contains metadata like title, description, image URL, and source URL mainly.
Each feed widget isn’t just a standard list item but a dynamic layout where the description text needs to wrap around the image like in the image below.
But the flutter does not support it directly, like in HTML/CSS.
So I had built the custom solution to tackle this limitation.
Introduction & Problem Statement
I had made an RSS aggregator and reader app that fetches and parses the RSS feed from many sources in the form of XML.
Each feed item contains meta data like title, description, media image, and URL, mainly source URL.
Initially I had only one layout option that was a simple vertical display layout of the feed data.

Limitations of the Vertical Layout
But it has a major problem with the space efficiency, as it covers much more space per feed item.
Introducing the Sideways Layout
Therefore, I have created another feed layout option: the sideways layout.
In this layout, details are as follows:
- The title and media image are placed side by side horizontally.
- And the description is split between the space left below the title and the left side of the image if available and the rest at the bottom.
- And other details like favicon, source name, and other options at the bottom.
Why The Sideways Layout is Better?
This technique allows for creating more space-efficient layouts that can display 3–5 items in the same space where a vertical layout might only fit 1–2 items, significantly improving content density while maintaining usability.
Complexities of The Sideways Layout !!!
But Sideways Layout has one problem, which is to wrap the description in the available space between the title and image because the description is a single string/text, and Flutter by default does not support floating image or text widgets as in HTML/CSS.
I had solved this limitation with two approaches.
1. Floating Text Layout: Description Splitting Approach
This approach solves the challenge of wrapping text around an image in Flutter by intelligently splitting a description text into two parts:
- Upper part: Displayed in the available space below the title and beside the image
- Lower part: Displayed below both the title and image
2. Custom Painter Approach for Sideways Layout Text Wrapping
The Custom Painter approach solves the challenge of creating an efficient sideways layout by rendering text that naturally flows around an image without requiring multiple separate text widgets.
Coming Soon…
So, let’s study both the approaches in detail.
If you find this helpful, please leave a clap or comment; it helps me and helps Medium know to show this to more developers.
Floating Text Layout: Description Splitting Approach
This approach solves the challenge of wrapping text around the image in Flutter by intelligently splitting a description text into two parts:
- Upper part: Displayed in the available space below the title and beside the image
- Lower part: Displayed below both the title and image
Key Implementation Details:
- Space Calculation: Measures the height and width of the available space by using GlobalKeys and RenderBox to get precise dimensions of title and image elements
- Text Fitting Algorithm: Uses TextPainter to accurately determine how much text can fit in the available space
- Binary Search Optimization: Efficiently finds the maximum amount of text that fits within the constraints.
- Word Boundary Preservation: Ensures words aren’t cut in half at the split point
- Text Scaling: Accounts for different TextStyles by calculating appropriate scaling factors
Advantages:
- Maximizes space utilization in the UI
- Preserves readability by keeping words intact
- Adapts to different font sizes and text styles
- Works with variable content and screen dimensions
- Preserve the Image aspect ratio
- Optimized to work for large lists with smooth performance
Step-by-Step Implementation
Sideways Layout Components Overview
Our sideways layout contains 4 main components/widgets.

- Title (Blue) : Text Widget
- Banner Image (Black): CachedNetworkImage Widget
- Upper Description (Pink): Text Widget
- Lower Description (Red): Text Widget
We are using a StatefulWidget.
Available Space for Upper Description Calculation
Title and BannerImage are our main component from which we will calculate all the dimensions needed.
- Upper Description Width = Title Width
- Upper Description Height = Image Height — Title Height
In case of title height is greater than that of BannerImage then we will make upper desc. height = 0 or partitioningIndex = 0;
Let’s Get through the Code
In this section I had given the overview of the implementation as the actual code contains other features and optimizations that are out of the scope of this article.
But you can see the full code here.
Setting Up GlobalKeys
First of all we needed GlobalKeys for Title and BannerImage to calculate dimensions and to perform lifecycle processes at runtime.
// for Title Text Widget
final GlobalKey _urlTitleKey = GlobalKey();
// for Banner Image's NetworkImageWidget
final GlobalKey _bannerImageKey = GlobalKey();ValueNotifier<Size> for Dimensions
Then we need variables to store dimensions of Title and Image. So we use ValueNotifier<Size?> for this.
And to update the widgets as per need (new data).
// Widget Layout Informations for description upper calculations
final ValueNotifier<Size?> _urlTitleSize = ValueNotifier<Size?>(null);
final ValueNotifier<Size?> _bannerImageSize = ValueNotifier<Size?>(null);Description Partitioning Index
Our approach requires splitting the description into two parts, so instead of storing two separate strings, we have stored the partitioning index.
// this is used to store last index of upper description string
// to help in partiontioning the description
final _upperDescriptionIndex = ValueNotifier(0);Default TextStyles for TextPainters
Then we also stored the TextStyle of Title Text Widget and Upper description Text Widget. Will see later in tutorial.
// this one is used to calculate the actual description that can fit in the
// upper part or _upperDescriptionIndex
// with TextPainter
final upperDescTextStyle = TextStyle(
color: Colors.grey.shade900,
fontSize: 14,
);
// this is used in TextPainter to calculate the Title dimensions
final titleTextStyle = TextStyle(
color: Colors.grey.shade900,
fontWeight: FontWeight.w500,
fontSize: 16,
);Method to Calculate Widget Dimensions At Runtime
Then we have a _updateSize method to update the size of the Widgets from their GlobalKey. Used for Title Text and NetworkImage in our case.
// to calculate the Widget size from their GlobalKey and updating
// the size notifiers
void _updateSize(GlobalKey key, ValueNotifier<Size?> notifier) {
final renderBox = key.currentContext?.findRenderObject() as RenderBox?;
if (renderBox != null && renderBox.hasSize) {
notifier.value = renderBox.size;
}
}Implementing the ‘_splitDescription’ Method for Splitting Algorithm
Next we have _splitDescription method that calculates the partitioning index from description for upper and lower description.
// This method used to find the partitioning index for description
String? _splitDescription({
required String description, // full description
required double containerWidth, // available width for upper description
required double containerHeight, // available height for upper description
required TextStyle targettTextStyle, // mostly the descriptionTextStyle
}) {
// this Creates a text painter that paints the given text.
// created to use multiple times
final textPainter = TextPainter(
textDirection: TextDirection.ltr,
)
..maxLines = 1 // for a reason
..text = TextSpan(text: 'description', style: targettTextStyle)
..layout(maxWidth: containerWidth);
// Calculating the maxlines that can be fitted into available height
// for upper description
int? maxLines = (containerHeight / textPainter.height).round();
maxLines = maxLines > 0 ? maxLines : null;
if (maxLines == null) return null;
// NOTE: WE COULD HAVE JUST USED LINES AND CONTAINER WIDTH TO CALCULATE THE UPPER
// DESCRIPTION STRING BUT THIS APPROACH WILL NOT COUNT FOR INDIVIDUAL WORDS
// AS UNITS BUT RATHER EACH CHARACTER INTO ACCOUNT. SO THIS IS DROPPED.
// THEREFORE WE ARE CALCULATING BY USING THE EACH WORD AS UNIT INTO ACCOUNT
// SEE THE ALGORITHM
final words = description.split(' '); // storing each word in a List<String>
final part1 = StringBuffer(); // for upper description
var currentLineCount = 0;
var totolPart1Length = 0;
var wordIndex = 0;
// Iterate over the available number of lines
while (currentLineCount < maxLines && wordIndex < words.length) {
final currentLine = StringBuffer();
// Add words to the current line until it exceeds or matches the container width
while (wordIndex < words.length) {
final nextWord = words[wordIndex];
// Create a new text span with the current line + the next word
textPainter
..text = TextSpan(
text:
'$currentLine $nextWord', // Use .toString() to get the current line's text
style: targettTextStyle,
)
..layout(
maxWidth: containerWidth * 1.5,
); // Measure the width without constraints
final textPainterWidth = textPainter.width;
// Case 1: When the text width exactly matches the container width
if (textPainterWidth <= containerWidth) {
currentLine.write(' $nextWord');
wordIndex++;
}
else {
break; // Break the loop as the line is overfilled
}
}
// Write the completed line to the final result and increment the line count
final newlineContent = currentLine.toString();
totolPart1Length += newlineContent.length;
part1.write(newlineContent);
currentLineCount++;
}
final part1t = part1.toString().trim();
final index = totolPart1Length > 0 ? totolPart1Length - 1 : 0;
// updating the description partitioning index
_upperDescriptionIndex.value = index;
return part1t; // Return the truncated description part
}Wrapper Method: Called By Widgets for Splitting
Then the wrapper method call to manage all of this is _getDescriptionContainerSize and it is called after the required main components/widgets have been rendered.
void _getDescriptionContainerSize({
required TextStyle textStyle,
required TextStyle titleTextStyle,
required BuildContext context,
}) {
// UPDATING TITLE AND BANNERIMAGE DIMENSIONS OR SIZE
_updateSize(_urlTitleKey, _urlTitleSize);
_updateSize(_bannerImageKey, _bannerImageSize);
if (_urlTitleSize.value == null ||
_bannerImageSize.value == null ||
_bannerImageSize.value!.width == 0 ||
_bannerImageSize.value!.height == 0) {
return;
}
final remainingHeightForDescription =
_bannerImageSize.value!.height - _urlTitleSize.value!.height;
if (remainingHeightForDescription <= 0) {
_upperDescriptionIndex.value = 0;
_bannerImageSize.value = null;
return;
}
final remainingWidthForDescription = _urlTitleSize.value!.width;
if (_bannerImageSize.value!.height > _bannerImageSize.value!.width * 1.1) {
_isSideWayLayout.value = true;
}
// CALLING TO SPLIT DESCRIPTIONS BY CALCULATING UPPER DESCRIPTION LAST INDEX
_splitDescription(
description: widget.urlModel.metaData?.description?.trim() ?? '',
containerWidth: remainingWidthForDescription,
containerHeight: remainingHeightForDescription,
targettTextStyle: textStyle,
);
}Widget Code for the Individual Feed Item
Ok, let's arrange all the missing pieces together in abuild method. The actual implementation is pretty complex with other features and optimizations; therefore, I cannot show all of it but will give some important parts for reference.
This is the code for the individual feed item at the sideways layout configuration.
The layout is simple overall with the Row as the parent having two children one that contains Title and Upper Description and other is the Image.
The Title and Upper Description are conained in a vertical Column.
With Title Text and Image Widgets have their GlobalKeys for calculating their dimensions/sizes at runtime.
I will thoroughly explain in the code as comments.
// TITLE, SIDEWAY UPPER DESCRIPTION, BANNERIMAGE
// THIS ROW HAS TWO CHILDREN ONE IS FOR TITLE AND UPPER DESCRIPTION WRAPPED IN COLUMN
// AND OTHER IS THE BANNER IMAGE
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// TITLE AND UPPERDESCRIPTION
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// THIS IS FOR THE TITLE AND CONTAINS _urlTitleKey
Text(
'${widget.urlModel.metaData?.title?.trim()}',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: isSideWays ? 16 : 18,
),
key: _urlTitleKey,
),
// THIS IS OUR UPPER DESCRIPTION TEXT WIDGET
if (descriptionAvailable && isSideWays)
ValueListenableBuilder(
valueListenable: _showFullDescription,
builder: (context, showFullDescription, _) {
if (!showFullDescription) {
return Container();
}
return ValueListenableBuilder(
valueListenable: _upperDescriptionIndex,
builder: (context, upperDescriptionIndex, _) {
// final imageSize = _bannerImageSize.value;
if (upperDescriptionIndex < 1) {
return Container();
}
var upperDescWidth = 0.0;
var upperDescHeigthht = 0.0;
if (_bannerImageSize.value != null &&
_urlTitleSize.value != null) {
upperDescWidth =
_urlTitleSize.value!.width;
final bannerImageHeight = min(
_bannerImageSize.value!.height,
size.width * 0.35,
);
upperDescHeigthht = bannerImageHeight -
_urlTitleSize.value!.height;
}
// used for calculating 1 line height
final textPainter = TextPainter(
textDirection: TextDirection.ltr,
)
..maxLines = 1
..text = TextSpan(
text: 'M',
style: upperDescTextStyle,
)
..layout(maxWidth: upperDescWidth);
int? maxLines =
(upperDescHeigthht / textPainter.height)
.round();
maxLines = maxLines > 0 ? maxLines : null;
if (maxLines == null) {
return Container();
}
return Text(
widget.urlModel.metaData!.description!,
style: upperDescTextStyle,
maxLines: maxLines,
);
},
);
},
),
],
),
),
if (imageBuilder != null && isSideWays)
const SizedBox(width: 8),
// SIDEWAYS BANNERIMAGE WITH GLOBALKEY _bannerImageKey
// THE imageBuilder IS INITIALIZED AT THE BEGINNING OF build METHOD
if (imageBuilder != null && isSideWays)
ValueListenableBuilder(
valueListenable: _bannerImageSize,
builder: (context, bannerImageSize, _) {
var bannerWidth = size.width * 0.35;
if (bannerImageSize?.width != null) {
bannerWidth =
min(bannerWidth, bannerImageSize!.width);
}
final bannerImageHeight =
bannerImageSize?.height != null &&
bannerImageSize!.height > 1.0
? min(
bannerImageSize.height,
size.width * 0.35,
)
: null;
return ValueListenableBuilder(
valueListenable: _showBannerImage,
builder: (context, showBannerImage, _) {
if (!showBannerImage) {
return const SizedBox.shrink();
}
return bannerImageHeight == null
? SizedBox(
key: _bannerImageKey,
width: bannerWidth,
child: imageBuilder,
)
: SizedBox(
key: _bannerImageKey,
width: bannerWidth,
height: bannerImageHeight,
child: imageBuilder,
);
},
);
},
),
],
),Calling the Wrapper Method in Widgets(imageBuilder)
Now we will implement our NetworkImage as this is the dynamic widget
as its height is dynamic. In the previous code we had seen the BannerImage will have a defined width of size.width*0.35. So to maintain the aspect ratio, the image will change its height.
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final descriptionAvailable =
widget.urlModel.metaData?.description != null &&
widget.urlModel.metaData!.description!.isNotEmpty;
FutureBuilder<FileInfo?>? imageBuilder;
// INITIALIZING THE IMAGEBUILDER OR BANNERIMAGE
// ACTUAL CODE CONTAINS A FUTURE BUILDER FOR GETTING THE IMAGE FROM EITHER
// CACHED OR FROM NETWORK. SO REDUCED SOME CODE FOR READABILITY
// THE MAIN PURPOSE OF THIS BANNERIMAGE IS THAT AFTER IT HAS BEEN RENDERED THEN
// WE ARE CALLING OUT METHOD FOR FINALIZE THE DESCRIPTION SPLITTING
imageBuilder = RepaintBoundary(
child: Image.file(
fileInfo.file,
width: size.width,
fit: BoxFit.cover,
errorBuilder: (context, _, __) =>
Container(), // Fallback in case of error
frameBuilder: (
BuildContext context,
Widget child,
int? frame,
bool wasSynchronouslyLoaded,
) {
if (frame == null) {
return Container(); // Placeholder while the image is loading
}
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
decoration: BoxDecoration(
color: ColourPallette.mystic.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade100),
),
child: ImageFileWidget(
initials: initials,
child: child,
postFrameCallback: () {
if (_bannerImageSize.value == null) {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
// CALLING THIS METHOD TO FINALIZE THE
// DESCRIPTION SPLITTING
_getDescriptionContainerSize(
context: context,
textStyle: upperDescTextStyle,
titleTextStyle: titleTextStyle,
);
},
);
}
},
),
),
);
},
),
);I know you have many questions floating around in your mind.
- Why did I use RepaintBoundary?
- Why did I use VisibilityDetector as the wrapper widget in
buildmethod ? - Why did I use
didUpdateWidgetlifecycle method anddetachWidgetfor detaching the feed widget from the widget tree ? - How had I optimized such a large list of feeds, around 1000+ with images and dynamic size calculations at runtime?
- What are other optimization techniques I used for the smoothest scrolling performance?
These techniques are out of the scope of this article, so I will write a separate tutorial for these optimization techniques.
Coming Soon…
Conclusion and Next Steps
Recap of the Process
In this article, I tackled one of Flutter’s most challenging layout limitations: displaying a feed where description text wraps around an image. I built a custom solution to overcome Flutter’s lack of native support for HTML/CSS–like floating text by splitting the description into two distinct parts. Here’s a quick summary of the steps involved:
- Problem Identification:
I began by outlining the limitations of Flutter’s standard layout system, using my RSS Reader app as the example where each feed item has a title, description, image, and source URL. - Solution Overview:
I introduced two approaches:
- The Floating Text Layout: Description Splitting Approach, where I measured available space beneath the title and beside the banner image using GlobalKeys and RenderBox. I then employed a text-measuring algorithm with TextPainter to determine a split index that preserves word boundaries, ensuring the upper part fits in the designated space.
- The Custom Painter Approach, which, although not discussed in full here, offers an alternative way to render text so that it naturally flows around the image without splitting it into multiple widgets.
- Implementation Details:
I demonstrated how to set up the essential variables, use ValueNotifiers for real-time dimension tracking, and implement methods like_updateSizeand_splitDescriptionto calculate and render the dynamic layout.
Key Takeaways
- Precision Matters:
Measuring widget dimensions at runtime (via GlobalKeys and RenderBox) is critical when building responsive, dynamic layouts in Flutter. - TextPainter is Powerful:
Leveraging TextPainter allows you to determine exactly how much text can fit within an allocated space, enabling you to split a single string into two parts seamlessly. - Optimized Layouts Enhance UX:
Implementing a sideways layout not only saves screen space but also improves content density, which is crucial for apps that need to display multiple feed items in limited space. - Adaptability is Key:
Your solution should gracefully handle variable content sizes and different screen dimensions. Maintaining readability while preserving aspect ratios and spacing reinforces best practices in adaptive design.
Call-to-Action
Now that you’ve seen how I cracked Flutter’s layout limitation by splitting description text to wrap around an image.
I invite you to implement this solution in your own projects.
Whether you’re working on an RSS aggregator, a newsfeed app, or any Flutter project with similar layout challenges, use the techniques shared here as your starting point.
I’d love to hear about your experiences:
- Have you faced similar challenges?
- What tweaks or optimizations have you tried?
- Do you have any additional ideas to improve upon this approach?
Leave a comment below and join the conversation — your feedback and insights might help refine this solution even further and inspire other developers facing the same problem.
Happy coding, and I look forward to your thoughts and suggestions!
This is my approach for solving this problem.
Subscribe to my free Medium Newsletter to get Email Notifications on my upcoming tutorials.
Additional Resources and Further Reading
Links to Related Articles:
