SwiftGen na iOS – automatyczne lokalizowanie tekstów, czyli jak wygodnie pracować z wieloma językami

SwiftGen na iOS – automatyczne lokalizowanie tekstów, czyli jak wygodnie pracować z wieloma językami

Rozwijasz aplikację na iOS, z której będą korzystać użytkownicy w kilku krajach? Prędzej czy później będziesz musiał zmierzyć się z kwestią obsługi wielu języków. Wtedy należy zadbać o lokalizowanie treści. A w tym właśnie pomoże Ci SwiftGen.

Czym jest lokalizowanie treści?

Lokalizowanie treści sprawia, że użytkownik widzi w aplikacji teksty przetłumaczone na wybrany język. Tłumaczone są wszystkie etykiety, przyciski i inne elementy tekstowe.

Im więcej języków obsługuje aplikacja, tym więcej użytkowników może być zainteresowanych korzystaniem z niej. Lokalizowanie to jednak żmudny proces, który wymaga użycia tzw. magic strings.

Na szczęście z pomocą przychodzi SwiftGen – narzędzie do automatycznego generowania kodu Swift dla zasobów Twojego projektu. Wcześniej pisaliśmy, jak wysyłać przetłumaczone powiadomienia na iOS. Z tego poradnika dowiesz się natomiast, jak przy wsparciu SwiftGen wygenerować strings, czyli teksty w aplikacji.

Lokalizowanie względem języka wybranego na urządzeniu

Po pierwsze, teksty znajdują się w pliku Localizable.strings.

Dla każdego języka tworzony jest osobny plik, w którym znajdują się pary złożone z klucza i wartości.

Pary możesz dodawać ręcznie lub wykorzystać narzędzia, które generują je automatycznie.

Localizable.string (Hungarian):

“order_info.order_number.title” = “Megrendelési számod:”;

"status" = "Fizetés státusza:";

Localizable.string (Romanian):

"order_info.order_number.title" = "Numărul tău de comandă este";

"status" = "Statusul tranzacției:";

Po lewej stronie znajduje się nazwa ciągu, którą chcesz zlokalizować. Jest to klucz stosowany po to, żeby móc odwołać się do każdego ciągu w kodzie. Z kolei po prawej stronie widać tekst, który chcesz powiązać z danym kluczem.

Do lokalizowania wykorzystaj metodę NSLocalizedString, która zwraca zlokalizowany ciąg z tabeli. Generuje go Xcode podczas eksportowania lokalizacji.

/// - note: Although `NSLocalizedString(_:tableName:bundle:value:comment:)`
/// and `Bundle.localizedString(forKey:value:table:)` can be used in a project
/// at the same time, data from manually managed strings files will be
/// overwritten by Xcode when their table is also used to look up localized
/// strings with `NSLocalizedString(_:tableName:bundle:value:comment:)`.

public func NSLocalizedString(_ key: String, tableName: String? = nil, bundle: Bundle = Bundle.main, value: String = "", comment: String) -> String

Stosowanie komentarzy

Jeśli należysz do osób, które lubią pozostawiać w kodzie komentarze, mam dla Ciebie dobrą wiadomość. Komentarz nie wpływa na tłumaczenie. Natomiast pokazuje tłumaczowi pewien kontekst dotyczący prezentacji zlokalizowanego ciągu.

Możesz swobodnie dodawać komentarze w kodzie aplikacji, wykorzystując metodę NSLocalizedString w taki sposób:

let loginTitle = NSLocalizedString(“status”, comment: “Order Info status”)

Pozwoli to lokalizować teksty w zależności od języka wybranego na urządzeniu. Jeżeli dany język nie jest obsługiwany w aplikacji, wtedy wykorzystany zostanie jej język bazowy.

Lokalizowanie względem języka wybranego w aplikacji w czasie rzeczywistym

Czasem to użytkownik może decydować, w jakim języku aplikacja ma wyświetlać treści. Wtedy nie pomoże podstawowe użycie metody NSLocalizedString. Rozwiązaniem może być za to Twoje własne rozszerzenie typu String. Jak je zaimplementować?

extension String {
    private enum Constants {
        static let pathType = "lproj"
    }
    
  func localized(_ comment: String? = nil) -> String {
    let selectedLanguage = Language.selectedLanguage.localeIdentifier
    guard let path = Bundle.main.path(forResource: selectedLanguage, ofType: Constants.pathType),
          let bundle = Bundle(path: path) else { return self }
    
    return NSLocalizedString(self, tableName: nil, bundle: bundle, value: .empty, comment: comment ?? .empty)
  }
}

W pierwszym kroku musisz wybrać lokalny identyfikator. Ja korzystam z managera, który zarządza aktualnie wybranym językiem w aplikacji. Listę kodów bez trudu znajdziesz w internecie, np. na stronie Apple dla developerów.

Następnie trzeba znaleźć ścieżkę do odpowiedniego pliku z tłumaczeniami, który jest zależny od aktualnie wybranego języka.

Należy jednak pamiętać, że jeżeli nie uda się znaleźć właściwej ścieżki, wyświetli się wpisany przez Ciebie klucz.

Wykorzystanie rozszerzenia w kodzie jest bardzo proste. Wystarczy wpisać:

let loginTitle = “status”.localized(“Order Info status”)

I to wszystko! Teraz masz nową supermoc i możesz zmieniać język aplikacji w czasie rzeczywistym.

Automatyczne generowanie kodu z pomocą SwiftGen

Podejście z wykorzystaniem metody NSLocalizedString niesie ze sobą konieczność umieszczania w kodzie łańcuchów tekstowych.

Nie jest to w pełni bezpieczne rozwiązanie. Wystarczy przypadkowa zmiana w pliku z tłumaczeniami i użytkownik – zamiast odpowiedniego tekstu – zobaczy przygotowany przez Ciebie klucz.

Dlatego potrzebny jest SwiftGen. On pozwoli na wygenerowanie kodu bezpiecznego dla typu, który może się odwoływać do lokalizowanych ciągów. Korzysta się z niego w przypadku zasobów, czcionek, kolorów, podstawowych danych czy właśnie lokalizowanych ciągów.

SwiftGen pozwala na używanie zmiennych do odwoływania się do zlokalizowanych ciągów.

Jak dodać SwiftGen do projektu?

Sposobów na implementację SwiftGen jest wiele, ale w tym poradniku korzystam akurat z CocoaPods.

Instrukcja dodawania SwiftGen do projektu aplikacji mobilnej

1. Dodaj bibliotekę do pliku Podfile i wywołaj komendę pod install w terminalu:

pod ‘SwiftGen’, ‘6.4.0’

2. Utwórz plik Swift, w którym znajdzie się struktura z tłumaczeniami.

3. Wygeneruj plik konfiguracyjny dla SwiftGen. Wystarczy otworzyć terminal i przejść do katalogu głównego projektu.

4. Wprowadź poniższe polecenie w terminalu:

./Pods/SwiftGen/bin/swiftgen config init

Wygenerowany zostanie plik konfiguracyjny o nazwie swiftgen.yml. Należy dodać go do projektu jako odniesienie do folderu. W tym pliku umieszczane będą wszystkie instrukcje dla SwiftGen.

Wygląd pliku:

input_dir: MyLib/Sources/
 output_dir: MyLib/Generated/

## Strings
 strings:
   inputs:
     - Resources/Base.lproj
   outputs:
     - templateName: structured-swift5
       output: Strings+Generated.swift

Co oznaczają zmienne?

input_dir mówi Swiftgen, że folder MyLib/Sources ma nawigować do wszystkich ścieżek plików, które zostaną dodane.

output_dir definiuje katalog wyjściowy wygenerowanych plików Swift.

inputs informuje, gdzie w projekcie znajduje się plik Localizable.strings.

templateName mówi, z jakiej wersji template chcesz skorzystać.

output informuje o lokalizacji pliku, w którym znajdą się zmienne odnoszące się do zlokalizowanych ciągów.

Aby uruchomić skrypt, trzeba dodać do projektu nową fazę kompilacji. W tym celu wybierz swój projekt w nawigatorze projektu, wejdź w zakładkę Build Phases i dodaj nowy skrypt.

Następnie dodaj poniższy tekst do nowo utworzonego skryptu.

if [[ -f "${PODS_ROOT}/SwiftGen/bin/swiftgen" ]]; then
  "${PODS_ROOT}/SwiftGen/bin/swiftgen"
else
  echo "warning: SwiftGen is not installed"
fi 

Teraz zbuduj aplikację w Xcode, żeby zobaczyć wygenerowane pary klucz-wartość.

Po otwarciu wcześniej zdefiniowanego pliku – w tym przypadku Strings+Generated – zobaczysz enum o nazwie L10n oraz podtypy, które odpowiadają Twoim lokalizowanym tekstom.

enum L10n {
   enum Filter {
     enum LastItem {
      /// Last Item!
       static let  info =  Localized.tr("Localizable", "filter.last_item.info") 
    }
  }

}

Struktura tego pliku zależy od tego, w jaki sposób zdefiniujesz klucze. Istnieją dwa podstawowe znaki, których używa się podczas tworzenia kluczy: “.” oraz “_”.

“_” pozwala zachować notację, w której kolejne wyrazy pisane są łącznie, a każdy następny wyraz rozpoczyna się od wielkiej litery, jak możesz zaobserwować w przypadku last_item.

“.” powoduje dodanie nowego poziomu zagnieżdżenia.

Wszystko jasne? Jeśli tak, to teraz możesz wykorzystać swoją strukturę w kodzie. Nie musisz pisać:

let filterTitle = NSLocalizedString(“filter.last_item.info”, comment: “Filter title”)

Zamiast tego użyj:

let filterTitle = L10n.Filter.LastItem.info

I gotowe! Gratulacje, udało Ci się zrealizować swój cel.

Powyższa struktura świetnie się sprawdza, gdy chcesz bazować na języku urządzenia. A co w przypadku, kiedy użytkownik może zmieniać język bezpośrednio w aplikacji?

Implementacja SwiftGen z własnym schematem

Niektóre aplikacje dają możliwość zmiany języka aplikacji w czasie rzeczywistym. Wtedy jednak opisane wcześniej rozwiązanie nie zadziała. Nie wystarczy, że stworzysz plik swiftgen.yml i podasz w nim gotowy schemat: templateName: structude-swift5.

Tym razem potrzebny Ci jest schemat, którzy stworzysz sam.

Aby to zrobić, należy dodać bibliotekę do pliku Podfile, tak jak w poprzednim punkcie. Następnie pobierz plik structured-swift4.stencil. Możesz dowolnie zmieniać jego nazwę np. na myProject_string.stencil.

Dodaj plik do projektu, bo to właśnie w nim będziesz dokonywać zmian względem działania skryptu. Tak jak w przykładzie opisanym wcześniej, ponownie musisz utworzyć plik Swift, w którym znajdzie się struktura z tłumaczeniami.

Tym razem również trzeba dodać nowy skrypt, ale będzie wyglądał on nieco inaczej:

"$PODS_ROOT"/SwiftGen/bin/swiftgen run strings -p "$PROJECT_DIR/FilePath/test_strings.stencil" -o "$PROJECT_DIR/FilePath/Localized.swift" --param enumName=Localized "$PROJECT_DIR/FilePath/Base.lproj/Localizable.strings" 

Za FilePath podstawiasz ścieżkę:

  • do pliku z template,
  • do pliku, w którym znajdzie się struktura z tłumaczeniami,
  • do tłumaczeń dodanych w aplikacji.

Jeśli postanowisz teraz uruchomić SwiftGen, nic się nie zmieni. Aby zmiany były widoczne, musisz jeszcze edytować template.

1.

// swiftlint:disable all
// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen

{% if tables.count > 0 %}
{% set accessModifier %}{% if param.publicAccess %}public{% else %}{% endif %}{% endset %}
import Foundation

// swiftlint:disable superfluous_disable_command file_length implicit_return

// MARK: - Strings

{% macro parametersBlock types %}{% filter removeNewlines:"leading" %}
  {% for type in types %}
    {% if type == "String" %}
    _ p{{forloop.counter}}: Any
    {% else %}
    _ p{{forloop.counter}}: {{type}}
    {% endif %}
    {{ ", " if not forloop.last }}
  {% endfor %}
{% endfilter %}{% endmacro %}
{% macro argumentsBlock types %}{% filter removeNewlines:"leading" %}
  {% for type in types %}
    {% if type == "String" %}
    String(describing: p{{forloop.counter}})
    {% elif type == "UnsafeRawPointer" %}
    Int(bitPattern: p{{forloop.counter}})
    {% else %}
    p{{forloop.counter}}
    {% endif %}
    {{ ", " if not forloop.last }}
  {% endfor %}
{% endfilter %}{% endmacro %}
{% macro recursiveBlock table item %}
  {% for string in item.strings %}
  {% if not param.noComments %}
  /// {{string.translation}}
  {% endif %}
  {% if string.types %}
  {{accessModifier}} static func {{string.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}}({% call parametersBlock string.types %}) -> String {
    return {{enumName}}.tr("{{table}}", "{{string.key}}", {% call argumentsBlock string.types %})
  }
  {% elif param.lookupFunction %}
  {# custom localization function is mostly used for in-app lang selection, so we want the loc to be recomputed at each call for those (hence the computed var) #}

2.

  {{accessModifier}} static var 
 {{string.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}}: String { return
  {{enumName}}.tr("{{table}}", "{{string.key}}") }

3.

  {% else %}
  {{accessModifier}} static var {{string.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}}: String { {{enumName}}.tr("{{table}}", "{{string.key}}") }
  {% endif %}
  {% endfor %}
  {% for child in item.children %}

  {{accessModifier}} enum {{child.name|swiftIdentifier:"pretty"|escapeReservedKeywords}} {
    {% filter indent:2 %}{% call recursiveBlock table child %}{% endfilter %}
  }
  {% endfor %}
{% endmacro %}
// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length
// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces
{% set enumName %}{{param.enumName|default:"L10n"}}{% endset %}
{{accessModifier}} enum {{enumName}} {
  {% if tables.count > 1 or param.forceFileNameEnum %}
  {% for table in tables %}
  {{accessModifier}} enum {{table.name|swiftIdentifier:"pretty"|escapeReservedKeywords}} {
    {% filter indent:2 %}{% call recursiveBlock table.name table.levels %}{% endfilter %}
  }
  {% endfor %}
  {% else %}
  {% call recursiveBlock tables.first.name tables.first.levels %}
  {% endif %}
}
// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length
// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces

// MARK: - Implementation Details

4.

extension {{enumName}} {

  static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String {

    let selectedLanguage = Language.selectedLanguage.localeIdentifier

    guard let path = Bundle.main.path(forResource: selectedLanguage, ofType: "lproj"),

    let bundle = Bundle(path: path) else { return self }




    return NSLocalizedString(key, tableName: table, bundle: bundle, value: .empty, comment: .empty)

  }

5.

}
{% else %}
// No string found
{% endif %}

Fragmenty oznaczone jako 2 i 4 to zmiany w pliku.

Pierwsza z nich dotyczy zmiany struktury klucza w pliku Localized.swift. Dzięki temu możesz bazować na zmiennych obliczeniowych (computed var) zamiast stałych. Pozwoli to na nowo uzyskiwać odpowiednio przetłumaczony tekst przy każdym użyciu danego klucza.

Teraz będzie wyglądało to tak:

enum Localized {
   enum Filter {
     enum LastItem {
      /// Last Item!
       static var info: String {  Localized.tr("Localizable", "filter.last_item.info") }
    }
  }

}

Druga zmiana pozwala na wybór klucza, który odpowiada aktualnemu językowi aplikacji.

Użycie w kodzie pozostaje takie samo jak w poprzednim przypadku:

let filterTitle = Localized.Filter.LastItem.info

Na co trzeba uważać?

Czasami nie wszystko idzie zgodnie z planem.

W moim przypadku, po skonfigurowaniu wszystkich elementów, okazało się, że lokalizowanie nadal nie działa w czasie rzeczywistym. Po zmianie języka w aplikacji teksty wyświetlały się w niewłaściwym języku. Pomagało dopiero zamknięcie aplikacji.

Po szybkich poszukiwaniach okazało się, że winny jest mój enum.

Teksty przechowywałem w plikach w ten sposób:

    private enum Constants {
        static let lastItemKey = Localized.Filter.LastItem.info
    }

Powodowało to problem z przełączaniem języka aplikacji w czasie rzeczywistym. Wartość raz przypisana, nie była później dynamicznie obliczana. Aby to naprawić, wystarczy jednak prosta zmiana:

    private enum Constants {
        static var lastItemKey: String { Localized.Filter.LastItem.info }
    }

Dzięki temu przy każdym użyciu wartość lastItemKey będzie definiowana na nowo.

Zmiana języka w czasie rzeczywistym – kiedy warto wykorzystać SwiftGen na iOS?

Wyświetlanie treści w języku ojczystym odgrywa kluczową rolę w aplikacjach mobilnych.

Użytkownicy lubią mieć wolność wyboru, dlatego brak możliwości zmiany języka w aplikacji może sprawić, że z niej wyjdą i już nie wrócą.

Na pierwszy rzut oka mechanizm, który dostarcza Apple może wydawać się wystarczająco użyteczny. Po co w takim razie zaprzątać sobie głowę dodatkową konfiguracją oraz instalacją nowej biblioteki?

Ponieważ SwiftGen świetnie się sprawdza wtedy, gdy trzeba prezentować treści w różnych językach. Dzięki niemu można bezpieczne i sprawne zarządzać zlokalizowanymi ciągami, warto więc poświęcić chwilę na wprowadzenie go do projektu.

Wycena projektu

Opowiedz nam o swoim projekcie i napisz, jak możemy Ci pomóc.

Dlaczego warto rozwijać z nami projekty?

Logo Mobile Trends Awards

Mobile Trends Awards 2021

Wygrana w kategorii
ŻYCIE CODZIENNE

Nagroda Legalnych Bukmacherów

Nagroda Legalnych Bukmacherów 2019

Najlepsza aplikacja mobilna

Mobile Trends Awards logo

Mobile Trends Awards 2020

Nominacja w kategorii
SPORT I REKREACJA

20

opinii klientów

Clutch logo