how do we create a proper Semi Circular Slider that has "steps" line division.
I have checked out many packages on pub.dev but they doesn't seem to provide a proper Semi Circular Slider. They look more like progress bar rather than a slider
Any thoughts please?
This can be done with a CustomPainter, GestureDetector, and a bunch of math.
Full example: https://gist.github.com/PixelToast/7dfbc4d743b108755b6521d0b8f24fd9
DartPad: https://dartpad.dartlang.org/?id=7dfbc4d743b108755b6521d0b8f24fd9
class SemiCircleSlider extends StatefulWidget {
const SemiCircleSlider({
Key? key,
required this.initialValue,
required this.divisions,
required this.onChanged,
required this.image,
}) : super(key: key);
final int initialValue;
final int divisions;
final ValueChanged<int> onChanged;
final ImageProvider image;
#override
State<SemiCircleSlider> createState() => _SemiCircleSliderState();
}
class _SemiCircleSliderState extends State<SemiCircleSlider> {
late var value = widget.initialValue;
#override
Widget build(BuildContext context) {
return SizedBox(
width: 350,
child: LayoutBuilder(
builder: (context, constraints) {
// Apply some padding to the outside so the nub doesn't go past the
// edge of the painter.
const inset = 32.0;
final arcWidth = constraints.maxWidth - inset * 2;
final height = (arcWidth / 2) + inset * 2;
final arcHeight = (height - inset * 2) * 2;
final arcRect = Rect.fromLTRB(
inset,
height - (inset + arcHeight),
arcWidth + inset,
height - inset,
);
Widget child = TweenAnimationBuilder<double>(
tween: Tween(begin: value.toDouble(), end: value.toDouble()),
duration: const Duration(milliseconds: 50),
curve: Curves.ease,
builder: (context, value, child) {
return CustomPaint(
painter: SemiCircleSliderPainter(
divisions: widget.divisions,
arcRect: arcRect,
// Map the value to the angle at which to display the nub
nubAngle: (1 - (value / (widget.divisions - 1))) * pi,
),
child: SizedBox(
height: height,
),
);
},
);
child = GestureDetector(
// Use TweenAnimationBuilder to smoothly animate between divisions
child: child,
onPanUpdate: (e) {
// Calculate the angle of the tap relative to the center of the
// arc, then map that angle to a value
final position = e.localPosition - arcRect.center;
final angle = atan2(position.dy, position.dx);
final newValue =
((1 - (angle / pi)) * (widget.divisions - 1)).round();
if (value != newValue &&
newValue >= 0 &&
newValue < widget.divisions) {
widget.onChanged(newValue);
setState(() {
value = newValue;
});
}
},
);
// Subtract by one to prevent the background from bleeding through
// and creating a seam
const imageInset = inset + SemiCircleSliderPainter.lineWidth - 1;
const imageTopInset = inset - SemiCircleSliderPainter.lineWidth / 2;
child = Stack(
fit: StackFit.passthrough,
children: [
// Position the image so that it fits neatly inside the semicircle
Positioned(
left: imageInset,
top: imageTopInset,
right: imageInset,
bottom: imageInset,
child: ClipRRect(
// A clever trick to round it into a semi-circle: round the
// bottom left and bottom right a large amount
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(1000.0),
),
child: Image(
image: widget.image,
fit: BoxFit.cover,
),
),
),
child,
],
);
return child;
},
),
);
}
}
class SemiCircleSliderPainter extends CustomPainter {
SemiCircleSliderPainter({
required this.divisions,
required this.arcRect,
required this.nubAngle,
});
final int divisions;
final Rect arcRect;
final double nubAngle;
static const nubRadius = 16.0;
static const lineWidth = 16.0;
static const stepThickness = 3.0;
static const stepLength = 2.0;
late final lineArcRect = arcRect.deflate(lineWidth / 2);
late final xradius = lineArcRect.width / 2;
late final yradius = lineArcRect.height / 2;
late final center = arcRect.center;
late final nubPath = Path()
..addPath(
Path()
..moveTo(0, 0)
..arcTo(
const Offset(nubRadius / 2, -nubRadius) &
const Size.fromRadius(nubRadius),
5 * pi / 4,
3 * pi / 2,
false,
),
Offset(
center.dx + cos(nubAngle) * xradius,
center.dy + sin(nubAngle) * yradius,
),
matrix4: Matrix4.rotationZ(nubAngle).storage,
);
#override
void paint(Canvas canvas, Size size) {
// Paint large arc
canvas.drawPath(
Path()
// Extend a line on the left and right so the markers aren't sitting
// right on the border
..moveTo(lineArcRect.right, lineArcRect.center.dy - lineWidth / 2)
..arcTo(
lineArcRect,
0,
pi,
false,
)
..lineTo(lineArcRect.left, lineArcRect.center.dy - lineWidth / 2),
Paint()
..style = PaintingStyle.stroke
..color = Colors.black
..strokeWidth = lineWidth,
);
// Paint division markers
for (var i = 0; i < divisions; i++) {
final angle = pi * i / (divisions - 1);
final xnorm = cos(angle);
final ynorm = sin(angle);
canvas.drawLine(
center +
Offset(
xnorm * (xradius - stepLength),
ynorm * (yradius - stepLength),
),
center +
Offset(
xnorm * (xradius + stepLength),
ynorm * (yradius + stepLength),
),
Paint()
..style = PaintingStyle.stroke
..color = Colors.white
..strokeWidth = stepThickness
..strokeCap = StrokeCap.round,
);
}
// Paint nub
canvas.drawPath(
nubPath,
Paint()..color = Colors.pink.shade200,
);
}
#override
bool? hitTest(Offset position) {
// Only respond to hit tests when tapping the nub
return nubPath.contains(position);
}
#override
bool shouldRepaint(SemiCircleSliderPainter oldDelegate) =>
divisions != oldDelegate.divisions ||
arcRect != oldDelegate.arcRect ||
nubAngle != oldDelegate.nubAngle;
}
For semi-circle, I would recommend sleek_circular_slider which doesn't require any kind of license. You can see this YouTube video for visual learning.
If you are ok with circular sliders, you've just got more options. You can use flutter_circular_slider package and the author has the behind the scene explanation here.
Here is another video tutorial if you are more into built on my own attitude. Here is the complete source code for it.
https://github.com/JideGuru/youtube_videos/tree/master/rainbow_circular_slider
This is based on the source code of sleek_circular_slider customized to be almost exactly the screenshot provided. I've included some new properties like touchWidth, innerTrackWidth, outerTrackWidth, handlerSize and handlerWidth.
Check out the live demo on DartPad
Source code:
/*
Copyright (c) 2019 Mat Nuckowski
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
#override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'Flutter Demo',
home: MyHomePage(),
debugShowCheckedModeBanner: false,
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
#override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
String _modifier(double value) => '${value.ceil().toInt()}';
#override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xff827c7e),
body: Center(
child: CircularSlider(
appearance: CircularSliderAppearance(
startAngle: 180,
angleRange: 180,
size: 300,
counterClockwise: true,
infoProperties: InfoProperties(modifier: _modifier),
customColors: CustomSliderColors(),
customWidths: CustomSliderWidths(
touchWidth: 60,
innerTrackWidth: 10,
outerTrackWidth: 4,
handlerSize: 20,
handlerWidth: 20,
),
),
min: 0,
max: 10,
initialValue: 6,
onChange: (value) {},
),
),
);
}
}
typedef OnChange = void Function(double value);
typedef InnerWidget = Widget Function(double percentage);
class CircularSlider extends StatefulWidget {
final double initialValue;
final double min;
final double max;
final CircularSliderAppearance appearance;
final OnChange? onChange;
final OnChange? onChangeStart;
final OnChange? onChangeEnd;
final InnerWidget? innerWidget;
static const defaultAppearance = CircularSliderAppearance();
double get angle =>
valueToAngle(initialValue, min, max, appearance.angleRange);
const CircularSlider({
Key? key,
this.initialValue = 50,
this.min = 0,
this.max = 100,
this.appearance = defaultAppearance,
this.onChange,
this.onChangeStart,
this.onChangeEnd,
this.innerWidget,
}) : assert(min <= max),
assert(initialValue >= min && initialValue <= max),
super(key: key);
#override
State<CircularSlider> createState() => _CircularSliderState();
}
class _CircularSliderState extends State<CircularSlider>
with SingleTickerProviderStateMixin {
bool _isHandlerSelected = false;
bool _animationInProgress = false;
_CurvePainter? _painter;
double? _oldWidgetAngle;
double? _oldWidgetValue;
double? _curAngle;
late double _startAngle;
late double _angleRange;
double? _selectedAngle;
double? _rotation;
SpinAnimationManager? _spinManager;
ValueChangedAnimationManager? _animationManager;
late int _appearanceHashCode;
bool get _interactionEnabled => (widget.onChangeEnd != null ||
widget.onChange != null && !widget.appearance.spinnerMode);
#override
void initState() {
super.initState();
_startAngle = widget.appearance.startAngle;
_angleRange = widget.appearance.angleRange;
_appearanceHashCode = widget.appearance.hashCode;
if (!widget.appearance.animationEnabled) return;
widget.appearance.spinnerMode ? _spin() : _animate();
}
#override
void didUpdateWidget(CircularSlider oldWidget) {
if (oldWidget.angle != widget.angle &&
_curAngle?.toStringAsFixed(4) != widget.angle.toStringAsFixed(4)) {
_animate();
}
super.didUpdateWidget(oldWidget);
}
void _animate() {
if (!widget.appearance.animationEnabled || widget.appearance.spinnerMode) {
_setupPainter();
_updateOnChange();
return;
}
_animationManager ??= ValueChangedAnimationManager(
tickerProvider: this,
minValue: widget.min,
maxValue: widget.max,
durationMultiplier: widget.appearance.animDurationMultiplier,
);
_animationManager!.animate(
initialValue: widget.initialValue,
angle: widget.angle,
oldAngle: _oldWidgetAngle,
oldValue: _oldWidgetValue,
valueChangedAnimation: ((double anim, bool animationCompleted) {
_animationInProgress = !animationCompleted;
setState(() {
if (!animationCompleted) {
_curAngle = anim;
// update painter and the on change closure
_setupPainter();
_updateOnChange();
}
});
}));
}
void _spin() {
_spinManager = SpinAnimationManager(
tickerProvider: this,
duration: Duration(milliseconds: widget.appearance.spinnerDuration),
spinAnimation: ((double anim1, anim2, anim3) {
setState(() {
_rotation = anim1;
_startAngle = math.pi * anim2;
_curAngle = anim3;
_setupPainter();
_updateOnChange();
});
}));
_spinManager!.spin();
}
#override
Widget build(BuildContext context) {
/// _setupPainter excution when _painter is null or appearance has changed.
if (_painter == null || _appearanceHashCode != widget.appearance.hashCode) {
_appearanceHashCode = widget.appearance.hashCode;
_setupPainter();
}
return RawGestureDetector(
gestures: <Type, GestureRecognizerFactory>{
_CustomPanGestureRecognizer:
GestureRecognizerFactoryWithHandlers<_CustomPanGestureRecognizer>(
() => _CustomPanGestureRecognizer(
onPanDown: _onPanDown,
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
),
(_CustomPanGestureRecognizer instance) {},
),
},
child: _buildRotatingPainter(
rotation: _rotation,
size: Size(widget.appearance.size, widget.appearance.size)));
}
#override
void dispose() {
_spinManager?.dispose();
_animationManager?.dispose();
super.dispose();
}
void _setupPainter({bool counterClockwise = false}) {
var defaultAngle = _curAngle ?? widget.angle;
if (_oldWidgetAngle != null) {
if (_oldWidgetAngle != widget.angle) {
_selectedAngle = null;
defaultAngle = widget.angle;
}
}
_curAngle = calculateAngle(
startAngle: _startAngle,
angleRange: _angleRange,
selectedAngle: _selectedAngle,
defaultAngle: defaultAngle,
counterClockwise: counterClockwise);
_painter = _CurvePainter(
startAngle: _startAngle,
angleRange: _angleRange,
angle: _curAngle! < 0.5 ? 0.5 : _curAngle!,
appearance: widget.appearance);
_oldWidgetAngle = widget.angle;
_oldWidgetValue = widget.initialValue;
}
void _updateOnChange() {
if (widget.onChange != null && !_animationInProgress) {
final value =
angleToValue(_curAngle!, widget.min, widget.max, _angleRange);
widget.onChange!(value);
}
}
Widget _buildRotatingPainter({double? rotation, required Size size}) {
if (rotation != null) {
return Transform(
transform: Matrix4.identity()..rotateZ((rotation) * 5 * math.pi / 6),
alignment: FractionalOffset.center,
child: _buildPainter(size: size));
}
return _buildPainter(size: size);
}
Widget _buildPainter({required Size size}) {
return CustomPaint(
painter: _painter,
child: SizedBox(
width: size.width,
height: size.height,
child: _buildChildWidget()));
}
Widget? _buildChildWidget() {
if (widget.appearance.spinnerMode) return null;
final value = angleToValue(_curAngle!, widget.min, widget.max, _angleRange);
final childWidget = widget.innerWidget != null
? widget.innerWidget!(value)
: SliderLabel(
value: value,
appearance: widget.appearance,
);
return childWidget;
}
void _onPanUpdate(Offset details) {
if (!_isHandlerSelected) return;
if (_painter?.center == null) return;
_handlePan(details, false);
}
void _onPanEnd(Offset details) {
_handlePan(details, true);
if (widget.onChangeEnd != null) {
widget.onChangeEnd!(
angleToValue(_curAngle!, widget.min, widget.max, _angleRange));
}
_isHandlerSelected = false;
}
void _handlePan(Offset details, bool isPanEnd) {
if (_painter?.center == null) return;
RenderBox renderBox = context.findRenderObject() as RenderBox;
var position = renderBox.globalToLocal(details);
final double touchWidth = widget.appearance.touchWidth >= 25.0
? widget.appearance.touchWidth
: 25.0;
if (isPointAlongCircle(
position, _painter!.center!, _painter!.radius, touchWidth)) {
_selectedAngle = coordinatesToRadians(_painter!.center!, position);
// setup painter with new angle values and update onChange
_setupPainter(counterClockwise: widget.appearance.counterClockwise);
_updateOnChange();
setState(() {});
}
}
bool _onPanDown(Offset details) {
if (_painter == null || _interactionEnabled == false) {
return false;
}
RenderBox renderBox = context.findRenderObject() as RenderBox;
var position = renderBox.globalToLocal(details);
final angleWithinRange = isAngleWithinRange(
startAngle: _startAngle,
angleRange: _angleRange,
touchAngle: coordinatesToRadians(_painter!.center!, position),
previousAngle: _curAngle,
counterClockwise: widget.appearance.counterClockwise);
if (!angleWithinRange) {
return false;
}
final double touchWidth = widget.appearance.touchWidth >= 25.0
? widget.appearance.touchWidth
: 25.0;
if (isPointAlongCircle(
position, _painter!.center!, _painter!.radius, touchWidth)) {
_isHandlerSelected = true;
if (widget.onChangeStart != null) {
widget.onChangeStart!(
angleToValue(_curAngle!, widget.min, widget.max, _angleRange));
}
_onPanUpdate(details);
} else {
_isHandlerSelected = false;
}
return _isHandlerSelected;
}
}
typedef SpinAnimation = void Function(
double animation1, double animation2, double animation3);
class SpinAnimationManager {
final TickerProvider tickerProvider;
final Duration duration;
final SpinAnimation spinAnimation;
SpinAnimationManager({
required this.spinAnimation,
required this.duration,
required this.tickerProvider,
});
late Animation<double> _animation1;
late Animation<double> _animation2;
late Animation<double> _animation3;
late AnimationController _animController;
void spin() {
_animController = AnimationController(
vsync: tickerProvider, duration: duration)
..addListener(() {
spinAnimation(_animation1.value, _animation2.value, _animation3.value);
})
..repeat();
_animation1 = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(
parent: _animController,
curve: const Interval(0.5, 1.0, curve: Curves.linear)));
_animation2 = Tween<double>(begin: -80.0, end: 100.0).animate(
CurvedAnimation(
parent: _animController,
curve: const Interval(0, 1.0, curve: Curves.linear)));
_animation3 = Tween(begin: 0.0, end: 360.0).animate(CurvedAnimation(
parent: _animController,
curve: const Interval(0.0, 1.0, curve: SpinnerCurve())));
}
void dispose() {
_animController.dispose();
}
}
class SpinnerCurve extends Curve {
const SpinnerCurve();
#override
double transform(double t) => (t <= 0.5) ? 1.9 * t : 1.85 * (1 - t);
}
typedef ValueChangeAnimation = void Function(
double animation, bool animationFinished);
class ValueChangedAnimationManager {
final TickerProvider tickerProvider;
final double durationMultiplier;
final double minValue;
final double maxValue;
ValueChangedAnimationManager({
required this.tickerProvider,
required this.minValue,
required this.maxValue,
this.durationMultiplier = 1.0,
});
late Animation<double> _animation;
late final AnimationController _animController =
AnimationController(vsync: tickerProvider);
bool _animationCompleted = false;
void animate(
{required double initialValue,
double? oldValue,
required double angle,
double? oldAngle,
required ValueChangeAnimation valueChangedAnimation}) {
_animationCompleted = false;
final duration = (durationMultiplier *
valueToDuration(
initialValue, oldValue ?? minValue, minValue, maxValue))
.toInt();
_animController.duration = Duration(milliseconds: duration);
final curvedAnimation = CurvedAnimation(
parent: _animController,
curve: Curves.easeOut,
);
_animation =
Tween<double>(begin: oldAngle ?? 0, end: angle).animate(curvedAnimation)
..addListener(() {
valueChangedAnimation(_animation.value, _animationCompleted);
})
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
_animationCompleted = true;
_animController.reset();
}
});
_animController.forward();
}
void dispose() {
_animController.dispose();
}
}
class _CurvePainter extends CustomPainter {
final double angle;
final CircularSliderAppearance appearance;
final double startAngle;
final double angleRange;
Offset? handler;
Offset? center;
late double radius;
_CurvePainter(
{required this.appearance,
this.angle = 30,
required this.startAngle,
required this.angleRange});
#override
void paint(Canvas canvas, Size size) {
radius =
math.min(size.width / 2, size.height / 2) - appearance.touchWidth * 0.5;
center = Offset(size.width / 2, size.height / 2);
final trackPaint = Paint()
..style = PaintingStyle.stroke
..color = appearance.outerTrackColor
..strokeWidth = appearance.outerTrackWidth;
drawCircularArc(
canvas: canvas,
size: Size(size.width, size.height),
paint: trackPaint,
radius: radius +
appearance.innerTrackWidth / 2 +
appearance.outerTrackWidth / 2,
ignoreAngle: true,
spinnerMode: appearance.spinnerMode);
trackPaint.color = appearance.innerTrackColor;
trackPaint.strokeWidth = appearance.innerTrackWidth;
drawCircularArc(
canvas: canvas,
size: size,
paint: trackPaint,
radius: radius,
ignoreAngle: true,
spinnerMode: appearance.spinnerMode);
final currentAngle = appearance.counterClockwise ? -angle : angle;
var dividersPaint = Paint()
..color = Colors.white
..strokeWidth = 1.5;
for (final angle in [0, 18, 36, 54, 72, 108, 126, 144, 162, 180]) {
Offset handler = degreesToCoordinates(center!, -math.pi / 2 + angle + 1.5,
radius - appearance.innerTrackWidth / 2 + 2);
Offset handler2 = degreesToCoordinates(
center!,
-math.pi / 2 + angle + 1.5,
radius + appearance.innerTrackWidth / 2 - 2);
canvas.drawLine(handler, handler2, dividersPaint);
}
var dotPaint = Paint();
Offset dotHandler =
degreesToCoordinates(center!, -math.pi / 2 + 90 + 1.5, radius);
dotPaint.shader = ui.Gradient.radial(
dotHandler,
appearance.innerTrackWidth / 3,
[const Color(0xfff963b5), const Color(0xfffe94cd)]);
canvas.drawCircle(dotHandler, appearance.innerTrackWidth / 3, dotPaint);
Offset handler = degreesToCoordinates(
center!, -math.pi / 2 + startAngle + currentAngle + 1.5, radius);
Offset handler2 = degreesToCoordinates(
center!,
-math.pi / 2 + startAngle + currentAngle + 1.5,
radius + appearance.handlerWidth);
var handlerPaint = Paint()
..shader = ui.Gradient.linear(handler, handler2,
[const Color(0xfffbf9fa), const Color(0xffff7ba3)]);
final path = Path()
..moveTo(handler2.dx, handler2.dy)
..arcTo(
Rect.fromCenter(
center: handler2,
width: appearance.handlerSize,
height: appearance.handlerSize),
degreeToRadians(currentAngle) + math.pi / 2 - math.pi / 4,
math.pi + math.pi / 2,
true,
)
..lineTo(handler.dx, handler.dy)
..close();
canvas.drawPath(path, handlerPaint);
}
drawCircularArc({
required Canvas canvas,
required Size size,
required Paint paint,
required double radius,
bool ignoreAngle = false,
bool spinnerMode = false,
}) {
final double angleValue = ignoreAngle ? 0 : (angleRange - angle);
final range = appearance.counterClockwise ? -angleRange : angleRange;
final currentAngle = appearance.counterClockwise ? angleValue : -angleValue;
canvas.drawArc(
Rect.fromCircle(center: center!, radius: radius),
degreeToRadians(spinnerMode ? 0 : startAngle),
degreeToRadians(spinnerMode ? 360 : range + currentAngle),
false,
paint,
);
}
#override
bool shouldRepaint(CustomPainter oldDelegate) {
return true;
}
}
double degreeToRadians(double degree) => (math.pi / 180) * degree;
double radiansToDegrees(double radians) => radians * (180 / math.pi);
Offset degreesToCoordinates(Offset center, double degrees, double radius) =>
radiansToCoordinates(center, degreeToRadians(degrees), radius);
Offset radiansToCoordinates(Offset center, double radians, double radius) {
var dx = center.dx + radius * math.cos(radians);
var dy = center.dy + radius * math.sin(radians);
return Offset(dx, dy);
}
double coordinatesToRadians(Offset center, Offset coords) {
var a = coords.dx - center.dx;
var b = center.dy - coords.dy;
return radiansNormalized(math.atan2(b, a));
}
double radiansNormalized(double radians) {
var normalized = radians < 0 ? -radians : 2 * math.pi - radians;
return normalized;
}
bool isPointInsideCircle(Offset point, Offset center, double rradius) {
var radius = rradius * 1.2;
return point.dx < (center.dx + radius) &&
point.dx > (center.dx - radius) &&
point.dy < (center.dy + radius) &&
point.dy > (center.dy - radius);
}
bool isPointAlongCircle(
Offset point, Offset center, double radius, double width) {
var dx = math.pow(point.dx - center.dx, 2);
var dy = math.pow(point.dy - center.dy, 2);
var distance = math.sqrt(dx + dy);
return (distance - radius).abs() < width;
}
double calculateRawAngle({
required double startAngle,
required double angleRange,
required double selectedAngle,
bool counterClockwise = false,
}) {
double angle = radiansToDegrees(selectedAngle);
if (!counterClockwise) {
if (angle >= startAngle && angle <= 360.0) return angle - startAngle;
return 360.0 - startAngle + angle;
}
if (angle <= startAngle) return startAngle - angle;
return 360.0 - angle + startAngle;
}
double calculateAngle({
required double startAngle,
required double angleRange,
required selectedAngle,
required defaultAngle,
bool counterClockwise = false,
}) {
if (selectedAngle == null) return defaultAngle;
double calcAngle = calculateRawAngle(
startAngle: startAngle,
angleRange: angleRange,
selectedAngle: selectedAngle,
counterClockwise: counterClockwise,
);
if (calcAngle - angleRange > (360.0 - angleRange) * 0.5) return 0.0;
if (calcAngle > angleRange) return angleRange;
return calcAngle;
}
bool isAngleWithinRange({
required double startAngle,
required double angleRange,
required touchAngle,
required previousAngle,
bool counterClockwise = false,
}) {
double calcAngle = calculateRawAngle(
startAngle: startAngle,
angleRange: angleRange,
selectedAngle: touchAngle,
counterClockwise: counterClockwise);
return !(calcAngle > angleRange);
}
int valueToDuration(double value, double previous, double min, double max) {
final divider = (max - min) / 100;
return divider != 0 ? (value - previous).abs() ~/ divider * 15 : 0;
}
double valueToPercentage(double value, double min, double max) =>
value / ((max - min) / 100);
double valueToAngle(double value, double min, double max, double angleRange) =>
percentageToAngle(valueToPercentage(value - min, min, max), angleRange);
double percentageToValue(double percentage, double min, double max) =>
((max - min) / 100) * percentage + min;
double percentageToAngle(double percentage, double angleRange) {
final step = angleRange / 100;
if (percentage > 100) return angleRange;
if (percentage < 0) return 0.5;
return percentage * step;
}
double angleToValue(double angle, double min, double max, double angleRange) {
return percentageToValue(angleToPercentage(angle, angleRange), min, max);
}
double angleToPercentage(double angle, double angleRange) {
final step = angleRange / 100;
if (angle > angleRange) return 100;
if (angle < 0.5) return 0;
return angle / step;
}
class _CustomPanGestureRecognizer extends OneSequenceGestureRecognizer {
final Function onPanDown;
final Function onPanUpdate;
final Function onPanEnd;
_CustomPanGestureRecognizer({
required this.onPanDown,
required this.onPanUpdate,
required this.onPanEnd,
});
#override
void addPointer(PointerEvent event) {
if (onPanDown(event.position)) {
startTrackingPointer(event.pointer);
resolve(GestureDisposition.accepted);
} else {
stopTrackingPointer(event.pointer);
}
}
#override
void handleEvent(PointerEvent event) {
if (event is PointerMoveEvent) {
onPanUpdate(event.position);
}
if (event is PointerUpEvent) {
onPanEnd(event.position);
stopTrackingPointer(event.pointer);
}
}
#override
String get debugDescription => 'customPan';
#override
void didStopTrackingLastPointer(int pointer) {}
}
class SliderLabel extends StatelessWidget {
final double value;
final CircularSliderAppearance appearance;
const SliderLabel({Key? key, required this.value, required this.appearance})
: super(key: key);
#override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: builtInfo(appearance),
);
}
List<Widget> builtInfo(CircularSliderAppearance appearance) {
var widgets = <Widget>[];
if (appearance.infoTopLabelText != null) {
widgets.add(Text(
appearance.infoTopLabelText!,
style: appearance.infoTopLabelStyle,
));
}
final modifier = appearance.infoModifier(value);
widgets.add(
Text(modifier, style: appearance.infoMainLabelStyle),
);
if (appearance.infoBottomLabelText != null) {
widgets.add(Text(
appearance.infoBottomLabelText!,
style: appearance.infoBottomLabelStyle,
));
}
return widgets;
}
}
typedef PercentageModifier = String Function(double percentage);
class CircularSliderAppearance {
static const double _defaultSize = 150.0;
static const double _defaultStartAngle = 150.0;
static const double _defaultAngleRange = 240.0;
static const Color _defaultInnerTrackColor = Colors.black;
static const Color _defaultOuterTrackColor = Colors.white;
String _defaultPercentageModifier(double value) =>
'${value.ceil().toInt()} %';
final double size;
final double startAngle;
final double angleRange;
final bool animationEnabled;
final bool spinnerMode;
final bool counterClockwise;
final double animDurationMultiplier;
final int spinnerDuration;
final CustomSliderWidths? customWidths;
final CustomSliderColors? customColors;
final InfoProperties? infoProperties;
double? get _customInnerTrackWidth => customWidths?.innerTrackWidth;
double? get _customOuterTrackWidth => customWidths?.outerTrackWidth;
double? get _customTouchWidth => customWidths?.touchWidth;
double? get _customHandlerSize => customWidths?.handlerSize;
double? get _customHandlerWidth => customWidths?.handlerWidth;
double get innerTrackWidth => _customInnerTrackWidth ?? touchWidth / 4.0;
double get outerTrackWidth => _customOuterTrackWidth ?? touchWidth / 4.0;
double get touchWidth => _customTouchWidth ?? size / 10.0;
double get handlerSize => _customHandlerSize ?? touchWidth / 5.0;
double get handlerWidth => _customHandlerWidth ?? touchWidth / 5.0;
Color? get _customInnerTrackColor => customColors?.innerTrackColor;
Color? get _customOuterTrackColor => customColors?.outerTrackColor;
Color get innerTrackColor =>
_customInnerTrackColor ?? _defaultInnerTrackColor;
Color get outerTrackColor =>
_customOuterTrackColor ?? _defaultOuterTrackColor;
String? get _topLabelText => infoProperties?.topLabelText;
String? get _bottomLabelText => infoProperties?.bottomLabelText;
TextStyle? get _mainLabelStyle => infoProperties?.mainLabelStyle;
TextStyle? get _topLabelStyle => infoProperties?.topLabelStyle;
TextStyle? get _bottomLabelStyle => infoProperties?.bottomLabelStyle;
PercentageModifier? get _modifier => infoProperties?.modifier;
PercentageModifier get infoModifier =>
_modifier ?? _defaultPercentageModifier;
String? get infoTopLabelText => _topLabelText;
String? get infoBottomLabelText => _bottomLabelText;
TextStyle get infoMainLabelStyle =>
_mainLabelStyle ??
TextStyle(
fontWeight: FontWeight.w100,
fontSize: size / 5.0,
color: const Color.fromRGBO(30, 0, 59, 1.0));
TextStyle get infoTopLabelStyle =>
_topLabelStyle ??
TextStyle(
fontWeight: FontWeight.w600,
fontSize: size / 10.0,
color: const Color.fromRGBO(147, 81, 120, 1.0));
TextStyle get infoBottomLabelStyle =>
_bottomLabelStyle ??
TextStyle(
fontWeight: FontWeight.w600,
fontSize: size / 10.0,
color: const Color.fromRGBO(147, 81, 120, 1.0));
const CircularSliderAppearance({
this.customWidths,
this.customColors,
this.size = _defaultSize,
this.startAngle = _defaultStartAngle,
this.angleRange = _defaultAngleRange,
this.infoProperties,
this.animationEnabled = true,
this.counterClockwise = false,
this.spinnerMode = false,
this.spinnerDuration = 1500,
this.animDurationMultiplier = 1.0,
});
}
class CustomSliderWidths {
final double? innerTrackWidth;
final double? outerTrackWidth;
final double? touchWidth;
final double? handlerSize;
final double? handlerWidth;
CustomSliderWidths({
this.innerTrackWidth,
this.outerTrackWidth,
this.touchWidth,
this.handlerSize,
this.handlerWidth,
});
}
class CustomSliderColors {
final Color? innerTrackColor;
final Color? outerTrackColor;
CustomSliderColors({
this.innerTrackColor,
this.outerTrackColor,
});
}
class InfoProperties {
final PercentageModifier? modifier;
final TextStyle? mainLabelStyle;
final TextStyle? topLabelStyle;
final TextStyle? bottomLabelStyle;
final String? topLabelText;
final String? bottomLabelText;
InfoProperties({
this.topLabelText,
this.bottomLabelText,
this.mainLabelStyle,
this.topLabelStyle,
this.bottomLabelStyle,
this.modifier,
});
}
You can use CustomPaint to paint various arcs and slider in your widget,
and then you can use GestureDetector to detect the pan action and slide the slider in the widget according to the pan update (onPanUpdate).
check out the working example of code here.
here is the screenshot of widget I made,
,
here is the code,
class CircularSlider extends StatefulWidget {
final double rotateAngle;
const CircularSlider({super.key, this.rotateAngle = 0});
#override
State<CircularSlider> createState() => _CircularSliderState();
}
class _CircularSliderState extends State<CircularSlider> {
double arcAngle = 0.0;
#override
void initState() {
arcAngle = widget.rotateAngle;
super.initState();
}
inRadiusArea(Offset position) {
final pos = position.translate(200.0, 200.0);
return 100 < pos.distance && 120 > pos.distance;
}
double getRadians(Offset center, Offset coords, double radius) {
var a = coords.dx - center.dx;
if (a > radius) {
a = radius;
} else if (a < -radius) {
a = -radius;
}
return math.acos(a / radius);
}
void _onPanUpdate(DragUpdateDetails details) {
final pos = details.localPosition;
final rad = getRadians(const Offset(200, 200), pos, 200);
if (rad >= 0 && rad <= math.pi) {
setState(() {
arcAngle = rad;
});
}
return;
}
#override
Widget build(BuildContext context) {
return GestureDetector(
onPanUpdate: _onPanUpdate,
behavior: HitTestBehavior.translucent,
child: SizedBox(
height: 400,
width: 400,
child: Column(
children: [
CustomPaint(
painter: MyPainter(
strokeWidth: 20,
color: Colors.white,
radius: 102,
),
),
CustomPaint(
painter: MyPainter(
strokeWidth: 15,
color: Colors.black,
radius: 100,
),
),
CustomPaint(
painter: MyStepsPainter(
strokeWidth: 13,
color: Colors.white,
radius: 102,
),
),
_MySlider(
radius: 118,
arcAngle: arcAngle,
color: Colors.pink,
),
],
),
),
);
}
}
class MyPainter extends CustomPainter {
double startAngle;
double endAngle;
double sweepAngle;
double strokeWidth;
double radius;
Color color;
MyPainter({
this.startAngle = 0.0,
this.endAngle = 0.0,
this.sweepAngle = math.pi,
this.radius = 200,
this.color = Colors.black,
this.strokeWidth = 4.0,
});
#override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final rect = Rect.fromCircle(center: center, radius: radius);
const useCenter = false;
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
canvas.drawArc(rect, startAngle, sweepAngle, useCenter, paint);
}
#override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}
class MyStepsPainter extends CustomPainter {
double startAngle;
double sweepAngle;
double stepSweepAngle;
double strokeWidth;
double radius;
int stepNumber;
Color color;
MyStepsPainter({
this.startAngle = 0.0,
this.sweepAngle = math.pi,
this.stepSweepAngle = math.pi / 100,
this.radius = 200,
this.stepNumber = 10,
this.color = Colors.black,
this.strokeWidth = 0.5,
});
#override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final rect = Rect.fromCircle(center: center, radius: radius);
const useCenter = false;
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
final steps = 2 * stepNumber + 2;
final sweepInterval = 2 * math.pi / steps;
final halfStepSweepAngle = stepSweepAngle / 2;
var angle = 0.0;
var firstHalf = angle - halfStepSweepAngle;
var secondHalf = angle + halfStepSweepAngle;
while (angle < math.pi) {
final startA = firstHalf > 0 ? firstHalf : angle;
final endA = secondHalf <= math.pi ? secondHalf : angle;
canvas.drawArc(rect, startA, endA - startA, useCenter, paint);
angle += sweepInterval;
firstHalf = angle - halfStepSweepAngle;
secondHalf = angle + halfStepSweepAngle;
}
}
#override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}
class _MySlider extends StatelessWidget {
final Color color;
final double radius;
final double arcAngle;
const _MySlider({
this.color = Colors.pink,
this.radius = 210.0,
this.arcAngle = 0.0,
});
#override
Widget build(BuildContext context) {
var sinT = math.sin(arcAngle);
var cosT = math.cos(arcAngle);
final center = Offset(cosT * radius, sinT * radius);
print(sinT);
print(cosT);
print(arcAngle);
return Transform.rotate(
origin: center,
angle: arcAngle,
child: CustomPaint(
painter: MySlider(
radius: radius,
arcAngle: arcAngle,
color: color,
),
),
);
}
}
class MySlider extends CustomPainter {
final Color color;
final double radius;
final double arcAngle;
MySlider({
this.color = Colors.pink,
this.radius = 210.0,
this.arcAngle = 0.0,
});
#override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.fill
..strokeWidth = 1;
var sinT = math.sin(arcAngle);
var cosT = math.cos(arcAngle);
var path = Path();
path.moveTo(cosT * radius - 20, sinT * radius);
path.lineTo(cosT * radius, sinT * radius - 20);
path.lineTo(cosT * radius, sinT * radius + 20);
path.lineTo(cosT * radius - 20, sinT * radius);
path.moveTo(cosT * radius, sinT * radius);
final center = Offset(cosT * radius, sinT * radius);
final rect = Rect.fromCircle(center: center, radius: 20.0);
path.addArc(rect, -math.pi / 2, math.pi);
canvas.drawPath(path, paint);
}
#override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}
I have a floating widget which I want to animate moving across the screen on a Bezier path.
I already have a path var which changes according to drag and fling gestures (predefined final offset).
How do I make the widget follow such a path?
Edit: as per request here's the code.
It's mostly a fork of https://pub.dev/packages/pip_view package.
You can look at the _onPadEnd, which is where I'm triggering the animation. Just trying to see how to make it follow a Bezier path.
import 'package:flutter/material.dart';
import 'dismiss_keyboard.dart';
import 'constants.dart';
import 'dart:developer';
import 'dart:math' hide log;
import 'package:arc_animator/arc_animator.dart'; //unused import
double sqrtNegSup(double val){ return val.isNegative ? -(sqrt(-val)) : sqrt(val); }
Map<PIPViewCorner, Offset> _calculateOffsets({Size spaceSize, Size widgetSize, EdgeInsets windowPadding,}) {
Offset getOffsetForCorner(PIPViewCorner corner) {
final spacing = 16;
final left = spacing + windowPadding.left;
final top = spacing + windowPadding.top;
final right = spaceSize.width - widgetSize.width - windowPadding.right - spacing;
final bottom = spaceSize.height - widgetSize.height - windowPadding.bottom - spacing;
switch (corner) {
case PIPViewCorner.topLeft: return Offset(left, top);
case PIPViewCorner.topRight: return Offset(right, top);
case PIPViewCorner.bottomLeft: return Offset(left, bottom);
case PIPViewCorner.bottomRight: return Offset(right, bottom);
default: throw Exception('Not implemented.');
}
}
final corners = PIPViewCorner.values;
final Map<PIPViewCorner, Offset> offsets = {};
for (final corner in corners) {
offsets[corner] = getOffsetForCorner(corner);
}
return offsets;
}
enum PIPViewCorner {
topLeft,
topRight,
bottomLeft,
bottomRight,
}
class _CornerDistance {
final PIPViewCorner corner;
final double distance;
_CornerDistance({this.corner, this.distance,});
}
class PIPView extends StatefulWidget {
final PIPViewCorner initialCorner;
final double floatingWidth;
final double floatingHeight;
final bool avoidKeyboard;
final Widget Function(BuildContext context, bool isFloating, ) builder;
const PIPView({
Key key, #required this.builder,
this.initialCorner = PIPViewCorner.topRight,
this.floatingWidth,
this.floatingHeight,
this.avoidKeyboard = true,
}) : super(key: key);
#override PIPViewState createState() => PIPViewState();
static PIPViewState of(BuildContext context) {return context.findAncestorStateOfType<PIPViewState>();}
}
/// pan functions correspond to callbacks of dragging gestures
/// those functions will update the last drag and offset vars accordingly
///
/// animations are performed independently
/// and are triggered when toggling or dragging is concluded
class PIPViewState extends State<PIPView> with TickerProviderStateMixin {
Widget _bottomView;
AnimationController _toggleFloatingAnimationController; //animation for toggling PIP
AnimationController _dragAnimationController; //animation for dragging PIP
PIPViewCorner _corner;
Offset _dragOffset = Offset.zero;
bool _isDragging = false;
Map<PIPViewCorner, Offset> _offsets = {};
//screenspace
double width;
double height;
//values for refining fling engagement (see usages)
double baseAdditionOfFlingVelocity = 0.75;
double baseMultiplierOfFlingVelocity = 2;
//values for refining fling duration (see usages)
int minimumDuration = 730;
int maximumDuration = 1280;
double durationMultiplier = 1.10;
bool _isAnimating() { return _toggleFloatingAnimationController.isAnimating || _dragAnimationController.isAnimating; }
#override void initState() {
super.initState();
_corner = widget.initialCorner;
_toggleFloatingAnimationController = AnimationController(duration: defaultAnimationDuration, vsync: this,);
_dragAnimationController = AnimationController(duration: defaultAnimationDuration, vsync: this,);
}
void _updateCornersOffsets({ Size spaceSize, Size widgetSize, EdgeInsets windowPadding,}) {_offsets = _calculateOffsets(spaceSize: spaceSize, widgetSize: widgetSize, windowPadding: windowPadding,);}
_CornerDistance _calculateNearestCornerAndDistance({Offset offset, Map<PIPViewCorner, Offset> offsets,}) {
_CornerDistance calculateDistance(PIPViewCorner corner) {
final distance = offsets[corner].translate(-offset.dx, -offset.dy,).distanceSquared;
return _CornerDistance(corner: corner, distance: distance,);
}
final distances = PIPViewCorner.values.map(calculateDistance).toList();
distances.sort((cd0, cd1) => cd0.distance.compareTo(cd1.distance));
return _CornerDistance(corner:distances.first.corner,distance:distances.first.distance);
}
_CornerDistance _calculateNearestCornerAndDistanceWithFling({Offset offsetWithFling, Offset offsetWithoutFling, Map<PIPViewCorner, Offset> offsets,}) {
_CornerDistance calculateDistance(PIPViewCorner corner) {
final distance = offsets[corner].translate(-offsetWithFling.dx, -offsetWithFling.dy,).distanceSquared;
return _CornerDistance(corner: corner, distance: distance,);
}
final distances = PIPViewCorner.values.map(calculateDistance).toList();
distances.sort((cd0, cd1) => cd0.distance.compareTo(cd1.distance));
final actualDistance = offsets[distances.first.corner].translate(-offsetWithoutFling.dx, -offsetWithoutFling.dy).distanceSquared;
return _CornerDistance(corner:distances.first.corner, distance: actualDistance);
}
#override Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
var windowPadding = mediaQuery.padding;
if (widget.avoidKeyboard) {windowPadding += mediaQuery.viewInsets;}
final isFloating = _bottomView != null;
return LayoutBuilder(
builder: (context, constraints) {
width = constraints.maxWidth;
height = constraints.maxHeight;
double floatingWidth = widget.floatingWidth;
double floatingHeight = widget.floatingHeight;
if (floatingWidth == null && floatingHeight != null) {floatingWidth = width / height * floatingHeight;}
floatingWidth ??= 100.0;
if (floatingHeight == null) {floatingHeight = height / width * floatingWidth;}
final floatingWidgetSize = Size(floatingWidth, floatingHeight);
final fullWidgetSize = Size(width, height);
_updateCornersOffsets(spaceSize: fullWidgetSize, widgetSize: floatingWidgetSize, windowPadding: windowPadding,);
final calculatedOffset = _offsets[_corner];
// BoxFit.cover
final widthRatio = floatingWidth / width;
final heightRatio = floatingHeight / height;
final scaledDownScale = widthRatio > heightRatio
? floatingWidgetSize.width / fullWidgetSize.width
: floatingWidgetSize.height / fullWidgetSize.height;
return Stack( children: <Widget>[
if (isFloating) Navigator(onGenerateRoute: (settings) {return MaterialPageRoute(builder: (_) {return _bottomView;});},),
AnimatedBuilder(
animation: Listenable.merge([_toggleFloatingAnimationController, _dragAnimationController,]),
builder: (context, child) {
final animationCurve = CurveTween(curve: Curves.fastLinearToSlowEaseIn,);
// final animationArc =
//region assign corresponding keys of controller and animation
final dragAnimationValue= animationCurve.transform(_dragAnimationController.value,);
final toggleFloatingAnimationValue = animationCurve.transform(_toggleFloatingAnimationController.value,);
//endregion
final floatingOffset = _isDragging ? _dragOffset : Tween<Offset>(begin: _dragOffset, end: calculatedOffset,)
.transform(_dragAnimationController.isAnimating ? dragAnimationValue : toggleFloatingAnimationValue);
final borderRadius = Tween<double>(begin: 0, end: 10,).transform(toggleFloatingAnimationValue);
final width = Tween<double>(begin: fullWidgetSize.width, end: floatingWidgetSize.width,).transform(toggleFloatingAnimationValue);
final height = Tween<double>(begin: fullWidgetSize.height, end: floatingWidgetSize.height,).transform(toggleFloatingAnimationValue);
final scale = Tween<double>(begin: 1, end: scaledDownScale,).transform(toggleFloatingAnimationValue);
return Positioned(
left: floatingOffset.dx,
top: floatingOffset.dy,
child: GestureDetector(
onPanStart: isFloating ? _onPanStart : null,
onPanUpdate: isFloating ? _onPanUpdate : null,
onPanCancel: isFloating ? _onPanCancel : null,
onPanEnd: isFloating ? _onPanEnd : null, //pass floatingWidgetSize
onTap: isFloating ? stopFloating : null,
child: Material(
elevation: 10,
borderRadius: BorderRadius.circular(borderRadius),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(borderRadius),
),
width: width,
height: height,
child: Transform.scale(
scale: scale,
child: OverflowBox(
maxHeight: fullWidgetSize.height,
maxWidth: fullWidgetSize.width,
child: IgnorePointer(
ignoring: isFloating,
child: child,
),
),
),
),
),
),
);
},
child: Builder(builder: (context) => widget.builder(context, isFloating),),
),
],);
},
);
}
//region padEndLogs
void logFlingDetails(DragEndDetails details){
// details.velocity.pixelsPerSecond.translate(translateX, translateY)
log(
"fling details"
+"\n dx sqrt: " + sqrtNegSup(details.velocity.pixelsPerSecond.dx).toInt().toString()
+"\n dy sqrt: " + sqrtNegSup(details.velocity.pixelsPerSecond.dy).toInt().toString()
+"\n direction: " + details.velocity.pixelsPerSecond.direction.toString()
);
}
void logOffsetDetails(Offset _off, bool beforeApplyingFling){
log(
(beforeApplyingFling ? "offset details BEFORE applying fling params" : "offset details AFTER applying fling params")
+"\n dx: " + _off.dx.toInt().toString()
+"\n dy: " + _off.dy.toInt().toString()
+"\n direction: " + _off.direction.toString()
);
}
void logCornerCalculationDetails(_CornerDistance cornerDistanceDetails, bool withFling){
log(
(withFling?"cornerDistanceDetails AFTER fling":"cornerDistanceDetails BEFORE fling")
+"\n nearest corner: " + cornerDistanceDetails.corner.toString()
+"\n distance: " + cornerDistanceDetails.distance.toInt().toString()
);
}
//endregion
//region toggles & gestures
/*toggle pip on, arg is page over which it will be presented*/ void presentBelow(Widget widget) {
if (_isAnimating() || _bottomView != null) return;
dismissKeyboard(context);
setState(() {_bottomView = widget;});
_toggleFloatingAnimationController.forward();
}
/*toggle pip off, triggered on tap*/ void stopFloating() {
if (_isAnimating() || _bottomView == null) return;
dismissKeyboard(context);
_toggleFloatingAnimationController.reverse().whenCompleteOrCancel(() {if (mounted) {setState(() {_bottomView = null;});}});
}
void _onPanCancel() {
if (!_isDragging) return;
setState(() {
_dragAnimationController.value = 0;
_dragOffset = Offset.zero;
_isDragging = false;
});
}
void _onPanStart(DragStartDetails details) {
if (_isAnimating()) return;
setState(() {_dragOffset = _offsets[_corner]; _isDragging = true;});
}
void _onPanUpdate(DragUpdateDetails details) {
if (!_isDragging) return;
setState(() {_dragOffset = _dragOffset.translate(details.delta.dx, details.delta.dy,);});
}
void _onPanEnd(DragEndDetails details) {
if (!_isDragging) return;
//region fling values
double minimum = min(width, height);
double flingDx = sqrtNegSup(details.velocity.pixelsPerSecond.dx) * ((width / minimum) + baseAdditionOfFlingVelocity) * baseMultiplierOfFlingVelocity;
double flingDy = sqrtNegSup(details.velocity.pixelsPerSecond.dy) * ((height / minimum) + baseAdditionOfFlingVelocity) * baseMultiplierOfFlingVelocity;
//endregion
Offset offsetWithFling = _dragOffset.translate(flingDx, flingDy);
final nearestCornerToUse = _calculateNearestCornerAndDistanceWithFling(offsetWithFling: offsetWithFling,offsetWithoutFling: _dragOffset,offsets: _offsets );
setState(() { _corner = nearestCornerToUse.corner; _isDragging = false; });
//region duration values
int newDuration = (sqrt(nearestCornerToUse.distance)*durationMultiplier).toInt();
if(newDuration<minimumDuration) newDuration=minimumDuration;
if(newDuration>maximumDuration) newDuration=maximumDuration;
//endregion
log("new duration is: "+newDuration.toString());
_dragAnimationController.duration = Duration(milliseconds: newDuration.toInt());
Path path = Path();
path.moveTo(_dragOffset.dx, _dragOffset.dy);
path.quadraticBezierTo(flingDx, flingDy, _offsets[nearestCornerToUse.corner].dx, _offsets[nearestCornerToUse.corner].dy);
double jDx = calculate(path, _dragAnimationController).dx;
double jDy = calculate(path, _dragAnimationController).dy;
_dragAnimationController.forward().whenCompleteOrCancel(() { _dragAnimationController.value = 0; _dragOffset = Offset.zero; });
}
//endregion
Offset calculate(Path path, value) {
PathMetrics pathMetrics = path.computeMetrics();
PathMetric pathMetric = pathMetrics.elementAt(0);
value = pathMetric.length * value;
Tangent pos = pathMetric.getTangentForOffset(value);
return pos.position;
}
}```
[1]: https://pub.dev/packages/pip_view
I'd like to achieve the path animation effect as seen over here :
This animation (I couldn't include it because the gif is too big)
I only want to achieve the path on the map animation, I know I need to use a stacked, place my map, then use a Painter to paint such path, but how can I animate it ?
I know this question has an accepted answer, but I'd like to show an alternate solution to this problem.
First of all, creating a custom path from individual points is not optimal for the following:
calculating the length of each segment is not trivial
animating the steps evenly at small increments is difficult and resource-heavy
does not work with quadratic / bezier segments
Just like in the good old Android there is this path tracing method, so does a very similar PathMetrics exist in Flutter.
Building upon the accepted answer of this question, here is a much more generic way of animating any path.
So given a path and an animation percent, we need to extract a path from the start until that percent:
Path createAnimatedPath(
Path originalPath,
double animationPercent,
) {
// ComputeMetrics can only be iterated once!
final totalLength = originalPath
.computeMetrics()
.fold(0.0, (double prev, PathMetric metric) => prev + metric.length);
final currentLength = totalLength * animationPercent;
return extractPathUntilLength(originalPath, currentLength);
}
So now I only need to extract a path until a given length (not the percent). We need to combine all existing paths until a certain distance. Then add to this existing path some part of the last path segment.
Doing that is pretty straightforward.
Path extractPathUntilLength(
Path originalPath,
double length,
) {
var currentLength = 0.0;
final path = new Path();
var metricsIterator = originalPath.computeMetrics().iterator;
while (metricsIterator.moveNext()) {
var metric = metricsIterator.current;
var nextLength = currentLength + metric.length;
final isLastSegment = nextLength > length;
if (isLastSegment) {
final remainingLength = length - currentLength;
final pathSegment = metric.extractPath(0.0, remainingLength);
path.addPath(pathSegment, Offset.zero);
break;
} else {
// There might be a more efficient way of extracting an entire path
final pathSegment = metric.extractPath(0.0, metric.length);
path.addPath(pathSegment, Offset.zero);
}
currentLength = nextLength;
}
return path;
}
The rest of the code required to an entire example:
void main() => runApp(
new MaterialApp(
home: new AnimatedPathDemo(),
),
);
class AnimatedPathPainter extends CustomPainter {
final Animation<double> _animation;
AnimatedPathPainter(this._animation) : super(repaint: _animation);
Path _createAnyPath(Size size) {
return Path()
..moveTo(size.height / 4, size.height / 4)
..lineTo(size.height, size.width / 2)
..lineTo(size.height / 2, size.width)
..quadraticBezierTo(size.height / 2, 100, size.width, size.height);
}
#override
void paint(Canvas canvas, Size size) {
final animationPercent = this._animation.value;
print("Painting + ${animationPercent} - ${size}");
final path = createAnimatedPath(_createAnyPath(size), animationPercent);
final Paint paint = Paint();
paint.color = Colors.amberAccent;
paint.style = PaintingStyle.stroke;
paint.strokeWidth = 10.0;
canvas.drawPath(path, paint);
}
#override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}
class AnimatedPathDemo extends StatefulWidget {
#override
_AnimatedPathDemoState createState() => _AnimatedPathDemoState();
}
class _AnimatedPathDemoState extends State<AnimatedPathDemo>
with SingleTickerProviderStateMixin {
AnimationController _controller;
void _startAnimation() {
_controller.stop();
_controller.reset();
_controller.repeat(
period: Duration(seconds: 5),
);
}
#override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(title: const Text('Animated Paint')),
body: SizedBox(
height: 300,
width: 300,
child: new CustomPaint(
painter: new AnimatedPathPainter(_controller),
),
),
floatingActionButton: new FloatingActionButton(
onPressed: _startAnimation,
child: new Icon(Icons.play_arrow),
),
);
}
#override
void initState() {
super.initState();
_controller = new AnimationController(
vsync: this,
);
}
#override
void dispose() {
_controller.dispose();
super.dispose();
}
}
I created a library for this: drawing_animation
You just have to provide the Path objects to the widget:
Resulting in this image output: imgur
import 'package:drawing_animation/drawing_animation.dart';
//...
List<Paths> dottedPathArray = ...;
bool run = true;
//...
AnimatedDrawing.paths(
this.dottedPathArray,
run: this.run,
animationOrder: PathOrders.original,
duration: new Duration(seconds: 2),
lineAnimation: LineAnimation.oneByOne,
animationCurve: Curves.linear,
onFinish: () => setState(() {
this.run = false;
}),
)),
You don't actually need a Stack; you could use a foregroundPainter over the map image. To animate a CustomPainter pass the AnimationController into its constructor and also to the super constructor. In paint use the value of the animation to decide how much of the path the draw. For example, if value is 0.25, draw just the first 25% of the path.
class AnimatedPainter extends CustomPainter {
final Animation<double> _animation;
AnimatedPainter(this._animation) : super(repaint: _animation);
#override
void paint(Canvas canvas, Size size) {
// _animation.value has a value between 0.0 and 1.0
// use this to draw the first X% of the path
}
#override
bool shouldRepaint(AnimatedPainter oldDelegate) {
return true;
}
}
class PainterDemo extends StatefulWidget {
#override
PainterDemoState createState() => new PainterDemoState();
}
class PainterDemoState extends State<PainterDemo>
with SingleTickerProviderStateMixin {
AnimationController _controller;
#override
void initState() {
super.initState();
_controller = new AnimationController(
vsync: this,
);
}
#override
void dispose() {
_controller.dispose();
super.dispose();
}
void _startAnimation() {
_controller.stop();
_controller.reset();
_controller.repeat(
period: Duration(seconds: 5),
);
}
#override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(title: const Text('Animated Paint')),
body: new CustomPaint(
foregroundPainter: new AnimatedPainter(_controller),
child: new SizedBox(
// doesn't have to be a SizedBox - could be the Map image
width: 200.0,
height: 200.0,
),
),
floatingActionButton: new FloatingActionButton(
onPressed: _startAnimation,
child: new Icon(Icons.play_arrow),
),
);
}
}
void main() {
runApp(
new MaterialApp(
home: new PainterDemo(),
),
);
}
Presumably you will have a list of coordinates that define the path. Assuming some list of points you'd draw the complete path with something like:
if (points.isEmpty) return;
Path path = Path();
Offset origin = points[0];
path.moveTo(origin.dx, origin.dy);
for (Offset o in points) {
path.lineTo(o.dx, o.dy);
}
canvas.drawPath(
path,
Paint()
..color = Colors.orange
..style = PaintingStyle.stroke
..strokeWidth = 4.0,
);
When value is less than 1.0 you need to devise a way to draw less than 100% of the path. For example, when value is 0.25, you might only add the first quarter of the points to the path. If your path consisted of relatively few points, you'd probably get the smoothest animation if you calculated the total length of the path and drew just the first segments of the path that added up to a quarter of the total length.
Flutter's AnimatedSize class animates its size according to the size of its child. I need to know how to listen for changes to the size, ideally when the resizing has finished.
With my use-case, this widget is contained within a ListView, but I only seem to be able to listen to scroll events on this with a NotificationListener (being able to listen to changes in scrollable height would solve my problem).
Alternatively, being able to listen for when a widget such as a Column changes it's number of children would work too.
There was a widget specifically made for this case. It's called:
SizeChangedLayoutNotifier (https://api.flutter.dev/flutter/widgets/SizeChangedLayoutNotifier-class.html)
You just have to wrap your widget with it and then listen with the NotificationListener widget (https://api.flutter.dev/flutter/widgets/NotificationListener-class.html) for changes.
An Example would be following:
NotificationListener(
onNotification: (SizeChangedLayoutNotification notification){
Future.delayed(Duration(milliseconds: 300),(){setState(() {
print('size changed');
_height++;
});});
return true;
},
child: SizeChangedLayoutNotifier( child: AnimatedContainer(width: 100, height: _height)))
Hope this will help all future people which will find this post.
I believe the last line of your question provides a hint as to what you're trying to do. It sounds like you're displaying a list of things, and you want something to be notified when that list of things changes. If I'm wrong about that, please clarify =).
There are two ways of doing this; one is that you could pass a callback function to the widget containing the list. When you added something to the list you could simply call the callback.
However, that is a little bit fragile and if you have multiple layers in between the place you need to know and the actual list it could get messy.
This is due in part to the fact that in flutter, for the most part, data goes downwards (through children) much easier than it goes up. It sounds like what you might want to do is have a parent widget that holds the list of items, and passes that down to whatever builds the actual list. If there are multiple layers of widgets between the parent and the child, you could use an InheritedWidget to get the information from the child without directly passing it.
EDIT: with clarification from the OP, this answer only provided an sub-optimal alternative to the original goal. See below for an answer to the main query:
I don't think that it is possible to do this with any existing flutter widgets. However, because flutter is open-source it's entirely possible to simply create your own widget based on the flutter one that does do what you need. You just need to dig into the source code a bit.
Please note that the code I'm pasting below contains a slightly modified version of the flutter implementation in rendering animated_size.dart and widgets animated_size.dart, and therefore usage of it must adhere to the flutter LICENSE file at the time of copying. Use of the code is governed by BSD style license, yada yada.
I've created a very slightly modified version of the AnimatedSize widget called NotifyingAnimatedSize (and the corresponding more-interesting NotifyingRenderAnimatedSize) in the code below, which simply calls a callback when it starts animated and when it's done animating. I've removed all of the comments from the source code as they made it even longer.
Look for notificationCallback throughout the code as that's basically all I added.
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
void main() => runApp(new MyApp());
enum NotifyingRenderAnimatedSizeState {
start,
stable,
changed,
unstable,
}
enum SizeChangingStatus {
changing,
done,
}
typedef void NotifyingAnimatedSizeCallback(SizeChangingStatus status);
class NotifyingRenderAnimatedSize extends RenderAligningShiftedBox {
NotifyingRenderAnimatedSize({
#required TickerProvider vsync,
#required Duration duration,
Curve curve: Curves.linear,
AlignmentGeometry alignment: Alignment.center,
TextDirection textDirection,
RenderBox child,
this.notificationCallback
}) : assert(vsync != null),
assert(duration != null),
assert(curve != null),
_vsync = vsync,
super(child: child, alignment: alignment, textDirection: textDirection) {
_controller = new AnimationController(
vsync: vsync,
duration: duration,
)..addListener(() {
if (_controller.value != _lastValue) markNeedsLayout();
});
_animation = new CurvedAnimation(parent: _controller, curve: curve);
}
AnimationController _controller;
CurvedAnimation _animation;
final SizeTween _sizeTween = new SizeTween();
bool _hasVisualOverflow;
double _lastValue;
final NotifyingAnimatedSizeCallback notificationCallback;
#visibleForTesting
NotifyingRenderAnimatedSizeState get state => _state;
NotifyingRenderAnimatedSizeState _state = NotifyingRenderAnimatedSizeState.start;
Duration get duration => _controller.duration;
set duration(Duration value) {
assert(value != null);
if (value == _controller.duration) return;
_controller.duration = value;
}
Curve get curve => _animation.curve;
set curve(Curve value) {
assert(value != null);
if (value == _animation.curve) return;
_animation.curve = value;
}
bool get isAnimating => _controller.isAnimating;
TickerProvider get vsync => _vsync;
TickerProvider _vsync;
set vsync(TickerProvider value) {
assert(value != null);
if (value == _vsync) return;
_vsync = value;
_controller.resync(vsync);
}
#override
void detach() {
_controller.stop();
super.detach();
}
Size get _animatedSize {
return _sizeTween.evaluate(_animation);
}
#override
void performLayout() {
_lastValue = _controller.value;
_hasVisualOverflow = false;
if (child == null || constraints.isTight) {
_controller.stop();
size = _sizeTween.begin = _sizeTween.end = constraints.smallest;
_state = NotifyingRenderAnimatedSizeState.start;
child?.layout(constraints);
return;
}
child.layout(constraints, parentUsesSize: true);
assert(_state != null);
switch (_state) {
case NotifyingRenderAnimatedSizeState.start:
_layoutStart();
break;
case NotifyingRenderAnimatedSizeState.stable:
_layoutStable();
break;
case NotifyingRenderAnimatedSizeState.changed:
_layoutChanged();
break;
case NotifyingRenderAnimatedSizeState.unstable:
_layoutUnstable();
break;
}
size = constraints.constrain(_animatedSize);
alignChild();
if (size.width < _sizeTween.end.width || size.height < _sizeTween.end.height) _hasVisualOverflow = true;
}
void _restartAnimation() {
_lastValue = 0.0;
_controller.forward(from: 0.0);
}
void _layoutStart() {
_sizeTween.begin = _sizeTween.end = debugAdoptSize(child.size);
_state = NotifyingRenderAnimatedSizeState.stable;
}
void _layoutStable() {
if (_sizeTween.end != child.size) {
_sizeTween.begin = size;
_sizeTween.end = debugAdoptSize(child.size);
_restartAnimation();
_state = NotifyingRenderAnimatedSizeState.changed;
} else if (_controller.value == _controller.upperBound) {
// Animation finished. Reset target sizes.
_sizeTween.begin = _sizeTween.end = debugAdoptSize(child.size);
notificationCallback(SizeChangingStatus.done);
} else if (!_controller.isAnimating) {
_controller.forward(); // resume the animation after being detached
}
}
void _layoutChanged() {
if (_sizeTween.end != child.size) {
// Child size changed again. Match the child's size and restart animation.
_sizeTween.begin = _sizeTween.end = debugAdoptSize(child.size);
_restartAnimation();
_state = NotifyingRenderAnimatedSizeState.unstable;
} else {
notificationCallback(SizeChangingStatus.changing);
// Child size stabilized.
_state = NotifyingRenderAnimatedSizeState.stable;
if (!_controller.isAnimating) _controller.forward(); // resume the animation after being detached
}
}
void _layoutUnstable() {
if (_sizeTween.end != child.size) {
// Still unstable. Continue tracking the child.
_sizeTween.begin = _sizeTween.end = debugAdoptSize(child.size);
_restartAnimation();
} else {
// Child size stabilized.
_controller.stop();
_state = NotifyingRenderAnimatedSizeState.stable;
}
}
#override
void paint(PaintingContext context, Offset offset) {
if (child != null && _hasVisualOverflow) {
final Rect rect = Offset.zero & size;
context.pushClipRect(needsCompositing, offset, rect, super.paint);
} else {
super.paint(context, offset);
}
}
}
class NotifyingAnimatedSize extends SingleChildRenderObjectWidget {
const NotifyingAnimatedSize({
Key key,
Widget child,
this.alignment: Alignment.center,
this.curve: Curves.linear,
#required this.duration,
#required this.vsync,
this.notificationCallback,
}) : super(key: key, child: child);
final AlignmentGeometry alignment;
final Curve curve;
final Duration duration;
final TickerProvider vsync;
final NotifyingAnimatedSizeCallback notificationCallback;
#override
NotifyingRenderAnimatedSize createRenderObject(BuildContext context) {
return new NotifyingRenderAnimatedSize(
alignment: alignment,
duration: duration,
curve: curve,
vsync: vsync,
textDirection: Directionality.of(context),
notificationCallback: notificationCallback
);
}
#override
void updateRenderObject(BuildContext context, NotifyingRenderAnimatedSize renderObject) {
renderObject
..alignment = alignment
..duration = duration
..curve = curve
..vsync = vsync
..textDirection = Directionality.of(context);
}
}
class MyApp extends StatefulWidget {
#override
State<StatefulWidget> createState() => MyAppState();
}
class MyAppState extends State<MyApp> with TickerProviderStateMixin<MyApp> {
double _containerSize = 100.0;
#override
Widget build(BuildContext context) {
return new MaterialApp(
home: new SafeArea(
child: new Container(
color: Colors.white,
child: new Column(children: [
new RaisedButton(
child: new Text("Press me to make the square change size!"),
onPressed: () => setState(
() {
if (_containerSize > 299.0)
_containerSize = 100.0;
else
_containerSize += 100.0;
},
),
),
new NotifyingAnimatedSize(
duration: new Duration(seconds: 2),
vsync: this,
child: new Container(
color: Colors.blue,
width: _containerSize,
height: _containerSize,
),
notificationCallback: (state) {
print("State is $state");
},
)
]),
),
),
);
}
}
This is not possible. Widgets have no clue about the size of their children. The only thing they do is apply constraints on them, but that's unrelated to the final size.
Here I repost rmtmckenzie's (credits to him) answer but with null safety. I decided not to edit his answer to offer with his and mine both answers with and without null safety. You can just use in your code the NotifyingAnimatedSize.
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
enum SizeChangingStatus {
changing,
done,
}
enum NotifyingRenderAnimatedSizeState {
start,
stable,
changed,
unstable,
}
typedef NotifyingAnimatedSizeCallback = void Function(SizeChangingStatus status);
class NotifyingRenderAnimatedSize extends RenderAligningShiftedBox {
NotifyingRenderAnimatedSize({
required TickerProvider vsync,
required Duration duration,
Duration? reverseDuration,
Curve curve = Curves.linear,
AlignmentGeometry alignment = Alignment.center,
required TextDirection textDirection,
RenderBox? child,
Clip clipBehavior = Clip.hardEdge,
required this.notificationCallback,
})
: _vsync = vsync,
_clipBehavior = clipBehavior,
super(textDirection: textDirection, alignment: alignment, child: child) {
_controller = AnimationController(
vsync: vsync,
duration: duration,
reverseDuration: reverseDuration,
)
..addListener(() {
if (_controller.value != _lastValue) {
markNeedsLayout();
}
});
_animation = CurvedAnimation(
parent: _controller,
curve: curve,
);
}
late final AnimationController _controller;
late final CurvedAnimation _animation;
final SizeTween _sizeTween = SizeTween();
late bool _hasVisualOverflow;
double? _lastValue;
final NotifyingAnimatedSizeCallback notificationCallback;
/// The state this size animation is in.
///
/// See [RenderAnimatedSizeState] for possible states.
#visibleForTesting
NotifyingRenderAnimatedSizeState get state => _state;
NotifyingRenderAnimatedSizeState _state = NotifyingRenderAnimatedSizeState.start;
/// The duration of the animation.
Duration get duration => _controller.duration!;
set duration(Duration value) {
if (value == _controller.duration) {
return;
}
_controller.duration = value;
}
/// The duration of the animation when running in reverse.
Duration? get reverseDuration => _controller.reverseDuration;
set reverseDuration(Duration? value) {
if (value == _controller.reverseDuration) {
return;
}
_controller.reverseDuration = value;
}
/// The curve of the animation.
Curve get curve => _animation.curve;
set curve(Curve value) {
if (value == _animation.curve) {
return;
}
_animation.curve = value;
}
/// {#macro flutter.material.Material.clipBehavior}
///
/// Defaults to [Clip.hardEdge], and must not be null.
Clip get clipBehavior => _clipBehavior;
Clip _clipBehavior = Clip.hardEdge;
set clipBehavior(Clip value) {
if (value != _clipBehavior) {
_clipBehavior = value;
markNeedsPaint();
markNeedsSemanticsUpdate();
}
}
/// Whether the size is being currently animated towards the child's size.
///
/// See [RenderAnimatedSizeState] for situations when we may not be animating
/// the size.
bool get isAnimating => _controller.isAnimating;
/// The [TickerProvider] for the [AnimationController] that runs the animation.
TickerProvider get vsync => _vsync;
TickerProvider _vsync;
set vsync(TickerProvider value) {
if (value == _vsync) {
return;
}
_vsync = value;
_controller.resync(vsync);
}
#override
void attach(PipelineOwner owner) {
super.attach(owner);
switch (state) {
case NotifyingRenderAnimatedSizeState.start:
case NotifyingRenderAnimatedSizeState.stable:
break;
case NotifyingRenderAnimatedSizeState.changed:
case NotifyingRenderAnimatedSizeState.unstable:
// Call markNeedsLayout in case the RenderObject isn't marked dirty
// already, to resume interrupted resizing animation.
markNeedsLayout();
break;
}
}
#override
void detach() {
_controller.stop();
super.detach();
}
Size? get _animatedSize => _sizeTween.evaluate(_animation);
#override
void performLayout() {
_lastValue = _controller.value;
_hasVisualOverflow = false;
final BoxConstraints constraints = this.constraints;
if (child == null || constraints.isTight) {
_controller.stop();
size = _sizeTween.begin = _sizeTween.end = constraints.smallest;
_state = NotifyingRenderAnimatedSizeState.start;
child?.layout(constraints);
return;
}
child!.layout(constraints, parentUsesSize: true);
switch (_state) {
case NotifyingRenderAnimatedSizeState.start:
_layoutStart();
break;
case NotifyingRenderAnimatedSizeState.stable:
_layoutStable();
break;
case NotifyingRenderAnimatedSizeState.changed:
_layoutChanged();
break;
case NotifyingRenderAnimatedSizeState.unstable:
_layoutUnstable();
break;
}
size = constraints.constrain(_animatedSize!);
alignChild();
if (size.width < _sizeTween.end!.width || size.height < _sizeTween.end!.height) {
_hasVisualOverflow = true;
}
}
#override
Size computeDryLayout(BoxConstraints constraints) {
if (child == null || constraints.isTight) {
return constraints.smallest;
}
// This simplified version of performLayout only calculates the current
// size without modifying global state. See performLayout for comments
// explaining the rational behind the implementation.
final Size childSize = child!.getDryLayout(constraints);
switch (_state) {
case NotifyingRenderAnimatedSizeState.start:
return constraints.constrain(childSize);
case NotifyingRenderAnimatedSizeState.stable:
if (_sizeTween.end != childSize) {
return constraints.constrain(size);
} else if (_controller.value == _controller.upperBound) {
return constraints.constrain(childSize);
}
break;
case NotifyingRenderAnimatedSizeState.unstable:
case NotifyingRenderAnimatedSizeState.changed:
if (_sizeTween.end != childSize) {
return constraints.constrain(childSize);
}
break;
}
return constraints.constrain(_animatedSize!);
}
void _restartAnimation() {
_lastValue = 0.0;
_controller.forward(from: 0.0);
}
/// Laying out the child for the first time.
///
/// We have the initial size to animate from, but we do not have the target
/// size to animate to, so we set both ends to child's size.
void _layoutStart() {
_sizeTween.begin = _sizeTween.end = debugAdoptSize(child!.size);
_state = NotifyingRenderAnimatedSizeState.stable;
}
/// At this state we're assuming the child size is stable and letting the
/// animation run its course.
///
/// If during animation the size of the child changes we restart the
/// animation.
void _layoutStable() {
if (_sizeTween.end != child!.size) {
_sizeTween.begin = size;
_sizeTween.end = debugAdoptSize(child!.size);
_restartAnimation();
_state = NotifyingRenderAnimatedSizeState.changed;
} else if (_controller.value == _controller.upperBound) {
// Animation finished. Reset target sizes.
_sizeTween.begin = _sizeTween.end = debugAdoptSize(child!.size);
notificationCallback(SizeChangingStatus.done);
} else if (!_controller.isAnimating) {
_controller.forward(); // resume the animation after being detached
}
}
/// This state indicates that the size of the child changed once after being
/// considered stable.
///
/// If the child stabilizes immediately, we go back to stable state. If it
/// changes again, we match the child's size, restart animation and go to
/// unstable state.
void _layoutChanged() {
if (_sizeTween.end != child!.size) {
// Child size changed again. Match the child's size and restart animation.
_sizeTween.begin = _sizeTween.end = debugAdoptSize(child!.size);
_restartAnimation();
_state = NotifyingRenderAnimatedSizeState.unstable;
} else {
notificationCallback(SizeChangingStatus.changing);
// Child size stabilized.
_state = NotifyingRenderAnimatedSizeState.stable;
if (!_controller.isAnimating) {
// Resume the animation after being detached.
_controller.forward();
}
}
}
/// The child's size is not stable.
///
/// Continue tracking the child's size until is stabilizes.
void _layoutUnstable() {
if (_sizeTween.end != child!.size) {
// Still unstable. Continue tracking the child.
_sizeTween.begin = _sizeTween.end = debugAdoptSize(child!.size);
_restartAnimation();
} else {
// Child size stabilized.
_controller.stop();
_state = NotifyingRenderAnimatedSizeState.stable;
}
}
#override
void paint(PaintingContext context, Offset offset) {
if (child != null && _hasVisualOverflow && clipBehavior != Clip.none) {
final Rect rect = Offset.zero & size;
_clipRectLayer.layer = context.pushClipRect(
needsCompositing,
offset,
rect,
super.paint,
clipBehavior: clipBehavior,
oldLayer: _clipRectLayer.layer,
);
} else {
_clipRectLayer.layer = null;
super.paint(context, offset);
}
}
final LayerHandle<ClipRectLayer> _clipRectLayer = LayerHandle<ClipRectLayer>();
#override
void dispose() {
_clipRectLayer.layer = null;
super.dispose();
}
}
class NotifyingAnimatedSize extends StatefulWidget {
/// Creates a widget that animates its size to match that of its child.
///
/// The [curve] and [duration] arguments must not be null.
const NotifyingAnimatedSize({
required this.child,
this.alignment = Alignment.center,
this.curve = Curves.linear,
required this.duration,
this.reverseDuration,
required this.notificationCallback,
this.clipBehavior = Clip.hardEdge,
});
/// The widget below this widget in the tree.
///
/// {#macro flutter.widgets.ProxyWidget.child}
final Widget child;
/// The alignment of the child within the parent when the parent is not yet
/// the same size as the child.
///
/// The x and y values of the alignment control the horizontal and vertical
/// alignment, respectively. An x value of -1.0 means that the left edge of
/// the child is aligned with the left edge of the parent whereas an x value
/// of 1.0 means that the right edge of the child is aligned with the right
/// edge of the parent. Other values interpolate (and extrapolate) linearly.
/// For example, a value of 0.0 means that the center of the child is aligned
/// with the center of the parent.
///
/// Defaults to [Alignment.center].
///
/// See also:
///
/// * [Alignment], a class with convenient constants typically used to
/// specify an [AlignmentGeometry].
/// * [AlignmentDirectional], like [Alignment] for specifying alignments
/// relative to text direction.
final AlignmentGeometry alignment;
/// The animation curve when transitioning this widget's size to match the
/// child's size.
final Curve curve;
/// The duration when transitioning this widget's size to match the child's
/// size.
final Duration duration;
/// The duration when transitioning this widget's size to match the child's
/// size when going in reverse.
///
/// If not specified, defaults to [duration].
final Duration? reverseDuration;
/// {#macro flutter.material.Material.clipBehavior}
///
/// Defaults to [Clip.hardEdge], and must not be null.
final Clip clipBehavior;
/// Callback to trigger when animation ends
final NotifyingAnimatedSizeCallback notificationCallback;
#override
State<NotifyingAnimatedSize> createState() => _NotifyingAnimatedSizeState();
}
class _NotifyingAnimatedSizeState extends State<NotifyingAnimatedSize> with SingleTickerProviderStateMixin {
#override
Widget build(BuildContext context) =>
_NotifyingAnimatedSize(
alignment: widget.alignment,
curve: widget.curve,
duration: widget.duration,
vsync: this,
notificationCallback: widget.notificationCallback,
child: widget.child,
);
}
class _NotifyingAnimatedSize extends SingleChildRenderObjectWidget {
const _NotifyingAnimatedSize({
Key? key,
required Widget child,
this.alignment = Alignment.center,
this.curve = Curves.linear,
required this.duration,
required this.vsync,
required this.notificationCallback,
}) : super(key: key, child: child);
final AlignmentGeometry alignment;
final Curve curve;
final Duration duration;
final TickerProvider vsync;
final NotifyingAnimatedSizeCallback notificationCallback;
#override
NotifyingRenderAnimatedSize createRenderObject(BuildContext context) =>
NotifyingRenderAnimatedSize(
alignment: alignment,
duration: duration,
curve: curve,
vsync: vsync,
textDirection: Directionality.of(context),
notificationCallback: notificationCallback);
#override
void updateRenderObject(BuildContext context, NotifyingRenderAnimatedSize renderObject) {
renderObject
..alignment = alignment
..duration = duration
..curve = curve
..vsync = vsync
..textDirection = Directionality.of(context);
}
}
User the widget like so:
NotifyingAnimatedSize(
duration: const Duration(milliseconds: 200),
notificationCallback: (status) {
if (status == SizeChangingStatus.done) {
//do something
}
},
child: Container(height: 50, width: 50, color: Colors.red),
);