How to animate a widget by non-linear path? - flutter

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

Related

Creating a Proper Semi Circular Slider Widget in Flutter

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;
}
}

From flame 0.29.0 to flame 1.0.0 and from box2d_flame: ^0.4.6 to flame_forge2d 0.11.0

I'm Migrating to flame 1.1.1 from flame v0.29.4 and I can't find a good roadmap.
How can I effectively replace Box2DComponent ? Attributes like components and viewport for example, i can't understand where and how to replace them.
Is correct replacing BaseGame with Flame game ? And would it be correct replacing Box2DComponent with Forge2DGame ?
under here my 3 classes. I knwo it's a difficult question but I could really use some help.
Thank you
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:artista_app/features/tastes/model/presentation/tastes_vm.dart';
import 'package:box2d_flame/box2d.dart';
import 'package:flame/box2d/box2d_component.dart';
import 'package:flame/box2d/viewport.dart' as box2d_viewport;
import 'package:flame/components/mixins/tapable.dart';
import 'package:flame/game/base_game.dart';
import 'package:flame/gestures.dart';
import 'package:flame/text_config.dart';
import 'package:flutter/material.dart';
class BubblePicker extends BaseGame with TapDetector {
PickerWorld _pickerWorld;
#override
ui.Color backgroundColor() {
return Colors.transparent;
}
final void Function(TastesVM) onTastesChange;
BubblePicker(TastesVM tastes, {this.onTastesChange}) : super() {
_pickerWorld = PickerWorld(tastes);
_pickerWorld.initializeWorld();
onTastesChange?.call(TastesVM(
tastes: (tastes.tastes.where((taste) => taste.checked).toList())));
}
#override
void onTapUp(TapUpDetails details) {
_pickerWorld.handleTap(details);
onTastesChange?.call(TastesVM(tastes: _pickerWorld.checkedTastes));
super.onTapUp(details);
}
#override
bool debugMode() => true;
#override
void render(Canvas canvas) {
super.render(canvas);
_pickerWorld.render(canvas);
}
#override
void resize(Size size) {
super.resize(size);
_pickerWorld.resize(size);
}
#override
void update(double t) {
super.update(t);
_pickerWorld.update(t);
}
}
class PickerWorld extends Box2DComponent {
final TastesVM tastes;
PickerWorld(this.tastes) : super(gravity: 0);
#override
void initializeWorld() {}
#override
void render(Canvas canvas) {
super.render(canvas);
}
Offset screenOffsetToWorldOffset(Offset position) {
return Offset(position.dx - (viewport.size.width / 2),
position.dy - (viewport.size.height / 2));
}
List<TasteVM> get checkedTastes => [
for (final component in components)
if (component is Ball && component.checked) component.model
];
void handleTap(TapUpDetails details) {
for (final component in components) {
if (component is Ball) {
final worldOffset = screenOffsetToWorldOffset(details.localPosition);
if (component.checkTapOverlap(worldOffset)) {
component.onTapUp(details);
}
}
}
}
#override
void resize(Size size) {
dimensions = Size(size.width, size.height);
viewport = box2d_viewport.Viewport(size, 1);
if (components.isEmpty) {
var tastesList = tastes.tastes;
tastesList.forEach((element) {
var ballPosOffset = Vector2(
math.Random().nextDouble() - 0.5, math.Random().nextDouble() - 0.5);
var x = ballPosOffset.x * 150;
var y = ballPosOffset.y * 150;
add(Ball(Vector2.array([x, y]), this, element));
});
}
}
}
class Ball extends BodyComponent with Tapable {
static const transitionSeconds = 0.5;
var transforming = false;
var kNormalRadius;
static const kExpandedRadius = 50.0;
var currentRadius;
var lastTapStamp = DateTime.utc(0);
final TasteVM model;
final TextConfig smallTextConfig = TextConfig(
fontSize: 12.0,
fontFamily: 'Arial',
color: Colors.white,
textAlign: TextAlign.center,
);
final TextConfig bigTextConfig = TextConfig(
fontSize: 24.0,
fontFamily: 'Arial',
color: Colors.white,
textAlign: TextAlign.center,
);
Size screenSize;
ui.Image ballImage;
bool get checked => model.checked;
Ball(
Vector2 position,
Box2DComponent box2dComponent,
this.model,
) : super(box2dComponent) {
ballImage = model.tasteimageResource;
final shape = CircleShape();
kNormalRadius = model.initialRadius;
currentRadius = (model.checked) ? kExpandedRadius : model.initialRadius;
shape.radius = currentRadius;
shape.p.x = 0.0;
// checked = model.checked;
final fixtureDef = FixtureDef();
fixtureDef.shape = shape;
fixtureDef.restitution = 0.1;
fixtureDef.density = 1;
fixtureDef.friction = 1;
fixtureDef.userData = model;
final bodyDef = BodyDef();
bodyDef.linearVelocity = Vector2(0.0, 0.0);
bodyDef.position = position;
bodyDef.type = BodyType.DYNAMIC;
bodyDef.userData = model;
body = world.createBody(bodyDef)..createFixtureFromFixtureDef(fixtureDef);
}
#override
void renderCircle(Canvas canvas, Offset center, double radius) async {
final rectFromCircle = Rect.fromCircle(center: center, radius: radius);
final ballDiameter = radius * 2;
if (ballImage == null) {
return;
}
final image = checked ? ballImage : null;
final paint = Paint()..color = const Color.fromARGB(255, 101, 101, 101);
final elapsed =
DateTime.now().difference(lastTapStamp).inMicroseconds / 1000000;
final transforming = elapsed < transitionSeconds;
if (transforming) {
_resizeBall(elapsed);
}
canvas.drawCircle(center, radius, paint);
if (image != null) {
//from: https://stackoverflow.com/questions/60468768/masking-two-images-in-flutter-using-a-custom-painter/60470034#60470034
canvas.saveLayer(rectFromCircle, Paint());
//draw the mask
canvas.drawCircle(
center,
radius,
Paint()..color = Colors.black,
);
//fit the image into the ball size
final inputSize = Size(image.width.toDouble(), image.height.toDouble());
final fittedSizes = applyBoxFit(
BoxFit.cover,
inputSize,
Size(ballDiameter, ballDiameter),
);
final sourceSize = fittedSizes.source;
final sourceRect =
Alignment.center.inscribe(sourceSize, Offset.zero & inputSize);
canvas.drawImageRect(
image,
sourceRect,
rectFromCircle,
Paint()..blendMode = BlendMode.srcIn,
);
canvas.restore();
}
final span = TextSpan(
style: TextStyle(color: Colors.white, fontSize: 10),
text: model.tasteDisplayName);
final tp = TextPainter(
text: span,
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
tp.layout(minWidth: ballDiameter, maxWidth: ballDiameter);
tp.paint(canvas, Offset(center.dx - radius, center.dy - (tp.height / 2)));
}
#override
void update(double t) {
final center = Vector2.copy(box.world.center);
final ball = body.position;
center.sub(ball);
var distance = center.distanceTo(ball);
body.applyForceToCenter(center..scale(1000000 / (distance)));
}
#override
ui.Rect toRect() {
var rect = Rect.fromCircle(
center: Offset(body.position.x, -body.position.y),
radius: currentRadius,
);
return rect;
}
#override
void onTapUp(TapUpDetails details) {
lastTapStamp = DateTime.now();
model.checked = !checked;
if (checked) {
currentRadius = kExpandedRadius;
} else {
currentRadius = kNormalRadius;
}
}
void _resizeBall(elapsed) {
var progress = elapsed / transitionSeconds;
final fixture = body.getFixtureList();
var sourceRadius = (checked) ? kNormalRadius : kExpandedRadius;
var targetRadius = (checked) ? kExpandedRadius : kNormalRadius;
var progressRad = ui.lerpDouble(0, math.pi / 2, progress);
var nonLinearProgress = math.sin(progressRad);
var actualRadius =
ui.lerpDouble(sourceRadius, targetRadius, nonLinearProgress);
fixture.getShape().radius = actualRadius;
}
}
This issue is a bit too wide for StackOverflow, but I'll try to answer it as well as I can.
To use Forge2D (previously box2d.dart) in Flame you have to add flame_forge2d as a dependency. From flame_forge2d you will get a Forge2DGame that you should use instead of FlameGame (and instead of the ancient BaseGame class that you are using).
After that you extend BodyComponents for each body that you want to add to your Forge2DGame.
class Ball extends BodyComponent {
final double radius;
final Vector2 _position;
Ball(this._position, {this.radius = 2});
#override
Body createBody() {
final shape = CircleShape();
shape.radius = radius;
final fixtureDef = FixtureDef(
shape,
restitution: 0.8,
density: 1.0,
friction: 0.4,
);
final bodyDef = BodyDef(
userData: this,
angularDamping: 0.8,
position: _position,
type: BodyType.dynamic,
);
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
}
In the createBody() method you have to create the Forge2D body, in this case a circle is created. If you don't want it to render the circle directly you can set renderBody = false. To render something else on top of the BodyComponent you either override the render method, or you add a normal Flame component as a child to it, for example a SpriteComponent or SpriteAnimationComponent.
To add a child, simply call add in the onLoad method (or in another fitting place):
class Ball extends BodyComponent {
...
#override
Future<void> onLoad() async {
await super.onLoad();
add(SpriteComponent(...));
}
...
}
Since you are using the Tappable mixin, you should also add the HasTappables mixin to your Forge2D game class.
You can find some examples here:
https://examples.flame-engine.org/#/flame_forge2d_Blob%20example
(press the < > in the upper right corner to get to the code).

Flutter Rotate CupertinoPicker

Is there any way to rotate the CupertinoPicker in Flutter by 90 degrees? So that you can pick horizontally and not vertically. Transform.rotate is not an option because then the width of the Picker is limited to the height of the parent widget. Or is there any good way to force the cupertino picker to be bigger than its parent widget?
How about the RotatedBox widget?
RotatedBox(
quarterTurns: 1,
child: CupertinoPicker(...),
)
Unlike Transform, which applies a transform just prior to painting, this object applies its rotation prior to layout, which means the entire rotated box consumes only as much space as required by the rotated child.
So I found 2 solutions. First you can use a RotatedBox. Thx to josxha for that idea. 2. solution: Make a complete custom picker. So if anyone has the same problem you can use my custom picker. The code is a total mess please dont judge lmao.
class CustomPicker extends StatefulWidget {
CustomPicker(
{#required double this.width,
#required double this.height,
#required double this.containerWidth,
#required double this.containerHeight,
#required double this.gapScaleFactor,
#required List<Widget> this.childrenW,
Function(int) this.onSnap});
double width;
double height;
double containerWidth;
double containerHeight;
double gapScaleFactor;
List<Widget> childrenW;
Function(int) onSnap;
_CustomPicker createState() => _CustomPicker(width, height, containerWidth,
containerHeight, gapScaleFactor, childrenW, onSnap);
}
class _CustomPicker extends State<CustomPicker>
with SingleTickerProviderStateMixin {
AnimationController controller;
double width;
double height;
double containerWidth;
double containerHeight;
double gapScaleFactor;
double currentScrollX = 0;
double oldAnimScrollX = 0;
double animDistance = 0;
int currentSnap = 0;
List<Widget> childrenW;
List<Positioned> scrollableContainer = [];
final Function(int) onSnap;
int currentPos;
_CustomPicker(
double this.width,
double this.height,
double this.containerWidth,
double this.containerHeight,
double this.gapScaleFactor,
List<Widget> this.childrenW,
Function(int) this.onSnap) {
initController();
init();
}
void initController() {
controller = AnimationController(
vsync: this,
duration: Duration(milliseconds: 200),
lowerBound: 0,
upperBound: 1,
)..addListener(() {
setState(() {
currentScrollX = oldAnimScrollX + controller.value * animDistance;
init();
});
});
}
void init() {
scrollableContainer.clear();
if (currentScrollX < 0) {
currentScrollX = 0;
}
double scrollableLength =
(containerWidth + containerWidth * gapScaleFactor) *
(childrenW.length) -
containerWidth * gapScaleFactor;
if (currentScrollX > scrollableLength - containerWidth) {
currentScrollX = scrollableLength - containerWidth;
}
for (int i = 0; i < childrenW.length; i++) {
double leftPos = width / 2 -
containerWidth / 2 -
currentScrollX +
containerWidth * i +
containerWidth * gapScaleFactor * i;
double mid = width / 2 - containerWidth / 2;
double topPos = containerHeight *
0.9 *
((leftPos - mid).abs() / scrollableLength) /
2;
scrollableContainer.add(Positioned(
//calculate X position
left: leftPos,
top: topPos,
child: Container(
height: containerHeight -
containerHeight *
0.9 *
((leftPos - mid).abs() / scrollableLength),
width: containerWidth -
containerWidth *
0.9 *
((leftPos - mid).abs() / scrollableLength),
child: childrenW[i],
)));
}
}
void lookForSnappoint() {
double distance = 1000000;
double animVal = 0;
int index = -2032;
for (int i = 0; i < scrollableContainer.length; i++) {
double snappoint = width / 2 - containerWidth / 2;
double currentLeftPos = width / 2 -
containerWidth / 2 -
currentScrollX +
containerWidth * i +
containerWidth * gapScaleFactor * i;
if ((currentLeftPos - snappoint).abs() < distance) {
distance = (currentLeftPos - snappoint).abs();
animVal = currentLeftPos - snappoint;
index = i;
}
}
animDistance = animVal;
oldAnimScrollX = currentScrollX;
controller.reset();
controller.forward();
this.onSnap(index);
}
#override
Widget build(BuildContext context) {
return Container(
width: widget.width,
height: widget.height,
child: GestureDetector(
onPanUpdate: (DragUpdateDetails dragUpdateDetails) {
setState(() {
this.currentScrollX -= dragUpdateDetails.delta.dx;
init();
});
},
onPanEnd: (DragEndDetails dragEndDetails) {
lookForSnappoint();
},
behavior: HitTestBehavior.translucent,
child: LayoutBuilder(builder: (context, constraint) {
return Container(
child: Stack(
children: <Widget>[
Stack(children: scrollableContainer),
],
),
);
}),
),
);
}
}

Slider with continuous track which snaps to a specific value

I want to create the custom Slider from the deign below with a continuous track which would snap to a specific value which is shown below as a black rectangle.
I was able to recreate the custom Slider by setting defining custom trackShape and thumbShape.
I'm not sure how to snap the thumb when it's very close to the small black rectangle and how to make the black rectangle clickable.
The behavior is very similar to the Slider with discrete divisions but it should be continuous and the small black rectangle must be clickable.
I ended up implementing it by myself with some inspiration from https://github.com/tomwyr/step-slider:
class SnapSlider extends StatefulWidget {
SnapSlider({
Key key,
this.sliderKey,
this.snapValues = const {},
this.value,
this.onSnapValueChanged,
this.snapDistance = 0.05,
this.animCurve: Curves.fastOutSlowIn,
this.animDuration: const Duration(milliseconds: 350),
this.min: 0.0,
this.max: 1.0,
this.label,
this.divisions,
this.onChanged,
this.onChangeEnd,
this.onChangeStart,
this.activeColor,
this.inactiveColor,
this.semanticFormatterCallback,
}) : assert(snapValues != null),
assert(snapValues.every((it) => it >= min && it <= max),
'Each snap value needs to be within slider values range.'),
super(key: key);
final Key sliderKey;
final Set<double> snapValues;
final double value;
final ValueChanged<double> onSnapValueChanged;
final double snapDistance;
final Curve animCurve;
final Duration animDuration;
final double min;
final double max;
final String label;
final int divisions;
final Color activeColor;
final Color inactiveColor;
final ValueChanged<double> onChanged;
final ValueChanged<double> onChangeEnd;
final ValueChanged<double> onChangeStart;
final SemanticFormatterCallback semanticFormatterCallback;
#override
_StepSliderState createState() => _StepSliderState();
}
class _StepSliderState extends State<SnapSlider>
with SingleTickerProviderStateMixin {
AnimationController _animator;
CurvedAnimation _baseAnim;
Animation<double> _animation;
double _lastSnapValue;
#override
void didUpdateWidget(SnapSlider oldWidget) {
super.didUpdateWidget(oldWidget);
_animator.duration = widget.animDuration;
_baseAnim.curve = widget.animCurve;
}
#override
void initState() {
super.initState();
_animator = AnimationController(
vsync: this, duration: widget.animDuration, value: 1.0);
_baseAnim = CurvedAnimation(parent: _animator, curve: widget.animCurve);
_recreateAnimation(widget.value, widget.value);
_animation.addListener(() {
_onSliderChanged(_animation.value);
widget.onChanged?.call(_animation.value);
});
}
#override
void dispose() {
_animator.dispose();
super.dispose();
}
#override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animator,
builder: (_, __) => Slider(
key: widget.sliderKey,
min: widget.min,
max: widget.max,
label: widget.label,
divisions: widget.divisions,
activeColor: widget.activeColor,
inactiveColor: widget.inactiveColor,
semanticFormatterCallback: widget.semanticFormatterCallback,
value: widget.value,
onChangeStart: (it) {
_animator.stop();
_onSliderChangeStart(it);
widget.onChangeStart?.call(it);
},
onChangeEnd: (it) {
_onSliderChangeEnd(it);
widget.onChangeEnd?.call(it);
},
onChanged: (it) {
_onSliderChanged(it);
widget.onChanged?.call(it);
},
),
);
}
void _onSliderChangeStart(double value) {
}
void _onSliderChangeEnd(double value) {
double snapValue = _closestSnapValue(value);
var distance = (value - snapValue).abs();
if (snapValue != _lastSnapValue) {
if (distance <= widget.snapDistance) {
_animateTo(widget.value, snapValue, true);
widget.onSnapValueChanged?.call(widget.value);
_lastSnapValue = snapValue;
}
} else {
if (distance > widget.snapDistance) {
_lastSnapValue = null;
}
}
}
double _closestSnapValue(double value) {
return widget.snapValues.reduce((a, b) {
var distanceA = (value - a).abs();
var distanceB = (value - b).abs();
return distanceA < distanceB ? a : b;
});
}
void _onSliderChanged(double value) {
}
void _animateTo(double start, double end, bool restart) {
_recreateAnimation(start, end);
_animator.forward(from: 0.0);
}
void _recreateAnimation(double start, double end) {
_animation = Tween(begin: start ?? end, end: end).animate(_baseAnim);
}
}

Changing speed of InkWell

I am having trouble replicating a normal settings menu in Flutter. I am using an InkWell to try to create the splash effect that normally occurs when you tap on a settings option. The problem is that the splash effect appears way too fast compared to how it normally is. Basically, I just want to slow down the InkWell.
If you would like a slower ripple effect, then you have to change splashFactory property in your MaterialApp theme from InkSplash.splashFactory (default) to InkRipple.splashFactory. InkRipple's splash looks more like native.
It's possible to create what you wanted but it requires a custom splashFactory under InkWell class.
As you see in the variables below, these are meant to be private values and they are not open to modification within classes.
const Duration _kUnconfirmedSplashDuration = const Duration(seconds: 1);
const Duration _kSplashFadeDuration = const Duration(milliseconds: 200);
const double _kSplashInitialSize = 0.0; // logical pixels
const double _kSplashConfirmedVelocity = 1.0;
To answer your question, yes you can do it. I just copied and pasted everything from the source code and change the animation values. After the code below just use it in splashFactory.
///Part to use within application
new InkWell(
onTap: () {},
splashFactory: CustomSplashFactory(),
child: Container(
padding: EdgeInsets.all(12.0),
child: Text('Flat Button'),
),
//Part to copy from the source code.
const Duration _kUnconfirmedSplashDuration = const Duration(seconds: 10);
const Duration _kSplashFadeDuration = const Duration(seconds: 2);
const double _kSplashInitialSize = 0.0; // logical pixels
const double _kSplashConfirmedVelocity = 0.1;
class CustomSplashFactory extends InteractiveInkFeatureFactory {
const CustomSplashFactory();
#override
InteractiveInkFeature create({
#required MaterialInkController controller,
#required RenderBox referenceBox,
#required Offset position,
#required Color color,
bool containedInkWell = false,
RectCallback rectCallback,
BorderRadius borderRadius,
double radius,
VoidCallback onRemoved,
}) {
return new CustomSplash(
controller: controller,
referenceBox: referenceBox,
position: position,
color: color,
containedInkWell: containedInkWell,
rectCallback: rectCallback,
borderRadius: borderRadius,
radius: radius,
onRemoved: onRemoved,
);
}
}
class CustomSplash extends InteractiveInkFeature {
/// Used to specify this type of ink splash for an [InkWell], [InkResponse]
/// or material [Theme].
static const InteractiveInkFeatureFactory splashFactory = const CustomSplashFactory();
/// Begin a splash, centered at position relative to [referenceBox].
///
/// The [controller] argument is typically obtained via
/// `Material.of(context)`.
///
/// If `containedInkWell` is true, then the splash will be sized to fit
/// the well rectangle, then clipped to it when drawn. The well
/// rectangle is the box returned by `rectCallback`, if provided, or
/// otherwise is the bounds of the [referenceBox].
///
/// If `containedInkWell` is false, then `rectCallback` should be null.
/// The ink splash is clipped only to the edges of the [Material].
/// This is the default.
///
/// When the splash is removed, `onRemoved` will be called.
CustomSplash({
#required MaterialInkController controller,
#required RenderBox referenceBox,
Offset position,
Color color,
bool containedInkWell = false,
RectCallback rectCallback,
BorderRadius borderRadius,
double radius,
VoidCallback onRemoved,
}) : _position = position,
_borderRadius = borderRadius ?? BorderRadius.zero,
_targetRadius = radius ?? _getTargetRadius(referenceBox, containedInkWell, rectCallback, position),
_clipCallback = _getClipCallback(referenceBox, containedInkWell, rectCallback),
_repositionToReferenceBox = !containedInkWell,
super(controller: controller, referenceBox: referenceBox, color: color, onRemoved: onRemoved) {
assert(_borderRadius != null);
_radiusController = new AnimationController(duration: _kUnconfirmedSplashDuration, vsync: controller.vsync)
..addListener(controller.markNeedsPaint)
..forward();
_radius = new Tween<double>(
begin: _kSplashInitialSize,
end: _targetRadius
).animate(_radiusController);
_alphaController = new AnimationController(duration: _kSplashFadeDuration, vsync: controller.vsync)
..addListener(controller.markNeedsPaint)
..addStatusListener(_handleAlphaStatusChanged);
_alpha = new IntTween(
begin: color.alpha,
end: 0
).animate(_alphaController);
controller.addInkFeature(this);
}
final Offset _position;
final BorderRadius _borderRadius;
final double _targetRadius;
final RectCallback _clipCallback;
final bool _repositionToReferenceBox;
Animation<double> _radius;
AnimationController _radiusController;
Animation<int> _alpha;
AnimationController _alphaController;
#override
void confirm() {
final int duration = (_targetRadius / _kSplashConfirmedVelocity).floor();
_radiusController
..duration = new Duration(milliseconds: duration)
..forward();
_alphaController.forward();
}
#override
void cancel() {
_alphaController?.forward();
}
void _handleAlphaStatusChanged(AnimationStatus status) {
if (status == AnimationStatus.completed)
dispose();
}
#override
void dispose() {
_radiusController.dispose();
_alphaController.dispose();
_alphaController = null;
super.dispose();
}
RRect _clipRRectFromRect(Rect rect) {
return new RRect.fromRectAndCorners(
rect,
topLeft: _borderRadius.topLeft, topRight: _borderRadius.topRight,
bottomLeft: _borderRadius.bottomLeft, bottomRight: _borderRadius.bottomRight,
);
}
void _clipCanvasWithRect(Canvas canvas, Rect rect, {Offset offset}) {
Rect clipRect = rect;
if (offset != null) {
clipRect = clipRect.shift(offset);
}
if (_borderRadius != BorderRadius.zero) {
canvas.clipRRect(_clipRRectFromRect(clipRect));
} else {
canvas.clipRect(clipRect);
}
}
#override
void paintFeature(Canvas canvas, Matrix4 transform) {
final Paint paint = new Paint()..color = color.withAlpha(_alpha.value);
Offset center = _position;
if (_repositionToReferenceBox)
center = Offset.lerp(center, referenceBox.size.center(Offset.zero), _radiusController.value);
final Offset originOffset = MatrixUtils.getAsTranslation(transform);
if (originOffset == null) {
canvas.save();
canvas.transform(transform.storage);
if (_clipCallback != null) {
_clipCanvasWithRect(canvas, _clipCallback());
}
canvas.drawCircle(center, _radius.value, paint);
canvas.restore();
} else {
if (_clipCallback != null) {
canvas.save();
_clipCanvasWithRect(canvas, _clipCallback(), offset: originOffset);
}
canvas.drawCircle(center + originOffset, _radius.value, paint);
if (_clipCallback != null)
canvas.restore();
}
}
}
double _getTargetRadius(RenderBox referenceBox, bool containedInkWell, RectCallback rectCallback, Offset position) {
if (containedInkWell) {
final Size size = rectCallback != null ? rectCallback().size : referenceBox.size;
return _getSplashRadiusForPositionInSize(size, position);
}
return Material.defaultSplashRadius;
}
double _getSplashRadiusForPositionInSize(Size bounds, Offset position) {
final double d1 = (position - bounds.topLeft(Offset.zero)).distance;
final double d2 = (position - bounds.topRight(Offset.zero)).distance;
final double d3 = (position - bounds.bottomLeft(Offset.zero)).distance;
final double d4 = (position - bounds.bottomRight(Offset.zero)).distance;
return math.max(math.max(d1, d2), math.max(d3, d4)).ceilToDouble();
}
RectCallback _getClipCallback(RenderBox referenceBox, bool containedInkWell, RectCallback rectCallback) {
if (rectCallback != null) {
assert(containedInkWell);
return rectCallback;
}
if (containedInkWell)
return () => Offset.zero & referenceBox.size;
return null;
}