Flutter. A Quarter Round Slider.
Pablo L
Posted on May 12, 2020
Introduction
This post describes the process of creating a custom slider that can be used to select a value from a range of them. The slider resembles the quarter of a circle instead of the typical linear shape.
Custom Painter
Obviously I will need to paint a custom component and this should be done using two Flutter API classes. CustomPainter and CustomPaint.
CustomPainter You must extend this class and overwrite the method void paint(Canvas, Size) in order to draw the Widget UI and bool shouldRepaint(CustomPainter) method to return true if the widget should be repainted when a new instance of CustomPainter is provided.
CustomPaint The class CustomPainter described above is used through the CustomPaint Widget. CustomPaint takes a constructor parameter named painter for this purpose.
The process is quite simple and can be applied to both simple projects like these and larger, more complex components.
Skeleton example
The classes used in the example are:
- MyApp extends StatelessWidget. A Stateless Widget which contains the MaterialApp, Scaffold Widgets of the Application.
- RoundSlider extends StatefulWidget. Statefull Widget class of the component.
- _RoundSliderState extends State Representing the state of the RoundSlider Widget .
- RoundPainter extends CustomPainter. The class which paints the UI.
Class MyApp
It's a stateful widget which launches the application and passes to the slider the constructor parameters title, radius and maxvalue which are self-described.
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text("Example")),
body: Center(
child: RoundSlider(
title: "Volume", radius: 90, maxvalue: 99))));
}
}
`
Classes RoundSlider and RoundSliderState
RoundSlider is the main slider Widget. Its a stateful widget and its state class named RoundSliderState returns into the building method, a GestureDetector which captures drag movements and calculates the proper angle.
Notice that the angle is limited in code to the range 0 to pi/2
The GestureDetector wraps an instance of RoundPainter that we will analyze later.
`
class RoundSlider extends StatefulWidget {
final double radius;
final double maxvalue;
final String title;
RoundSlider({this.radius, this.maxvalue, this.title});
@override
_RoundSliderState createState() => _RoundSliderState();
}
class _RoundSliderState extends State<RoundSlider> {
double angle = 0;
void _update(u) {
setState(() {
double testAngle = atan2(u.localPosition.dy, u.localPosition.dx);
if (testAngle >= 0 && testAngle <= pi / 2) {
setState(() {
angle = testAngle;
});
}
});
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragUpdate: (update) {
_update(update);
},
onVerticalDragUpdate: (update) {
_update(update);
},
child: Column(children: <Widget>[
Text(widget.title,textScaleFactor: widget.radius/60,),
Container(
padding: EdgeInsets.all(widget.radius * 0.20),
width: widget.radius + widget.radius * 0.20,
height: widget.radius + widget.radius * 0.20,
decoration: BoxDecoration(border: Border.all()),
child: CustomPaint(
painter: RoundPainter(angle: angle, maxvalue: widget.maxvalue)),
),
]));
}
}
`
Class RoundPainter
The constructor takes the parameter angle in order to calculate the coordinates x,y of the selector and the parameter maxvalue to calculate the actual value selected.
Values like strokeWidth, textScaleFactor, etc...are calculated proportionally to width value of the component.
`
class RoundPainter extends CustomPainter {
double angle;
double maxvalue;
Paint strokePaint = Paint()..style = PaintingStyle.stroke;
Paint fillPaint = Paint()
..style = PaintingStyle.fill
..color = Colors.white;
RoundPainter({this.angle, this.maxvalue});
Offset offset = Offset(0, 0);
@override
void paint(Canvas canvas, Size size) {
strokePaint.strokeWidth = size.width / 25;
canvas.drawArc(Rect.fromCircle(center: offset, radius: size.width), 0,
pi / 2, false, strokePaint);
_drawSelector(canvas, size, angle);
_drawValue(canvas, size, (maxvalue * angle / pi).round());
}
void _drawValue(Canvas canvas, Size size, int value) {
TextSpan span = new TextSpan(
style: new TextStyle(color: Colors.black), text: value.toString());
TextPainter tp = TextPainter(
text: span,
textDirection: TextDirection.ltr,
textAlign: TextAlign.left,
textScaleFactor: size.width / 40);
tp.layout(minWidth: 0);
Offset newOffset = Offset(offset.dx, offset.dy);
tp.paint(canvas, newOffset);
}
void _drawSelector(Canvas canvas, Size size, double angle) {
strokePaint.strokeWidth = 1;
double x = size.width * cos(angle);
double y = size.height * sin(angle);
canvas.drawCircle(Offset(x, y), size.width / 10, fillPaint);
canvas.drawCircle(Offset(x, y), size.height / 10, strokePaint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return true;
}
}
`
All the code
The rest of the code...
`
import 'package:flutter/material.dart';
import 'dart:math';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text("Example")),
body: Center(
child: RoundSlider(
title: "Volume", radius: 90, maxvalue: 99))));
}
}
class RoundSlider extends StatefulWidget {
double radius;
double maxvalue;
String title;
RoundSlider({this.radius, this.maxvalue, this.title});
@override
_RoundSliderState createState() => _RoundSliderState();
}
class _RoundSliderState extends State<RoundSlider> {
double angle = 0;
void _update(u) {
setState(() {
double testAngle = atan2(u.localPosition.dy, u.localPosition.dx);
if (testAngle >= 0 && testAngle <= pi / 2) {
setState(() {
angle = testAngle;
});
}
});
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragUpdate: (update) {
_update(update);
},
onVerticalDragUpdate: (update) {
_update(update);
},
child: Column(children: <Widget>[
Text(widget.title,textScaleFactor: widget.radius/60,),
Container(
padding: EdgeInsets.all(widget.radius * 0.20),
width: widget.radius + widget.radius * 0.20,
height: widget.radius + widget.radius * 0.20,
decoration: BoxDecoration(border: Border.all()),
child: CustomPaint(
painter: RoundPainter(angle: angle, maxvalue: widget.maxvalue)),
),
]));
}
}
class RoundPainter extends CustomPainter {
double angle;
double maxvalue;
Paint strokePaint = Paint()..style = PaintingStyle.stroke;
Paint fillPaint = Paint()
..style = PaintingStyle.fill
..color = Colors.white;
RoundPainter({this.angle, this.maxvalue});
Offset offset = Offset(0, 0);
@override
void paint(Canvas canvas, Size size) {
strokePaint.strokeWidth = size.width / 25;
canvas.drawArc(Rect.fromCircle(center: offset, radius: size.width), 0,
pi / 2, false, strokePaint);
_drawSelector(canvas, size, angle);
_drawValue(canvas, size, (maxvalue * angle / pi).round());
}
void _drawValue(Canvas canvas, Size size, int value) {
TextSpan span = new TextSpan(
style: new TextStyle(color: Colors.black), text: value.toString());
TextPainter tp = TextPainter(
text: span,
textDirection: TextDirection.ltr,
textAlign: TextAlign.left,
textScaleFactor: size.width / 40);
tp.layout(minWidth: 0);
Offset newOffset = Offset(offset.dx, offset.dy);
tp.paint(canvas, newOffset);
}
void _drawSelector(Canvas canvas, Size size, double angle) {
strokePaint.strokeWidth = 1;
double x = size.width * cos(angle);
double y = size.height * sin(angle);
canvas.drawCircle(Offset(x, y), size.width / 10, fillPaint);
canvas.drawCircle(Offset(x, y), size.height / 10, strokePaint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return true;
}
}
`
Posted on May 12, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.