Platform Customization
- Compiler Directives
- Skip Comments
- Kotlin and Java Files
- Working in Android Studio
- Compose Integration
- Model Integration
- Android Context
- iOS and Android Libraries
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 anArray
, Skip’s transpiled output usesskip.lib.Array
, a Skip type designed to mirror the API and value semantics of Swift arrays. CallingsomeArray.kotlin()
returns akotlin.collections.List
containing the same elements. By default, Skip returns a copy which also recursively invokes.kotlin()
on each element. You can use the optionalnocopy
parameter to hint that this is not necessary. In that case, Skip will return theArray
’s backingList
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 thein
keyword. - To import everything in a Kotlin package, use
import com.xyz.__
rather thanimport 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
andnodispatch
. See the Structs topic for an explanation ofnocopy
. Usenodispatch
on an async function to prevent Skip from inserting Kotlin to run it on aDispatcher
.// 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. UnlikeSKIP 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:
- 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.
- 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:
- Control-click the
Android/settings.gradle.kts
file and selectOpen with External Editor
from the resulting context menu. - In Android Studio, select
File → Sync Project with Gradle Files
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:
- Make sure you’re running the latest version of Android Studio.
- In Terminal, run
brew upgrade gradle
to install the latest Gradle version locally. - Point Android Studio’s Settings at your local Gradle installation
/opt/homebrew/opt/gradle/libexec
, as in the image below.
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.
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:
- Make sure that you have built your project in Xcode.
- Follow the instructions to create the SkipLink Xcode group.
- Control-click the
Android/settings.gradle.kts
file and selectOpen 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.