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)
}
})
Categories