Menu

Platform Customization

Whether to work around limitations in Skip’s Android support, differentiate your iOS and Android experiences, take advantage of OS-specific features, or simply because you prefer to write parts of your app separately for each platform, you will likely find yourself wanting to write iOS-only or Android-only code. Skip makes this easy.

Compiler Directives

The most common and convenient mechanism for writing iOS or Android-only code is conditional compiler directives. You probably already use these in your Swift. For example:

#if DEBUG
...
#else
...
#endif

Like the Swift compiler, the Skip transpiler can conditionally include or exclude code based on these conditionals. By default, Skip only recognizes two symbols as evaluating to true: SKIP or os(Android). The Swift compiler, meanwhile, considers these symbols false. You can use them, therefore, to create blocks of code that are only compiled into your iOS app or only transpiled into your Android app.

The following code blocks would all print “Android” on Android and “iOS” on iOS:

#if SKIP
print("Android")
#endif

#if !SKIP
print("iOS")
#endif

    
#if SKIP
print("Android")
#else
print("iOS")
#endif

    
#if os(Android)
print("Android")
#else
print("iOS")
#endif

You can also use compiler directives in SwiftUI modifier chains:

Text("Hello World")
    #if SKIP
    .italic()
    #else
    .bold()
    #endif

Calling Kotlin and Java API

These compiler directives unlock a Skip superpower: the ability to directly use Kotlin and Java API. Android-only code in #if SKIP blocks is still parsed by Xcode, so it must have valid Swift syntax. But it is excluded from your iOS build and invisible to the Swift compiler, which allows it to make any syntactically valid API call without causing Xcode errors. Your Swift can call Kotlin and Java Android API just as if you were writing in Kotlin, because Skip’s transpilation strategy means you are effectively writing in Kotlin.

Imagine, for example, that Skip did not support the iOS date formatting API. You could work around this limitation with code like the following:

#if SKIP
let dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.getDefault())
dateFormat.timeZone = java.util.TimeZone.getTimeZone("GMT")
return dateFormat.format(java.util.Date())
#else
... equivalent iOS code ...
#endif

While this example uses fully-qualified Java type names, that is not required. Skip allows you to import Kotlin and Java packages so that you can use unqualified names. See the documentation on dependencies.

Of course, you aren’t limited to simple tasks like date formatting. When interfacing with more involved Kotlin and Java API, you may need to pass complex data back and forth. This is typically easy, because the Skip transpiler unifies the Swift and Kotlin type systems. When Skip does use bespoke types to represent common data structures, though, we have standardized on a simple method for converting these types to their Kotlin equivalents:

  • .kotlin(nocopy: Bool = false): Skip implements this function for its Swift types on Android to return the equivalent standard Kotlin object. For example, when your Swift code uses an Array, Skip’s transpiled output uses skip.lib.Array, a Skip type designed to mirror the API and value semantics of Swift arrays. Calling someArray.kotlin() returns a kotlin.collections.List containing the same elements. By default, Skip returns a copy which also recursively invokes .kotlin() on each element. You can use the optional nocopy parameter to hint that this is not necessary. In that case, Skip will return the Array’s backing List instance directly if possible.

Here is the actual implementation of this function for Calendar in the SkipFoundation package. Note that SkipFoundation’s Calendar uses an internal platformValue member of type java.util.Calendar to take advantage of Java’s existing calendaring functionality.

#if SKIP
extension Calendar: KotlinConverting<java.util.Calendar> {
    public func kotlin(nocopy: Bool = false) -> java.util.Calendar {
        return nocopy ? platformValue : platformValue.clone() as java.util.Calendar
    }
}
#endif

Again, the .kotlin() function is only needed when passing builtin types with different representations to Kotlin and Java API. It isn’t needed for basic types like Int or String, and types that you define yourself can directly implement Kotlin or Java protocols for interfacing with Android API.

Types that provide custom .kotlin() conversion functions also provide a constructor that accepts their Kotlin form, should you need to convert from the Kotlin type back to the Swift equivalent. To build on the example above, SkipFoundation’s Calendar has an init(platformValue: java.util.Calendar) constructor.

The ability to interact so easily with Kotlin and Java APIs is powerful. Later, we’ll see how to take advantage of it to mix SwiftUI and Compose views.

Syntax for Calling Kotlin and Java

When you call Kotlin and Java code from within #if SKIP blocks, remember that you are still writing in Swift. Your code will be syntax-checked by Xcode, and it will be parsed and transpiled by Skip. Just write natural Swift code, and imagine that the Kotlin or Java API you’re calling is a Swift library API.

If you’re attempting to cut and paste Kotlin inline, you’ll have to turn it into valid Swift to avoid syntax errors. Luckily, the languages are extremely similar. You’ll typically only have to change a few calling conventions:

  • Named parameter values in Kotlin are specified with an equals (=) sign, whereas in Swift they use a colon (:). Note that parameter names are never required in Kotlin.
  • Closure arguments in Kotlin are specified by an arrow (->), whereas in Swift they use the in keyword.
  • To import everything in a Kotlin package, use import com.xyz.__ rather than import com.xyz.*. The latter is not valid Swift.

For example, the following Kotlin:

val start = 1
var result = 0
for (i in 1 until 10) { 
    result += someFunction(value = start + i, block = { arg -> arg + 1 })
}

Would use the following syntax when embedded in a Swift #if SKIP block:

let start = 1
var result = 0
for i in 1..<10 {
    result += someFunction(value: start + i, block: { arg in arg + 1 })
}

Skip Comments

Skip treats any // or /* */ comment line beginning with “SKIP” as a transpiler instruction. Skip supports several instructions, all of which influence the transpiler’s output or behavior.

  • SKIP ATTRIBUTES: <Attributes> This instruction applies Skip-specific attributes to the target element. Skip currently recognizes two attributes: nocopy and nodispatch. See the Structs topic for an explanation of nocopy. Use nodispatch on an async function to prevent Skip from inserting Kotlin to run it on a Dispatcher.

      // SKIP ATTRIBUTES: nocopy
      struct S {
          ...
      }
    
  • SKIP DECLARE: <Kotlin> Replace the target declaration with custom Kotlin. This is useful to customize how a type, function, or property is declared in Kotlin, without affecting the transpilation of its body.

      // SKIP DECLARE: override fun SaverScope.save(value: Any): Any?
      override func save(value: Any) -> Any? {
          ...
      }
    
  • SKIP EXTERN: Denote an external native function. This instruction is used in C integration.
  • SKIP INSERT: <Kotlin> Insert arbitrary Kotlin.

      // SKIP INSERT: var count by remember { mutableStateOf(100) }
      var countString = count.description
    
  • SKIP NOWARN Place this comment on the offending line to silence a Skip warning or error.

      // SKIP NOWARN
      let dict = obj as? Dictionary<Int, String>
    
  • SKIP REPLACE: <Kotlin> Replace the target statement with arbitrary Kotlin. Unlike SKIP DECLARE, this includes the body.

      // SKIP REPLACE:
      // fun printOS() {
      //     print("Android")
      // }
      public func printOS() {
          print("iOS")
      }
    
  • SKIP SYMBOLFILE This instruction is used at the top of a Swift source file. It tells Skip that the file should only be used to gather symbols about declared API. Skip expects that you will implement the API with a corresponding Kotlin file. In effect, it turns the Swift source into a header file. SkipLib uses this technique extensively to implement aspects of the Swift standard library in Kotlin.

Instructions that specify Kotlin code can span multiple comment lines. Skip assumes that the instruction’s Kotlin continues until Skip sees one of:

  • A new instruction
  • A blank comment line
  • The end of the comment block

Thus, this:

// SKIP INSERT: var count by remember { mutableStateOf(100) }

And this are both valid:

// SKIP INSERT: 
// var count by remember {
//     mutableStateOf(100)
// }

Kotlin and Java Files

The mechanisms covered so far allow you to use Kotlin and Java API from Swift or to embed bits of pure Kotlin within your Swift source. But Skip also allows you to include entire Kotlin and Java source files in your Xcode project. Simply use the standard .kt and .java extensions and place your source files into the Sources/<ModuleName>/Skip directory alongside your skip.yml file. Skip will include them in the resulting Android build. SkipLib uses this technique to implement aspects of the Swift standard library in Kotlin.

When working on Kotlin and Java files, we typically prefer to open the generated Android project in Android Studio to take advantage of its syntax highlighting, autocompletion, and other niceties. In fact, you could have a dedicated Android team working to extend and customize the Android version of your app using Android Studio, while other teams work on the iOS and shared portions of the codebase.

The package name of your Kotlin and Java files included in the Skip/ folder must match the derived (de-camel-cased) package name from the Swift module. For example, if you are including the file Sources/MyApp/Skip/CustomKotlin.kt, the package header should be package my.app. If the Swift module is only one word, append .module to satisfy Kotlin’s requirement for multi-part packages. So Sources/Foo/Skip/CustomKotlin.kt should declare package foo.module.


Working in Android Studio

Skip generates a complete gradle project from your dual-platform app or framework. You can open this project in Android Studio to debug Android-specific issues and unit tests, iterate on your Kotlin files, or prototype solutions that you back-port to inline Swift using compiler directives.

Setup

By default, Xcode and Android Studio transpile and build to different locations: Xcode uses DerivedData, while Android Studio uses a .build folder in your project directory. This won’t affect you if you’re building and running only from one IDE or the other, but it can be problematic if:

  1. You want to be able to build and run the same transpiled output from both Xcode and Android Studio, moving back and forth between the two.
  2. You’re using an Xcode Workspace to iterate on local copies of SwiftPM dependencies alongside your app. Android Studio won’t see your Workspace copies, and instead will always use the dependency sources defined in Package.swift.

If you’d like to share the same build location, you can do so by pointing Android Studio at Xcode’s DerivedData location for your project. Edit the Android/settings.gradle.kts file to un-comment the line setting the BUILT_PRODUCTS_DIR system property, and specify the path to your project’s Debug-iphonesimulator folder. The result should look something like:

pluginManagement {
    // local override of BUILT_PRODUCTS_DIR
    if (System.getenv("BUILT_PRODUCTS_DIR") == null) {
        System.setProperty("BUILT_PRODUCTS_DIR", "${System.getProperty("user.home")}/Library/Developer/Xcode/DerivedData/MySkipProject-aqywrhrzhkbvfseiqgxuufbdwdft/Build/Products/Debug-iphonesimulator")
    }

    ...

You can open the DerivedData folder from the Locations tab of Xcode’s Settings. Then navigate to your project’s Debug-iphonesimulator folder. After opening settings.gradle.kts in Xcode, drag the folder onto the open Xcode window to insert the file path.

Dual-Platform Apps

To open your dual-platform Xcode app in Android Studio:

  1. Control-click the Android/settings.gradle.kts file and select Open with External Editor from the resulting context menu.
  2. In Android Studio, select File → Sync Project with Gradle Files

Open in Android Studio

Once your app is open in Android Studio, you can run and debug it from there. Keep in mind that while you can iterate on your app’s custom Kotlin files with Android Studio, any edits you make to transpiled files will get overwritten the next time you update the source Swift.

If you’d like to update your Swift, make the desired edits in Xcode, then rebuild in Android Studio. If you’ve pointed Android Studio at DerivedData as described in the previous section, you can choose to rebuild from Xcode as well.

Attempting to run your app in Android Studio may result in an “SDK not found” error. If you receive this error simply copy your Xcode project’s Android/local.properties file to the path given in the error message.

Gradle

As of this writing, there is a compatibility issue between Android Studio’s Gradle installation and Skip’s use of the Kotlin 2 compiler. If you run into this error:

  1. Make sure you’re running the latest version of Android Studio.
  2. In Terminal, run brew upgrade gradle to install the latest Gradle version locally.
  3. Point Android Studio’s Settings at your local Gradle installation /opt/homebrew/opt/gradle/libexec, as in the image below.

Android Studio Gradle settings

Unit Testing

To run your unit tests in Android Studio, first run them in Xcode so that they get transpiled. Then find your Test module’s output folder within SkipStone/plugins, and use Open with External Editor on its settings.gradle.kts file. Running your unit tests in Android Studio will allow you to use Android’s native debugging tools to debug your tests as well.

Open in Android Studio

Separate iOS and Android Apps

If you’ve created separate iOS and Android apps that share dual-platform frameworks, running your Android app in Android Studio is the same as running any other Android app. See the Getting Started guide for tips on integrating dual-platform frameworks into your Android development workflow.

Frameworks

To open your dual-platform framework in Android Studio:

  1. Make sure that you have built your project in Xcode.
  2. Follow the instructions to create the SkipLink Xcode group.
  3. Control-click the Android/settings.gradle.kts file and select Open with External Editor from the resulting context menu.

To run your framework unit tests in Android Studio, first run them in Xcode so that they get transpiled. They will then be available in the gradle project. Running your unit tests in Android Studio will allow you to use Android’s native debugging tools to debug your tests as well.


Compose Integration

Skip’s SwiftUI implementation includes additional API that allows you to move fluidly between SwiftUI and pure Compose code. This powerful capability is useful for several reasons:

  • If you prefer to code portions of your app entirely in Compose, Skip’s interoperability allows you to share as much or as little of your UI as you like between the platforms, without worrying about fighting the framework. You can add Kotlin libraries and include Kotlin files full of custom Kotlin and Compose functions that you call using the techniques in this chapter.
  • Easily embed Android-specific UI components and access Android-specific features from anywhere in your app.
  • Skip hasn’t yet implemented every SwiftUI API for Android. Being able to trivially mix Compose views into your UI enables you to work around any of Skip’s temporary shortcomings.

See the ComposeView and composeModifier topics in the SkipUI module for a discussion of embedding Compose view and modifiers in SwiftUI and vice versa.

Skip also provides Android-specific SwiftUI API that allows you to customize Skip’s underlying Compose components. Read more in the SkipUI module’s Material documentation.


Model Integration

Skip ensures that the @Observable and ObservableObject types you define in your shared Swift business logic can seamlessly power your Compose UI as well. The SkipModel module describes Skip’s dual-platform model object support.

Skip’s AsyncStream implementations also feature deep Kotlin integration. You can construct an AsyncStream from a Kotlin Flow, and you can retrieve a Flow from an AsyncStream using the standard .kotlin() function.

These model integrations are additional ways that Skip allows you to move seamlessly between shared and platform-specific code.


Android Context

This chapter has shown you how to call Android APIs from your Swift code. Many Android system calls, however, require a Context or Activity reference. Skip provides additional Swift API to retrieve these references:

#if SKIP
let applicationContext = ProcessInfo.processInfo.androidContext
let activity = UIApplication.shared.androidActivity
#endif

You can only use these calls within an #if SKIP block.

For example, the following code asks the user for permission to record audio:

#if SKIP
let activity = UIApplication.shared.androidActivity!
// These must match permissions in your AndroidManifest
let permissions = listOf(android.Manifest.permission.RECORD_AUDIO,
    android.Manifest.permission.READ_EXTERNAL_STORAGE,
    android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
androidx.core.app.ActivityCompat.requestPermissions(activity, permissions.toTypedArray(), 1)
#endif

iOS and Android Libraries

Read the chapter covering Dependencies to learn how to use iOS and Android libraries in your shared code.