Naciśnij “Enter” aby skoczyć do treści

Tiny Radio Player #12 – Obsługa skórek (skins)

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:
Skin files structure
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:
Example of skin xml

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;

Tiny Radio Player Skins Menu
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

Tiny Radio Player - Change of skins

Kod aplikacji dostępny jest na GitHubie. Bezpośredni link do pliku Skins.