Custom Views for the Android SDK
Register custom Android views with the AirshipCustomViewManager to use them in Scenes. Scene control: Android SDK 20.0+

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.
All Custom Views should be registered during the onAirshipReady callback to ensure the view is available before a Scene is rendered.
Registering Custom Views
// 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")
}
}
}
}// Return an Android View
AirshipCustomViewManager.register("my-custom-view", (context, args) -> {
TextView textView = new TextView(context);
String text = args.getProperties().optionalField(String.class, "text");
textView.setText(text != null ? text : "Fallback");
return textView;
});
// For compose, use a ComposeView
AirshipCustomViewManager.register("my-custom-view", (context, args) -> {
ComposeView composeView = new ComposeView(context);
String text = args.getProperties().optionalField(String.class, "text");
composeView.setContent(content -> {
MaterialTheme.INSTANCE.invoke(content, theme -> {
Text.INSTANCE.invoke(text != null ? text : "Fallback");
});
});
return composeView;
});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:
AirshipCustomViewManager.register("map", MapCustomViewHandler())First, define the view handler:
/**
* Custom View that displays a Google Map with a marker at a specified place.
*/
public class MapCustomViewHandler implements AirshipCustomViewHandler {
@Override
public View onCreateView(@NonNull Context context, @NonNull AirshipCustomViewArguments args) {
MapView mapView = new MapView(context);
String place = args.getProperties().requireField(String.class, "place");
mapView.getMapAsync(map -> onMapReady(context, map, place));
return mapView;
}
private void onMapReady(Context context, GoogleMap map, String place) {
Geocoder geocoder = new Geocoder(context);
try {
List<Address> addresses = geocoder.getFromLocationName(place, 1);
if (!addresses.isEmpty()) {
Address address = addresses.get(0);
LatLng latLng = new LatLng(address.getLatitude(), address.getLongitude());
map.moveCamera(CameraUpdateFactory.newLatLngZoom(latLng, 10f));
map.addMarker(new MarkerOptions().position(latLng).title(place));
} else {
Toast.makeText(context, "Location '" + place + "' not found", Toast.LENGTH_SHORT).show();
}
} catch (IOException e) {
Toast.makeText(context, "Error geocoding: " + e.getMessage(), Toast.LENGTH_SHORT).show();
}
}
}Then register the view:
AirshipCustomViewManager.register("map", new MapCustomViewHandler());Scene Control Android SDK 20.0+
Custom views control their parent scene through the SceneController, which is provided in AirshipCustomViewArguments.
Accessing SceneController
Access the scene controller via args.sceneController in your view handler’s onCreateView method.
Accessing SceneController
class CustomViewHandler : AirshipCustomViewHandler {
override fun onCreateView(context: Context, args: AirshipCustomViewArguments): View {
// Access via args.sceneController
args.sceneController.dismiss()
return yourView
}
}public class CustomViewHandler implements AirshipCustomViewHandler {
@Override
public View onCreateView(@NonNull Context context, @NonNull AirshipCustomViewArguments args) {
// Access via args.getSceneController()
args.getSceneController().dismiss();
return yourView;
}
}Dismissing scenes
Call dismiss() to close the scene, or set cancelFutureDisplays to prevent it from displaying again.
Dismissing scenes
// Simple dismiss
args.sceneController.dismiss()
// Dismiss and cancel future displays
args.sceneController.dismiss(cancelFutureDisplays = true)// Simple dismiss
args.getSceneController().dismiss();
// Dismiss and cancel future displays
args.getSceneController().dismiss(true);Pager navigation
Navigate between pages using navigate(), which returns true if navigation succeeded.
Pager navigation
// Navigate forward or backward
args.sceneController.pager.navigate(NavigationRequest.NEXT)
args.sceneController.pager.navigate(NavigationRequest.BACK)
// Check if navigation succeeded
val success = args.sceneController.pager.navigate(NavigationRequest.NEXT)// Navigate forward or backward
args.getSceneController().getPager().navigate(NavigationRequest.NEXT);
args.getSceneController().getPager().navigate(NavigationRequest.BACK);
// Check if navigation succeeded
boolean success = args.getSceneController().getPager().navigate(NavigationRequest.NEXT);Observe the pager’s StateFlow to check current navigation state and enable/disable navigation UI.
Observing pager state
// Compose
val state = args.sceneController.pager.state.collectAsState()
if (state.value.canGoNext) {
// Can navigate forward
}
// View-based
lifecycleScope.launch {
args.sceneController.pager.state.collect { state ->
// Update UI based on state.canGoBack and state.canGoNext
}
}LifecycleOwner lifecycleOwner = // get from context
CoroutineScope scope = LifecycleScopeKt.getLifecycleScope(lifecycleOwner);
FlowKt.launchIn(
FlowKt.onEach(args.getSceneController().getPager().getState(), state -> {
// Update UI based on state.getCanGoBack() and state.getCanGoNext()
return Unit.INSTANCE;
}),
scope
);Sizing management
Use args.sizeInfo to determine appropriate sizing for your custom view.
Sizing with SizeInfo
// Compose
val modifier = when {
args.sizeInfo.isAutoWidth && args.sizeInfo.isAutoHeight -> Modifier.wrapContentSize()
args.sizeInfo.isAutoWidth -> Modifier.wrapContentWidth().fillMaxHeight()
args.sizeInfo.isAutoHeight -> Modifier.fillMaxWidth().wrapContentHeight()
else -> Modifier.fillMaxSize()
}
// View-based
val width = if (args.sizeInfo.isAutoWidth) {
ViewGroup.LayoutParams.WRAP_CONTENT
} else {
ViewGroup.LayoutParams.MATCH_PARENT
}
val height = if (args.sizeInfo.isAutoHeight) {
ViewGroup.LayoutParams.WRAP_CONTENT
} else {
ViewGroup.LayoutParams.MATCH_PARENT
}// View-based
int width = args.getSizeInfo().isAutoWidth()
? ViewGroup.LayoutParams.WRAP_CONTENT
: ViewGroup.LayoutParams.MATCH_PARENT;
int height = args.getSizeInfo().isAutoHeight()
? ViewGroup.LayoutParams.WRAP_CONTENT
: ViewGroup.LayoutParams.MATCH_PARENT;
Embedding Airship Views
Airship views like Preference Center can be embedded as custom views.
Embedding Preference Center
import com.urbanairship.preferencecenter.compose.ui.PreferenceCenterContent
import com.urbanairship.preferencecenter.compose.ui.PreferenceCenterScreen
import com.urbanairship.preferencecenter.ui.PreferenceCenterFragment
// Compose - Content only (no navigation bar)
AirshipCustomViewManager.register("preference_center_content") { context, args ->
val id = args.properties.opt("id").optString()
ComposeView(context).apply {
setContent {
PreferenceCenterContent(
identifier = id,
modifier = Modifier.fillMaxSize()
)
}
}
}
// Compose - Full preference center with navigation bar
AirshipCustomViewManager.register("preference_center") { context, args ->
val id = args.properties.opt("id").optString()
ComposeView(context).apply {
setContent {
PreferenceCenterScreen(
identifier = id,
modifier = Modifier.fillMaxSize(),
onNavigateUp = { args.sceneController.dismiss() }
)
}
}
}
// View - Content only (no navigation bar)
AirshipCustomViewManager.register("preference_center_content") { context, args ->
val id = args.properties.opt("id").optString()
val activity = context as? FragmentActivity ?: throw IllegalStateException("Context must be FragmentActivity")
FrameLayout(context).apply {
id = View.generateViewId()
activity.supportFragmentManager.beginTransaction()
.add(id, PreferenceCenterFragment.create(id))
.commitNow()
}
}
// View - Full preference center with navigation bar
AirshipCustomViewManager.register("preference_center") { context, args ->
val id = args.properties.opt("id").optString()
val activity = context as? FragmentActivity ?: throw IllegalStateException("Context must be FragmentActivity")
LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
val toolbar = MaterialToolbar(context).apply {
setNavigationIcon(R.drawable.abc_ic_ab_back_material)
setNavigationOnClickListener { args.sceneController.dismiss() }
}
addView(toolbar, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
val container = FragmentContainerView(context).apply {
id = View.generateViewId()
}
addView(container, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
activity.supportFragmentManager.beginTransaction()
.add(container.id, PreferenceCenterFragment.create(id))
.commitNow()
}
}import com.urbanairship.preferencecenter.compose.ui.PreferenceCenterContent;
import com.urbanairship.preferencecenter.compose.ui.PreferenceCenterScreen;
import com.urbanairship.preferencecenter.ui.PreferenceCenterFragment;
// Compose - Content only (no navigation bar)
AirshipCustomViewManager.register("preference_center_content", (context, args) -> {
String id = args.getProperties().opt("id").optString();
ComposeView composeView = new ComposeView(context);
composeView.setContent(() -> {
PreferenceCenterContent(id, Modifier.fillMaxSize(), null);
});
return composeView;
});
// Compose - Full preference center with navigation bar
AirshipCustomViewManager.register("preference_center", (context, args) -> {
String id = args.getProperties().opt("id").optString();
ComposeView composeView = new ComposeView(context);
composeView.setContent(() -> {
PreferenceCenterScreen(
id,
Modifier.fillMaxSize(),
() -> {
args.getSceneController().dismiss();
return Unit.INSTANCE;
}
);
});
return composeView;
});
// View - Content only (no navigation bar)
AirshipCustomViewManager.register("preference_center_content", (context, args) -> {
String id = args.getProperties().opt("id").optString();
FragmentActivity activity = (FragmentActivity) context;
FrameLayout layout = new FrameLayout(context);
int containerId = View.generateViewId();
layout.setId(containerId);
activity.getSupportFragmentManager()
.beginTransaction()
.add(containerId, PreferenceCenterFragment.create(id))
.commitNow();
return layout;
});
// View - Full preference center with navigation bar
AirshipCustomViewManager.register("preference_center", (context, args) -> {
String id = args.getProperties().opt("id").optString();
FragmentActivity activity = (FragmentActivity) context;
LinearLayout layout = new LinearLayout(context);
layout.setOrientation(LinearLayout.VERTICAL);
MaterialToolbar toolbar = new MaterialToolbar(context);
toolbar.setNavigationIcon(R.drawable.abc_ic_ab_back_material);
toolbar.setNavigationOnClickListener(v -> args.getSceneController().dismiss());
layout.addView(toolbar, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
FragmentContainerView container = new FragmentContainerView(context);
int containerId = View.generateViewId();
container.setId(containerId);
layout.addView(container, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
activity.getSupportFragmentManager()
.beginTransaction()
.add(containerId, PreferenceCenterFragment.create(id))
.commitNow();
return layout;
});Categories