Created
June 24, 2025 13:07
-
-
Save PlugFox/58b92cde63e137b0a88ce7666ff0ee88 to your computer and use it in GitHub Desktop.
Revisions
-
PlugFox revised this gist
Jun 24, 2025 . 1 changed file with 2 additions and 2 deletions.There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -1,7 +1,7 @@ /* * Custom render object * https://gist.github.com/PlugFox/58b92cde63e137b0a88ce7666ff0ee88 * https://dartpad.dev?id=58b92cde63e137b0a88ce7666ff0ee88 * Mike Matiunin <[email protected]>, 24 June 2025 */ -
PlugFox created this gist
Jun 24, 2025 .There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,484 @@ /* * Custom render object * https://gist.github.com/PlugFox/ * https://dartpad.dev?id= * 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); } }