How to Dynamically Size a CustomPainter - flutter

I need to render my custom object inside of a ListTile with custom painter in order to draw some custom text.
ListTile(
title: CustomPaint(
painter: RowPainter.name(
_titleFontSelected,
_titleFont,
text,
index,
MediaQuery.of(context),
currentRow,
),
),
);
Inside my RowPainter I draw the text with the font selected.
When the row is too large, it automatically wraps and get drawn outside the given paint size.
void paint(Canvas canvas, Size size)
I like this behavior, but how can I resize the height of my paint area? Because this is a problem since this overlaps the next List row.
I know that the CustomPaint has a property Size settable, but I know the text dimension only inside my paint function using the TextPainter getBoxesForSelection but it's too late.
How can I "resize" my row painter height dynamically if the text wraps?

TL;DR
You cannot dynamically size a custom painter, however, your problem can be solved using a CustomPaint.
I will first elaborate on the dynamic sizing and then explain how to solve this problem using a constant size.
Dynamic size
This is essentially, where CustomPaint has its limits because it does not provide a way for you to size the painter based on the content.
The proper way of doing this is implementing your own RenderBox and overriding performLayout to size your render object based on the contents.
The RenderBox documentation is quite detailed on this, however, you might still find it difficult to get into it as it is quite different from building widgets.
Constant size
All of the above should not be needed in your case because you do not have a child for your custom paint.
You can simply supply the size parameter to your CustomPaint and calculate the required height in the parent widget.
You can use a LayoutBuilder to get the available width:
LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
...
}
)
Now, you can simply use a TextPainter to retrieve the required size before even entering your custom paint:
builder: (context, constraints) {
...
final textPainter = TextPainter(
text: TextSpan(
text: 'Your text',
style: yourTextStyle,
),
textDirection: TextDirection.ltr,
);
textPainter.layout(maxWidth: maxWidth); // This will make the size available.
return CustomPaint(
size: textPainter.size,
...
);
}
Now, you can even pass your textPainter to your custom painter directly instead of passing the style arguments.
Your logic might be a bit more complicated, however, the point is that you can calculate the size before creating the CustomPaint, which allows you to set the size.
If you need something more complicated, you will likely have to implement your own RenderBox.

I haven't tested it out, but this might work:
First of all, you wrap the CustomPaint into a stateful widget (called e.g. DynamicCustomPaint), to manipulate your widget dynamically.
You give your CustomPainter a function onResize, which will give you the new size of the canvas when you know it.
You call this function once you know the exact size the Canvas has to be. By using, for example, this technique where you won't have to draw the text to know what size it will be.
When the onResize function will be called, you get the new size for the canvas and call setState in the DynamicCustomPaint state.
This might look like this:
class DynamicCustomPaint extends StatefulWidget {
#override
_DynamicCustomPaintState createState() => _DynamicCustomPaintState();
}
class _DynamicCustomPaintState extends State<DynamicCustomPaint> {
Size canvasSize;
#override
Widget build(BuildContext context) {
// Set inital size, maybe move this to initState function
if (canvasSize == null) {
// Decide what makes sense in your use-case as inital size
canvasSize = MediaQuery.of(context).size;
}
return CustomPaint(
size: canvasSize,
painter: RowPainter.name(_titleFontSelected, _titleFont, text, index, currentRow, onResize: (size) {
setState(() {
canvasSize = size;
});
}),
);
}
}
typedef OnResize = void Function(Size size);
class RowPainter extends CustomPainter {
RowPainter.name(
this._titleFontSelected,
this._titleFont,
this.text,
this.index,
this.currentRow,
{ this.onResize },
);
final FontStyle _titleFontSelected;
final FontStyle _titleFont;
final String text;
final int index;
final int currentRow;
final OnResize onResize;
#override
void paint(Canvas canvas, Size size) {
// TODO: implement paint
// call onResize somewhere in here
// onResize(newSize);
}
#override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}

Use SingleChildRenderObjectWidget and RenderBox instead. Full simple example with dynamic resizing.
DartPad
import 'dart:async';
import 'package:flutter/material.dart';
void main() {
runApp(MaterialApp(
home: Scaffold(
body: Center(
child: Column(
children: [
SizedBox(height: 100,),
Text('I am above'),
MyWidget(),
Text('I am below')
],
),
),
),
));
}
class MyWidget extends SingleChildRenderObjectWidget {
#override
MyRenderBox createRenderObject(BuildContext context) {
return MyRenderBox();
}
}
class MyRenderBox extends RenderBox {
double myHeight = 200;
#override
void paint(PaintingContext context, Offset offset) {
Paint paint = Paint()
..color = Colors.black..style = PaintingStyle.fill;
context.canvas.drawRect(
Rect.fromLTRB(offset.dx, offset.dy,
offset.dx + size.width, offset.dy + size.height,), paint);
}
#override
void performLayout() {
size = Size(
constraints.constrainWidth(200),
constraints.constrainHeight(myHeight),
);
}
// Timer just an example to show dynamic behavior
MyRenderBox(){
Timer.periodic(Duration(seconds: 2), handleTimeout);
}
void handleTimeout(timer) {
myHeight += 40;
markNeedsLayoutForSizedByParentChange();
layout(constraints);
}
}
CustomPainter will only size to its children's size or initial value passed to the constructor. Documentation:
Custom painters normally size themselves to their child. If they do not have a child, they attempt to size themselves to the size, which defaults to Size.zero. size must not be null.
Basics of RenderBox
https://programmer.group/the-operation-instruction-of-flutter-s-renderbox-principle-analysis.html
https://api.flutter.dev/flutter/rendering/RenderBox-class.html

Related

Is there a Flutter widget specifying a min or max aspect ratio, rather than an exact ratio?

I want to force a child widget to have a max aspect ratio of e.g. 2.0: in other words, it is no wider than 2:1, but is as tall as possible. So in a tall skinny parent it would behave like SizedBox.expand(child: child), and in a short wide parent it would behave like AspectRatio(aspectRatio: 2.0, child: child).
Is there a widget that does this, like ConstrainedAspectRatio(maxAspectRatio: 2.0, child: child)? Or do I have to choose between (1) using a LayoutBuilder or (2) writing a custom SingleChildLayoutDelegate / SingleChildRenderObjectWidget?
Edit: In case there is no good answer out there, here's my SingleChildLayoutDelegate implementation. It is probably brittle.
import 'dart:math';
import 'package:flutter/material.dart';
class ConstrainedAspectRatio extends StatelessWidget {
const ConstrainedAspectRatio({required this.child, required this.maxAspectRatio, super.key});
final Widget child;
final double maxAspectRatio;
#override
Widget build(BuildContext context) => CustomSingleChildLayout(delegate: _CARDelegate(maxAspectRatio), child: child);
}
class _CARDelegate extends SingleChildLayoutDelegate {
_CARDelegate(this.maxAspectRatio);
final double maxAspectRatio;
Size _size = Size.zero;
#override
Size getSize(BoxConstraints constraints) {
// Full height, wide as allowed
final double w = constraints.maxWidth, h = constraints.maxHeight;
if (h.isInfinite) {
// Container infinitely tall; use max aspect ratio as exact aspect ratio
assert(w.isFinite, () => "Need at least one bounded constraint for $runtimeType.");
return _size = Size(w, w / maxAspectRatio);
} else {
// Finite height. Use all of it, and go as wide as maxAspectRatio allows.
return _size = Size(min(h * maxAspectRatio, w), h);
}
}
#override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return BoxConstraints.tight(_size);
}
#override
bool shouldRelayout(covariant _CARDelegate oldDelegate) {
return oldDelegate.maxAspectRatio != maxAspectRatio;
}
}

How does CustomPainter.shouldRepaint() work in Flutter?

In Flutter, when creating a CustomPainter, there is an override method, shouldRepaint() that you can return either true or false... presumably to tell the system whether or not to repaint the view.
And in the docs, the description for the method is:
shouldRepaint(covariant CustomPainter oldDelegate) → bool Called
whenever a new instance of the custom painter delegate class is
provided to the RenderCustomPaint object, or any time that a new
CustomPaint object is created with a new instance of the custom
painter delegate class (which amounts to the same thing, because the
latter is implemented in terms of the former). [...]
I basically don't understand any of that other than the fact that it returns a bool. That makes my head hurt! I also suspect that delving deeper into the definition of "custom painter delegate class," or "RenderCustomPaint object," will not be an enlightening experience.
I'm confused because:
I thought we didn't have to worry about when a widget "should repaint" because Flutter was supposed to decide where and when to re-render the widget tree based on it's own complex optimization decisions.
I thought the paint() method was where you define "this is how this view paints itself, (always and whenever that is necessary)"
All the examples I have found simply return false from this method... but I have noticed different behavior when using true vs false.
If we are always returning false, then how does it ever repaint? (And it does repaint even when false)
If the only possible logic available to us is comparing the "oldDelegate" to (something?) then why are we required to override the method at all?
I haven't seen any example that demonstrates why or how you would return TRUE, and what the logic of such an example would look like in order to make that decision.
Why and how would a knowledgable person decide to return false?
Why and how would a knowledgable person decide to return true?
Can anyone explain it like you're talking to a 13 year old (not Linus Torvalds)?
A simple code example and counter-example would be great (as opposed to an exhaustive explicit explanation!)
I have used CustomPainter extensively, and here is my answer.
Firstly, here is the full doc. You may have only read the starting sentences instead of the full doc. https://api.flutter.dev/flutter/rendering/CustomPainter/shouldRepaint.html
Why and how would a knowledgeable person decide to return false/true?
Here is the rule: If the new instance represents different information than the old instance, then the method should return true, otherwise it should return false.
Example:
class MyPainter extends CustomPainter {
MyPainter() : super();
#override
void paint(Canvas canvas, Size size) => canvas.drawRect(Offset.zero & size, Paint());
// false since all instances of MyPainter contain same information
#override
bool shouldRepaint(MyPainter oldDelegate) => false;
}
class MyPainter extends CustomPainter {
final Color color;
final double width;
MyPainter(this.color, this.width) : super();
#override
void paint(Canvas canvas, Size size) => canvas.drawRect(
Offset.zero & size,
Paint()
..color = color
..strokeWidth = width);
#override
bool shouldRepaint(MyPainter oldDelegate) => oldDelegate.color != this.color || oldDelegate.width != this.width;
}
I thought we didn't have to worry about when a widget "should repaint" because Flutter was supposed to decide where and when to re-render the widget tree based on it's own complex optimization decisions.
Yes and no. This shouldRepaint() is basically an optimization for speed. You can return constantly true if you do not care about performance.
I thought the paint() method was where you define "this is how this view paints itself, (always and whenever that is necessary)"
"this is how this view paints itself" - yes. "always and whenever that is necessary" - partially no. If you provide wrong information to shouldRepaint() you may miss some paints.
All the examples I have found simply return false from this method... but I have noticed different behavior when using true vs false.
What??? I see people returning true, or returning with comparison (see my example below). But when returning false, it can cause problems. Even simply look at comments of this function you will see it should cause problems with constant false. But anyway, if your painter really does not contain any information that can change, it is ok...
If we are always returning false, then how does it ever repaint? (And it does repaint even when false)
/// If the method returns false, then the [paint] call might be optimized
/// away.
///
/// It's possible that the [paint] method will get called even if
/// [shouldRepaint] returns false (e.g. if an ancestor or descendant needed to
/// be repainted). It's also possible that the [paint] method will get called
/// without [shouldRepaint] being called at all (e.g. if the box changes
/// size).
Notice the "might", and the paragraph below. Flutter can choose to paint or not to paint.
If the only possible logic available to us is comparing the "oldDelegate" to (something?) then why are we required to override the method at all?
See my example
I haven't seen any example that demonstrates why or how you would return TRUE, and what the logic of such an example would look like in order to make that decision.
See my example
By the way, it would be great if you gain some insights of Flutter, such as the widget/layout/paint logic etc, then you will easily understand the problem. But anyway I have answered above using words that are hopefully easy to understand even without deep understanding of Flutter.
It means always repaint if setState() method is called from the State class
The widgets in the State class will be updated if setState() changes their data.
shouldRepaint() EXAMPLE:
check at the bottom for inline comments!!
#override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
// true means Always repaint if this CustomPainter is updated anywhere else
// e.g. from the State class
// State class can be called as below
}
Complete code ...
import 'package:flutter/material.dart';
import 'dart:math';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Custom Paint example ',
//theme: ThemeData( //use theme if needed
// brightness: Brightness.dark,
// primaryColor: Colors.indigo,
// accentColor: Colors.indigoAccent
// ),
home: _CustomPainterView() // _is implemented in this file
//first return the StatefulWidget
);
}
}
class _CustomPainterView extends StatefulWidget{ //must be a stateful widget
#override
State<StatefulWidget> createState() {
return _CustomViewState(); //here return the State widget
}
}
//State class below references the Stateful widget from which it was called...
class _CustomViewState extends State<_CustomPainterView>{
Map<String, Object> painterData = {'screenMessage': 'not yet swiped', 'var_1': 0, 'var_2': 0.0 };
//all the needed parameters for the instance of CustomPainter class must be defined here in the State Class.
//they will be passed by setState() method to the CustomPainter which will provides the actual canvas
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar( title: Text("Custom Painter Example") ) ,
body: Container(
width: MediaQuery.of(context).size.width,//get width from device
height: MediaQuery.of(context).size.width*2, //i use the the custom width from half of the height
// ignore: sort_child_properties_last
child: GestureDetector( //to allow screen swipe or drag
child: CustomPaint(//CustomPaint() is just a container for actual painter.
//note the spelling
painter: _CustomPainterExample(painterData)
//return CustomPainter()
//supply constructor data
),
onVerticalDragUpdate: (details) {
int sensitivity = 1;// = 1 every pixel swiped will be detected by below code
setState( (){
if (details.delta.dy > sensitivity) {
debugPrint("swipe down"); //print to the debug console
painterData['screenMessage'] = "swipe down";
//this change only the key-value that needs to be changed in the key-value pairs, then repaint.
// painterData map will change but inside setState() will cause other effect
//setState recalls CustomPainter consctructor every time
//setState force a repaint in CustomPainter
}
if(details.delta.dy < -sensitivity){
debugPrint("swipe up");
painterData['screenMessage'] = "swipe up";
}
}
);
},
onHorizontalDragUpdate: (details) {
int sensitivity = 1;
setState( (){
if (details.delta.dx > sensitivity) {
debugPrint("swipe right");
painterData['screenMessage'] = "swipe right";
}
if(details.delta.dx < -sensitivity){
debugPrint("swipe left");
painterData['screenMessage'] = "swipe left";
}
}
);
},
),
//color: Color.fromARGB(255, 50, 57, 126)
// ignore: prefer_const_constructors
color: Color.fromARGB(255, 1, 108, 141),
),
);
}
}
class _CustomPainterExample extends CustomPainter {
Map<String, Object> painterData = new Map<String, Object>();
_CustomPainterExample(Map<String, Object> pdata){
painterData = pdata;
}
#override
void paint(Canvas canvas, Size size) {
var centerX = size.width/2;
var centerY = size.height/2;
var center = Offset(centerX,centerY);
var radius = min(centerX,centerY);
var fillBrush = Paint()
// ignore: prefer_const_constructors
..color = Color.fromARGB(255, 202, 122, 29);
canvas.drawCircle( center, radius, fillBrush );
//can also draw using the data from constructor method
var textPainter = TextPainter(
text: TextSpan(
text: painterData['screenMessage'].toString(),
style: TextStyle(color: Color.fromARGB(255, 245, 242, 242),fontSize: 30,),
),
textDirection: TextDirection.ltr,
);
textPainter.layout(minWidth: 0, maxWidth: size.width);//<<< needed method
textPainter.paint(canvas, Offset(5.0, (90/100)*size.height));
} //customPainter
#override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true; //always repaint if setState() is called from the State class. Look for setState() method in the class: _CustomViewState
//It will update canvas since the _CustomPainterExample is one of widgets in the _CustomViewState which is the State class. All widgets in the State class will be updated if SetState() changes their data.
}
}

Flutter: How to handle Renderflex overflow in SliverPersistentHeader

I have a SliverPersistentHeader which contains a video. The desired behavior of this view is that as a user scrolls upward, the view should cover or minimize the size of the video. The video header is a widget containing a Chewie video player. The desired behavior works up to a certain point at which I get a pixel overflow as shown in this animation:
When the scroll reaches a certain point, the video can no longer resize and it results in a render overflow. The desired behavior would be for the video to continue to resize until it's gone, or to catch the error and hide or remove the video from the view. The code rendering this scroll view is:
Widget buildScollView(GenericScreenModel model) {
return CustomScrollView(
slivers: [
StandardHeader(),
SliverFillRemaining(
child: Container(
// color: Colors.transparent,
decoration: BoxDecoration(
border: Border.all(
color: Colors.white,
),
borderRadius: BorderRadius.only(topRight: radius, topLeft: radius)),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Text(model.model?.getContentText ?? 'Empty'),
)),
)
],
);
}
The StandardHeader class is a simple widget containing a Chewie video.
class _StandardHeaderState extends State<StandardHeader> {
#override
Widget build(BuildContext context) {
return SliverPersistentHeader(
floating: true,
delegate: Delegate(
Colors.blue,
'Header Title',
),
pinned: true,
);
}
}
Is there a way to catch this error and hide the video player? Can anyone help with this or point me to a resource? Thanks!
The issue seems to be with the Chewie and/or video player widget. If the header's height is less than the required height of the player, the overflow occurs.
You can achieve the desired effect by using a SingleChildRenderObjectWidget. I added an opacity factor that you can easily remove that gives it (in my opinion) an extra touch.
I named this widget: ClipBelowHeight
Output:
Source:
ClipBelowHeight is SingleChildRenderObjectWidget that adds the desired effect by using a clipHeight parameter to clamp the height of the child to one that does not overflow. It centers its child vertically (Chewie player in this case).
To understand more, read the comments inside the performLayout and paint method.
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class ClipBelowHeight extends SingleChildRenderObjectWidget {
const ClipBelowHeight({
super.key,
super.child,
required this.clipHeight,
required this.opacityFactor,
});
/// The minimum height the [child] must have, as well as the height at which
/// clipping begins.
final double clipHeight;
/// The opacity factor to apply when the height decreases.
final double opacityFactor;
#override
RenderObject createRenderObject(BuildContext context) {
return RenderClipBelowHeight(clipHeight: clipHeight, factor: opacityFactor);
}
#override
void updateRenderObject(
BuildContext context,
RenderClipBelowHeight renderObject,
) {
renderObject
..clipHeight = clipHeight
..factor = opacityFactor;
}
}
class RenderClipBelowHeight extends RenderBox with RenderObjectWithChildMixin {
RenderClipBelowHeight({required double clipHeight, required double factor})
: _clipHeight = clipHeight,
_factor = factor;
double _clipHeight;
double get clipHeight => _clipHeight;
set clipHeight(double value) {
assert(value >= .0);
if (_clipHeight == value) return;
_clipHeight = value;
markNeedsLayout();
}
double _factor;
double get factor => _factor;
set factor(double value) {
assert(value >= .0);
if (_factor == value) return;
_factor = value;
markNeedsLayout();
}
#override
bool get sizedByParent => false;
#override
void performLayout() {
/// The child contraints depend on whether [constraints.maxHeight] is less
/// than [clipHeight]. This RenderObject's responsibility is to ensure that
/// the child's height is never below [clipHeight], because when the
/// child's height is below [clipHeight], then there will be visual
/// overflow.
final childConstraints = constraints.maxHeight < _clipHeight
? BoxConstraints.tight(Size(constraints.maxWidth, _clipHeight))
: constraints;
(child as RenderBox).layout(childConstraints, parentUsesSize: true);
size = Size(constraints.maxWidth, constraints.maxHeight);
}
#override
void paint(PaintingContext context, Offset offset) {
final theChild = child as RenderBox;
/// Clip the painted area to [size], which allows the [child] height to
/// be greater than [size] without overflowing.
context.pushClipRect(
true,
offset,
Offset.zero & size,
(PaintingContext context, Offset offset) {
/// (optional) Set the opacity by applying the specified factor.
context.pushOpacity(
offset,
/// The opacity begins to take effect at approximately half [size].
((255.0 + 128.0) * _factor).toInt(),
(context, offset) {
/// Ensure the child remains centered vertically based on [size].
final centeredOffset =
Offset(.0, (size.height - theChild.size.height) / 2.0);
context.paintChild(theChild, centeredOffset + offset);
},
);
},
);
}
#override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
final theChild = child as RenderBox;
var childParentData = theChild.parentData as BoxParentData;
final isHit = result.addWithPaintOffset(
offset: childParentData.offset,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
assert(transformed == position - childParentData.offset);
return theChild.hitTest(result, position: transformed);
},
);
return isHit;
}
#override
Size computeDryLayout(BoxConstraints constraints) => constraints.biggest;
#override
double computeMinIntrinsicWidth(double height) =>
(child as RenderBox).getMinIntrinsicWidth(height);
#override
double computeMaxIntrinsicWidth(double height) =>
(child as RenderBox).getMaxIntrinsicWidth(height);
#override
double computeMinIntrinsicHeight(double width) =>
(child as RenderBox).getMinIntrinsicHeight(width);
#override
double computeMaxIntrinsicHeight(double width) =>
(child as RenderBox).getMaxIntrinsicHeight(width);
}
The widget that uses the ClipBelowHeight widget is your header delegate. This widget should be self-explanatory and I think that you will be able to understand it.
class Delegate extends SliverPersistentHeaderDelegate {
Delegate(this.color, this.player);
final Color color;
final Chewie player;
#override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return Container(
color: color,
child: ClipBelowHeight(
clipHeight: 80.0,
opacityFactor: 1.0 - shrinkOffset / maxExtent,
child: player,
),
);
}
#override
double get maxExtent => 150.0;
#override
double get minExtent => .0;
#override
bool shouldRebuild(Delegate oldDelegate) {
return color != oldDelegate.color || player != oldDelegate.player;
}
}

How setState and shouldRepaint are coupled in CustomPainter?

Minimal reproducible code:
void main() => runApp(MaterialApp(home: HomePage()));
class HomePage extends StatefulWidget {
#override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final List<Offset> _points = [];
#override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() {}), // This setState works
child: Icon(Icons.refresh),
),
body: GestureDetector(
onPanUpdate: (details) => setState(() => _points.add(details.localPosition)), // but this doesn't...
child: CustomPaint(
painter: MyCustomPainter(_points),
size: Size.infinite,
),
),
);
}
}
class MyCustomPainter extends CustomPainter {
final List<Offset> points;
MyCustomPainter(this.points);
#override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = Colors.red;
for (var i = 0; i < points.length; i++) {
if (i + 1 < points.length) {
final p1 = points[i];
final p2 = points[i + 1];
canvas.drawLine(p1, p2, paint);
}
}
}
#override
bool shouldRepaint(MyCustomPainter oldDelegate) => false;
}
Try to draw something by long dragging on the screen, you won't see anything drawn. Now, press the FAB which will reveal the drawn painting maybe because FAB calls setState but onPanUpdate also calls setState and that call doesn't paint anything on the screen. Why?
Note: I'm not looking for a solution on how to enable the paint, a simple return true does the job. What I need to know is why one setState works (paints on the screen) but the other fails.
To understand why setState() in onPanUpdate is not working you might want to look into the widget paint Renderer i.e., CustomPaint.
The CustomPaint (As stated by docs as well) access the painter object (in your case MyCustomPainter) after finishing up the rendering of that frame. To confirm we can check the source of CustomPainter. we can see markNeedsPaint() is called only while we are accessing painter object through setter. For more clarity you might want to look into source of RenderCustomPaint , you will definitely understand it :
void _didUpdatePainter(CustomPainter? newPainter, CustomPainter? oldPainter) {
// Check if we need to repaint.
if (newPainter == null) {
assert(oldPainter != null); // We should be called only for changes.
markNeedsPaint();
} else if (oldPainter == null ||
newPainter.runtimeType != oldPainter.runtimeType ||
newPainter.shouldRepaint(oldPainter)) { //THIS
markNeedsPaint();
}
.
.
.
}
While on every setState call your points are updating but every time creating new instances of 'MyCustomPainter` is created and the widget tree is already rendered but painter have not yet painted due to reason mentioned above.
That is why the only way to call markNeedPaint()(i.e., to paint your object), is by returning true to shouldRepaint or Either oldDeleagate is null which only happens and Fist UI build of the CustomPainter, you can verify this providing some default points in the list.
It is also stated that
It's possible that the [paint] method will get called even if
[shouldRepaint] returns false (e.g. if an ancestor or descendant
needed to be repainted). It's also possible that the [paint] method
will get called without [shouldRepaint] being called at all (e.g. if
the box changes size).
So the only reason of setState of Fab to be working here (which seams valid) is that Fab is somehow rebuilding the any parent of the custom painter. You can also try to resize the UI in 'web build' or using dartpad you will find that as parent rebuilds itself the points will become visible So setState directly have nothing to do with shouldRepaint. Even hovering on the fab (in dartpad) button will cause the ui to rebuild and hence points will be visible.

What is the meaning Flutter's width metrics for the Paragraph class?

The documentation for Paragraph has four different ways to get a width distance:
width → double
The amount of horizontal space this paragraph occupies.
longestLine → double
The distance from the left edge of the leftmost glyph to the right edge of the rightmost glyph in the paragraph.
maxIntrinsicWidth → double
Returns the smallest width beyond which increasing the width never decreases the height.
minIntrinsicWidth → double
The minimum width that this paragraph could be without failing to paint its contents within itself.
Note that tightWidth no longer appears in the Flutter 1.7 stable version.
I still don't understand clearly how these are different, though. Does width include some extra padding?
In the following examples, the following text is used:
Hello, world.
Another line of text.
A line of text that wraps around.
The red rectangles are meant to illustrate the width metrics. The height can be ignored.
width
This is the width of the paragraph as defined by the ParagraphConstraints width argument when the paragraph is laid out. It does not depend of the content of the paragraph text.
longestLine
This is the length of the longest line of text with the soft wrapping taken into account. It will be less than or equal to the paragraph width.
maxIntrinsicWidth
This is how wide the paragraph would like to be if it had its choice. It's the width of the longest line when there is no soft line wrapping. That is, it is the width of what "A line of text that wraps around." would be if it hadn't been forced onto a new line.
minIntrinsicWidth
This is the narrowest the paragraph could be without causing some word to be broken unnaturally. You can see in the example below that the minIntrinsicWidth is the width of the word "Another".
Supplemental code
You can create a new Flutter project and replace main.dart with the following code if you would like to play around with it yourself.
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'dart:ui' as ui;
void main() {
debugPaintSizeEnabled = false;
runApp(MyApp());
}
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
backgroundColor: Colors.white,
body: HomeWidget(),
),
);
}
}
class HomeWidget extends StatelessWidget {
#override
Widget build(BuildContext context) {
return Center(
child: CustomPaint(
size: Size(300, 200),
painter: MyPainter(),
),
);
}
}
class MyPainter extends CustomPainter {
#override
void paint(Canvas canvas, Size size) {
final text = 'Hello, world.\nAnother line of text.\nA line of text that wraps around.';
// draw the text
final textStyle = ui.TextStyle(
color: Colors.black,
fontSize: 30,
);
final paragraphStyle = ui.ParagraphStyle(
textDirection: TextDirection.ltr,
);
final paragraphBuilder = ui.ParagraphBuilder(paragraphStyle)
..pushStyle(textStyle)
..addText(text);
final constraints = ui.ParagraphConstraints(width: 300);
final paragraph = paragraphBuilder.build();
paragraph.layout(constraints);
final offset = Offset(0, 0);
canvas.drawParagraph(paragraph, offset);
// draw a rectangle around the text
final left = 0.0;
final top = 0.0;
//final right = paragraph.width;
//final right = paragraph.longestLine;
//final right = paragraph.maxIntrinsicWidth;
final right = paragraph.minIntrinsicWidth;
final bottom = paragraph.height;
final rect = Rect.fromLTRB(left, top, right, bottom);
final paint = Paint()
..color = Colors.red
..style = PaintingStyle.stroke
..strokeWidth = 1;
canvas.drawRect(rect, paint);
}
#override
bool shouldRepaint(CustomPainter old) {
return false;
}
}
See also
Unexpected behavior with ui.Paragraph.minIntrinsicWidth
Meaning of Paragraph in Flutter classes