Custom Views

A Custom View is a native view from your mobile or web application embedded into a Scene. Custom Views can display any native content your app exposes, so you can reuse that existing content within any screen in a Scene. iOS SDK 19.2+Android SDK 19.4+

Implementation

To use Custom Views, you must first register the view’s name with the AirshipCustomViewManager. The name is referenced when adding the Custom View to a Scene.

The view manager will call through to the view builders registered for that view’s name and provide the properties, name, and some layout hints as arguments.

Registering Custom Views

All Custom Views should be registered during the onAirshipReady callback to ensure the view is available before a Scene is rendered.

// Return an Android View
AirshipCustomViewManager.register("my-custom-view") { context, args ->
    TextView(context).apply {
        text = args.properties.optionalField<String>("text") ?: "Fallback"
    }
}

// For compose, use a ComposeView
AirshipCustomViewManager.register("my-custom-view") { context, args ->
    ComposeView(context).apply {
        setContent {
            MaterialTheme {
                Text(args.properties.optionalField<String>("text") ?: "Fallback")
            }
        }
    }
}

All Custom Views should be registered after takeOff.

struct CustomViewProperties: Decodable {
    var text: String
}

AirshipCustomViewManager.shared.register(name: "custom_view") { args in
    let viewProperties: CustomViewProperties = if let decoded = try? args.properties?.decode() {
        decoded
    } else {
        CustomViewProperties(text: "fallback")
    }

    Text(viewProperties.text)
}

Example Custom View

The following example shows a Custom View that renders an embedded map when called to render a Custom View named map.

In our example, we have properties that defines a single place field, which is the address of the location that the map should render.

Custom Map View

First, define the view handler:

/**
 * Custom View that displays a Google Map with a marker at a specified `place`.
 *
 * Roughly based on the [Compose Google Map implementation](https://github.com/googlemaps/android-maps-compose/blob/main/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt)
 */
class MapCustomViewHandler: AirshipCustomViewHandler {

    override fun onCreateView(context: Context, args: AirshipCustomViewArguments): View {
        val mapView = MapView(context)

        val lifecycleObserver = MapLifecycleEventObserver(mapView)

        val onAttachStateListener = object : View.OnAttachStateChangeListener {
            private var lifecycle: Lifecycle? = null

            override fun onViewAttachedToWindow(v: View) {
                lifecycle = mapView.findViewTreeLifecycleOwner()!!.lifecycle.also {
                    it.addObserver(lifecycleObserver)
                }
            }

            override fun onViewDetachedFromWindow(v: View) {
                lifecycle?.removeObserver(lifecycleObserver)
                lifecycle = null
                lifecycleObserver.moveToBaseState()
            }
        }

        mapView.addOnAttachStateChangeListener(onAttachStateListener)

        val place: String = args.properties.requireField<String>("place")

        mapView.getMapAsync { map -> onMapReady(context, map, place) }

        return mapView
    }

    private fun onMapReady(context: Context, map: GoogleMap, place: String) {
        val geocoder = Geocoder(context)
        val location = geocoder.getFromLocationName(place, 1).orEmpty()
        if (location.isNotEmpty()) {
            val latitude = location[0].latitude
            val longitude = location[0].longitude
            val latLng = LatLng(latitude, longitude)

            // Set up the map with the location
            map.moveCamera(CameraUpdateFactory.newLatLngZoom(latLng, 10f))
            // Optionally, add a marker at the location
            map.addMarker(MarkerOptions().position(latLng).title(place))
        } else {
            // Show error toast
            Toast.makeText(context, "Location '$place' not found", Toast.LENGTH_SHORT).show()
        }
    }
}

/**
 * A [LifecycleEventObserver] that manages the lifecycle of a [MapView].
 *
 * This is used to ensure that the [MapView] is properly managed by the Android lifecycle.
 */
private class MapLifecycleEventObserver(private val mapView: MapView) : LifecycleEventObserver {
    private var currentLifecycleState: Lifecycle.State = Lifecycle.State.INITIALIZED

    override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
        when (event) {
            // [mapView.onDestroy] is only invoked from AndroidView->onRelease.
            Lifecycle.Event.ON_DESTROY -> moveToBaseState()
            else -> moveToLifecycleState(event.targetState)
        }
    }

    /**
     * Move down to [Lifecycle.State.CREATED] but only if [currentLifecycleState] is actually above that.
     * It's theoretically possible that [currentLifecycleState] is still in [Lifecycle.State.INITIALIZED] state.
     * */
    fun moveToBaseState() {
        if (currentLifecycleState > Lifecycle.State.CREATED) {
            moveToLifecycleState(Lifecycle.State.CREATED)
        }
    }

    fun moveToDestroyedState() {
        if (currentLifecycleState > Lifecycle.State.INITIALIZED) {
            moveToLifecycleState(Lifecycle.State.DESTROYED)
        }
    }

    private fun moveToLifecycleState(targetState: Lifecycle.State) {
        while (currentLifecycleState != targetState) {
            when {
                currentLifecycleState < targetState -> moveUp()
                currentLifecycleState > targetState -> moveDown()
            }
        }
    }

    private fun moveDown() {
        val event = Lifecycle.Event.downFrom(currentLifecycleState)
            ?: error("no event down from $currentLifecycleState")
        invokeEvent(event)
    }

    private fun moveUp() {
        val event = Lifecycle.Event.upFrom(currentLifecycleState)
            ?: error("no event up from $currentLifecycleState")
        invokeEvent(event)
    }

    private fun invokeEvent(event: Lifecycle.Event) {
        when (event) {
            Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
            Lifecycle.Event.ON_START -> mapView.onStart()
            Lifecycle.Event.ON_RESUME -> mapView.onResume()
            Lifecycle.Event.ON_PAUSE -> mapView.onPause()
            Lifecycle.Event.ON_STOP -> mapView.onStop()
            Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
            else -> error("Unsupported lifecycle event: $event")
        }
        currentLifecycleState = event.targetState
    }
}

Then register the view after takeOff:

AirshipCustomViewManager.register("map", MapCustomViewHandler())

First, define the view and its properties:

import SwiftUI
import MapKit
import CoreLocation

struct CustomMapView: View {
    struct Args: Decodable {
        let place: String
    }

    let args: Args
    @State private var region: MKCoordinateRegion?
    @State private var pinCoordinate: CLLocationCoordinate2D?

    var body: some View {
        if let region = region, let pinCoordinate = pinCoordinate {
            Map(coordinateRegion: .constant(region), annotationItems: [MapPin(coordinate: pinCoordinate)]) { pin in
                MapMarker(coordinate: pin.coordinate, tint: Color.red)
            }
        } else {
            Text("Loading map...")
                .onAppear {
                    geocodePlace()
                }
        }
    }

    private func geocodePlace() {
        let geocoder = CLGeocoder()
        geocoder.geocodeAddressString(args.place) { placemarks, error in
            if let placemark = placemarks?.first, let location = placemark.location {
                let coordinate = location.coordinate
                self.region = MKCoordinateRegion(
                    center: coordinate,
                    span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
                )
                self.pinCoordinate = coordinate
            } else {
                print("Failed to geocode place: \(error?.localizedDescription ?? "Unknown error")")
            }
        }
    }
}

struct MapPin: Identifiable {
    let id = UUID()
    let coordinate: CLLocationCoordinate2D
}

struct CustomMapView_Previews: PreviewProvider {
    static var previews: some View {
        CustomMapView(args: CustomMapView.Args(place: "Eiffel Tower"))
    }
}

Then register the view after takeOff:

AirshipCustomViewManager.shared.register(name: "map", builder: { args in
    if let args: CustomMapView.Args = try? args.properties?.decode() {
        CustomMapView(args: args)
    }
})