Creating a Proper Semi Circular Slider Widget in Flutter

Issue

how do we create a proper Semi Circular Slider that has "steps" line division.
I have checked out many packages on pub.dev but they doesn’t seem to provide a proper Semi Circular Slider. They look more like progress bar rather than a slider

Any thoughts please?

ILLUSTRATION OF WHAT I MEANT BY SEMI CIRCULAR SLIDER

Solution

This can be done with a CustomPainter, GestureDetector, and a bunch of math.

enter image description here

Full example: https://gist.github.com/PixelToast/7dfbc4d743b108755b6521d0b8f24fd9

DartPad: https://dartpad.dartlang.org/?id=7dfbc4d743b108755b6521d0b8f24fd9

class SemiCircleSlider extends StatefulWidget {
  const SemiCircleSlider({
    Key? key,
    required this.initialValue,
    required this.divisions,
    required this.onChanged,
  }) : super(key: key);

  final int initialValue;
  final int divisions;
  final ValueChanged<int> onChanged;

  @override
  State<SemiCircleSlider> createState() => _SemiCircleSliderState();
}

class _SemiCircleSliderState extends State<SemiCircleSlider> {
  late var value = widget.initialValue;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 350,
      child: LayoutBuilder(
        builder: (context, constraints) {
          // Apply some padding to the outside so the nub doesn't go past the
          // edge of the painter.
          const inset = 32.0;
          final arcWidth = constraints.maxWidth - inset * 2;
          final height = (arcWidth / 2) + inset * 2;
          final arcHeight = (height - inset * 2) * 2;
          final arcRect = Rect.fromLTRB(
            inset,
            height - (inset + arcHeight),
            arcWidth + inset,
            height - inset,
          );
          return GestureDetector(
            // Use TweenAnimationBuilder to smoothly animate between divisions
            child: TweenAnimationBuilder<double>(
              tween: Tween(begin: value.toDouble(), end: value.toDouble()),
              duration: const Duration(milliseconds: 50),
              curve: Curves.ease,
              builder: (context, value, child) {
                return CustomPaint(
                  painter: SemiCircleSliderPainter(
                    divisions: widget.divisions,
                    arcRect: arcRect,
                    // Map the value to the angle at which to display the nub
                    nubAngle: (1 - (value / (widget.divisions - 1))) * pi,
                  ),
                  child: SizedBox(
                    height: height,
                  ),
                );
              },
            ),
            onPanUpdate: (e) {
              // Calculate the angle of the tap relative to the center of the
              // arc, then map that angle to a value
              final position = e.localPosition - arcRect.center;
              final angle = atan2(position.dy, position.dx);
              final newValue =
                  ((1 - (angle / pi)) * (widget.divisions - 1)).round();
              if (value != newValue &&
                  newValue >= 0 &&
                  newValue < widget.divisions) {
                widget.onChanged(newValue);
                setState(() {
                  value = newValue;
                });
              }
            },
          );
        },
      ),
    );
  }
}

class SemiCircleSliderPainter extends CustomPainter {
  SemiCircleSliderPainter({
    required this.divisions,
    required this.arcRect,
    required this.nubAngle,
  });

  final int divisions;
  final Rect arcRect;
  final double nubAngle;

  static const nubRadius = 16.0;
  static const lineWidth = 16.0;
  static const stepThickness = 3.0;
  static const stepLength = 2.0;
  late final lineArcRect = arcRect.deflate(lineWidth / 2);
  late final xradius = lineArcRect.width / 2;
  late final yradius = lineArcRect.height / 2;
  late final center = arcRect.center;
  late final nubPath = Path()
    ..addPath(
      Path()
        ..moveTo(0, 0)
        ..arcTo(
          const Offset(nubRadius / 2, -nubRadius) &
              const Size.fromRadius(nubRadius),
          5 * pi / 4,
          3 * pi / 2,
          false,
        ),
      Offset(
        center.dx + cos(nubAngle) * xradius,
        center.dy + sin(nubAngle) * yradius,
      ),
      matrix4: Matrix4.rotationZ(nubAngle).storage,
    );

  @override
  void paint(Canvas canvas, Size size) {
    // Paint large arc
    canvas.drawArc(
      lineArcRect,
      0,
      pi,
      false,
      Paint()
        ..style = PaintingStyle.stroke
        ..color = Colors.black
        ..strokeWidth = lineWidth
        ..strokeCap = StrokeCap.round,
    );

    // Paint division markers
    for (var i = 0; i < divisions; i++) {
      final angle = pi * i / (divisions - 1);
      final xnorm = cos(angle);
      final ynorm = sin(angle);
      canvas.drawLine(
        center +
            Offset(
              xnorm * (xradius - stepLength),
              ynorm * (yradius - stepLength),
            ),
        center +
            Offset(
              xnorm * (xradius + stepLength),
              ynorm * (yradius + stepLength),
            ),
        Paint()
          ..style = PaintingStyle.stroke
          ..color = Colors.white
          ..strokeWidth = stepThickness
          ..strokeCap = StrokeCap.round,
      );
    }

    // Paint nub
    canvas.drawPath(
      nubPath,
      Paint()..color = Colors.pink.shade200,
    );
  }

  @override
  bool? hitTest(Offset position) {
    // Only respond to hit tests when tapping the nub
    return nubPath.contains(position);
  }

  @override
  bool shouldRepaint(SemiCircleSliderPainter oldDelegate) =>
      divisions != oldDelegate.divisions ||
      arcRect != oldDelegate.arcRect ||
      nubAngle != oldDelegate.nubAngle;
}

Answered By – PixelToast

Answer Checked By – Timothy Miller (FlutterFixes Admin)

Leave a Reply

Your email address will not be published. Required fields are marked *