Skip to content

Instantly share code, notes, and snippets.

@PlugFox
Created June 24, 2025 13:07
Show Gist options
  • Save PlugFox/58b92cde63e137b0a88ce7666ff0ee88 to your computer and use it in GitHub Desktop.
Save PlugFox/58b92cde63e137b0a88ce7666ff0ee88 to your computer and use it in GitHub Desktop.
Flutter - custom render object
/*
* 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