Last active
January 8, 2026 00:23
-
-
Save PlugFox/82ea9e73e60968bb068b4fb104dae262 to your computer and use it in GitHub Desktop.
Ribbon widget for Flutter
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
| /* | |
| * Ribbon widget for Flutter. | |
| * https://gist.github.com/PlugFox/82ea9e73e60968bb068b4fb104dae262 | |
| * https://dartpad.dev?id=82ea9e73e60968bb068b4fb104dae262 | |
| * Mike Matiunin <plugfox@gmail.com>, 07 January 2026 | |
| */ | |
| // ignore_for_file: curly_braces_in_flow_control_structures | |
| import 'dart:math' as math; | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/rendering.dart' | |
| show RenderProxyBoxMixin, RenderObjectWithChildMixin, PipelineOwner; | |
| void main() => runApp(const App()); | |
| class App extends StatelessWidget { | |
| const App({super.key}); | |
| @override | |
| Widget build(BuildContext context) => MaterialApp( | |
| title: 'Ribbon', | |
| debugShowCheckedModeBanner: false, | |
| home: RibbonWidget( | |
| label: 'Ribbon demo'.toUpperCase(), | |
| gradient: const LinearGradient( | |
| begin: Alignment.centerLeft, | |
| end: Alignment.centerRight, | |
| colors: <Color>[Colors.deepPurple, Colors.deepOrange], | |
| ), | |
| child: Scaffold( | |
| appBar: AppBar(title: const Text('Ribbon')), | |
| body: const SafeArea(child: Center(child: Text('Hello World'))), | |
| ), | |
| ), | |
| ); | |
| } | |
| /// {@template ribbon_widget} | |
| /// Ribbon widget that decorates its child with a ribbon on the top-right corner. | |
| /// {@endtemplate} | |
| class RibbonWidget extends SingleChildRenderObjectWidget { | |
| /// {@macro ribbon_widget} | |
| const RibbonWidget({ | |
| required Widget super.child, | |
| this.label = 'MOST POPULAR', | |
| this.textStyle = const TextStyle( | |
| color: Colors.white, | |
| fontWeight: FontWeight.w600, | |
| fontSize: 12, | |
| height: 1, | |
| ), | |
| this.gradient = const LinearGradient( | |
| begin: Alignment.centerLeft, | |
| end: Alignment.centerRight, | |
| colors: <Color>[Color(0xFFFFB900), Color(0xFFF0B100)], | |
| ), | |
| this.shadows = const <BoxShadow>[ | |
| BoxShadow( | |
| color: Color(0x1A000000), | |
| offset: Offset(0, 10), | |
| blurRadius: 15, | |
| spreadRadius: -3, | |
| ), | |
| BoxShadow( | |
| color: Color(0x1A000000), | |
| offset: Offset(0, 4), | |
| blurRadius: 6, | |
| spreadRadius: -4, | |
| ), | |
| ], | |
| super.key, // ignore: unused_element_parameter | |
| }); | |
| /// Label text displayed on the ribbon. | |
| final String label; | |
| /// Text style for the label. | |
| final TextStyle textStyle; | |
| /// Gradient used for the ribbon background. | |
| final Gradient gradient; | |
| /// Shadows applied to the ribbon. | |
| final List<BoxShadow> shadows; | |
| @override | |
| RenderObject createRenderObject(BuildContext context) => | |
| RibbonWidgetRenderObject( | |
| label: label, | |
| textStyle: textStyle, | |
| gradient: gradient, | |
| shadows: shadows, | |
| ); | |
| @override | |
| void updateRenderObject( | |
| BuildContext context, | |
| covariant RibbonWidgetRenderObject renderObject, | |
| ) { | |
| if (identical(renderObject.label, label) && | |
| identical(renderObject.textStyle, textStyle) && | |
| identical(renderObject.gradient, gradient) && | |
| identical(renderObject.shadows, shadows)) | |
| return; | |
| renderObject | |
| ..label = label | |
| ..textStyle = textStyle | |
| ..gradient = gradient | |
| ..shadows = shadows | |
| ..markNeedsPaint(); | |
| } | |
| } | |
| class RibbonWidgetRenderObject extends RenderBox | |
| with RenderObjectWithChildMixin<RenderBox>, RenderProxyBoxMixin<RenderBox> { | |
| RibbonWidgetRenderObject({ | |
| required this.label, | |
| required this.textStyle, | |
| required this.gradient, | |
| required this.shadows, | |
| }); | |
| String label; | |
| TextStyle textStyle; | |
| Gradient gradient; | |
| List<BoxShadow> shadows; | |
| @override | |
| // ignore: unnecessary_overrides | |
| void attach(PipelineOwner owner) { | |
| super.attach(owner); | |
| PaintingBinding.instance.systemFonts.addListener(_handleSystemFontsChange); | |
| } | |
| @override | |
| @protected | |
| void detach() { | |
| PaintingBinding.instance.systemFonts.removeListener( | |
| _handleSystemFontsChange, | |
| ); | |
| super.detach(); | |
| } | |
| @override | |
| void performLayout() { | |
| size = | |
| (child?..layout(constraints, parentUsesSize: true))?.size ?? | |
| computeSizeForNoChild(constraints); | |
| } | |
| /// Handles system font changes by marking the render object as needing paint | |
| /// to ensure text is redrawn with updated fonts. | |
| /// This is important for non latin fonts that may have different metrics and | |
| /// can be downloaded at runtime. Such as Korean, Chinese, Japanese, etc. | |
| void _handleSystemFontsChange() { | |
| markNeedsPaint(); | |
| } | |
| @override | |
| void paint(PaintingContext context, Offset offset) { | |
| // Validate input | |
| final child = this.child; | |
| final size = this.size; | |
| if (child == null || size.isEmpty) return; | |
| // Draw child | |
| context.paintChild(child, offset); | |
| // Draw ribbon | |
| final canvas = context.canvas; | |
| // Create and layout text painter to measure text dimensions | |
| final textPainter = TextPainter( | |
| text: TextSpan(text: label, style: textStyle), | |
| textDirection: TextDirection.ltr, | |
| textAlign: TextAlign.center, | |
| )..layout(); | |
| // Ribbon dimensions | |
| // 35° in radians (positive for clockwise rotation) | |
| const ribbonAngle = 35.0 * math.pi / 180.0; | |
| const horizontalPadding = 24.0; | |
| const verticalPadding = 6.0; | |
| // Extra length to make ribbon longer (will be clipped) | |
| const extraRibbonLength = 120.0; | |
| // Text rect with padding | |
| final textWidth = textPainter.width; | |
| final textHeight = textPainter.height; | |
| final ribbonWidth = textWidth + horizontalPadding * 2 + extraRibbonLength; | |
| final ribbonHeight = textHeight + verticalPadding * 2; | |
| // Calculate bounding box of rotated text rectangle | |
| // When rotated by angle θ, rectangle WxH has bounding box: | |
| // boundingWidth = W * cos(θ) + H * sin(θ) | |
| // boundingHeight = W * sin(θ) + H * cos(θ) | |
| final cos35 = math.cos(ribbonAngle), sin35 = math.sin(ribbonAngle); | |
| final boundingWidth = textWidth * cos35 + textHeight * sin35; | |
| final boundingHeight = textWidth * sin35 + textHeight * cos35; | |
| // Add padding to bounding box | |
| final totalBoundingWidth = boundingWidth + horizontalPadding * 2; | |
| final totalBoundingHeight = boundingHeight + verticalPadding * 2; | |
| // Start a new layer and clip to bounds | |
| // to prevent drawing outside the widget | |
| canvas | |
| ..save() | |
| ..clipRect(offset & size); | |
| // Position: top-right corner minus the bounding width (so it fits inside) | |
| // Plus a small offset to let part of it extend beyond | |
| final cornerOffset = totalBoundingWidth * 0.15; // 15% extends beyond corner | |
| canvas | |
| ..translate( | |
| offset.dx + size.width - totalBoundingWidth + cornerOffset, | |
| offset.dy, | |
| ) | |
| // Move to center of where the ribbon will be | |
| ..translate(totalBoundingWidth / 2, totalBoundingHeight / 2) | |
| // Rotate the canvas | |
| ..rotate(ribbonAngle); | |
| // Draw ribbon background (centered at origin after rotation) | |
| final ribbonRect = Rect.fromLTWH( | |
| -ribbonWidth / 2, | |
| -ribbonHeight / 2, | |
| ribbonWidth, | |
| ribbonHeight, | |
| ); | |
| // Draw shadows | |
| for (final shadow in shadows) { | |
| final shadowPaint = shadow.toPaint(); | |
| canvas.drawRect(ribbonRect.shift(shadow.offset), shadowPaint); | |
| } | |
| // Draw ribbon with gradient | |
| final ribbonPaint = Paint() | |
| ..shader = gradient.createShader(ribbonRect) | |
| ..style = PaintingStyle.fill; | |
| // Draw ribbon rectangle | |
| canvas.drawRect(ribbonRect, ribbonPaint); | |
| // Draw text centered on the ribbon | |
| textPainter.paint(canvas, Offset(-textWidth / 2, -textHeight / 2)); | |
| // Restore canvas and apply clipping | |
| canvas.restore(); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment