Last active
February 28, 2025 14:37
-
-
Save callmephil/f50e7fbc4db613ff098f8c24adfdcc2e to your computer and use it in GitHub Desktop.
bi-directional gradient border progress on hover
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:ui' as ui; | |
import 'package:flutter/foundation.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 MaterialApp( | |
debugShowCheckedModeBanner: false, | |
theme: ThemeData.dark(), | |
home: Scaffold( | |
body: Center( | |
child: Column( | |
mainAxisAlignment: MainAxisAlignment.center, | |
spacing: 16, | |
children: [ | |
const Text('Hover over the widgets to see the animation!'), | |
const AnimatedBorder( | |
duration: Duration(milliseconds: 500), | |
gradientColors: [Colors.indigo, Colors.blue, Colors.cyan], | |
stops: [0.0, 0.5, 1.0], | |
child: Padding( | |
padding: EdgeInsets.all(8), | |
child: Text(' Top Left '), | |
), | |
), | |
const AnimatedBorder( | |
startCorner: StartCorner.topRight, | |
gradientColors: [Colors.purple, Colors.red, Colors.orange], | |
stops: [0.0, 0.5, 1.0], | |
duration: Duration(milliseconds: 800), | |
borderColor: Colors.white, | |
child: Padding( | |
padding: EdgeInsets.all(8), | |
child: Text('Top Right'), | |
), | |
), | |
const AnimatedBorder( | |
startCorner: StartCorner.bottomLeft, | |
gradientColors: [Colors.green, Colors.yellow], | |
stops: [0.0, 1.0], | |
duration: Duration(milliseconds: 700), | |
child: Padding( | |
padding: EdgeInsets.all(8), | |
child: Text('Bottom Left'), | |
), | |
), | |
Builder( | |
builder: (ctx) { | |
return AnimatedBorder( | |
startCorner: StartCorner.bottomRight, | |
onAnimationComplete: () { | |
if (!context.mounted) return; | |
showAboutDialog( | |
context: ctx, | |
applicationName: | |
'AnimatedBorder - By Philippe Makzoume (Original Idea from jaspr.site)', | |
); | |
}, | |
gradientColors: const [Colors.cyan, Colors.indigo], | |
stops: const [0.0, 1.0], | |
duration: const Duration(milliseconds: 1000), | |
borderColor: Colors.white, | |
child: const Padding( | |
padding: EdgeInsets.all(8), | |
child: Text('Bottom Right'), | |
), | |
); | |
}, | |
), | |
], | |
), | |
), | |
), | |
); | |
} | |
} | |
enum StartCorner { topLeft, topRight, bottomLeft, bottomRight } | |
class AnimatedBorder extends StatefulWidget { | |
const AnimatedBorder({ | |
super.key, | |
required this.child, | |
this.radius = 12, | |
this.onAnimationComplete, | |
this.startCorner = StartCorner.topLeft, | |
required this.gradientColors, | |
required this.stops, | |
this.strokeWidth = 1, | |
this.duration = const Duration(milliseconds: 600), | |
this.borderColor = Colors.grey, | |
}); | |
final Widget child; | |
final double radius; | |
final VoidCallback? onAnimationComplete; | |
final StartCorner startCorner; | |
// New configuration options: | |
final List<Color> gradientColors; | |
final List<double> stops; | |
final double strokeWidth; | |
final Duration duration; | |
final Color borderColor; | |
@override | |
State<AnimatedBorder> createState() => _AnimatedBorderState(); | |
} | |
class _AnimatedBorderState extends State<AnimatedBorder> | |
with SingleTickerProviderStateMixin { | |
late AnimationController _controller; | |
late Animation<double> _animation; | |
@override | |
void initState() { | |
super.initState(); | |
_controller = AnimationController(vsync: this, duration: widget.duration); | |
_animation = Tween<double>(begin: 0, end: 1).animate( | |
CurvedAnimation(parent: _controller, curve: Curves.easeInOutQuad), | |
); | |
if (widget.onAnimationComplete != null) { | |
_animation.addListener(_onAnimationComplete); | |
} | |
} | |
@override | |
void dispose() { | |
_controller.dispose(); | |
if (widget.onAnimationComplete != null) { | |
_animation.removeListener(_onAnimationComplete); | |
} | |
super.dispose(); | |
} | |
void _onAnimationComplete() { | |
if (_animation.isCompleted) { | |
widget.onAnimationComplete?.call(); | |
} | |
} | |
@override | |
Widget build(BuildContext context) { | |
return AnimatedBuilder( | |
animation: _animation, | |
builder: (_, child) { | |
return MouseRegion( | |
onEnter: (_) => _controller.forward(), | |
onExit: (_) => _controller.reverse(), | |
child: CustomPaint( | |
painter: GradientBorderPainter( | |
radius: widget.radius, | |
progress: _animation.value, | |
startCorner: widget.startCorner, | |
gradientColors: widget.gradientColors, | |
stops: widget.stops, | |
strokeWidth: widget.strokeWidth, | |
borderColor: widget.borderColor, | |
), | |
child: widget.child, | |
), | |
); | |
}, | |
); | |
} | |
} | |
// Helper: builds a rounded rectangle path starting at the chosen corner. | |
Path createRRectPath(Rect rect, double radius, StartCorner startCorner) { | |
final path = Path(); | |
switch (startCorner) { | |
case StartCorner.bottomRight: | |
path.moveTo(rect.left + radius, rect.top); | |
path.lineTo(rect.right - radius, rect.top); | |
path.arcToPoint( | |
Offset(rect.right, rect.top + radius), | |
radius: Radius.circular(radius), | |
); | |
path.lineTo(rect.right, rect.bottom - radius); | |
path.arcToPoint( | |
Offset(rect.right - radius, rect.bottom), | |
radius: Radius.circular(radius), | |
); | |
path.lineTo(rect.left + radius, rect.bottom); | |
path.arcToPoint( | |
Offset(rect.left, rect.bottom - radius), | |
radius: Radius.circular(radius), | |
); | |
path.lineTo(rect.left, rect.top + radius); | |
path.arcToPoint( | |
Offset(rect.left + radius, rect.top), | |
radius: Radius.circular(radius), | |
); | |
case StartCorner.bottomLeft: | |
path.moveTo(rect.right - radius, rect.top); | |
path.arcToPoint( | |
Offset(rect.right, rect.top + radius), | |
radius: Radius.circular(radius), | |
); | |
path.lineTo(rect.right, rect.bottom - radius); | |
path.arcToPoint( | |
Offset(rect.right - radius, rect.bottom), | |
radius: Radius.circular(radius), | |
); | |
path.lineTo(rect.left + radius, rect.bottom); | |
path.arcToPoint( | |
Offset(rect.left, rect.bottom - radius), | |
radius: Radius.circular(radius), | |
); | |
path.lineTo(rect.left, rect.top + radius); | |
path.arcToPoint( | |
Offset(rect.left + radius, rect.top), | |
radius: Radius.circular(radius), | |
); | |
path.lineTo(rect.right - radius, rect.top); | |
case StartCorner.topLeft: | |
path.moveTo(rect.right, rect.bottom - radius); | |
path.arcToPoint( | |
Offset(rect.right - radius, rect.bottom), | |
radius: Radius.circular(radius), | |
); | |
path.lineTo(rect.left + radius, rect.bottom); | |
path.arcToPoint( | |
Offset(rect.left, rect.bottom - radius), | |
radius: Radius.circular(radius), | |
); | |
path.lineTo(rect.left, rect.top + radius); | |
path.arcToPoint( | |
Offset(rect.left + radius, rect.top), | |
radius: Radius.circular(radius), | |
); | |
path.lineTo(rect.right - radius, rect.top); | |
path.arcToPoint( | |
Offset(rect.right, rect.top + radius), | |
radius: Radius.circular(radius), | |
); | |
path.lineTo(rect.right, rect.bottom - radius); | |
case StartCorner.topRight: | |
path.moveTo(rect.left + radius, rect.bottom); | |
path.arcToPoint( | |
Offset(rect.left, rect.bottom - radius), | |
radius: Radius.circular(radius), | |
); | |
path.lineTo(rect.left, rect.top + radius); | |
path.arcToPoint( | |
Offset(rect.left + radius, rect.top), | |
radius: Radius.circular(radius), | |
); | |
path.lineTo(rect.right - radius, rect.top); | |
path.arcToPoint( | |
Offset(rect.right, rect.top + radius), | |
radius: Radius.circular(radius), | |
); | |
path.lineTo(rect.right, rect.bottom - radius); | |
path.arcToPoint( | |
Offset(rect.right - radius, rect.bottom), | |
radius: Radius.circular(radius), | |
); | |
path.lineTo(rect.left + radius, rect.bottom); | |
} | |
path.close(); | |
return path; | |
} | |
class GradientBorderPainter extends CustomPainter { | |
const GradientBorderPainter({ | |
required this.radius, | |
required this.progress, | |
required this.startCorner, | |
required this.gradientColors, | |
required this.stops, | |
required this.strokeWidth, | |
required this.borderColor, | |
}); | |
final double radius; | |
final double progress; | |
final StartCorner startCorner; | |
final List<Color> gradientColors; | |
final List<double> stops; | |
final double strokeWidth; | |
final Color borderColor; | |
@override | |
void paint(Canvas canvas, Size size) { | |
// Border paint using the provided color and stroke width. | |
final borderPaint = | |
Paint() | |
..color = borderColor | |
..style = PaintingStyle.stroke | |
..strokeWidth = strokeWidth; | |
// Gradient paint using the provided colors, stops, and stroke width. | |
final gradientPaint = | |
Paint() | |
..shader = ui.Gradient.linear( | |
switch (startCorner) { | |
StartCorner.topLeft => Offset.zero, | |
StartCorner.topRight => Offset(size.width, 0), | |
StartCorner.bottomLeft => Offset(0, size.height), | |
StartCorner.bottomRight => Offset(size.width, size.height), | |
}, | |
switch (startCorner) { | |
StartCorner.topLeft => Offset(size.width, size.height), | |
StartCorner.topRight => Offset(0, size.height), | |
StartCorner.bottomLeft => Offset(size.width, 0), | |
StartCorner.bottomRight => Offset.zero, | |
}, | |
gradientColors, | |
stops, | |
) | |
..style = PaintingStyle.stroke | |
..strokeWidth = strokeWidth * 1.2; | |
final rect = Rect.fromLTWH(0, 0, size.width, size.height); | |
// Build the custom rounded rectangle path starting at the desired corner. | |
final path = createRRectPath(rect, radius, startCorner); | |
final metrics = path.computeMetrics().toList(growable: false); | |
if (metrics.isNotEmpty) { | |
final pathMetric = metrics.firstOrNull; | |
if (pathMetric == null) { | |
assert(false, 'GradientBorderPainter: PathMetric is null'); | |
return; | |
} | |
final length = pathMetric.length; | |
final startLength = length * (1 - progress) / 2; | |
final endLength = length * (1 + progress) / 2; | |
// Draw the non-animated portion with borderPaint | |
if (progress < 1.0) { | |
final nonAnimatedPath1 = pathMetric.extractPath(0, startLength); | |
final nonAnimatedPath2 = pathMetric.extractPath(endLength, length); | |
canvas | |
..drawPath(nonAnimatedPath1, borderPaint) | |
..drawPath(nonAnimatedPath2, borderPaint); | |
} | |
// Draw the animated portion with gradientPaint | |
final animatedPath = pathMetric.extractPath(startLength, endLength); | |
canvas.drawPath(animatedPath, gradientPaint); | |
} | |
} | |
@override | |
bool shouldRepaint(covariant GradientBorderPainter oldDelegate) { | |
return oldDelegate.progress != progress || | |
oldDelegate.startCorner != startCorner || | |
!listEquals(oldDelegate.gradientColors, gradientColors) || | |
!listEquals(oldDelegate.stops, stops) || | |
oldDelegate.strokeWidth != strokeWidth || | |
oldDelegate.borderColor != borderColor; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment