Menu

Development Topics

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:

Xcode screenshot of editing an .xcstrings file

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:

Localization screenshot Localization screenshot Localization screenshot Localization screenshot Localization screenshot
Localization screenshot Localization screenshot Localization screenshot Localization screenshot Localization screenshot

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 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.

Xcode screenshot of adding a custom URL scheme

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.