Software development, as well as any other industry, is greatly influenced by trends and market tendencies. That’s why to make sure that your apps correspond to the existing realities, you need to keep an eye on them. Trends in programming algorithms simply enable you to perform the same tasks faster, more conveniently, and to maintain modern standards. As a result, if you do not adhere to them, you will miss out on opportunities for efficiency. Nevertheless, when it comes to GUI design, not following trends can significantly harm the end product. If there’s a solution on the market with similar functionality but a more attractive design, it’s highly likely that customers will choose it.
Mobile platforms and their rules had a significant impact on the habits and expectations of users. That’s why it would be a good idea to take some approaches and methods of organizing GUI for building your Windows apps. One of the interesting features that have come from mobile development to Windows is the support for light and dark design themes.
As this blog is devoted to software development, I think that even if you do not share the same opinion, you definitely have seen dozens of memes about the light IDE theme. Fortunately, adding themes to the VCL is deeply integrated into the framework and a programmer needs to make just a couple of clicks to use a theme in an app. But here we can face another pitfall – icons. Yes, that’s not a rare case when the chosen icons do not look good on a light or dark background.
Of course, Delphi programmers are not the first developers who can face such issues. And today, there is a range of recommendations on how to address them. Developers can use guidelines for building interfaces, for example, Material Design by Google or Fluent Design by Microsoft. The topic of icons and colors is widely covered in these materials and I believe that it will be useful to familiarize yourselves with them.
The problem with icons can be solved in several ways:
- You can maintain two sets of icons for light and dark themes;
- You can redraw icons following the recommendations and techniques for drawing and choosing colors to make sure that icons will look good on any background;
- You can change the color of icons dynamically with the help of TintColor.
FMX was developed with a focus on multi-platform compatibility and it incorporates many useful functions for working with graphics. However, when it comes to ready-made solutions in VCL, there are some challenges. In this article, we will explore how to implement dynamic icon color changes using events in ImageCollection.
If you have earlier worked with images, you probably know that in VCL access to image data is mainly provided via Canvas. The TCanvas class has such a property as pixel. It allows us to get or set a color for each pixel of the image.
In our case, we won’t use this approach because of several reasons:
- This approach makes it possible to set a color in TColor that doesn’t consider the channel with transparency.
- It typically takes quite a lot of time to get access with the help of this method. That’s why it is not recommended to use it for significant changes.
As modern icons are distributed either in the vector SVG format or in PNG with a transparency channel, this approach doesn’t suit us. That’s why we will use direct memory access for conducting the required manipulations.
The vector SVG format is not supported directly in VCL. To work with it, we need to use external components, such as Skia4Delphi. But that’s already a completely different story and it is already out of the scope of this article.
With PNG images, everything is not all that simple as well. There can be a lot of color storage formats. To ensure the required simplicity of writing our example code, we will consider PNG with a color depth of 32 bits and the BGRA storage format. Since while converting the ImageCollection-native TWICImage format to a regular TBitmap, many variations will be provided in BGRA, this example will cover the majority of cases that you may encounter.
Let’s start with the preparation of images for our application. I have decided to use them from a couple of popular sources, such as:
- https://fonts.google.com/icons – official material design icons
- https://fluent2.microsoft.design/iconography – official fluent2 design icons
- https://icons8.com/icons
- https://www.iconfinder.com/
- https://www.flaticon.com/
To demonstrate the case better, we’ve downloaded icons in different styles and different colors. Now let’s proceed to create our program.
To begin with, we should create a regular VCL application. Let’s place our key components in the main form. These are TImageCollection where our icons will be stored and TVirtualImageList which will be responsible for drawing modified versions of our icons.
To demonstrate the result of our work, we will place a TToolbar at the top of the form and create several TToolButtons on it (in accordance with the number of downloaded icons). Then we will link our components.
Let’s do the following things:
- Set the TVirtualImageList component to the Images property of TToolbar;
- Set the TImageCollection component to the ImageCollection property of TVirtualImageList;
- Set the desired icon size in the TVirtualImageList (for example, 32×32);
- Set the AutoSize property of TToolbar to true so that its size will be adjusted automatically to fit the buttons, which in turn will be adjusted to the icon size;
- Set the AutoFill property of TVirtualImageList to true to automatically place all icons from ImageCollection into TVirtualImageList;
- Load the icons into ImageCollection.
After these simple actions, the application will be ready for the first launch.
Some icons were downloaded in white color (toolbutton1, toolbutton11), and as you can see in the screenshot, in the standard VCL theme in Windows 11, they are hardly noticeable. Therefore, let’s ensure the possibility of dynamic theme switching.
To do this, we need to open Project->Options->Appearance->CustomStyles. We should check the boxes next to the preferred themes and apply the changes to compile these themes into our project.
We will place a GroupBox on the form, label it, and insert a ComboBox (let’s name it cbThemes) into it. Now let’s set the Style property of the ComboBox to csDropDownList (this will let us limit the selection to the items in the list without allowing free input).
Let’s create an event OnChanhe of our cbThemes so that a theme is automatically applied to the project after its selection.
12345 | procedure TForm2.cbThemesChange(Sender: TObject);
begin
if cbThemes.ItemIndex <> -1 then
TStyleManager.SetStyle(cbThemes.Items[cbThemes.ItemIndex]);
end; |
Do not forget to add Vcl.Themes to the uses section in order to get access to TStyleManager.
And now we need only to fill in our list with the available themes. To do this, it is necessary to create an OnCreate event for the form:
1234567 | procedure TForm2.FormCreate(Sender: TObject);
begin
var xThemeName := TStyleManager.ActiveStyle.Name;
cbThemes.Items.AddStrings(TStyleManager.StyleNames);
cbThemes.ItemIndex := cbThemes.Items.IndexOf(xThemeName);
cbThemesChange(nil);
end; |
Let’s run and test it:
As you can see in the screenshots, icons are absolutely not suitable for being used in these two styles. Those that are visible in the light theme can be hardly seen in the dark one and vice versa. That’s why we should move on to the next stage – changing the icon color.
To offer a user the possibility of choosing a color, we will add to the form TColorDialog (to choose a color), TButton (to call a dialog), and TPaintBox (to display the chosen color). And also let’s declare a variable to store the color FIconColor.
To display the color, we will create an OnPaint event for TPaintBox:
123456789 | procedure TForm2.PaintBox1Paint(Sender: TObject);
begin
with PaintBox1.Canvas do
begin
Brush.Style := bsSolid;
Brush.Color := FIconColor;
FillRect(ClipRect);
end;
end; |
And also let’s add a handler for the button click event to select a color:
123456789 | procedure TForm2.btnSelectColorClick(Sender: TObject);
begin
ColorDialog1.Color := FIconColor;
if ColorDialog1.Execute(Handle) then
begin
FIconColor := ColorDialog1.Color;
PaintBox1.Invalidate;
end;
end; |
The ideal place for performing various manipulations with images is the OnGetBitmap event of the TImageCollection component. It occurs every time the TVirtualImageList wants to update its cache with a specific image. Thus, by modifying the result, we can be sure that the changes have been applied to the image displayed in the program. These manipulations won’t occur too frequently, as, for example, they would if we did them with every rendering, which would load the system. Moreover, we will still have the original image available. When conditions dynamically change, we can work with the original image.
Let’s have a look at the parameters of this event:
12345 | procedure TForm2.ImageCollection1GetBitmap(ASourceImage: TWICImage; AWidth,
AHeight: Integer; out ABitmap: TBitmap);
begin
//
end; |
We will receive the optimal source image ASourceImage, already selected according to the internal rules of TImageCollection, and the requested dimensions by our TVirtualImageList. All that will be left to do is to fill the outgoing parameter ABitmap, and everything will be ready.
For high-quality scaling, we will use the CreateScaledCopy method of the TWICImage class. This function returns an object of the TWICImage type, so we will need to declare a temporary variable. To transfer data from TWICImage to the resulting TBitmap, let’s use the Assign method. There shouldn’t be any issues with this, so let’s move on to color replacement.
To get access to the icon data, we will use the ScanLine method of TBitmap. It returns a pointer to the beginning of the data for a specific row of pixels in the image. However, the Pointer type means that the data is not strictly typed and may vary. In reality, if this were a universal program, we would have to write a lot of lines of code to analyze PixelFormat, Monochrome, Mask, AlphaFormat, Palette, etc., covering all possible scenarios. Fortunately, we know for sure that we loaded only 32-bit PNG images into TImageCollection, so we can handle only this case.
In the WinApi.Windows module, there is a special type called TRGBQuad, which is a record consisting of four bytes:
1234567 | tagRGBQUAD = record
rgbBlue: Byte;
rgbGreen: Byte;
rgbRed: Byte;
rgbReserved: Byte;
end;
TRGBQuad = tagRGBQUAD; |
Here rgbReserved is responsible for the channel with transparency. To simplify our work, let’s declare two helper types:
TRGBALine is an array of our four-byte pixels, while PRGBALine is a pointer to such an array. Now we can cast the result of the ScanLine function to the PRGBALine type and work conveniently with colors.
The simplest way to set the color will be to replace the value of the original color with our chosen color while retaining the original transparency value. For this implementation, the code will look the following way:
12345678910111213141516171819202122232425262728293031 | procedure TForm2.ImageCollection1GetBitmap(ASourceImage: TWICImage; AWidth,
AHeight: Integer; out ABitmap: TBitmap);
type
TRGBALine = array[word] of TRGBQuad;
PRGBALine = ^TRGBALine;
var
xBufferImage: TWICImage;
dst : PRGBALine;
begin
ABitmap := TBitmap.Create;
xBufferImage := ASourceImage.CreateScaledCopy(AWidth, AHeight);
try
ABitmap.Assign(xBufferImage);
var tintRed := GetRValue(FIconColor);
var tintGreen := GetGValue(FIconColor);
var tintBlue := GetBValue(FIconColor);
for var y := 0 to ABitmap.Height -1 do
begin
dst := ABitmap.ScanLine[y];
for var X := 0 to ABitmap.Width – 1 do
begin
dst[x].rgbRed := tintRed;
dst[x].rgbGreen := tintGreen;
dst[x].rgbBlue := tintBlue;
end;
end;
finally
xBufferImage.Free;
end;
end; |
Here, we will perform image scaling, converting it to TBitmap. Next, we should take the value of each color channel from FIconColor and cache them in variables. Then, we will go through each row of our image, assigning the cached color values to each pixel of this row.
If we run our application now, you may notice that all icons will be always black regardless of the color chosen. This will happen because the TVirtualImageList cached the image at the start and no longer triggers the ImageCollection1GetBitmap event. To reset the cache, we will call the VirtualImageList1.UpdateImageList method while selecting a new color. And to redraw the changed icons on the TToolBar, we will call ToolBar1.Invalidate.
This code is a good base for further modification and adaptation to the requirements of your task. For example, you can easily add icon transparency just with a few lines of code.
Let’s add aTTrackbar component and call it tbAlpha. Let’s set its Min property to 1 and Max to 255. We will create an OnChange event handler to redraw the changed icons when the transparency is altered.
12345 | procedure TForm2.tbAlphaChange(Sender: TObject);
begin
VirtualImageList1.UpdateImageList;
ToolBar1.Invalidate;
end; |
And in the ImageCollection1GetBitmap method, in the cycle of manipulations with an icon let’s write one additional code line:
As a result, we will have the possibility to set the transparency of our icons:
If you use not monochrome icons in your app, then by using such formulas for each channel, we will get not a color replacement but TintColor change in the understanding of Color theory (https://en.wikipedia.org/wiki/Tint,_shade_and_tone).
1234567891011121314151617181920212223242526272829303132 | procedure TForm2.ImageCollection1GetBitmap(ASourceImage: TWICImage; AWidth,
AHeight: Integer; out ABitmap: TBitmap);
type
TRGBALine = array[word] of TRGBQuad;
PRGBALine = ^TRGBALine;
var
xBufferImage: TWICImage;
dst : PRGBALine;
begin
ABitmap := TBitmap.Create;
xBufferImage := ASourceImage.CreateScaledCopy(AWidth, AHeight);
try
ABitmap.Assign(xBufferImage);
var tintRed := GetRValue(FIconColor);
var tintGreen := GetGValue(FIconColor);
var tintBlue := GetBValue(FIconColor);
for var y := 0 to ABitmap.Height -1 do
begin
dst := ABitmap.ScanLine[y];
for var X := 0 to ABitmap.Width – 1 do
begin
dst[x].rgbRed := integer(dst[x].rgbRed * tintRed) div 255;
dst[x].rgbGreen := integer(dst[x].rgbGreen * tintGreen) div 255;
dst[x].rgbBlue := integer(dst[x].rgbBlue * tintBlue) div 255;
dst[x].rgbReserved := integer(dst[x].rgbReserved * tbAlpha.Position) div 255;
end;
end;
finally
xBufferImage.Free;
end;
end; |
For a more clear demonstration of the difference between the approaches, I have displayed the original icons and added a colored folder icon drawn in the Fluent 2 Design style. Please pay attention to the transition of tones between the front and back parts of the folder, as well as the gradient change of the folder’s spine. Ordinary color replacement would not have given us such an effect.
But at the same time, this approach did not affect a completely black color. That’s why always choose algorithms considering your specific tasks.