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);
}
}
}
Related
I want to design a simple game in which the ball hits the boxes and the user has to try to bring the ball up with the cursor.
When the ball returns, end of ball movement, is the offset at the bottom of the screen, and I want to reset the animation if the ball offset equals the cursor and then give it a new direction, but that never happens.
Please see the values I have printed.
532.0 is cursor.position.dy and others are positionBall.dy + renderBall.size.height.
Why only when the ball moves up (the moment I tap on the screen) the ball offset and the cursor offset are equal, but not in return?
---update---
When I increase the duration (for example, 10 seconds), or activate the Slow Animations button from the flutter inspector, the numbers get closer to each other, and by adjusting them to the int, the condition is made.
I/flutter (21563): 532.0
I/flutter (21563): 532.45585
I'm really confused and I do not know what is going on in the background.
void initState() {
super.initState();
Offset init = initialBallPosition();
final g = Provider.of<GameStatus>(context, listen: false);
var key = ball.key;
_animationController = AnimationController(duration: Duration(seconds: 1), vsync: this);
_tweenOffset = Tween<Offset>(begin: init, end: init);
_animationOffset = _tweenOffset.animate(
CurvedAnimation(parent: _animationController, curve: Curves.linear),
)..addListener(() {
if (_animationController.isAnimating) {
//if (_animationController.status == AnimationStatus.forward) {
RenderBox renderBall = key.currentContext.findRenderObject();
final positionBall = renderBall.localToGlobal(Offset.zero);
print(cursor.position.dy);
print(positionBall.dy + renderBall.size.height);
if (positionBall.dy + renderBall.size.height == cursor.position.dy && g.ballDirection == 270) {
print('bang');
colideWithCursor();
}
}
if (_animationController.status == AnimationStatus.completed) {
if (bottomOfBall().dy == Screen.screenHeight / ball.width) {
gameOver();
} else
collision();
}
});
_animationController.isDismissed;
}
#override
Widget build(BuildContext context) {
final game = Provider.of<GameStatus>(context, listen: false);
return Selector<GameStatus, bool>(
selector: (ctx, game) => game.firstShoot,
builder: (context, startGame, child) {
if (startGame) {
game.ballDirection = 90;
routing(game.ballDirection);
}
return UnconstrainedBox(child: (SlideTransition(position: _animationOffset, child: ball.createBall())));
});
}
The two numbers are never exactly matching because the animation value is checked every frame and the overlap is occurring between frames.
You probably either want to add a tolerance (eg consider the values to have matched if they're within a certain amount) or create some interpolation logic where you check if the ball is about to collide with the cursor in-between the current frame and the next. eg replace:
positionBall.dy + renderBall.size.height == cursor.position.dy && g.ballDirection == 270
With:
positionBall.dy + renderBall.size.height + <current_speed_per_frame_of_ball> <= cursor.position.dy && g.ballDirection == 270
The important thing here is that the animations aren't actually fluid. An animation doesn't pass from 0.0 continuously through every conceivable value to 1.0. The value of the animation is only calculated when a frame is rendered so the values you'll actually get might be something along the lines of: 0.0, 0.14, 0.30, 0.44, 0.58....0.86, 0.99, 1.0. The exact values will depend on the duration of the animation and the exact times the Flutter framework renders each frame.
Since you asked (in the comments) for an example using onTick, here's an example app I wrote up for a ball that bounces randomly around the screen. You can tap to randomize it's direction and speed. Right now it kinda hurts your eyes because it's redrawing the ball in a new position on every frame.
You'd probably want to smoothly animate the ball between each change in direction (eg replace Positioned with AnimatedPositioned) to get rid of the eye-strain. This refactor is beyond what I have time to do.
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:vector_math/vector_math.dart' hide Colors;
Random _rng = Random();
void main() {
runApp(MyApp());
}
class MyApp extends StatefulWidget {
#override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with SingleTickerProviderStateMixin {
get randomizedDirection =>
_randomDirectionWithVelocity((150 + _rng.nextInt(600)).toDouble());
Ticker _ticker;
Vector2 _initialDirection;
Duration prevT = Duration.zero;
BallModel _ballModel;
#override
void dispose() {
super.dispose();
_ticker.dispose();
}
void _init(Size size) {
_ballModel = BallModel(
Vector2(size.width / 2, size.height / 2),
randomizedDirection,
16.0,
);
_ticker = createTicker((t) {
// This sets state and forces a rebuild on every frame. A good optimization would be
// to only build when the ball changes direction and use AnimatedPositioned to fluidly
// draw the ball between changes in direction.
setState(() {
_ballModel.updateBall(t - prevT, size);
});
prevT = t;
});
_ticker.start();
}
#override
Widget build(BuildContext context) {
return MaterialApp(
home: GestureDetector(
child: Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
// Initialize everything here because we need to access the constraints.
if (_ticker == null) _init(constraints.biggest);
return Stack(children: [
Ball(_ballModel),
]);
},
),
),
onTap: () => setState(() => _ballModel.v = randomizedDirection),
),
);
}
}
class BallModel {
// The current x,y position of the ball.
Vector2 p;
// The direction, including speed in pixels per second, of the ball
Vector2 v;
// The radius of the ball.
double r;
BallModel(this.p, this.v, this.r);
void updateBall(Duration elapsed, Size size) {
// Move the ball by v, scaled by what fraction of a second has passed
// since the last frame.
p = p + v * (elapsed.inMilliseconds / 1000);
// If the ball overflows on a given dimension, correct the overflow and update v.
var newX = _correctOverflow(p.x, r, 0, size.width);
var newY = _correctOverflow(p.y, r, 0, size.height);
if (newX != p.x) v.x = -v.x;
if (newY != p.y) v.y = -v.y;
p = Vector2(newX, newY);
}
}
class Ball extends StatelessWidget {
final BallModel b;
Ball(this.b);
#override
Widget build(BuildContext context) {
return Positioned(
left: b.p.x - b.r,
bottom: b.p.y - b.r,
child: DecoratedBox(
decoration:
BoxDecoration(shape: BoxShape.circle, color: Colors.black)),
width: 2 * b.r,
height: 2 * b.r);
}
}
double _correctOverflow(s, r, lowerBound, upperBound) {
var underflow = s - r - lowerBound;
// Reflect s across lowerBound.
if (underflow < 0) return s - 2 * underflow;
var overflow = s + r - upperBound;
// Reflect s across upper bound.
if (overflow > 0) return s - 2 * overflow;
// No over or underflow, return s.
return s;
}
Vector2 _randomDirectionWithVelocity(double velocity) {
return Vector2(_rng.nextDouble() - .5, _rng.nextDouble() - 0.5).normalized() *
velocity;
}
Writing game and physics logic from scratch gets really complicated really fast. I encourage you to use a game engine like Unity so that you don't have to build everything yourself. There's also a Flutter based game engine called flame that you could try out:
https://github.com/flame-engine/flame.
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
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.
Assume I have 3 Containers on the screen that react touching by changing their color. When user's finger is on them, they should change color and when touching ends they should turn back to normal. What I want is that those containers to react when the user finger/pointer on them even if the touch started on a random area on the screen, outside of the containers. So basically what i am looking for is something just like css hover.
If i wrap each container with GestureDetector seperately, they will not react to touch events which start outside of them. On another question (unfortunately i dont have the link now) it is suggested to wrap all the containers with one GestureDetector and assign a GlobalKey to each to differ them from each other.
Here is the board that detects touch gestures:
class MyBoard extends StatefulWidget {
static final keys = List<GlobalKey>.generate(3, (ndx) => GlobalKey());
...
}
class _MyBoardState extends State<MyBoard> {
...
/// I control the number of buttons by the number of keys,
/// since all the buttons should have a key
List<Widget> makeButtons() {
return MyBoard.keys.map((key) {
// preapre some funcitonality here
var someFunctionality;
return MyButton(key, someFunctionality);
}).toList();
}
#override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (tapDownDetails) {
handleTouch(context, tapDownDetails.globalPosition);
},
onTapUp: (tapUpDetails) {
finishTouch(context);
},
onPanUpdate: (dragUpdateDetails) {
handleTouch(context, dragUpdateDetails.globalPosition);
},
onPanEnd: (panEndDetails) {
finishTouch(context);
},
onPanCancel: () {
finishTouch(context);
},
child: Container(
color: Colors.green,
width: 300.0,
height: 600.0,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: makeButtons(),
),
),
);
}
Here is the simple MyButton:
class MyButton extends StatelessWidget {
final GlobalKey key;
final Function someFunctionality;
MyButton(this.key, this.someFunctionality) : super(key: key);
#override
Widget build(BuildContext context) {
return Consumer<KeyState>(
builder: (buildContext, keyState, child) {
return Container(
width: 100.0,
height: 40.0,
color: keyState.touchedKey == this.key ? Colors.red : Colors.white,
);
},
);
}
}
In the _MyBoardState that's how i handle detecting which MyButton is touched:
MyButton getTouchingButton(Offset globalPosition) {
MyButton currentButton;
// result is outside of [isTouchingMyButtonKey] for performance
final result = BoxHitTestResult();
bool isTouchingButtonKey(GlobalKey key) {
RenderBox renderBox = key.currentContext.findRenderObject();
Offset offset = renderBox.globalToLocal(globalPosition);
return renderBox.hitTest(result, position: offset);
}
var key = MyBoard.keys.firstWhere(isTouchingButtonKey, orElse: () => null);
if (key != null) currentButton = key.currentWidget;
return currentButton;
}
I am using provider package and instead of rebuilding the whole MyBoard with setState only the related part of related MyButton will be rebuilded. Still each button has a listener which rebuilds at every update and I am using GlobalKey. On the flutter docs it says:
Global keys are relatively expensive. If you don't need any of the features listed above, consider using a Key, ValueKey, ObjectKey, or UniqueKey instead.
However if we look at getTouchingButton method I need GlobalKey to get renderBox object and to get currentWidget. I also have to make a hitTest for each GlobalKey. This computation repeats when the onPanUpdate is triggered, which means when the user's finger moves a pixel.
UI is not updating fast enough. With a single tap (tapDown and tapUp in regular speed) you usually do not see any change on the MyButtons.
If I need to sum up my question, How can i detect same single touch (no lifting) from different widgets when finger is hoverin on them in more efficient and elegant way?
Since no one answered yet, I am sharing my own solution which I figured out lately. Now I can see the visual reaction everytime i touch. It is fast enough, but still it feels like there is a little delay on the UI and it feels a little hacky instead of a concrete solution. If someone can give better solution, I can mark it as accepted answer.
My solution:
First things first; since we have to detect Pan gesture and we are using onPanUpdate and onPanEnd, I can erase onTapDown and onTapUp and instead just use onPanStart. This can also detect non-moving simple tap touches.
I also do not use Provider and Consumer anymore, since it rebuilds all the Consumers at every update. This is really a big problem when the the number of MyButtons increase. Instead of keeping MyButton simple and dummy, I moved touch handling work into it. Each MyButton hold the data of if they are touched at the moment.
Updated button is something like this:
class NewButton extends StatefulWidget {
NewButton(Key key) : super(key: key);
#override
NewButtonState createState() => NewButtonState();
}
class NewButtonState extends State<NewButton> {
bool isCurrentlyTouching = false;
void handleTouch(bool isTouching) {
setState(() {
isCurrentlyTouching = isTouching;
});
// some other functionality on touch
}
#override
Widget build(BuildContext context) {
return Container(
width: 100.0,
height: 40.0,
color: isTouched ? Colors.red : Colors.white,
);
}
}
Notice that NewButtonState is not private. We will be using globalKey.currentState.handleTouch(bool) where we detect the touch gesture.
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,
);
}
}