Development Topics
- Configuration with
Skip.env
- JSON
- Regular Expressions
- Localization
- Notifications
- Deep Links
singleTop
- Resources
- Images
- Fonts
- Themes
This chapter covers how to perform a variety of common development tasks across platform with Skip.
Configuration with Skip.env
Skip app customization should be primarily done by directly editing the included Skip.env
file, rather than changing the appâs settings in Xcode. Only properties that are set in the Skip.env
file, such as PRODUCT_NAME
, PRODUCT_BUNDLE_IDENTIFIER
and MARKETING_VERSION
, will carry through to both HelloSkip.xcconfig
and AndroidManifest.xml
. This is how a subset of the appâs metadata can be kept in sync between the iOS and Android versions, but it requires that you forgo using the Xcode Build Settings interface (which does not modify the .xcconfig
file, but instead overrides the settings in the local .xcodeproj/
folder).
JSON
Most apps use JSON encoding and decoding in some form or fashion. Skip implements Foundation
âs JSONSerialization
, JSONEncoder
, and JSONDecoder
types for Android. Skip also supports the Encodable
, Decodable
, and Codable
Swift protocols, allowing you to write shared Swift types that serialize to and from the same JSON format across platforms. There are, however, considerations you must be aware of. For example, differences in Kotlinâs and Swiftâs treatment of generics often come into play when attempting to write generic decoding utilities. See the Skip Codable
documentation for details.
Regular Expressions
Swift has supported multiple regular expression APIs over the course of its evolution. Skip supports the Swift standard library Regex
type. See Skipâs Regex
unit tests for examples.
Localization
Localizing your app into multiple languages gives it the maximum possible reach. Localization is a critical part of making your app accessible to users all around the world. Skip helps unlock the promise of true universality for your app by bringing SwiftUI to Android, but the other half of the equation is ensuring that your users can understand the content of your app.
Skip embraces the xcstrings
catalog format, new in Xcode 15, to provide a simple and easy solution to adding support for multiple languages to your app.
Localization example
Consider the following SwiftUI snippet from the default âHelloâ app of a screen in a tab bar with a âHello Skipper!â message.
VStack {
Text("Hello \(name)!")
Image(systemName: "heart.fill")
.foregroundStyle(.red)
}
.font(.largeTitle)
.tabItem { Label("Welcome", systemImage: "heart.fill") }
For users with their device language set to French, we want the tab item to be âBienvenueâ and the message to be displayed as âBonjour Skipper!â. This can be accomplished by editing the Localizable.xcstrings
file in Xcode and filling in the translations for each supported language. String interpolation is handled by substituting the variable (e.g., "\(name)"
) with the token "%@"
, which will cause the translated string to insert any variables that need to be substituted at runtime.
To localize strings used outside of SwiftUI, use iOSâs standard NSLocalizedString
function, which also requires you to insert tokens for any variables:
let localizedTitle = NSLocalizedString("License key for %@")
sendLicenseKey(key, to: user, title: String(format: localizedTitle, user.fullName))
Xcode 15 has native support for editing string catalogs with a convenient user interface:
The result is that by updating this single file, you can localize your app into many languages, enabling native speakers of those languages to use your app with ease. For example, the default âHelloâ app has localizations for English, French, Spanish, Japanese, and Chinese, shown here for both the iOS and Android versions of the app:
The xcstrings
Format
The Skip transpiler handles the .xcstrings
localization format, which is used by Xcode 15 as a single source of truth for the appâs localization. The default project created by skip init --appid=⌠hello-skip HelloSkip
will create a Sources/HelloSkip/Resources/Localizable.xcstrings
file which can be used as a starting point for adding new languages and string translations to your project.
Xcode 15 will automatically fill in any strings that it finds in common SwiftUI components, such as Text
, Label
, and Button
. This happens in the background, as part of a âSync Localizationsâ operation that periodically scans your code for new and updated strings. You will rarely, if ever, need to manually add a localization key to the Localizable.xcstrings
catalog.
The Localizable.xcstrings
file is a simple JSON file which can be either edited through Xcode or handed off to specialist translators and then re-integrated back into your application. The structure is simple, and can be edited either by hand or using machine translation tools. An excerpt of the format is as follows:
{
"sourceLanguage" : "en",
"strings" : {
"Hello %@!" : {
"localizations" : {
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "ÂĄHola %@!"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bonjour %@!"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ăăăŤăĄăŻă%@!"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "ä˝ ĺĽ˝ďź%@!"
}
}
}
},
"Welcome" : {
"localizations" : {
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bienvenido"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bienvenue"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ăăăă"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "揢čż"
}
}
}
}
},
"version" : "1.0"
}
The following table lists the supported tokens that will be substituted at runtime:
Token | Meaning |
---|---|
$@ | String or stringified instance |
%ld or %lld | integer number |
%lf or %llf | floating point number |
%.3f | formatted floating point number |
%1$@ | maually-specified positional argument |
%% | literal escaped percent sign |
More information on the Xcode editor for the xcstrings
format can be found at https://developer.apple.com/documentation/xcode/localizing-and-varying-text-with-a-string-catalog.
Android has a separate localization system that involved adding keys to a res/values/strings.xml
file. Skip does not use this system, as it is incompatible with modularization; adding entries to this XML file will not have any effect on a SkipUI app.
Localizing modules
SwiftUI components that accept a String
, such as Text("Hello \(name)!")
or Button("Click Me") { doSomething() }
, as well as the standard NSLocalizedString
function assume that the localized string is defined in the main
bundle. On the Android side, Bundle.main
uses the resources defined in the primary app module, which is the module in the app that contains the entry point and top-level resources.
However, when you modularize your app by breaking it up into separate SwiftPM modules, references to these string keys will still assume that they are defined in the main
bundle, rather than in the module
bundle. This assumption is built in to both SwiftUI as well as the transpiled SkipUI. This means that if you create a separate library module of UI components to be shared between apps, you need to manually specify the bundle that should be referenced for the translated keys.
Each SwiftUI component that accepts a localization string will also have a constructor that accepts a Bundle
parameter. The NSLocalizedString
function takes an optional Bundle
as well. You generally want to use the Bundle.module
value as the argument, which will cause it to reference the componentâs module. For example:
VStack {
Text("Hello \(name)!", bundle: .module)
Button {
doSomething()
} label: {
Text("Click Me", bundle: .module)
}
}
...
let localizedTitle = NSLocalizedString("License key for %@", bundle: .module)
sendLicenseKey(key, to: user, title: String(format: localizedTitle, user.fullName))
The additional bundle parameter makes your code more verbose, but has the advantage that the component can be used irrespective of whether it is defined in the top-level app package on in a separate module.
Localizing raw strings
In addition to localizing SwiftUI components, the string localization dictionary can be accessed using the Foundation function NSLocalizedString
, which enables the localization of strings that are constructed outside the context of user-interface elements.
For example, to create a local variable with a localized string:
let helloWorld = NSLocalizedString("Hello World", bundle: .module, comment: "greeting string")
The comment
element is required and is used to provide context to translators.
Parameterizing strings localized in this manner is a bit more complex than SwiftUI elements, since automatic string interpolation being converted into the %@
tokens does not take place:
let personName = "Skipper"
let greeting = String(format: NSLocalizedString("Hello, %@!", bundle: .module, comment: "parameterized greeting"), personName)
Limitations
Skip does not currently handle String Catalog Plural Variants (described at https://developer.apple.com/documentation/xcode/localizing-and-varying-text-with-a-string-catalog#Add-pluralizations).
Notifications
Skip supports the core API of Appleâs UserNotifications
framework so that your iOS notification-handling code works across platforms. The setup for integrating notification functionality into your app, however, will vary depending on the push service you are using. Skipâs Firebase support includes push messaging out of the box. Follow its instructions to support push notifications on top of Firebase Cloud Messaging in your dual-platform app.
You often want to take a user to a particular part of your app when they tap on a notification. To do so, we recommend sending a deep link URL in the notification metadata. The next section discusses how to support deep links in your cross-platform app, and our FireSide sample app demonstrates push notifications, deep linking, and using them together. Here is an excerpt showing how you might send a user to a location in your app from your UNUserNotificationCenterDelegate
:
@MainActor
public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
// Look for a custom 'deep_link' key in the notification payload, which should be a URL with our app's scheme
let content = response.notification.request.content
if let deepLink = content.userInfo["deep_link"] as? String, let url = URL(string: deepLink) {
await UIApplication.shared.open(url)
}
}
Deep Links
Deep links allow you to bring a user to a particular part of your app. Skip supports custom URL schemes and SwiftUI deep link handling.
Darwin Setup
To support deep links in your iOS build, first follow Appleâs instructions to register your custom URL scheme in Xcode.
Only pay attention to the instructions for registering your custom URL scheme. Ignore the remaining instructions about handling deep links in your code, because youâll be using SwiftUIâs deep link processing instead.
Android Setup
Edit your Android buildâs AndroidManifest.xml
to add an intent-filter
for your custom URL scheme. For example to support myurlscheme
, add the following to your AndroidManifest.xml
:
<manifest ...>
...
<application ...>
...
<activity ...>
...
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="myurlscheme" />
</intent-filter>
</activity>
</application>
</manifest>
While iOS is flexible, Android will expect deep link URLs with the general form scheme://host
or scheme://host/path
.
SwiftUI
SwiftUI uses the onOpenURL
view modifier to intercept and process deep links. Place the modifier on a view that will be rendered when the app opens, and use its action to process the given URL. This will typically involve updating your navigation bindings to take the user to a specified location in the app, as in the following sample:
enum Tab : String {
case cities, favorites, settings
}
public struct ContentView: View {
@AppStorage("tab") var tab = Tab.cities
@State var cityListPath = NavigationPath()
public var body: some View {
TabView(selection: $tab) {
NavigationStack(path: $cityListPath) {
CityListView()
}
...
.tag(Tab.cities)
NavigationStack {
FavoriteCityListView()
}
...
.tag(Tab.favorites)
SettingsView()
...
.tag(Tab.settings)
}
// travel://<tab>[/<city>], e.g. travel://cities/London or travel://favorites
.onOpenURL { url in
if let tabName = url.host(), let tab = Tab(rawValue: tabName) {
self.tab = tab // Select the encoded tab
if tab == .cities, let city = city(forName: url.lastPathComponent)) {
// iOS needs an async dispatch after switching tabs to read navigationDestinations
DispatchQueue.main.async {
// Set nav stack to root + specified city
cityListPath.removeLast(cityListPath.count)
cityListPath.append(city.id)
}
}
}
}
}
}
Testing
On iOS, the easiest way to test your deep link handling is by entering a URL with your custom scheme into Safari. You can also write a URL into a Calendar event or a Note. iOS will linkify the text so that tapping it will open your app.
Android includes an adb
command for sending intents to the running emulator or device, including deep links. Building on our SwiftUI example above, enter a command like the following in Terminal:
% adb shell am start -W -a android.intent.action.VIEW -d "travel://cities/London"
singleTop
When a user taps a notification or deep link for your app on Android, the system fires an Intent
to open your app. By default, this will initialize a new instance of your appâs main Activity
, even if your app is already running. That means that any transient UI state may be lost.
If youâd like the more iOS-like behavior of keeping your UI as-is when your app is brought to the foreground via a notification or deep link, you can use the singleTop
launch mode. Edit your Android/app/src/main/AndroidManifest.xml
as follows:
<manifest ...>
...
<application ...>
...
<activity
...
android:launchMode="singleTop">
...
</activity>
</application>
</manifest>
Read more about launch modes and other Activity
options here.
Resources
Place shared resources in the Sources/ModuleName/Resources/
folder. Skip will copy the files in this folder to your Android build and make them available to the standard Bundle
loading APIs. For example, the following Swift loads the Sources/ModuleName/Resources/sample.dat
file on both iOS and Android:
let resourceURL = Bundle.module.url(forResource: "sample", withExtension: "dat")
let resourceData = Data(contentsOf: resourceURL)
Images
Skip supports iOS asset catalogs containing PNG, JPG, and PDF image files, as well as exported Google Material Icons and SF Symbols SVG files. Skip also supports network images and bundled image files. The SkipUI module documentation details where to place shared assets catalogs, how to supply SF Symbols to your Android app, and how to load and display images in your SwiftUI.
Fonts
Skip allows you to use your own custom fonts on iOS and Android. The SkipUI module documentation details how to install and use custom fonts.
Themes
Skip fully supports iOS and Android system color schemes, as well as SwiftUI styling modifiers like .background
, .foregroundStyle
, .tint
, and so on. You may, however, want to customize aspects of your Android UIâs colors and components that cannot be configured through SwiftUIâs standard modifiers. Skip provides additional Android-only API for this purpose. These SwiftUI add-ons allow you to reach âunder the coversâ and manipulate Skipâs underlying use of Compose. They are detailed in the SkipUI module documentationâs Material topic.