How I made a custom color picker slider using Flutter and Dart

The finalized color slider layout

My problem with existing color pickers

I tried using already available color picker packages (mainly ), but I didn’t like the experience any of them offered. So I decided I had to make my own. The main requirements were to only have 2 sliders in total; one to pick the primary color and one to adjust the shade of color (ie. sliding from darkest to lightest). Since other color sliders map values directly in RGB, they all had 3 sliders (one for each value) which I didn’t like.

Learning how colors work with RGB

Before I could start coding I needed a deeper understanding of how RGB values translate into colors. My journey with RGB started to play around with colors in the “RGB color codes chart” section. I highly suggest you go and move around the chart and watch how the RGB values change between shades of the same base color (the middle row), and between different colors in the chart.

RGB color codes chart

After using this it’s easy to see that starting from a color in the middle row and moving up in the column the RGB values converge to 0 to create the darker shade. As you move down the column, the RGB values converge to 255 to create the lighter shades. If you didn’t already know an RGB values of (0, 0, 0) is black and an RGB value of (255, 255, 255) is white, so this makes sense. With the understanding of how these values needed to change within the sliders it was time to start coding.

Creating sliders

Knowing that I would have two sliders, one for the base color and one for the shade, I started by constructing a list of the base RGB color values that I wanted to include. These are essentially just the color values from the middle row of the color code chart above. In Dart I used the Color.fromARGB constructor to create these colors. the “A” in “ARGB” stands for alpha and it controls the opacity of a color. Since I wouldn’t be modifying the opacities at all I set all of the alpha values to 255 (fully opaque).

With the colors list created, we can create a Container in Flutter that displays these colors using a and .

class ColorPicker extends StatefulWidget {
final double width;
ColorPicker(this.width);

_ColorPickerState createState() => _ColorPickerState();
}
class _ColorPickerState extends State<ColorPicker> {
final List<Color> _colors = [
Color.fromARGB(255, 255, 0, 0),
Color.fromARGB(255, 255, 128, 0),
Color.fromARGB(255, 255, 255, 0),
Color.fromARGB(255, 128, 255, 0),
Color.fromARGB(255, 0, 255, 0),
Color.fromARGB(255, 0, 255, 128),
Color.fromARGB(255, 0, 255, 255),
Color.fromARGB(255, 0, 128, 255),
Color.fromARGB(255, 0, 0, 255),
Color.fromARGB(255, 127, 0, 255),
Color.fromARGB(255, 255, 0, 255),
Color.fromARGB(255, 255, 0, 127),
Color.fromARGB(255, 128, 128, 128),
];

Widget build(BuildContext context) {
return Column(
children: <Widget>[
Center(
child: Container(
width: widget.width,
height: 15,
decoration: BoxDecoration(
border: Border.all(width: 2, color: Colors.grey[800]),
borderRadius: BorderRadius.circular(15),
gradient: LinearGradient(colors: _colors),
),
),
),
],
);
}
}

I created a stateful widget because it will be necessary to manage the state changes when a new slider position gets selected later on. The widget currently displays the outline of the slider and uses the list of colors to fill the inside of the container using a gradient (so colors transition nicely into one another). It also takes a width input so we know how wide to make the slider (and because a lot of the math later on that calculates the resulting RGB value uses that width to determine what color is currently selected).

Container with linear gradient using the provided base colors

Now that we have the container that displays the colors, we need a way to actually use it as a slider. Flutter offers the class which I will use to draw a very simple circle at the current position of the slider. My custom painter looks like this:

class _SliderIndicatorPainter extends CustomPainter {
final double position;
_SliderIndicatorPainter(this.position);

void paint(Canvas canvas, Size size) {
canvas.drawCircle(
Offset(position, size.height / 2), 12, Paint()..color = Colors.black);
}

bool shouldRepaint(_SliderIndicatorPainter old) {
return true;
}
}

It takes a position input and draws a black circle at the current position, vertically centered (which is where the size.height/2 comes from in the Offset). With the _SliderIndicatorPainter class created it’s time to build the slider interactions and paint the slider position onto the container from above. I will wrap the original container with a GestureDetector and use events from horizontal drags as well as taps to update the slider position.

GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (DragStartDetails details) {
print("_-------------------------STARTED DRAG");
_colorChangeHandler(details.localPosition.dx);
},
onHorizontalDragUpdate: (DragUpdateDetails details) {
_colorChangeHandler(details.localPosition.dx);
},
onTapDown: (TapDownDetails details) {
_colorChangeHandler(details.localPosition.dx);
},
//This outside padding makes it much easier to grab the slider because the gesture detector has
// the extra padding to recognize gestures inside of
child: Padding(
padding: EdgeInsets.all(15),
child: Container(
width: widget.width,
height: 15,
decoration: BoxDecoration(
border: Border.all(width: 2, color: Colors.grey[800]),
borderRadius: BorderRadius.circular(15),
gradient: LinearGradient(colors: _colors),
),
child: CustomPaint(
painter: _SliderIndicatorPainter(_colorSliderPosition),
),
),
),
),

You can see in the GestureDetector I’m passing the new position to a method called “_colorChangeHandler”. Let’s have a look at that method:

_colorChangeHandler(double position) {
//handle out of bounds positions
if (position > widget.width) {
position = widget.width;
}
if (position < 0) {
position = 0;
}
print("New pos: $position");
setState(() {
_colorSliderPosition = position;
});
}

The method takes the position input, and ensures that it’s not outside of the boundaries of the provided width. This is important because otherwise, if you dragged the slider outside of the container, it would just keep going! The method updates a double called _colorSliderPosition and sets that equal to the new position (from the GestureDetector) during setState. The _SliderIndicatorPainter takes this _colorSliderPosition as an input and so the slider will appear to move to the new position. This means we now have something that actually resembles a slider!

Container with slider indicator being painted on top of it

Great! The only problem is that the slider doesn’t actually do anything. It’s only updating its position but is not changing the actual color selection, so let’s work on that.

I will introduce a new color variable that will be used to track the currently selected color in the slider. To use this new variable, there needs to be a way to actually determine what color is selected in the slider. Remember, the gradient of colors being displayed is only a display. The slider position can’t determine what color is selected based on the parent Container’s gradient, because it doesn’t know anything about that. This is where we will start to use some math to determine the colors!

Some math

Color _calculateSelectedColor(double position) {
//determine color
double positionInColorArray = (position / widget.width * (_colors.length - 1));
print(positionInColorArray);
int index = positionInColorArray.truncate();
print(index);
double remainder = positionInColorArray - index;
if (remainder == 0.0) {
_currentColor = _colors[index];
} else {
//calculate new color
int redValue = _colors[index].red == _colors[index + 1].red
? _colors[index].red
: (_colors[index].red +
(_colors[index + 1].red - _colors[index].red) * remainder)
.round();
int greenValue = _colors[index].green == _colors[index + 1].green
? _colors[index].green
: (_colors[index].green +
(_colors[index + 1].green - _colors[index].green) * remainder)
.round();
int blueValue = _colors[index].blue == _colors[index + 1].blue
? _colors[index].blue
: (_colors[index].blue +
(_colors[index + 1].blue - _colors[index].blue) * remainder)
.round();
_currentColor = Color.fromARGB(255, redValue, greenValue, blueValue);
}
return _currentColor;
}

There’s a lot going on in this new method, so let’s walk through it. First it takes the new position as an input and uses that position as well as the width to determine what color is selected. By knowing the position and the width, we can determine where in the color gradient the slider is. The “positionInColorArray” variable is set by taking the current position and dividing it by the total width, giving us a ratio. Multiplying that ratio by the number of items in the colors list tells us precisely where in the list we would be if the slider and list were actually tied together (which of course they are not, both are displayed independently).

Next, we truncate that value to determine the index of the color that is closest to the left of the slider. We save the remainder so we know how far past that base color the slider actually is. If there is no remainder then the slider is directly on top of a color in the color list, so the method just returns that color.

If it’s not directly on top of a color then we have to do some math to determine what the output color should be based on the colors to the left and right of the slider. Essentially, we use the color list value to the left and right of the slider and use the remainder to decide how much to transition each RGB value. For each of the RGB values we read the color to the left’s value and compare it to the color to the right of the slider. If they are different then we take the right color’s value, subtract the left color’s value to determine the difference in color value and multiply it by the remainder value (which represents how far between the two colors we are), then add the left color’s original value back to that. We round that value to an int to use as an RGB value. For any point on the displayed gradient this will return a value that is very close to the actual value in the gradient itself.

At this point it will be helpful to add a container that shows the currently selected color so you can see this in action. In the _colorChangeHandler method’s setState call you need to update the new current color and then it can be displayed in a container.

class _ColorPickerState extends State<ColorPicker> {
final List<Color> _colors = [
Color.fromARGB(255, 255, 0, 0),
Color.fromARGB(255, 255, 128, 0),
Color.fromARGB(255, 255, 255, 0),
Color.fromARGB(255, 128, 255, 0),
Color.fromARGB(255, 0, 255, 0),
Color.fromARGB(255, 0, 255, 128),
Color.fromARGB(255, 0, 255, 255),
Color.fromARGB(255, 0, 128, 255),
Color.fromARGB(255, 0, 0, 255),
Color.fromARGB(255, 127, 0, 255),
Color.fromARGB(255, 255, 0, 255),
Color.fromARGB(255, 255, 0, 127),
Color.fromARGB(255, 128, 128, 128),
];
double _colorSliderPosition = 0;
Color _currentColor;
@override
initState(){
super.initState();
_currentColor = _calculateSelectedColor(_colorSliderPosition);
}
_colorChangeHandler(double position) {
//handle out of bounds positions
if (position > widget.width) {
position = widget.width;
}
if (position < 0) {
position = 0;
}
print("New pos: $position");
setState(() {
_colorSliderPosition = position;
_currentColor = _calculateSelectedColor(_colorSliderPosition);
});
}
Color _calculateSelectedColor(double position) {
//determine color
double positionInColorArray = (position / widget.width * (_colors.length - 1));
print(positionInColorArray);
int index = positionInColorArray.truncate();
print(index);
double remainder = positionInColorArray - index;
if (remainder == 0.0) {
_currentColor = _colors[index];
} else {
//calculate new color
int redValue = _colors[index].red == _colors[index + 1].red
? _colors[index].red
: (_colors[index].red +
(_colors[index + 1].red - _colors[index].red) * remainder)
.round();
int greenValue = _colors[index].green == _colors[index + 1].green
? _colors[index].green
: (_colors[index].green +
(_colors[index + 1].green - _colors[index].green) * remainder)
.round();
int blueValue = _colors[index].blue == _colors[index + 1].blue
? _colors[index].blue
: (_colors[index].blue +
(_colors[index + 1].blue - _colors[index].blue) * remainder)
.round();
_currentColor = Color.fromARGB(255, redValue, greenValue, blueValue);
}
return _currentColor;
}

Widget build(BuildContext context) {
return Column(
children: <Widget>[
Center(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (DragStartDetails details) {
print("_-------------------------STARTED DRAG");
_colorChangeHandler(details.localPosition.dx);
},
onHorizontalDragUpdate: (DragUpdateDetails details) {
_colorChangeHandler(details.localPosition.dx);
},
onTapDown: (TapDownDetails details) {
_colorChangeHandler(details.localPosition.dx);
},
//This outside padding makes it much easier to grab the slider because the gesture detector has
// the extra padding to recognize gestures inside of
child: Padding(
padding: EdgeInsets.all(15),
child: Container(
width: widget.width,
height: 15,
decoration: BoxDecoration(
border: Border.all(width: 2, color: Colors.grey[800]),
borderRadius: BorderRadius.circular(15),
gradient: LinearGradient(colors: _colors),
),
child: CustomPaint(
painter: _SliderIndicatorPainter(_colorSliderPosition),
),
),
),
),
),
Container(
height: 50,
width: 50,
decoration: BoxDecoration(
color: _currentColor,
shape: BoxShape.circle,
),
)

],
);
}
}
Slider that updates the selected color

…Another slider?

If you only wanted to select from those base colors, then congratulations you’re done. For me, I needed a second slider that can be used to select a shade of color to make it lighter or darker. I’ll add a second slider with it’s own position tracking and color calculations for this. You end up with two color trackers, one for the selected base color and one for the shaded color.

The second slider works similarly to the first one so I’m not going to walk through it step by step. The main difference is that the gradient for this one will transition from black, to the currently selected base color (from the first slider), and then to white, to indicate shade adjustment. I’ll show the calculations for this one and then show the final code with everything included.

_shadeChangeHandler(double position) {
//handle out of bounds gestures
if (position > widget.width) position = widget.width;
if (position < 0) position = 0;
setState(() {
_shadedColor = _calculateShadedColor(position);
print(
"r: ${_shadedColor.red}, g: ${_shadedColor.green}, b: ${_shadedColor.blue}");
});
}
Color _calculateShadedColor(double position) {
double ratio = position / widget.width;
if (ratio > 0.5) {
//Calculate new color (values converge to 255 to make the color lighter)
int redVal = _currentColor.red != 255
? (_currentColor.red +
(255 - _currentColor.red) * (ratio - 0.5) / 0.5)
.round()
: 255;
int greenVal = _currentColor.green != 255
? (_currentColor.green +
(255 - _currentColor.green) * (ratio - 0.5) / 0.5)
.round()
: 255;
int blueVal = _currentColor.blue != 255
? (_currentColor.blue +
(255 - _currentColor.blue) * (ratio - 0.5) / 0.5)
.round()
: 255;
return Color.fromARGB(255, redVal, greenVal, blueVal);
} else if (ratio < 0.5) {
//Calculate new color (values converge to 0 to make the color darker)
int redVal = _currentColor.red != 0
? (_currentColor.red * ratio / 0.5).round()
: 0;
int greenVal = _currentColor.green != 0
? (_currentColor.green * ratio / 0.5).round()
: 0;
int blueVal = _currentColor.blue != 0
? (_currentColor.blue * ratio / 0.5).round()
: 0;
return Color.fromARGB(255, redVal, greenVal, blueVal);
} else {
//return the base color
return _currentColor;
}
}

Just like before we have to calculate the color given the current position. In this case, each side of the slider represents a different calculation. The center of the slider is the base color from the previous slider, the left side darkens that base color, and the right side lightens the base color. If the ratio (position/width) is greater than 0.5, we should lighten the color. In order to do that we take the base color’s RGB values and for each of them we converge them towards 255. So, we calculate how far from 255 the current color is, and multiply that remaining value by how far up the slider the current position is and add it back to the original value.

Since the ratio value (position/width) goes from 0 to 1 but each half of the slider does a different transformation on the color, we have to adjust the results to always be in a ratio from 0 to 0.5. That means when lightening the color, we take the current ratio and subtract 0.5 so that we have it on a scale from 0 to 0.5 instead of 0.5 to 1.

Similar calculations are done to darken the shades, but instead of converging to 255 the values converge to 0. These calculations are more simple, just multiply the base color value by the position/width ratio and divide by 0.5 (since darkening is half of the slider).

There are a couple of other small changes before the code is finalized, such as changing the container that shows the current color to display the shaded color instead of only the base color, updating the shaded color whenever the current base color changes, and correctly initializing state. You can see the full code and demo below.

Final Code (main.dart)

Final color picker with color shade selection
import 'dart:math';
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {

Widget build(BuildContext context) {
return MaterialApp(
title: 'Color Picker Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: Scaffold(
appBar: AppBar(
title: Text("Color Picker Demo"),
),
body: SafeArea(
child: ColorPicker(300),
),
),
);
}
}
class _SliderIndicatorPainter extends CustomPainter {
final double position;
_SliderIndicatorPainter(this.position);

void paint(Canvas canvas, Size size) {
canvas.drawCircle(
Offset(position, size.height / 2), 12, Paint()..color = Colors.black);
}

bool shouldRepaint(_SliderIndicatorPainter old) {
return true;
}
}
class ColorPicker extends StatefulWidget {
final double width;
ColorPicker(this.width);

_ColorPickerState createState() => _ColorPickerState();
}
class _ColorPickerState extends State<ColorPicker> {
final List<Color> _colors = [
Color.fromARGB(255, 255, 0, 0),
Color.fromARGB(255, 255, 128, 0),
Color.fromARGB(255, 255, 255, 0),
Color.fromARGB(255, 128, 255, 0),
Color.fromARGB(255, 0, 255, 0),
Color.fromARGB(255, 0, 255, 128),
Color.fromARGB(255, 0, 255, 255),
Color.fromARGB(255, 0, 128, 255),
Color.fromARGB(255, 0, 0, 255),
Color.fromARGB(255, 127, 0, 255),
Color.fromARGB(255, 255, 0, 255),
Color.fromARGB(255, 255, 0, 127),
Color.fromARGB(255, 128, 128, 128),
];
double _colorSliderPosition = 0;
double _shadeSliderPosition;
Color _currentColor;
Color _shadedColor;

initState() {
super.initState();
_currentColor = _calculateSelectedColor(_colorSliderPosition);
_shadeSliderPosition = widget.width / 2; //center the shader selector
_shadedColor = _calculateShadedColor(_shadeSliderPosition);
}
_colorChangeHandler(double position) {
//handle out of bounds positions
if (position > widget.width) {
position = widget.width;
}
if (position < 0) {
position = 0;
}
print("New pos: $position");
setState(() {
_colorSliderPosition = position;
_currentColor = _calculateSelectedColor(_colorSliderPosition);
_shadedColor = _calculateShadedColor(_shadeSliderPosition);
});
}
_shadeChangeHandler(double position) {
//handle out of bounds gestures
if (position > widget.width) position = widget.width;
if (position < 0) position = 0;
setState(() {
_shadeSliderPosition = position;
_shadedColor = _calculateShadedColor(_shadeSliderPosition);
print(
"r: ${_shadedColor.red}, g: ${_shadedColor.green}, b: ${_shadedColor.blue}");
});
}
Color _calculateShadedColor(double position) {
double ratio = position / widget.width;
if (ratio > 0.5) {
//Calculate new color (values converge to 255 to make the color lighter)
int redVal = _currentColor.red != 255
? (_currentColor.red +
(255 - _currentColor.red) * (ratio - 0.5) / 0.5)
.round()
: 255;
int greenVal = _currentColor.green != 255
? (_currentColor.green +
(255 - _currentColor.green) * (ratio - 0.5) / 0.5)
.round()
: 255;
int blueVal = _currentColor.blue != 255
? (_currentColor.blue +
(255 - _currentColor.blue) * (ratio - 0.5) / 0.5)
.round()
: 255;
return Color.fromARGB(255, redVal, greenVal, blueVal);
} else if (ratio < 0.5) {
//Calculate new color (values converge to 0 to make the color darker)
int redVal = _currentColor.red != 0
? (_currentColor.red * ratio / 0.5).round()
: 0;
int greenVal = _currentColor.green != 0
? (_currentColor.green * ratio / 0.5).round()
: 0;
int blueVal = _currentColor.blue != 0
? (_currentColor.blue * ratio / 0.5).round()
: 0;
return Color.fromARGB(255, redVal, greenVal, blueVal);
} else {
//return the base color
return _currentColor;
}
}
Color _calculateSelectedColor(double position) {
//determine color
double positionInColorArray =
(position / widget.width * (_colors.length - 1));
print(positionInColorArray);
int index = positionInColorArray.truncate();
print(index);
double remainder = positionInColorArray - index;
if (remainder == 0.0) {
_currentColor = _colors[index];
} else {
//calculate new color
int redValue = _colors[index].red == _colors[index + 1].red
? _colors[index].red
: (_colors[index].red +
(_colors[index + 1].red - _colors[index].red) * remainder)
.round();
int greenValue = _colors[index].green == _colors[index + 1].green
? _colors[index].green
: (_colors[index].green +
(_colors[index + 1].green - _colors[index].green) * remainder)
.round();
int blueValue = _colors[index].blue == _colors[index + 1].blue
? _colors[index].blue
: (_colors[index].blue +
(_colors[index + 1].blue - _colors[index].blue) * remainder)
.round();
_currentColor = Color.fromARGB(255, redValue, greenValue, blueValue);
}
return _currentColor;
}

Widget build(BuildContext context) {
return Column(
children: <Widget>[
Center(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (DragStartDetails details) {
print("_-------------------------STARTED DRAG");
_colorChangeHandler(details.localPosition.dx);
},
onHorizontalDragUpdate: (DragUpdateDetails details) {
_colorChangeHandler(details.localPosition.dx);
},
onTapDown: (TapDownDetails details) {
_colorChangeHandler(details.localPosition.dx);
},
//This outside padding makes it much easier to grab the slider because the gesture detector has
// the extra padding to recognize gestures inside of
child: Padding(
padding: EdgeInsets.all(15),
child: Container(
width: widget.width,
height: 15,
decoration: BoxDecoration(
border: Border.all(width: 2, color: Colors.grey[800]),
borderRadius: BorderRadius.circular(15),
gradient: LinearGradient(colors: _colors),
),
child: CustomPaint(
painter: _SliderIndicatorPainter(_colorSliderPosition),
),
),
),
),
),
Center(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (DragStartDetails details) {
print("_-------------------------STARTED DRAG");
_shadeChangeHandler(details.localPosition.dx);
},
onHorizontalDragUpdate: (DragUpdateDetails details) {
_shadeChangeHandler(details.localPosition.dx);
},
onTapDown: (TapDownDetails details) {
_shadeChangeHandler(details.localPosition.dx);
},
//This outside padding makes it much easier to grab the slider because the gesture detector has
// the extra padding to recognize gestures inside of
child: Padding(
padding: EdgeInsets.all(15),
child: Container(
width: widget.width,
height: 15,
decoration: BoxDecoration(
border: Border.all(width: 2, color: Colors.grey[800]),
borderRadius: BorderRadius.circular(15),
gradient: LinearGradient(
colors: [Colors.black, _currentColor, Colors.white]),
),
child: CustomPaint(
painter: _SliderIndicatorPainter(_shadeSliderPosition),
),
),
),
),
),
Container(
height: 50,
width: 50,
decoration: BoxDecoration(
color: _shadedColor,
shape: BoxShape.circle,
),
)
],
);
}
}

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store