Custom Views for the Capacitor Plugin

Register custom native views to use within Scenes.

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.

Custom Views allow you to embed native iOS and Android views within Scenes, giving you full control over design and layout while leveraging Airship’s targeting and orchestration capabilities.

Requirements

To use Custom Views in Capacitor, you must extend the native Airship modules using the AirshipPluginExtender. See Extending Airship for setup instructions.

Registering Custom Views

Custom Views must be registered on each native platform separately:

iOS

See the Apple Custom Views documentation for detailed implementation instructions.

Custom Views should be registered after takeOff in your native iOS code:

import AirshipKit

@objc(AirshipExtender)
class AirshipExtender: NSObject, AirshipPluginExtenderDelegate {
    func onAirshipReady() {
        // Register custom views
        AirshipCustomViewManager.shared.register(name: "my-custom-view") { args in
            // Return your SwiftUI view
            MyCustomView(args: args)
        }
    }
}

Android

See the Android Custom Views documentation for detailed implementation instructions.

Custom Views should be registered during the onAirshipReady callback in your native Android code:

import com.urbanairship.AirshipCustomViewManager

class AirshipExtender : AirshipPluginExtender {
    override fun onAirshipReady(context: Context) {
        // Register custom views
        AirshipCustomViewManager.register("my-custom-view") { context, args ->
            // Return your Android View
            MyCustomView(context, args)
        }
    }
}

Using Custom Views

Once registered, Custom Views can be added to Scenes in the Airship dashboard:

  1. Create or edit a Scene
  2. Add the Custom View content element to a screen
  3. Enter the view name (e.g., my-custom-view) that matches the name you registered in your native code
  4. Optionally add key-value pairs to pass custom properties to the view

The native view will be displayed within the Scene with the properties you configured.

Example: Embedding Capacitor Web Views

A powerful use case for Custom Views in Capacitor is embedding your Capacitor web app (or a subset of it) directly within a Scene. This allows you to display specific routes or pages from your web app inside an Airship Scene.

iOS Implementation

import Foundation
import AirshipFrameworkProxy
import Capacitor
import AirshipCore
import SwiftUI
import UIKit

@objc(AirshipPluginExtender)
public class AirshipPluginExtender: NSObject, AirshipPluginExtenderProtocol {
    
    @MainActor
    public static func onAirshipReady() {
        AirshipCustomViewManager.shared.register(name: "capacitor") { args in
            CapView(
                startPath: args.properties?.object?["path"]?.string
            )
            .edgesIgnoringSafeArea(.all)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
    }
}

struct CapView: UIViewControllerRepresentable {
    let startPath: String?
    
    func makeUIViewController(context: Context) -> CustomCapViewController {
        let controller = CustomCapViewController()
        controller.startPath = startPath
        return controller
    }
    
    func updateUIViewController(_ uiViewController: CustomCapViewController, context: Context) {
        // Handle updates (if necessary)
    }
    
    class CustomCapViewController: CAPBridgeViewController {
        var startPath: String? = nil
        
        override func instanceDescriptor() -> InstanceDescriptor {
            let descriptor = super.instanceDescriptor()
            descriptor.appStartPath = startPath
            return descriptor
        }
    }
}

Android Implementation

First, create a layout file at res/layout/custom_view.xml:

<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent">

    <com.getcapacitor.CapacitorWebView
        android:id="@+id/webview"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent" />

</androidx.core.widget.NestedScrollView>

Then, register the custom view in your AirshipExtender.kt:

package com.example.plugin

import android.content.Context
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.annotation.Keep
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.doOnAttach
import androidx.fragment.app.Fragment
import com.getcapacitor.Bridge
import com.getcapacitor.CapConfig
import com.getcapacitor.Logger
import com.getcapacitor.PluginLoadException
import com.getcapacitor.PluginManager
import com.urbanairship.UAirship
import com.urbanairship.android.framework.proxy.AirshipPluginExtender
import com.urbanairship.android.layout.AirshipCustomViewArguments
import com.urbanairship.android.layout.AirshipCustomViewHandler
import com.urbanairship.android.layout.AirshipCustomViewManager
import com.urbanairship.json.optionalField

@Keep
class AirshipExtender: AirshipPluginExtender {
    override fun onAirshipReady(context: Context, airship: UAirship) {
        AirshipCustomViewManager.register("capacitor", CustomCapViewHandler())
    }
}

class CustomCapViewHandler: AirshipCustomViewHandler {
    override fun onCreateView(context: Context, args: AirshipCustomViewArguments): View {
        return CapView(context).also {
            it.startPath = args.properties.optionalField<String>("path")
        }
    }
}

class CapView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0,
    defStyleRes: Int = 0
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {

    var startPath: String? = null

    init {
        id = View.generateViewId()

        val fm = (context as AppCompatActivity).supportFragmentManager

        fm.findFragmentById(id)?.let {
            fm.beginTransaction()
                .remove(it)
                .commit()
        }

        doOnAttach {
            findViewById<CapView>(id)?.let {
                fm.beginTransaction()
                    .setReorderingAllowed(true)
                    .add(id, CapFragment.newInstance(startPath))
                    .commitNowAllowingStateLoss()
            }
        }
    }
}

class CapFragment: Fragment() {
    private var bridge: Bridge? = null
    private val path: String? by lazy { arguments?.getString(ARG_PATH) }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.custom_view, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        load(savedInstanceState)
    }

    override fun onStart() {
        super.onStart()
        bridge?.onStart()
    }

    override fun onResume() {
        super.onResume()
        bridge?.app?.fireStatusChange(true)
        bridge?.onResume()
    }

    override fun onPause() {
        super.onPause()
        bridge?.app?.fireStatusChange(false)
        bridge?.onPause()
    }

    override fun onStop() {
        super.onStop()
        bridge?.onStop()
    }

    override fun onDestroy() {
        super.onDestroy()
        bridge?.onDestroy()
        bridge?.onDetachedFromWindow()
    }

    override fun onConfigurationChanged(newConfig: Configuration) {
        super.onConfigurationChanged(newConfig)
        bridge?.onConfigurationChanged(newConfig)
    }

    private fun load(savedInstanceState: Bundle?) {
        if (bridge == null) {
            bridge = Bridge.Builder(this)
                .apply {
                    try {
                        val loader = PluginManager(requireActivity().assets)
                        addPlugins(loader.loadPluginClasses())
                    } catch (ex: PluginLoadException) {
                        Logger.error("Error loading plugins.", ex)
                    }
                }
                .setConfig(
                    CapConfig.Builder(context)
                        .setStartPath(this.path)
                        .create()
                )
                .setInstanceState(savedInstanceState)
                .create()
        }
    }

    companion object {
        const val ARG_PATH: String = "path"

        @JvmStatic
        @JvmOverloads
        fun newInstance(path: String? = null): CapFragment = CapFragment().apply {
            arguments = Bundle().apply {
                path?.let { putString(ARG_PATH, it) }
            }
        }
    }
}

Usage in Scenes

When creating a Scene in the Airship dashboard:

  1. Add a Custom View content element
  2. Set the view name to capacitor
  3. Optionally add a path property to specify which route in your Capacitor app to display (e.g., /products, /settings)
 Note

Sizing limitation: The custom view must use hard-coded sizing (percentages or pixels). Auto-sizing is not currently supported for embedded Capacitor views.

This implementation creates a fully functional Capacitor web view within the Scene, complete with all your Capacitor plugins and routing capabilities. The optional path property allows you to display specific routes or pages from your web app.