Created
June 24, 2025 13:07
-
-
Save PlugFox/58b92cde63e137b0a88ce7666ff0ee88 to your computer and use it in GitHub Desktop.
Flutter - custom render object
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
/* | |
* Custom render object | |
* https://gist.github.com/PlugFox/58b92cde63e137b0a88ce7666ff0ee88 | |
* https://dartpad.dev?id=58b92cde63e137b0a88ce7666ff0ee88 | |
* Mike Matiunin <[email protected]>, 24 June 2025 | |
*/ | |
// ignore_for_file: cascade_invocations, unnecessary_overrides | |
import 'dart:async'; | |
import 'dart:math' as math; | |
import 'dart:typed_data'; | |
import 'dart:ui'; | |
import 'package:flutter/gestures.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/rendering.dart'; | |
import 'package:flutter/scheduler.dart'; | |
typedef StepData = ({String title, String subtitle}); | |
final List<StepData> data = <StepData>[ | |
(title: 'Nevsky', subtitle: '11:00'), | |
(title: 'Gostiniy', subtitle: '11:25'), | |
(title: 'Moskovskaya', subtitle: '11:32'), | |
(title: 'Leninsky', subtitle: '11:40'), | |
(title: 'Avtovo', subtitle: '11:48'), | |
(title: 'Kirovsky Zavod', subtitle: '11:55'), | |
(title: 'Narvskaya', subtitle: '12:03'), | |
(title: 'Baltiyskaya', subtitle: '12:10'), | |
(title: 'Pushkinskaya', subtitle: '12:17'), | |
]; | |
void main() => runZonedGuarded<void>( | |
() => runApp(const App()), | |
(error, stackTrace) => print('Top level exception: $error\n$stackTrace'), // ignore: avoid_print | |
); | |
/// {@template app} | |
/// App widget. | |
/// {@endtemplate} | |
class App extends StatelessWidget { | |
/// {@macro app} | |
const App({super.key}); | |
@override | |
Widget build(BuildContext context) => MaterialApp( | |
title: 'Stepper', | |
home: Scaffold( | |
appBar: AppBar( | |
title: const Text('Stepper'), | |
), | |
body: SafeArea( | |
child: Align( | |
alignment: Alignment.topLeft, | |
child: Builder( | |
builder: (context) => SingleChildScrollView( | |
padding: const EdgeInsets.all(16), | |
child: Stepper( | |
steps: data, | |
onTap: (step) { | |
print('Tapped step: ${step.title}'); // ignore: avoid_print | |
ScaffoldMessenger.maybeOf(context) | |
?..clearSnackBars() | |
..showSnackBar( | |
SnackBar(content: Text('Tapped step: ${step.title}')), | |
); | |
}), | |
), | |
), | |
), | |
), | |
), | |
); | |
} | |
/// {@template stepper_widget} | |
/// StepperWidget widget. | |
/// {@endtemplate} | |
class StepperWidget extends StatelessWidget { | |
/// {@macro stepper_widget} | |
const StepperWidget({ | |
required this.steps, | |
super.key, // ignore: unused_element | |
}); | |
final List<StepData> steps; | |
@override | |
Widget build(BuildContext context) => Stack( | |
alignment: Alignment.topLeft, | |
children: <Widget>[ | |
Align( | |
alignment: Alignment.topLeft, | |
child: SizedBox( | |
width: 32, | |
height: 60.0 * steps.length + 20.0, | |
child: const VerticalDivider( | |
color: Colors.grey, | |
thickness: 1, | |
indent: 32, | |
endIndent: 32, | |
), | |
), | |
), | |
for (int i = 0; i < steps.length; i++) | |
Positioned( | |
top: 20.0 + (i * 60.0), | |
child: Row( | |
mainAxisSize: MainAxisSize.min, | |
mainAxisAlignment: MainAxisAlignment.start, | |
crossAxisAlignment: CrossAxisAlignment.center, | |
spacing: 16, | |
children: <Widget>[ | |
SizedBox.square( | |
dimension: 32, | |
child: CircleAvatar( | |
backgroundColor: Colors.blue, | |
child: Text( | |
'${i + 1}', | |
style: const TextStyle(color: Colors.white, fontSize: 20), | |
), | |
), | |
), | |
Column( | |
mainAxisSize: MainAxisSize.min, | |
mainAxisAlignment: MainAxisAlignment.start, | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: <Widget>[ | |
Text( | |
steps[i].title, | |
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), | |
), | |
Text( | |
steps[i].subtitle, | |
style: const TextStyle(fontSize: 16, color: Colors.grey), | |
), | |
], | |
), | |
], | |
), | |
), | |
], | |
); | |
} | |
/// {@template stepper} | |
/// Stepper widget. | |
/// {@endtemplate} | |
class Stepper extends LeafRenderObjectWidget { | |
/// {@macro stepper} | |
const Stepper({ | |
required this.steps, | |
this.onTap, | |
super.key, // ignore: unused_element | |
}); | |
final List<StepData> steps; | |
final void Function(StepData step)? onTap; | |
@override | |
RenderObject createRenderObject(BuildContext context) { | |
final theme = Theme.of(context); | |
final direction = Directionality.of(context); | |
final textScale = MediaQuery.textScalerOf(context); | |
final painter = StepperPainter( | |
theme: theme, | |
directionality: direction, | |
textScaler: textScale, | |
steps: steps, | |
onTap: onTap, | |
); | |
return StepperRenderObject( | |
painter: painter, | |
); | |
} | |
@override | |
void updateRenderObject(BuildContext context, covariant StepperRenderObject renderObject) { | |
final theme = Theme.of(context); | |
final direction = Directionality.of(context); | |
final textScale = MediaQuery.textScalerOf(context); | |
//if (identical(steps, renderObject.painter.steps)) return; | |
renderObject.painter | |
..theme = theme | |
..directionality = direction | |
..textScaler = textScale | |
..steps = steps | |
..onTap = onTap; | |
} | |
} | |
class StepperRenderObject extends RenderBox with WidgetsBindingObserver { | |
StepperRenderObject({required this.painter}); | |
final StepperPainter painter; | |
@override | |
bool get isRepaintBoundary => false; | |
@override | |
bool get alwaysNeedsCompositing => false; | |
@override | |
bool get sizedByParent => false; | |
Size _size = Size.zero; | |
@override | |
Size get size => _size; | |
@override | |
set size(Size value) { | |
final prev = super.hasSize ? super.size : null; | |
super.size = value; | |
if (prev == value) return; | |
_size = value; | |
} | |
Ticker? _animationTicker; | |
@override | |
void attach(PipelineOwner owner) { | |
super.attach(owner); | |
WidgetsBinding.instance.addObserver(this); | |
_animationTicker = Ticker(_onTick)..start(); | |
} | |
void _onTick(Duration elapsed) { | |
// Redraw the painter if it needs repainting | |
// Get the duration since the last tick from `elapsed` | |
if (painter._isNeedPaint) { | |
markNeedsPaint(); | |
painter._isNeedPaint = false; | |
} | |
} | |
@override | |
@protected | |
void detach() { | |
super.detach(); | |
_animationTicker?.dispose(); | |
WidgetsBinding.instance.removeObserver(this); | |
} | |
@override | |
void didChangeAppLifecycleState(AppLifecycleState state) { | |
super.didChangeAppLifecycleState(state); | |
// Do something when the app lifecycle state changes | |
} | |
@override | |
bool hitTestSelf(Offset position) => true; | |
@override | |
bool hitTestChildren( | |
BoxHitTestResult result, { | |
required Offset position, | |
}) => | |
false; | |
@override | |
bool hitTest(BoxHitTestResult result, {required Offset position}) { | |
var hitTarget = false; | |
if (size.contains(position)) { | |
hitTarget = hitTestSelf(position); | |
result.add(BoxHitTestEntry(this, position)); | |
} | |
return hitTarget; | |
} | |
@override | |
void handleEvent(PointerEvent event, BoxHitTestEntry entry) { | |
if (event is! PointerDownEvent) return; | |
painter.handleTap(event); | |
} | |
@override | |
Size computeDryLayout(BoxConstraints constraints) => | |
constraints.constrain(painter.layout(maxWidth: constraints.maxWidth)); | |
@override | |
void performLayout() { | |
size = constraints.constrain(painter.layout(maxWidth: constraints.maxWidth)); | |
} | |
@override | |
void performResize() { | |
size = computeDryLayout(constraints); | |
} | |
@override | |
void paint(PaintingContext context, Offset offset) { | |
final canvas = context.canvas | |
..save() | |
..translate(offset.dx, offset.dy) | |
..clipRect( | |
Rect.fromLTWH(0, 0, size.width, size.height).inflate(64), | |
); | |
painter.paint(canvas, size); | |
canvas.restore(); | |
} | |
} | |
class StepperPainter { | |
StepperPainter( | |
{required this.theme, | |
required this.directionality, | |
required this.textScaler, | |
required this.steps, | |
required this.onTap}) | |
: _size = Size.zero; | |
ThemeData theme; | |
TextDirection directionality; | |
TextScaler textScaler; | |
List<StepData> steps; | |
void Function(StepData step)? onTap; | |
/// The size of the painted markdown content. | |
Size get size => _size; | |
Size _size; | |
Picture? _picture; | |
final List<({Rect boundary, VoidCallback callback})> _gestureTargets = []; | |
Size layout({required double maxWidth}) { | |
_gestureTargets.clear(); | |
final recorder = PictureRecorder(); | |
final canvas = Canvas(recorder); | |
final textPainter = TextPainter( | |
textDirection: TextDirection.ltr, | |
textAlign: TextAlign.left, | |
ellipsis: '...', | |
maxLines: 2, | |
); | |
const iconSize = 32.0; | |
const padding = 16.0; | |
final pointsF32L = Float32List(math.max(steps.length - 2, 0) * 2); | |
final linesF32L = Float32List(math.max(steps.length - 1, 0) * 4); | |
var height = 0.0; | |
for (var i = 0; i < steps.length; i++) { | |
final step = steps[i]; | |
final isFirst = i == 0; | |
final isLast = i == steps.length - 1; | |
if (!isFirst) { | |
height += padding * 2; // Space between steps | |
} | |
var heightStart = height; | |
if (isFirst) { | |
canvas.drawCircle( | |
Offset(iconSize / 2, height + iconSize / 2), | |
iconSize / 2, | |
Paint() | |
..color = Colors.blue | |
..style = PaintingStyle.fill | |
..isAntiAlias = true, | |
); | |
} else if (isLast) { | |
canvas.drawCircle( | |
Offset(iconSize / 2, height + iconSize / 2), | |
iconSize / 2, | |
Paint() | |
..color = Colors.red | |
..style = PaintingStyle.fill | |
..isAntiAlias = true, | |
); | |
} else { | |
pointsF32L | |
..[(i - 1) * 2] = iconSize / 2 // dx | |
..[(i - 1) * 2 + 1] = height + iconSize / 2; // dy | |
} | |
if (!isLast) { | |
linesF32L[i * 4] = iconSize / 2; // x1 | |
linesF32L[i * 4 + 1] = height + iconSize + 4; // y1 | |
linesF32L[i * 4 + 2] = iconSize / 2; // x2 | |
} | |
// Draw the stepper title | |
textPainter | |
..text = TextSpan( | |
text: step.title, | |
style: theme.textTheme.titleLarge, | |
) | |
..layout(maxWidth: maxWidth - iconSize - padding); | |
textPainter.paint( | |
canvas, | |
Offset(iconSize + padding, height), | |
); | |
height += textPainter.height; | |
height += 4; // Add some space between title and subtitle | |
// Draw the stepper subtitle | |
textPainter | |
..text = TextSpan( | |
text: step.subtitle, | |
style: theme.textTheme.bodyLarge?.copyWith(color: Colors.grey, height: 1), | |
) | |
..layout(maxWidth: maxWidth - iconSize - padding); | |
textPainter.paint( | |
canvas, | |
Offset(iconSize + padding, height), | |
); | |
height += textPainter.height; | |
if (!isLast) { | |
linesF32L[i * 4 + 3] = height + iconSize - 4; // y2 | |
} | |
_gestureTargets.add(( | |
boundary: Rect.fromLTRB(0, heightStart, maxWidth, height), | |
callback: () => onTap?.call(step), | |
)); | |
} | |
// Draw the middle points | |
if (pointsF32L.isNotEmpty) { | |
canvas.drawRawPoints( | |
PointMode.points, | |
pointsF32L, | |
Paint() | |
..color = Colors.green | |
..strokeWidth = iconSize | |
..strokeCap = StrokeCap.round | |
..style = PaintingStyle.stroke | |
..blendMode = BlendMode.srcOver | |
..isAntiAlias = true, | |
); | |
} | |
// Draw the lines between the points | |
if (linesF32L.isNotEmpty) { | |
canvas.drawRawPoints( | |
PointMode.lines, | |
linesF32L, | |
Paint() | |
..color = Colors.green | |
..strokeWidth = 4.0 | |
..strokeCap = StrokeCap.round | |
..style = PaintingStyle.stroke | |
..blendMode = BlendMode.srcOver | |
..isAntiAlias = true, | |
); | |
} | |
_picture = recorder.endRecording(); | |
return _size = Size( | |
maxWidth, | |
height, | |
); | |
} | |
bool _isNeedPaint = false; | |
void handleTap(PointerDownEvent event) { | |
// Handle only primary button taps | |
if ((event.buttons & kPrimaryButton) == 0) return; | |
final localPosition = event.localPosition; | |
for (final target in _gestureTargets) { | |
if (!target.boundary.contains(localPosition)) continue; | |
target.callback(); | |
_isNeedPaint = true; | |
break; // Exit after handling the first tap | |
} | |
} | |
void paint(Canvas canvas, Size size) { | |
if (_size.width < 128) return; | |
if (_picture case Picture picture) canvas.drawPicture(picture); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment