How to add image-manipulating route transitions to Flutter - flutter

I'm trying to create a custom route transition using Flutter. The existing route transitions (fade, scale, etc) are fine, but I want to create screen transitions that manipulate the screens' content by capturing their render and applying effects to it. Basically, I want to recreate DOOM screen melt effect as a route transition with Flutter.
It feels to me that its reliance on Skia and its own Canvas for rendering screen elements would make this possible, if not somewhat trivial. I haven't been able to do it, though. I can't seem to be able to capture the screen, or at least to render the target screen in chunks using clipping paths. A lot of this has to do with my lack of understanding of how Flutter composition works, so I'm still uncertain on which avenues to investigate.
My first approach was creating a custom transition by basically replicating what FadeTransition does.
Route createRouteWithTransitionCustom() {
return PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => ThirdScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return CustomTransition(
animation: animation,
child: child,
);
},
);
}
RaisedButton(
child: Text('Open Third screen (custom transition, custom code)'),
onPressed: () {
Navigator.push(context, createRouteWithTransitionCustom());
},
),
In this case, CustomTransition is a near exact duplicate of FadeTransition, with some light renaming (opacity becomes animation).
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'RenderAnimatedCustom.dart';
/// A custom transition to animate a widget.
/// This is a copy of FadeTransition: https://github.com/flutter/flutter/blob/27321ebbad/packages/flutter/lib/src/widgets/transitions.dart#L530
class CustomTransition extends SingleChildRenderObjectWidget {
const CustomTransition({
Key key,
#required this.animation,
this.alwaysIncludeSemantics = false,
Widget child,
}) : assert(animation != null),
super(key: key, child: child);
final Animation<double> animation;
final bool alwaysIncludeSemantics;
#override
RenderAnimatedCustom createRenderObject(BuildContext context) {
return RenderAnimatedCustom(
buildContext: context,
phase: animation,
alwaysIncludeSemantics: alwaysIncludeSemantics,
);
}
#override
void updateRenderObject(BuildContext context, RenderAnimatedCustom renderObject) {
renderObject
..phase = animation
..alwaysIncludeSemantics = alwaysIncludeSemantics;
}
#override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Animation<double>>('animation', animation));
properties.add(FlagProperty('alwaysIncludeSemantics', value: alwaysIncludeSemantics, ifTrue: 'alwaysIncludeSemantics'));
}
}
This new CustomTransition also creates a new RenderAnimatedCustom inside createRenderObject() (instead of FadeTransition's own RenderAnimatedOpacity). Sure enough, my custom RenderAnimatedCustom is a near duplicate of RenderAnimatedOpacity:
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// A custom renderer.
/// This is a copy of RenderAnimatedOpacity: https://github.com/flutter/flutter/blob/27321ebbad/packages/flutter/lib/src/rendering/proxy_box.dart#L825
class RenderAnimatedCustom extends RenderProxyBox {
RenderAnimatedCustom({
#required BuildContext buildContext,
#required Animation<double> phase,
bool alwaysIncludeSemantics = false,
RenderBox child,
}) : assert(phase != null),
assert(alwaysIncludeSemantics != null),
_alwaysIncludeSemantics = alwaysIncludeSemantics,
super(child) {
this.phase = phase;
this.buildContext = buildContext;
}
BuildContext buildContext;
double _lastUsedPhase;
#override
bool get alwaysNeedsCompositing => child != null && _currentlyNeedsCompositing;
bool _currentlyNeedsCompositing;
Animation<double> get phase => _phase;
Animation<double> _phase;
set phase(Animation<double> value) {
assert(value != null);
if (_phase == value) return;
if (attached && _phase != null) _phase.removeListener(_updatePhase);
_phase = value;
if (attached) _phase.addListener(_updatePhase);
_updatePhase();
}
/// Whether child semantics are included regardless of the opacity.
///
/// If false, semantics are excluded when [opacity] is 0.0.
///
/// Defaults to false.
bool get alwaysIncludeSemantics => _alwaysIncludeSemantics;
bool _alwaysIncludeSemantics;
set alwaysIncludeSemantics(bool value) {
if (value == _alwaysIncludeSemantics) return;
_alwaysIncludeSemantics = value;
markNeedsSemanticsUpdate();
}
#override
void attach(PipelineOwner owner) {
super.attach(owner);
_phase.addListener(_updatePhase);
_updatePhase(); // in case it changed while we weren't listening
}
#override
void detach() {
_phase.removeListener(_updatePhase);
super.detach();
}
void _updatePhase() {
final double newPhase = _phase.value;
if (_lastUsedPhase != newPhase) {
_lastUsedPhase = newPhase;
final bool didNeedCompositing = _currentlyNeedsCompositing;
_currentlyNeedsCompositing = _lastUsedPhase > 0 && _lastUsedPhase < 1;
if (child != null && didNeedCompositing != _currentlyNeedsCompositing) {
markNeedsCompositingBitsUpdate();
}
markNeedsPaint();
if (newPhase == 0 || _lastUsedPhase == 0) {
markNeedsSemanticsUpdate();
}
}
}
#override
void paint(PaintingContext context, Offset offset) {
if (child != null) {
if (_lastUsedPhase == 0) {
// No need to keep the layer. We'll create a new one if necessary.
layer = null;
return;
}
if (_lastUsedPhase == 1) {
// No need to keep the layer. We'll create a new one if necessary.
layer = null;
context.paintChild(child, offset);
return;
}
assert(needsCompositing);
// Basic example, slides the screen in
context.paintChild(child, Offset((1 - _lastUsedPhase) * 255, 0));
}
}
#override
void visitChildrenForSemantics(RenderObjectVisitor visitor) {
if (child != null && (_lastUsedPhase != 0 || alwaysIncludeSemantics)) {
visitor(child);
}
}
#override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Animation<double>>('phase', phase));
properties.add(FlagProperty('alwaysIncludeSemantics', value: alwaysIncludeSemantics, ifTrue: 'alwaysIncludeSemantics'));
}
}
Finally, the problem is as such. In the above file, inside paint(), this placeholder code simply moves the screen sideways by rendering with different offsets using context.paintChild().
But I want to draw chunks of child instead. In this case, vertical strips so I can create the screen melt effect. But that's really just an example; I want to find a way to manipulate the render of the child so I could have other different image-based effects.
What I have tried
Looping and drawing parts with clip rectangles
Instead of simply doing context.paintChild(child, offset), I've tried looping and drawing it chunk by chunk. This is not super generic, but would work for the screen melt effect at least.
Inside paint() (ignore the clumsiness of the prototype code):
int segments = 10;
double width = context.estimatedBounds.width;
double height = context.estimatedBounds.height;
for (var i = 0; i < segments; i++) {
double l = ((width / segments) * i).round().toDouble();
double r = ((width / segments) * (i + 1)).round().toDouble();
double y = (1 - _lastUsedPhase) * 50 * (i + 1);
layer = context.pushClipRect(
true,
Offset(l, y),
Rect.fromLTWH(0, 0, r - l, height),
(c, o) => c.paintChild(child, Offset(0, o.dy)),
oldLayer: layer
);
}
Unfortunately, this doesn't work. Every call to paintChild() seems to clear out the previous call, so only the last "strip" is kept.
I've tried combinations of this with different "layer" properties, using clipRectAndPaint(), etc, but can't get anything different from the above example.
Capturing the image with toImage()
I haven't gone much further in this, but my first attempt was of course to simply capture a widget as an image, something I assume was straightforward.
Unfortunately this requires that my widget is wrapped around a RepaintBoundary() in the custom route. Something like this:
return CustomTransition(
animation: animation,
child: RepaintBoundary(child: child),
);
Then maybe we could just do child.toImage(), manipulate that inside a canvas, and present that.
My issue with that is that every time one defines the transition, the child would need to be wrapped in that way. I'd like the CustomTransition() to handle that instead, but I haven't found a way and I'm wondering if this is actually necessary.
There are other classes with a toImage() function - Picture, Scene, OffsetLayer - but none of them seemed to be readily available. Ideally there would be an easy way for me to capture stuff as an image from inside paint() on RenderAnimatedCustom`. I could then do any kind of manipulation to that image, and paint it instead.
Other orthogonal solutions
I know there's several answers on StackOverflow (and other places) about how to "capture an image from a Widget", but they seem to specific to using an existing Canvas, using RepaintBoundary, etc.
In sum, what I need is: a way to create custom canvas-manipulating screen transitions. An ability to capture arbitrary widgets (without an explicit RepaintBoundary) seem to be the key to this.
Any hints? Am I being foolish for avoiding RepaintBoundary? Is it the only way? Or is there any other way for me to use "layers" for accomplish this sort of segmented child drawing?
Minimal source code for this app example is available on GitHub.
PS. I'm aware the transition example as it is is trying to manipulate the oncoming screen, not the outgoing one, as it should work for this to work like Doom's screen melt. That's a further problem I'm not investigating right now.

Related

What's the best way to make a spinning wheel?

I'm trying to make a wheel that can spin when a user drags up and down on a screen. It's essentially an infinite vertical scroll. So far I can make it turn while actually scrolling, but I'd like to incorporate physics to make it keep spinning when you let go. At the moment I'm using a GestureDetector to put an angle into Provider, which is used to transform some child widgets that make up the wheel, like so:
GestureDetector(
onVerticalDragUpdate: (offset) {
provider.wheelAngle += atan(offset.delta.dy / wheelRadius);
},
);
I'm sure I can do the physics part manually by handling the onVerticalDragEnd, but given that this is essentially just scrolling, I was wondering if it would make more sense to somehow leverage Flutter's built in scrolling stuff - maybe ScrollPhysics or one of the classes that derive from it. I don't want to reinvent the wheel (no pun intended), but I also don't want extra complexity by trying to force something else into doing what I need if it isn't a good fit. I can't quite wrap my head around ScrollPhysics, so I feel like it might be going down the route of over-complicated. Any gut feelings on what the best technique would be?
As pskink mentioned in the comments, animateWith is the key. In case it helps anyone in the future, here's an untested, slimmed-down version of what I ended up with. It switches between using FrictionSimulation when spinning freely and SpringSimulation when snapping to a particular angle.
class Wheel extends StatefulWidget {
const Wheel({Key? key}) : super(key: key);
#override
State<Wheel> createState() => _WheelState();
}
class _WheelState extends State<Wheel> with SingleTickerProviderStateMixin {
late AnimationController _wheelAnimationController;
bool _isSnapping = false;
double _radius = 0.0; // Probably set this in the constructor.
static const double velocitySnapThreshold = 1.0;
static const double distanceSnapThreshold = 0.25;
#override
Widget build(BuildContext context) {
var provider = context.read<WheelAngleProvider>();
_wheelAnimationController = AnimationController.unbounded(vsync: this, value: provider.wheelAngle);
_wheelAnimationController.addListener(() {
if (!_isSnapping) {
// Snap to an item if not spinning quickly.
var wheelAngle = _wheelAnimationController.value;
var velocity = _wheelAnimationController.velocity.abs();
var closestSnapAngle = getClosestSnapAngle(wheelAngle);
var distance = (closestSnapAngle - wheelAngle).abs();
if (velocity == 0 || (velocity < velocitySnapThreshold && distance < distanceSnapThreshold)) {
snapTo(closestSnapAngle);
}
}
provider.wheelAngle = _wheelAnimationController.value;
});
return Stack(
children: [
// ... <-- Visible things go here
// Vertical dragging anywhere on the screen rotates the wheel, hence the SafeArea.
SafeArea(
child: GestureDetector(
onVerticalDragDown: (details) {
_wheelAnimationController.stop();
_isSnapping = false;
},
onVerticalDragUpdate: (offset) =>
provider.wheelAngle = provider.wheelAngle + atan(offset.delta.dy / _radius),
onVerticalDragEnd: (details) => onRotationEnd(provider, details.primaryVelocity),
),
),
],
);
}
double getClosestSnapAngle(double currentAngle) {
// Do what you gotta do here.
return 0.0;
}
void snapTo(double snapAngle) {
var wheelAngle = _wheelAnimationController.value;
_wheelAnimationController.stop();
_isSnapping = true;
var springSimulation = SpringSimulation(
SpringDescription(mass: 20.0, stiffness: 10.0, damping: 1.0),
wheelAngle,
snapAngle,
_wheelAnimationController.velocity,
);
_wheelAnimationController.animateWith(springSimulation);
}
void onRotationEnd(WheelAngleProvider provider, double? velocity) {
// When velocity is not null, this is the result of a fling and it needs to spin freely.
if (velocity != null) {
_wheelAnimationController.stop();
var frictionSimulation = FrictionSimulation(0.5, provider.wheelAngle, velocity / 200);
_wheelAnimationController.animateWith(frictionSimulation);
}
}
}

Load phone's gallery as fast as native IOS in Flutter without OOM

I am trying to load the phone's gallery (with pagination) in a GridView.builder widget.
Here is the issue i have created using the photo_manager package.
I have got some help and it made me think about a possible solution (see my last comment on the issue).
I would like to be able to load the assets without blinking or white page.
On IOS native it's veeery fast and smooth, i want to achieve the same in Flutter.
You will find all the pieces of code i have made in the github link above. I have managed to do so using a Map object in memory but i need to improve the algorithm to not be in OOM.
Solutions wanted (one or the other) :
A simple way to do this, load the phone's gallery as fast as the native IOS into a GridView, no matter which package is used the time it's working.
An improvement of my currently poor algorithm that would keep for example the 15 assets above the current one, 15 assets below in memory and during the scroll, keep updating these values to move the range around the current position in the list.
Please let me know if this is not clear enough, as a reminder please have a look at my last big comment on this issue.
You can use a logic like this :
final Map<String, Uint8List?> _cachedMap = {};
void precacheAssets(int index) async {
// Handle cache before index
for (int i = max(0, index - 50); i < 50; i++) {
getItemAtIndex(i);
}
// Handle cache after index
for (int i = min(assetsList.length, index + 50); i < 50 + min(assetsList.length, index + 50); i++) {
getItemAtIndex(i);
}
_cachedMap.removeWhere((key, value) {
int currIndex = assetsList.indexWhere((element) => element.id == key);
return currIndex < index - 50 && currIndex > index + 50;
});
}
/// Get the asset from memory or fetch it if it doesn’t exist yet.
/// Called in the builder method to display assets, not to precache them.
Future<Uint8List?> getItemAtIndex(int index) async {
AssetEntity entity = assetsList[index];
if (_cachedMap.containsKey(entity.id)) {
return _cachedMap[entity.id];
}
else {
Uint8List? thumb = await entity.thumbDataWithOption(
ThumbOption.ios(
width: width,
height: height,
deliveryMode: DeliveryMode.highQualityFormat,
quality: 90));
_cachedMap[entity.id] = thumb;
return thumb;
}
}
And you can call the precacheAssets method in your GridView.builder at a specific index for example if (index % 25 == 0) which will tell every 25 items, put in cache the 50 next ones so it will add 25 more items to the existing cache.
Also, call the getItemAtIndex in your Future.builder as future param and you will get instantly the asset if it’s in memory, otherwise load it as usual.
Feel free to change the values and test it, it’s already improved with these values in my iPhone but if you are scrolling VERY fast you will still see as before a bit.
You can add a FadeTransition in this case which will result in a non-ugly UI.
The solution is quite simple: populate your GridView with thumbs instead of large images:
// Load [AssetEntity]s:
images = await album.getAssetListRange(start: 0, end: 50);
// Then build [GridView]:
GridView.builder(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 3,
mainAxisSpacing: 3,
),
itemCount: images.length,
itemBuilder: (context, index) {
final image = images[index];
return Image(
image: DeviceImage(
image,
size: const Size(200, 200),
),
fit: BoxFit.cover,
);
},
);
As you can see, I pass AssetEntity to DeviceImage provider. DeviceImage loads raw bytes of size (200, 200), and Image displays the thumb.
Here's DeviceImage code, I built it based on local_image_provider:
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'dart:ui' as ui show Codec;
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter/services.dart';
import 'package:photo_manager/photo_manager.dart';
/// Decodes the given [LocalImage] object as an [ImageProvider], associating it with the given
/// scale.
///
///
/// In general only the constructor of this object should be used directly,
/// after that use the resulting object wherever an [ImageProvider] is
/// needed in Flutter. In particular with an [Image] widget.
///
class DeviceImage extends ImageProvider<DeviceImage> {
static const int _kMaxSize = 1200;
static const String _kNoImageBase64 =
"iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAIAAABLixI0AAAAA3NCSVQICAjb4U/gAAADCUlEQVQ4ja2VT0zTYBTAX9dttB3b6AzFAEIlgEaH7gRxHIyBYGIkGMNAQpaBCdEYjiacPHD0QCQSlcSL8aaZhIN/4xBFlkVj5mY0hgMskwDyt5SNtdBu9VDp1m6AGt/p/fm+33vv+16/IpIkwX8S/d7hZCy2NTMtzs/rS0ryDlegZvPfs1IpZsQbuzeYYlcz3Yi5wNx1lex0I/ocG5HsHoWlxcUr3eLs9G75dYXF1NB9rLJyH5YkCHOdLnHme9qF6nU2SoqxEr+Zxh04WPLkGZqfr8qhYa/cvpUG6VBLb1+5P1z2Yrx8Mlh496GuoFCOpFZ/Lvff0NabafA8n3j9XDFN167j7R28IHAcx3GcruYEOXAnvTjwbovjcvSYSCS8Xi83NXX2lVcObOkMo+fbthFEk9z19BGWFGR9vKEFPW5vbW0lCAKUewyFQtFoFDBstLmDjMdNsY0UimaDiriEAkoCEsNxNhoNhUJOpzPNYhhGVqiq6tra2rW1Nb/fD/F4JsiYStmDHxSTMZMsqs/cqxoTDMNcLpfBYAAAk8k0MjKihMxJsf79WCG7LJsSwGzVMU3VKlZxcbEMAgCaptOgVPL0m5dkYl0Bfaup+3qIBrWoWJFIhGVZq9UKAOFwWGnNOeFTQADIRsulz4BClqhYkiQNDw/b7XaGYSKRiOys/zhJ7bQGANa+ftrVvuzzBQIBDUs7q9vb28FgcGFhQa6uQBRLF38oUUtvH+lqB4DGxsa6urp9WABAEITH4+np6aEoqmhlSfFjZ87Zui4rZlNTE4Zhe7EIgnC73RRF4TjudrtLd6YJAIw1JzWLEfUAqs4LRVEZpHCPnqrfnIvKZl71kewmdmUZjUYFJIvN023zdO+NyM0SBEFzO5a3Y8aJMVkX7Y71i22a9TlYJEkCgCiKPp8vM1wf+lSxPC/rK1/AZ7FllyPvBeXsHQ5H5qD/udA07XA4ZF31rvI8r3lm2aFB/vEDWTc4G2w3BzKjCIJkjoX229akRZov8FW/r89QVobj+B415vh3/LP8AvvVK04ZJmjyAAAAAElFTkSuQmCC";
static final Uint8List noImageBytes = base64Decode(_kNoImageBase64);
/// Creates an object that decodes a [LocalImage] as an image.
///
/// The arguments must not be null. [scale] returns a scaled down
/// version of the image. For example to load a thumbnail you could
/// use something like .1 as the scale. There's a convenience method
/// on [LocalImage] that can calculate the scale for a given pixel
/// size.
/// [minPixels] can be used to specify a minimum independent of the
/// requested scale. The idea is that scaling an image that you don't know
/// the original size of can result in some results that are too small.
/// If the goal is to display the image in a 50x50 thumbnail then you might
/// want to set 50 as the minPixels, then regardless of the image size and
/// scale you'll get at least 50 pixels in each dimension. This parameter
/// was added as a result of a strange result in iOS where an image with
/// a portrait aspect ratio was failing to load when scaled below 120 pixels.
/// Setting 150 as the minimum in this case resolved the problem.
const DeviceImage(this.assetEntity,
{this.scale = 1.0, this.minPixels = 0, this.quality = 70, this.size});
/// The LocalImage to decode into an image.
final AssetEntity assetEntity;
/// The scale to place in the [ImageInfo] object of the image.
final double scale;
/// The minPixels to place in the [ImageInfo] object of the image.
final int minPixels;
/// Optional image quality (0-100), default is set to 70.
final int quality;
/// Optional image size. If null, then full size will be loaded.
final Size? size;
#override
Future<DeviceImage> obtainKey(ImageConfiguration? configuration) {
return SynchronousFuture<DeviceImage>(this);
}
#override
ImageStreamCompleter load(DeviceImage key, DecoderCallback decode) {
return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
scale: key.scale,
informationCollector: () sync* {
yield ErrorDescription('Id: ${assetEntity.id}');
},
);
}
#override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream,
DeviceImage key, ImageErrorListener handleError) {
if (shouldCache()) {
super.resolveStreamForKey(configuration, stream, key, handleError);
return;
}
final ImageStreamCompleter completer =
load(key, PaintingBinding.instance!.instantiateImageCodec);
stream.setCompleter(completer);
}
int get height => max((assetEntity.height * scale).round(), minPixels);
int get width => max((assetEntity.width * scale).round(), minPixels);
#visibleForTesting
bool shouldCache() {
return size == null;
}
Future<ui.Codec> _loadAsync(DeviceImage key, DecoderCallback decoder) async {
assert(key == this);
try {
final int width;
final int height;
if (size == null) {
width = _kMaxSize;
height = _kMaxSize;
} else {
width = size!.width.toInt();
height = size!.height.toInt();
}
final bytes = await assetEntity.thumbDataWithSize(
width,
height,
quality: quality,
);
if (bytes == null || bytes.lengthInBytes == 0) {
return decoder(noImageBytes);
}
return await decoder(bytes);
} on PlatformException {
return await decoder(noImageBytes);
}
}
#override
bool operator ==(dynamic other) {
if (other.runtimeType != runtimeType) return false;
final DeviceImage typedOther = other;
return assetEntity.id == typedOther.assetEntity.id &&
scale == typedOther.scale;
}
#override
int get hashCode => assetEntity.hashCode;
#override
String toString() => '$runtimeType($assetEntity, scale: $scale)';
}
Note: the code is not production-ready.

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.

Performance issue in drawing using Path Flutter

I am testing the performance in drawing using Flutter. I am using Path to draw line between each point detected by Listener because I have read that the performance would increase using it. I am using Listener because I tried also the Apple Pencil on iPad 2017 by changing the kind property to stylus.
The problem is that I was hoping to get a response in the stroke design similar to notability, it seems much slower, acceptable but not as much as I would like.
So I'm looking for tips to increase performance in terms of speed.
At the following link they recommended using NotifyListener(), but I didn't understand how to proceed. If it really improves performance I would like even an example to be able to implement it.
If Flutter has some limitations when it comes to drawing with your fingers or with a stylus then let me know.
performance issue in drawing using flutter
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
class DrawWidget extends StatefulWidget {
#override
_DrawWidgetState createState() => _DrawWidgetState();
}
class _DrawWidgetState extends State<DrawWidget> {
Color selectedColor = Colors.black;
double strokeWidth = 3.0;
List<MapEntry<Path, Object>> pathList = List();
StrokeCap strokeCap = (Platform.isAndroid) ? StrokeCap.butt : StrokeCap.round;
double opacity = 1.0;
Paint pa = Paint();
#override
Widget build(BuildContext context) {
return Listener(
child: CustomPaint(
size: Size.infinite,
painter: DrawingPainter(
pathList: this.pathList,
),
),
onPointerDown: (details) {
if (details.kind == PointerDeviceKind.touch) {
print('down');
setState(() {
Path p = Path();
p.moveTo(details.localPosition.dx, details.localPosition.dy);
pa.strokeCap = strokeCap;
pa.isAntiAlias = true;
pa.color = selectedColor.withOpacity(opacity);
pa.strokeWidth = strokeWidth;
pa.style = PaintingStyle.stroke;
var drawObj = MapEntry<Path,Paint>(p, pa);
pathList.add(drawObj);
});
}
},
onPointerMove: (details) {
if (details.kind == PointerDeviceKind.touch) {
print('move');
setState(() {
pathList.last.key.lineTo(details.localPosition.dx, details.localPosition.dy);
});
}
},
/*onPointerUp: (details) {
setState(() {
});
},*/
);
}
}
class DrawingPainter extends CustomPainter {
DrawingPainter({this.pathList});
List<MapEntry<Path, Object>> pathList;
#override
void paint(Canvas canvas, Size size) {
for(MapEntry<Path, Paint> m in pathList) {
canvas.drawPath(m.key, m.value);
}
}
#override
bool shouldRepaint(DrawingPainter oldDelegate) => true;
}
I think you should not use setState, rather use state management like Bloc or ChangeNotifier or smth.
Also, just drawing a path with this:
canvas.drawPath(m.key, m.value);
Works for only small stroke widths, it leaves a weird-looking line full of blank spaces when drawing.
I implemented this by using Bloc that updates the UI based on the gesture functions (onPanStart, onPanEnd, onPanUpdate). It holds a List of a data model that I called CanvasPath that represents one line (so from onPanStart to onPanEnd events), and it holds the resulting Path of that line, list of Offsets and Paint used to paint it.
In the end paint() method draws every single Path from this CanvasPath object and also a circle in every Offset.
` for every canvasPath do this:
canvas.drawPath(canvasPath.path, _paint);
for (int i = 0; i < canvasPath.drawPoints.length - 1; i++) {
//draw Circle on every Offset of user interaction
canvas.drawCircle(
canvasPath.drawPoints[i],
_raidus,
_paint);
}`
I made a blog about this, where it is explained in much more details:
https://ivanstajcer.medium.com/flutter-drawing-erasing-and-undo-with-custompainter-6d00fec2bbc2

Find out which items in a ListView are visible

How can I find out which items are currently visible or invisible in a ListView?
For example, I have 100 items in ListView and when i scroll to top of screen or list, I want to detect which items appear or disappear from the viewport.
Illustration:
There is no easy way to do this. Here is the same question, however, it does not have an answer.
There is an active GitHub issue about this.
There are multiple solutions for the problem in that issue. This Gist features one that requires the rect_getter package.
Alternatively, you could take a look at this proposal.
TL;DR
This is not yet implemented if you are searching for an easy way to find it out. However, there are solutions, like the ones I mentioned above and from other packages, say VisibilityDetector from flutter_widgets.
You can also use inview_notifier_list. It's basically a normal ListView which defines a visible region and it's children get notified when they are in that region.
There is a package for this purpose.
A VisibilityDetector widget wraps an existing Flutter widget and fires a callback when the widget's visibility changes.
Usage:
VisibilityDetector(
key: Key('my-widget-key'),
onVisibilityChanged: (visibilityInfo) {
var visiblePercentage = visibilityInfo.visibleFraction * 100;
debugPrint(
'Widget ${visibilityInfo.key} is ${visiblePercentage}% visible');
},
child: someOtherWidget,
)
I'm Sharing for visibility on how to approach detecting position of widget in general.
I was curious as to how you access positional data of widgets, and also wanted to be able to control the animated state of a ListView child element.
Looks like the main point of access to a widgets, size and position is via the BuildContext's context.findRenderObject()
However, this is only usable after the component has been built and the widget is mounted.
This is addressed by using context.findRenderObject() in a function called using WidgetsBinding.instance.addPostFrameCallback((_) => calculatePosition(context));
Here's a wrapper component you can use in your ListView.itemBuilder() code
import 'package:flutter/cupertino.dart';
import 'dart:developer' as developer;
enum POCInViewDirection { up, down, static }
class POCInView extends StatefulWidget {
final Widget child;
final double scrollHeight;
const POCInView({super.key, required this.child, required this.scrollHeight});
#override
POCInState createState() => POCInState();
}
class POCInState extends State<POCInView> {
bool inView = false; // are you in view or not.
double lastPositionY = 0; // used to determine which direction your widget is moving.
POCInViewDirection direction = POCInViewDirection.static; // Set based on direction your moving.
RenderBox? renderBoxRef;
bool skip = true;
#override
void initState() {
super.initState();
developer.log('InitState', name: 'POCInView');
lastPositionY = 0;
renderBoxRef = null;
direction = POCInViewDirection.static;
skip = true;
}
/// Calculate if this widget is in view.
/// uses BuildContext.findRenderObject() to get the RenderBox.
/// RenderBox has localToGlobal which will give you the objects offset(position)
/// Do some math to workout if you object is in view.
/// i.e. take into account widget height and position.
///
/// I only do Y coordinates.
///
void calculatePosition(BuildContext context) {
// findRenderObject() will fail if the widget has been unmounted. so leave if not mounted.
if (!mounted) {
renderBoxRef = null;
return;
}
// It says this can be quite expensive as it will hunt through the view tree to find a RenderBox.
// probably worth timing or seeing if its too much for you view.
// I've put a rough cache in, deleting the ref when its unmounted. mmmmm.
renderBoxRef ??= context.findRenderObject() as RenderBox;
//
inView = false;
if (renderBoxRef is RenderBox) {
Offset childOffset = renderBoxRef!.localToGlobal(Offset.zero);
final double y = childOffset.dy;
final double componentHeight = context.size!.height;
final double screenHeight = widget.scrollHeight;
if (y < screenHeight) {
if (y + componentHeight < -20) {
inView = false;
} else {
inView = true;
}
} else {
inView = false;
}
// work out which direction we're moving. Not quite working right yet.
direction = y > lastPositionY ? POCInViewDirection.down : POCInViewDirection.up;
lastPositionY = y;
//developer.log('In View: $inView, childOffset: ${childOffset.dy.toString()}', name: 'POCInView');
}
skip = false;
}
#override
Widget build(BuildContext context) {
// calculate position after build is complete. this is required to use context.findRenderObject().
WidgetsBinding.instance.addPostFrameCallback((_) => calculatePosition(context));
// fade in when in view.
final oChild = AnimatedOpacity(opacity: inView ? 1 : 0, duration: const Duration(seconds: 1), child: widget.child);
// slide in when in view, and adjust slide direction based on scroll direction.
return AnimatedSlide(
duration: Duration(seconds: inView ? 1 : 0),
offset: Offset(0, inView ? 0.0 : 0.25 * (skip == true ? 0 : (direction == POCInViewDirection.up ? 1 : -1))),
child: oChild,
);
}
}