Tym razem chciałbym opisać mechanizm zmiany skórek w naszej aplikacji.
Założenia
- Każda skórka zostanie zapisana w osobnym pliku archiwum zip
- W jego skład wejdą pliki graficzne png oraz plik xml z dodatkową konfiguracją
- Wszystkie nazwy plików ze skórkami zostaną wczytane podczas startu aplikacji.
- Zmiana skórki będzie możliwa z poziomu menu głównego.
Tworzenie pliku ze skórką
Spakowany plik nazwa_skorki.zip zawierał będzie pliki graficzne *.png oraz plik skin.xml. Struktura plików może wyglądać mniej więcej tak:

Pliki graficzne prezentują po prostu elementy, które chcemy podmienić np. przyciski. W pliku skin.xml możemy natomiast opisać całą resztę konfiguracji np. wszystkie elementy, którym chcemy zmienić kolory, rozmiar, położenie:

Wczytanie danych o skórkach
Aby wczytać listę skórek, musimy przeszukać katalog, w którym się one znajdują. Zapisujemy tylko nazwy plików bez pełnej ścieżki i rozszerzeń.
const
SKINS_PATH = 'data/skins/';
SKIN_FILE_EXTENSION = '.zip';
SKIN_FILE_PATTERN = '*' + SKIN_FILE_EXTENSION;
class procedure TSkins.SearchForAllSkinsFiles();
var
files: TStringList;
i: Integer;
begin
if not Assigned(SkinFiles) then
SkinFiles := TStringList.Create
else
SkinFiles.Clear;
files :=
FindAllFiles(ConcatPaths([GetApplicationPath, SKINS_PATH]), SKIN_FILE_PATTERN, false);
try
for i := 0 to Pred(files.Count) do
SkinFiles.Add(RemoveFileExtension(ExtractFileName(files[i])));
finally
files.Free;
end;
end;
Wczytanie domyślnej skórki
Z ustawień aplikacji pobieramy nazwę domyślnej skórki.
class function TSkins.GetSkinFileName(const UseDefaultSkin: Boolean): string;
var
skinName: string;
begin
// try to get the skin name from the settings
if UseDefaultSkin then
skinName := DEFAULT_SKIN
else
skinName := TTRPSettings.GetValue('SelectedSkin', EMPTY_STR);
if skinName = EMPTY_STR then
skinName := DEFAULT_SKIN;
TTRPSettings.SetValue('SelectedSkin', skinName);
Result := skinName;
end;
Następnie próbujemy wczytać plik i wypakować jego składowe. Dodatkowo trzeba pamiętać, aby podać dokładne nazwy zasobów, które mają zostać wypakowane.
class function TSkins.GetSkinResources(const SkinName: string): Boolean;
var
zipFile: TUnZipper;
items: TStringList;
begin
Result := false;
CurrentSkinName := SkinName;
items := TStringList.Create;
try
// Search icon
items.Add('icoSearch.png');
// Bottom function panel
items.Add('btnPlay.png');
items.Add('btnPause.png');
items.Add('btnPrev.png');
items.Add('btnStop.png');
items.Add('btnNext.png');
items.Add('btnRec.png');
items.Add('btnOpen.png');
// Station List Popup Menu
items.Add('btnAdd.png');
items.Add('btnEdit.png');
items.Add('btnDelete.png');
// xml
items.Add('skin.xml');
zipFile := TUnZipper.Create;
try
zipFile.FileName := GetSkinFilePath(SkinName);
zipFile.OnOpenInputStream := @zipFileOpenInputStream;
zipFile.OnCreateStream := @zipFileCreateStream;
zipFile.OnDoneStream := @zipFileDoneStream;
zipFile.OnCloseInputStream := @zipFileCloseInputStream;
zipFile.UnZipFiles(items);
finally
FreeAndNil(zipFile);
end;
finally
FreeAndNil(items);
end;
Result := true;
end;
Klasa TUnZipper działa w taki sposób, że po każdej akcji np. wypakowaniu pliku, emitowane jest zdarzenie pod które możemy się podpiąć. W naszym przypadku zdarzenie OnDoneStream umożliwi zapisanie w pamięci plików graficznych oraz deserializację konfiguracji.
class procedure TSkins.zipFileDoneStream(Sender: TObject; var AStream: TStream;
AItem: TFullZipFileEntry);
var
xmlNode: TDOMNode;
xmlDoc: TXMLDocument;
i: integer;
begin
if AItem.DiskFileName = 'skin.xml' then
begin
AStream.Position := 0;
// Load xml file from stream
ReadXMLFile(xmlDoc, AStream);
try
xmlNode := xmlDoc.FirstChild;
if Assigned(xmlNode) then
begin
// Skin items
with xmlNode.ChildNodes do
begin
try
for i := 0 to Count - 1 do
begin
if Item[i].NodeName = 'Item' then
begin
if (Item[i].Attributes.GetNamedItem('name') <> nil) and
(Item[i].FirstChild <> nil) then
begin
FSkinData.AddItem(
Item[i].Attributes.GetNamedItem('name').NodeValue,
Item[i].FirstChild.NodeValue);
end;
end;
end;
finally
Free;
end;
end;
end;
finally
// Finally, free the xml document
xmlDoc.Free;
end;
end
else
begin
// Load bitmaps
FSkinData.AddBitmap(RemoveFileExtension(AItem.DiskFileName), AStream);
if Assigned(OnSkinDoneStream) then
OnSkinDoneStream(Sender, AStream, AItem);
end;
Astream.Free;
end;
Zdarzenie OnCloseInputStream emitowane jest po wczytaniu wszystkich plików i zamknięciu pliku ze skórką. Podepniemy tu nasze zdarzenie, które poinformuje o wczytaniu skórki.
procedure TSkins.zipFileCloseInputStream(Sender: TObject; var AStream: TStream);
begin
if Assigned(OnSkinLoaded) then
OnSkinLoaded(Sender, FSkinData);
end;
Teraz wystarczy już tylko podmienić odpowiednie elementy na GUI. Możemy to zrobić np. na głównej formatce aplikacji podpinając się pod zdarzenie OnSkinLoaded.
procedure TMainForm.LoadSkin;
begin
TSkins.OnSkinLoaded := @SkinLoaded;
TSkins.LoadSkin();
end;
procedure TMainForm.SkinLoaded(Sender: TObject; var ASkinData: TSkinData);
begin
// Panels
SearchPanel.Background.Color := ASkinData.GetColorItem('SearchPanel.BackgroundColor');
StationListPanel.Background.Color := ASkinData.GetColorItem('StationListPanel.BackgroundColor');
MainPanel.Background.Color := ASkinData.GetColorItem('MainPanel.BackgroundColor');
// PeakmeterPanel
PeakmeterPanel.Background.Color := ASkinData.GetColorItem('PeakmeterPanel.BackgroundColor');
PeakmeterPanel.Border.Color := ASkinData.GetColorItem('PeakmeterPanel.BorderColor');
// BottomFunctionPanel
BottomFunctionPanel.Background.Gradient1.StartColor :=
ASkinData.GetColorItem('BottomFunctionPanel.Background.Gradient1.StartColor');
BottomFunctionPanel.Background.Gradient1.EndColor :=
ASkinData.GetColorItem('BottomFunctionPanel.Background.Gradient1.EndColor');
// SearchEdit
SearchEdit.Color := TSkins.GetColorItem('SearchEdit.Color');
SearchEdit.Font.Color := TSkins.GetColorItem('SearchEdit.FontColor');
// Bitmaps
miAddStation.Bitmap.Assign(ASkinData.GetBitmapItem('btnAdd'));
miEditStation.Bitmap.Assign(ASkinData.GetBitmapItem('btnEdit'));
miDeleteStation.Bitmap.Assign(ASkinData.GetBitmapItem('btnDelete'));
btnPlay.Glyph.Assign(ASkinData.GetBitmapItem('btnPlay'));
btnPrev.Glyph.Assign(ASkinData.GetBitmapItem('btnPrev'));
btnStop.Glyph.Assign(ASkinData.GetBitmapItem('btnStop'));
btnNext.Glyph.Assign(ASkinData.GetBitmapItem('btnNext'));
btnOpen.Glyph.Assign(ASkinData.GetBitmapItem('btnOpen'));
end;
Zmiana skórki
Listę stacji wczytujemy i umieszczamy np. w głównym menu aplikacji.
procedure TMainForm.AddMenuSkinItems;
var
i: integer;
subItem: TMenuItem;
begin
for i := 0 to Pred(TSkins.SkinFiles.Count) do
begin
subItem := TMenuItem.Create(miSkins);
subItem.Caption := TSkins.SkinFiles[i];
subItem.Tag := i;
subItem.OnClick:= @miSkinsItemClick; // after clicking on the skin
subItem.Checked := TSkins.CurrentSkinName = TSkins.SkinFiles[i];
miSkins.Add(subItem);
end;
end;

Pod każdą skórkę podpięliśmy zdarzenie OnClick, które po kliknięciu spowoduje załadowanie danej skórki.
procedure TMainForm.miSkinsItemClick(Sender: TObject);
var
i: integer;
mi : TMenuItem;
begin
if Sender is TMenuItem then
begin
// mark the selected item and deselect all others
mi := TMenuItem(Sender);
for i := 0 to Pred(mi.Parent.Count) do
mi.Parent.Items[i].Checked := mi.Parent.Items[i] = mi;
TSkins.ChangeSkin(mi.Caption);
end;
end;
Końcowy efekt

Kod aplikacji dostępny jest na GitHubie. Bezpośredni link do pliku Skins.
- Tiny Radio Player #01 – Wprowadzenie
- Tiny Radio Player #02 – Instalacja komponentów
- Tiny Radio Player #03 – Roboczy interfejs aplikacji
- Tiny Radio Player #04 – Budujemy silnik
- Tiny Radio Player #05 – Zapis ustawień aplikacji
- Tiny Radio Player #06 – Zmiana języka aplikacji
- Tiny Radio Player #07 – Logowanie błędów
- Tiny Radio Player #08 – Baza danych
- Tiny Radio Player #09 – Zarządzanie bazą SQLite
- Tiny Radio Player #10 – Lista stacji radiowych, konfiguracja VirtualTreeView
- Tiny Radio Player #11 – Lista stacji radiowych, zarządzanie danymi w VirtualStringTree
- Jesteś tu => Tiny Radio Player #12 – Obsługa skórek (skins)
