Skip to content

Instantly share code, notes, and snippets.

@PlugFox
Created June 24, 2025 13:07
Show Gist options
  • Select an option

  • Save PlugFox/58b92cde63e137b0a88ce7666ff0ee88 to your computer and use it in GitHub Desktop.

Select an option

Save PlugFox/58b92cde63e137b0a88ce7666ff0ee88 to your computer and use it in GitHub Desktop.

Revisions

  1. PlugFox revised this gist Jun 24, 2025. 1 changed file with 2 additions and 2 deletions.
    4 changes: 2 additions & 2 deletions main.dart
    Original file line number Diff line number Diff line change
    @@ -1,7 +1,7 @@
    /*
    * Custom render object
    * https://gist.github.com/PlugFox/
    * https://dartpad.dev?id=
    * https://gist.github.com/PlugFox/58b92cde63e137b0a88ce7666ff0ee88
    * https://dartpad.dev?id=58b92cde63e137b0a88ce7666ff0ee88
    * Mike Matiunin <[email protected]>, 24 June 2025
    */

  2. PlugFox created this gist Jun 24, 2025.
    484 changes: 484 additions & 0 deletions main.dart
    Original 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);
    }
    }