XP style icons met TImageList en TBitmap

Erik Stok (aka Baldo)

Tijdens een klein onderhoud met Richard op de NLDelphi chat kwam het uiterlijk van applicaties ter sprake. Richard had zijn applicatie met een XP manifest en mooie xp style icons een prachtig uiterlijk gegeven. Richard demonstreerde hoe eenvoudig dit gedaan kon worden met een (Delphi 7) xp manifest en de juiste icons (internet).

Tijdens mijn zoektocht op het internet naar icons voor mijn eigen applicaties kwam ik diverse mooie sets tegen, maar ik stuitte telkens weer op het probleem dat deze libraries vaak in een bestandsformaat worden aangeleverd waarbij vloeiende randen en schaduweffecten worden gebruikt met behulp van zogenaamd alpha blending. En zowel TImageList als TBitmap, die we denk ik bijna allemaal gebruiken voor onze applicaties, ondersteunen deze faciliteit niet.

De vraag is dus: hoe kan ik TImageList, TBitmap of de controls die ze gebruiken zo manipuleren dat alpha blending toch gebruikt wordt? Want dat mooie effect van die vloeiende randen en schaduw willen we natuurlijk ook allemaal wel...

Icons

Om te beginnen is er een icon library nodig waarmee gewerkt gaat worden. Het bedrijf waar ik werk vindt de icons van Incors een mooie set. Gelukkig hebben ze ook een gratis set icons te downloaden waarvan in dit artikel gebruikt gemaakt kan worden.

Er zijn natuurlijk ook icon libraries die reeds in niet alpha-blending formaat worden geleverd, bijvoorbeeld die van Glyfx, maar die hebben niet die vloeiende randen en schaduweffecten die ik zo graag in mijn icons wil hebben.

De verschillen tussen beide oplossingen zijn trouwens goed zichtbaar in onderstaand voorbeeld (en dat is dan nog zonder schaduw):

Uitvergroting van een icon met alpha blended randen. Uitvergroting van een icon zonder alpha blended randen.

Png images

De Incors icons worden geleverd in Png formaat, een formaat dat een alpha channel ondersteunt en dus alpha blending aankan. Delphi ondersteunt standaard geen Png, dus na de icons volgt de zoektocht naar een library met Png support code. Google levert met de zoekterm TPngImage direct resultaat: pngdelphi.sourceforge.net is een gratis Png implementatie voor Delphi.

Omdat deze library zonder package wordt geleverd zal ik voor het gemak deze library insluiten in het component package met het eindresultaat van dit artikel.

TBitmap en TSpeedbutton

Het eerste waarmee ik aan de slag wil is een TSpeedButton. De glyph property van dit component ondersteunt alleen maar TBitmap. Transparency wordt bereikt door naar de kleur van de pixel linksonder in de bitmap te kijken en alle pixels in die kleur te vervangen door de kleur van de button achtergrond.

TBitmap aanpassen om alpha blending te bereiken haalt weinig uit, want het bepalen van de transparantie wordt niet in de bitmap zelf geïmplementeerd. De aanpassing zal dus in TSpeedButton zelf gedaan moeten worden.

Een makkelijke oplossing is de bitmap niet transparant te maken, maar de kleur van een knop, en daarop een alpha blended image te tekenen. Dit zal niet werken in alle gevallen van transparantie van de speedbutton, maar die voor mij uitzonderlijke gevallen laat ik voor wat ze zijn.

TSpeedButton uitbreiden met een PngImage property is eenvoudig: voeg een published property toe van het type TPngObject (zo heeft de maker van de library het type helaas genoemd), maak een private variabele aan om het image te bevatten en schrijf Get en Set procedures om de property te benaderen.

De truuk van de oplossing zit in het omzetten van het Png image naar een bitmap. Daarvoor maak ik een bitmap aan van 2x de breedte en 1x de hoogte van het Png image. Er is dan ruimte voor een enabled en een disabled versie van het Png image.

Daarna vul ik de bitmap met de kleur van een knop, teken het Png image er alpha blended op en teken een disabled versie van het Png image er alpha blended op. Deze bitmap wordt dan als Glyph van de speedbutton gezet.

procedure TPngSpeedButton.CreatePngGlyph;
var
Bmp : TBitmap;
Png : TPNGObject;
begin
// Maak images voor transport
Bmp := TBitmap.Create;
Png := TPNGObject.Create;

try

// Zety bitmap 2x de breedte van de png
Bmp.Width := FPngImage.Width * 2;
Bmp.Height := FPngImage.Height;

// Maak bitmap kleur van een knop
Bmp.Canvas.Brush.Color := clBtnFace;
Bmp.Canvas.FillRect(Rect(0, 0, Bmp.Width, Bmp.Height));

// Teken enabled png
FPngImage.Draw(Bmp.Canvas, Rect(0, 0, FPngImage.Width, FPngImage.Height));

// Maak disabled png
Png.Assign(FPngImage);
MakeImageHalfTransparent(Png);

// Teken disabled png
Png.Draw(Bmp.Canvas, Rect(Png.Width, 0, Bmp.Width, Png.Height));

// Assign 2 image glyph
Glyph.Assign(Bmp);
NumGlyphs := 2;

finally
Png.Free;
Bmp.Free;
end;

end;

MakeImageHalfTransparent is een functie die, zoals de naam al zegt, een Png half transparant maakt.

Een probleem bij de gekozen oplossing is echter wel dat de glyph gedurende designtime ook wordt aangemaakt. Het Png image staat dan 3 keer in de dfm: 1 keer onder de PngImage property, 1 keer als de linkerhelf van de Glyph bitmap en 1 keer als de rechterhelft van de Glyph bitmap. En die laatste twee zijn niet nodig, want tijdens runtime zal de Glyph toch opnieuw bepaald moeten worden want clBtnFace kan per configuratie verschillen. Door code toe te voegen aan de setter van de PngImage property

procedure TPngSpeedButton.SetPngImage(const Value: TPngObject);
begin
// Zet png image
FPngImage.Assign(Value);

// Forceer paint tijdens designtime, maar laat glyph niet gezet worden zodat
// deze niet in de dfm geschreven wordt. In rutime, reset de glyph.
if csDesigning in ComponentState then
Paint
else
CreatePngGlyph;
end;

aan de Paint method

procedure TPngSpeedButton.Paint;
var
PaintRect : TRect;
dx : Integer;
dy : Integer;
Png : TPNGObject;
begin
inherited;

// In designtime, paint png in op een standaard manier. Niet fancy, gewoon
// het image tonen
if csDesigning in ComponentState then
begin

Png := TPNGObject.Create;

try

Png.Assign(FPngImage);

if not Enabled then
MakeImageHalfTransparent(Png);

dx := (ClientRect.Right - ClientRect.Left) - FPngImage.Width;
dy := (ClientRect.Bottom - ClientRect.Top) - FPngImage.Height;

PaintRect := Rect(dx div 2,
dy div 2,
(dx div 2) + FPngImage.Width,
(dy div 2) + FPngImage.Height);

Canvas.Brush.Color := clBtnFace;
Canvas.FillRect(PaintRect);

Png.Draw(Canvas, PaintRect);

finally
Png.Free;
end;

end;

end;

en aan de Loaded method

procedure TPngSpeedButton.Loaded;
begin
inherited;

// Maak glyph during runtime
if not (csDesigning in ComponentState) then
CreatePngGlyph;
end;

kan ervoor worden gezorgd dat de Glyph alleen tijdens runtime bepaald wordt, terwijl tijdens designtime het Png image toch zichtbaar is.

Het resultaat is bereikt: de Glyph wordt getekend met alpha blending en de Png wordt maar 1 keer in de dfm opgeslagen.

TImagelist, TMainMenu en TToolbar

De truuk die bij TSpeedButton uitgehaald kan worden is niet haalbaar bij images in TMainMenu en TToolbar. Deze halen hun images immers uit een TImageList.

Gelukkig gebruiken TMainMenu en TToolbar een standaard method van een TImageList om hun images te tekenen. Daar ligt dan ook de sleutel tot de oplossing. Ik ga deze method van TImageList override en daar de Png tekenen.

Een probleem is echter wel: waar kan ik de Png’s opslaan en hoe zorg ik er voor dat bijvoorbeeld de Count van de ImageList toch klopt met die van de opslag?

TPngImageCollection

Daarom heb ik de TPngImageCollection gemaakt. TPngImageCollection is een component dat een collection van TPngImages bevat.

TPngImageCollection bevat zoals gezegd een collection van TPngImages, dus een collection class TPngImageCollectionItems en een collection item class TPngImageCollectionItem liggen voor de hand.

TPngImageCollectionItem heeft een property PngImage, waarin op dezelfde manier als bij TPngSpeedButton het image wordt opgeslagen.

TPngImageList

Ook heb ik een afgeleide van TImageList gemaakt, TPngImageList, die naar dit component verwijst. Door in deze imagelist ook interne images aan te maken op basis van de inhoud van de png image collection, lopen de Count en alle andere properties van de imagelist synchroon met die van de png image collection.

Een TImageList uitrusten met een referentie property naar een TPngImageCollection is eenvoudig: voeg een published property PngImageCollection toe van het type TPngImageCollection, schrijf hiervoor de Get en Set methods en klaar.

Voor het overzetten van de images uit de collection naar images in de list is de method CopyPngs gemaakt.

{
Procedure : TPngImageList.CopyPngs
Auteur : Erik Stok
Doel : Kopieer png's van de PngImageCollection zodat alle properties
goed blijven werken. Deze images worden nooit in de dfm opgeslagen.
}
procedure TPngImageList.CopyPngs(ImageList: TCustomImageList);
var
i : Integer;
Bmp : TBitmap;
Png : TPngObject;
begin
// Maak eerst de list schoon
ImageList.Clear;

// Kopieer alleen als er images zijn
if Assigned(FPngImageCollection) then
begin

// Maak transfer images
Png := TPngObject.Create;
Bmp := TBitmap.Create;

try
// Stel bitmap in
Bmp.Width := ImageList.Width;
Bmp.Height := ImageList.Height;

// Vul bitmap altijd met button color
Bmp.Canvas.Brush.Color := clBtnFace;

// Loop door alle png images
for i := 0 to FPngImageCollection.Items.Count - 1 do
begin
// Wis bitmap
Bmp.Canvas.FillRect(Rect(0, 0, Bmp.Width, Bmp.Height));

// Dupliceer png
Png.Assign(TPngImageCollectionItem( FPngImageCollection.Items.Items[i]).PngImage);

if Assigned(Png) and (Png.Width > 0) then
begin

// Indien niet enabled, maak de png dan semi-transparant
if not FEnabledImages then
MakeImageHalfTransparent(Png);

// Teken png op bitmap
Png.Draw(Bmp.Canvas, Rect(0, 0, Bmp.Width, Bmp.Height));

end;

// Voor aan de list toe
ImageList.Add(Bmp, nil);
end;

finally
// Ruim op
Bmp.Free;
Png.Free;
end;

end;

end;

Deze wordt natuurlijk in de Set method van de PngImageCollection property aangeroepen, en bij iedere update van de PngImageCollection.

Dat laatste is een minder eenvoudig principe. Er moet vanuit een collection item een update signaal naar de collection worden gestuurd. Dit gebeurt in de Set method van de PngImage property:

procedure TPngImageCollectionItem.SetPngImage(const Value: TPngObject);
begin
// Zet image
FPngImage.Assign(Value);

// Forceer changed
Changed(False);
end;

Zodra de collection een wijziging doorkrijgt of zelf wijzigt (er wordt een item toegevoegd of verwijderd), dan moet de PngImageCollection waar de collection bij hoort een signaal krijgen. Dit gebeurt in de Update method:

procedure TPngImageCollectionItems.Update(Item: TCollectionItem);
begin
inherited;

// Update owner indien nodig
if Assigned(FPngImageCollection) then
FPngImageCollection.Update;
end;

Zodra de collection wordt aangemaakt zet de PngImageCollection zich als owner van de lijst, zodat deze de update signalen kan ontvangen.

Bij het updaten van de imagelists die aan de PngImageCollection gekoppeld zijn ligt het allemaal wat complexer. Er kunnen namelijk meerdere PngImageLists naar dezelfde PngImageCollection refereren.

Daarom wordt in de PngImageCollection een lijst bijgehouden van alle refererende PngImageLists. Zodra een PngImageList refereert naar een PngImageCollection roept deze de AddList method aan. Zodra de referentie ophoudt te bestaan roept de PngImageList de RemoveList method aan.

procedure TPngImageList.SetPngImageCollection(const Value: TPngImageCollection);
begin
// Unregister bij vorige collection
if Assigned(FPngImageCollection) then
FPngImageCollection.RemoveList(Self);

// Stel collection in
FPngImageCollection := Value;

// Zet deze lijst als update listener
if Assigned(FPngImageCollection) then
FPngImageCollection.AddList(Self);

// Refresh images in imagelist
CopyPngs(Self);
end;

{
Procedure : TPngImageCollection.AddList
Auteur : Erik Stok
Doel : Voeg list als listener toe
}
procedure TPngImageCollection.AddList(List: TPngImageList);
begin
if FPngImageLists.IndexOf(List) = -1 then
FPngImageLists.Add(List);
end;

{
Procedure : TPngImageCollection.RemoveList
Auteur : Erik Stok
Purpose : Wis list uit listener lijst
}
procedure TPngImageCollection.RemoveList(List: TPngImageList);
var
i : Integer;
begin
i := FPngImageLists.IndexOf(List);
if i <> -1 then
FPngImageLists.Delete(i);
end;

Op deze manier krijgen alle PngImageLists die refereren naar een een PngImageCollection een update signaal zodra er iets wijzigt.

De referentie naar de png images is geregeld, nu kan het tekenen gebeuren. Een TImageList kent de DoDraw method waarin het tekeken van het image gebeurt. Door die aan te passen naar een routine die een soortgelijke actie als TPngSpeedButton.CreatePngGlyph uithaalt kan het image worden getekend met alpha blending:

procedure TPngImageList.DoDraw(Index: Integer; Canvas: TCanvas;
                               X, Y: Integer; Style: Cardinal; 
                               Enabled: Boolean);
var
PngImage : TPngObject;
PaintRect : TRect;
begin
if Assigned(FPngImageCollection) then
begin
// Bepaal rect
PaintRect := Rect(x, y, x + Width, y + Height);

// Teken background
Canvas.Brush.Style := bsClear;
Canvas.FillRect(PaintRect);

// Teken transparante png daarover
PngImage := TPngObject.Create;

try
PngImage.Assign( TPngImageCollectionItem(FPngImageCollection.Items.Items[Index]).PngImage);

if not Enabled then
MakeImageHalfTransparent(PngImage);

PngImage.Draw(Canvas, PaintRect);
finally
PngImage.Free;
end;
end;

end;


TToolbar

Voor TMainMenu werkt nu alles naar behoren, maar voor TToolbar is er nog een klein probleem. Hoewel TToolbar gebruik maakt van een routine die uiteindelijk DoDraw aanroept, wordt voor de disabled images gebruik gemaakt van de standaard disabled image routine van TImageList. Deze routine bakt helemaal niets van een image met alpha blending en bovendien wil ik net als bij de TPngSpeedButton een half transparant image.

TToolbar heeft echter wel een DisabledImages property waarmee je naar een imagelist kunt verwijzen waaruit de disabled images worden gehaald. Door deze te laten verwijzen naar een TPngImageList die al zijn images disabled tekent is het probleem opgelost.

Om gebruik te kunnen maken van dezelfde PngImageCollection heb ik aan de PngImageList een EnabledImages property toegevoegd. Indien deze op true wordt gezet worden de images uit de PngImageCollection enabled in de list weergegeven, als deze op false staat worden ze dus disabled weergegeven. Dit is terug te vinden in de CopyPngs routine van TPngImageList:

[...]
// Indien niet enabled, maak de png dan semi-transparant
if not FEnabledImages then
  MakeImageHalfTransparent(Png);
[...]

Door een tweede TPngImageList aan te maken met de property EnabledImages op false, is een TPngImageList aangemaakt die geschikt is als DisabledImages van TToolbar.

Designtime

Alle componenten werken nu netjes, alleen moet er designtime nog het een en ander geregeld worden om te voorkomen dat er zaken ontsporen door het onjuist invullen van properties door de programmeur. Voor de TPngSpeedButton heb ik de Glyph en de NumGlyphs properties verborgen want deze worden voortaan door de button zelf bepaald. Helaas is een geen TCustomSpeedButton waarmee dit netjes opgelost kan worden.

Voor de TPngImageList heb ik gezorgd dat de component editor van TImageList onderdrukt wordt want de inhoud van de imagelist wordt geregeld door het component zelf.

Deze properties worden allemaal nog wel door het Delphi component streaming mechanisme meegenomen, maar er zijn geen faciliteiten om deze gemakkelijk, buiten directe invoer in de dfm, te manipuleren.

Code

De code die bij dit artikel hoort is een zip bestand met daarin twee mappen: PngComponents en PngComponents demo.

In de eerste map zit een projectgroup met daarin het runtime en designtime package van de componenten (en de pngimage code). Build beide packages en install het designtime package. Daarna zullen onder de tab Png in het componenent pallet de drie nieuwe componenten te vinden zijn.

In de tweede map zit een klein demo project waarin te zien is wat het verschil is tussen de standaard TSpeedButton en de TPngSpeedbutton, hoe je de componenten aan elkaar knoopt en hoe images eruit zijn in enabled en disabled toestand.

De code is geschreven voor Delphi versie 7, maar zou met wat kleine aanpassingen ook moeten werken voor Delphi 6 en 5. De code is nog niet helemaal perfect, maar het is zeker een goede aanzet om XP style icons in je eigen applicaties in te bouwen...

Conclusie

Hoewel Delphi standaard geen ondersteuning biedt voor XP style icons kan er door de flexibele opzet en goed toegankelijke broncode toch nog het een en ander bereikt worden om je applicatie die XP look-and-feel te geven. Met deze code als basis kan voor verschillende image formats die alpha blending ondersteunen een implementatie worden gemaakt voor de gangbare GUI componenten met images.