Skip to content

Instantly share code, notes, and snippets.

@callmephil
Last active February 28, 2025 14:37
Show Gist options
  • Save callmephil/f50e7fbc4db613ff098f8c24adfdcc2e to your computer and use it in GitHub Desktop.
Save callmephil/f50e7fbc4db613ff098f8c24adfdcc2e to your computer and use it in GitHub Desktop.
bi-directional gradient border progress on hover
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