# Custom Views for the Android SDK

Register custom Android views with the AirshipCustomViewManager to use them in Scenes.

![Custom Views for the Android SDK](https://www.airship.com/docs/images/custom-views-android_hu_e02177d308b3e6b5.webp)

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](https://www.airship.com/docs/developer/sdk-integration/android/installation/getting-started/#customizing-airship) to ensure the view is available before a Scene is rendered.


#### Kotlin



```kotlin
// 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")
            }
        }
    }
}
```



#### Java



```java
// 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;
});
```




<!-- TODO: Add image: custom-view-registration.png - Screenshot showing where to register custom views in the Airship dashboard or code -->

## 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.


#### Kotlin



First, define the view handler:

```kotlin
/**
 * 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:

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


<!-- TODO: Add image: custom-map-view-in-scene.png - Screenshot showing a Custom Map View rendered within a Scene, displaying a map with a location marker -->



#### Java



First, define the view handler:

```java
/**
 * 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:

```java
AirshipCustomViewManager.register("map", new MapCustomViewHandler());
```





## Scene Control

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.


#### Kotlin


```kotlin
class CustomViewHandler : AirshipCustomViewHandler {
    override fun onCreateView(context: Context, args: AirshipCustomViewArguments): View {
        // Access via args.sceneController
        args.sceneController.dismiss()

        return yourView
    }
}
```



#### Java


```java
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.


#### Kotlin


```kotlin
// Simple dismiss
args.sceneController.dismiss()

// Dismiss and cancel future displays
args.sceneController.dismiss(cancelFutureDisplays = true)
```



#### Java


```java
// 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.


#### Kotlin


```kotlin
// 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)
```



#### Java


```java
// 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.


#### Kotlin


```kotlin
// 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
    }
}
```



#### Java


```java
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.


#### Kotlin


```kotlin
// 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
}
```



#### Java


```java
// 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;
```




![Custom Views for the Android SDK](https://www.airship.com/docs/images/custom-views-map-scene-android_hu_9a022a240a253d3a.webp)

## Embedding Airship Views

Airship views like Preference Center can be embedded as custom views.


#### Kotlin


```kotlin
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()
    }
}
```



#### Java


```java
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;
});
```




![Custom Views for the Android SDK](https://www.airship.com/docs/images/custom-views-preference-center-scene-android_hu_a81aa6610c793dfd.webp)
