Integrate Dragon Medical SpeechKit (Android edition) with Jetpack Compose

This article describes how to speech-enable your Android EHR app by using Jetpack Compose. For more information, see the Compose UI sample app. The following integration patterns are supported:

Choose a pattern based on whether the app's speech-enabled fields are existing XML views, Android views hosted by Compose, or native Compose fields.

Sample app flow

The examples in this article reference sample code in our GitHub repo.

SpeechKitComposeUISample is the launcher activity. It displays a pure Compose sign-in screen, opens the SpeechKit session, and launches ComposeUIReportsActivity.

ComposeUIReportsActivity displays the three supported reports in a Material 3 LazyColumn and launches:

  • ComposeUIAndroidViewBindingActivity

  • ComposeUIAndroidViewWrapperActivity

  • PureComposeCustomVCReportActivity

All report activities are internal to the app (android:exported="false").

AndroidViewBinding and XML layout

Use this pattern when you need to host an existing XML form in a Compose screen.

Reference files:

  • reports/ComposeUIAndroidViewBindingActivity.kt

  • res/layout/activity_compose_ui_android_view_binding_report.xml

Implementation requirements:

  • Use com.nuance.speechanywhere.VuiController as the XML root.

  • Place every speech-enabled EditText or TextInputEditText control in that controller hierarchy.

  • Inflate the layout with AndroidViewBinding.

  • Keep a reference to the controller and call close("") when navigating back and during activity destruction.

  • Make the content scrollable and apply IME insets so the keyboard and SpeechBar don't cover the active field.

Sample code:

AndroidViewBinding(
  modifier = Modifier
    .fillMaxSize()
    .padding(innerPadding),
  factory = ActivityComposeUiAndroidViewBindingReportBinding::inflate,
  update = {
    onControllerAvailable(vuiController)
    val scrollView = reportScrollView
    val contentPadding = (16 * scrollView.resources.displayMetrics.density).toInt()
    ViewCompat.setOnApplyWindowInsetsListener(scrollView) { view, insets ->
      val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
      view.setPadding(
        contentPadding,
        contentPadding,
        contentPadding,
        contentPadding + imeBottom,
      )
      insets
    }
    ViewCompat.requestApplyInsets(scrollView)
  },
)

The XML layout uses a ScrollView with android:fillViewport="true", android:clipToPadding="false", and vertical scrollbars. The report description uses textMultiLine and a minimum of three lines. Both fields use flagNoExtractUi.

<com.nuance.speechanywhere.VuiController
  android:id="@+id/vuiController"
  android:layout_width="match_parent"
  android:layout_height="match_parent">

  <ScrollView
    android:id="@+id/reportScrollView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:clipToPadding="false"
    android:fillViewport="true"
    android:padding="16dp"
    android:scrollbars="vertical">

<!-- Speech-enabled TextInputEditText controls -->

  </ScrollView>
</com.nuance.speechanywhere.VuiController>

AndroidView wrapper

Use this pattern for a Compose-first screen that still uses classic Android EditText controls. VuiControllerView hosts the Compose content under one SDK VuiController and discovers the wrapped fields.

Reference file:

  • /composeuisample/reports/ComposeUIAndroidViewWrapperActivity.kt

SpeechKit APIs:

  • com.nuance.speechanywhere.compose.VuiControllerView

  • com.nuance.speechanywhere.compose.VuiControllerState

  • com.nuance.speechanywhere.compose.rememberVuiControllerState

Implementation requirements:

  • Create controller state with rememberVuiControllerState.

  • Wrap all speech-enabled fields with VuiControllerView.

  • Add classic fields with AndroidView and assign stable resource IDs.

  • Preserve values with rememberSaveable and synchronize Compose and View state in factory and update.

  • Use multiline input and EditorInfo.IME_FLAG_NO_EXTRACT_UI for the sample report fields.

  • Place fields in an imePadding() and vertically scrollable container.

  • Retain the controller callback and close the controller on back navigation and destruction.

Sample code:

val state = rememberVuiControllerState(
  language = "en-US",
  topic = VuiController.NUSA_TOPIC_GENERAL_MEDICINE,
  )

VuiControllerView(
  modifier = Modifier.fillMaxSize(),
  language = state.language,
  topic = state.topic,
  onControllerChanged = onControllerChanged,
) {
  Column(
    modifier = Modifier
      .fillMaxWidth()
      .imePadding()
      .verticalScroll(rememberScrollState()),
  ) {
    AndroidView(
      factory = { context ->
        EditText(context).apply {
          id = R.id.compose_wrapper_patient_name
          imeOptions = EditorInfo.IME_ACTION_NONE or
            EditorInfo.IME_FLAG_NO_EXTRACT_UI
          minLines = 3
          maxLines = 6
        }
      },
    )
  }
}

VuiControllerView assigns an ID only when a wrapped EditText has no ID. It synchronizes after composition, after attachment, and on lifecycle resume to avoid missing hosted views. Its ComposeView uses DisposeOnViewTreeLifecycleDestroyed, and the lifecycle effect releases the controller when appropriate.

Pure Compose UI with custom VuiController

Use this pattern for native Compose OutlinedTextField controls. The SDK ComposeFieldAdapter bridges Compose TextFieldValue state to custom.ITextControl. The custom controller itself is hosted in an AndroidView.

Reference file:

  • /composeuisample/reports/PureComposeCustomVCReportActivity.kt

SpeechKit APIs:

  • com.nuance.speechanywhere.compose.ComposeFieldAdapter

  • com.nuance.speechanywhere.compose.rememberComposeFieldAdapter

  • com.nuance.speechanywhere.compose.rememberComposeFieldSetup

  • com.nuance.speechanywhere.custom.VuiController

Implementation requirements:

  • Create saveable field state with rememberComposeFieldSetup.

  • Create one stable ComposeFieldAdapter per field with rememberComposeFieldAdapter.

  • Track the active control ID and call controlGotFocus when focus changes.

  • Host custom.VuiController in AndroidView and set the language and topic.

  • Synchronize adapters after controller creation and again on ON_RESUME.

  • Use an imePadding() and vertically scrollable field container.

  • Close the controller on back navigation and destruction.

Sample code:

AndroidView(
  modifier = Modifier.fillMaxSize(),
  factory = { context ->
    VuiController(context).apply {
      setLanguage("en-US")
      setTopic(VuiController.NUSA_TOPIC_GENERAL_MEDICINE)
    }
  },
  update = { controller ->
    if (synchronizedController !== controller) {
      synchronizedController = controller
      controller.post { controller.synchronize(controls) }
    }
    when (activeControlId) {
      firstControlId -> controller.controlGotFocus(firstControl)
      secondControlId -> controller.controlGotFocus(secondControl)
    }
  }
)

ComposeFieldAdapter bridges text, selection, replacement, and focus operations between Compose state and SpeechKit.

Add SpeechBar and keyboard UI

The current sample includes the following UI handling to keep speech controls and editable content usable when the soft keyboard or SpeechBar is visible:

  • The three report activities use android:windowSoftInputMode="adjustResize".

  • The three report activities handle orientation|screenSize|keyboard|keyboardHidden configuration changes.

  • The XML report opts into edge-to-edge layout with WindowCompat.setDecorFitsSystemWindows(window, false) and applies the current IME bottom inset to its ScrollView padding.

  • The XML ScrollView sets clipToPadding="false", allowing content to scroll through the inset-padded area.

  • The launcher, wrapped-view report, and pure Compose report use imePadding() plus vertical scrolling.

  • Wrapped Android fields and pure Compose fields are multiline (minLines = 3, maxLines = 6) so dictated text remains readable.

  • XML and wrapped Android fields disable extract UI to avoid a full-screen keyboard editor.

  • Report controllers are explicitly closed on system back, toolbar back, and activity destruction so a stale SpeechBar isn't retained after leaving a report.

  • android.permission.HIDE_OVERLAY_WINDOWS remains declared for SDK overlay behavior.

Manifest configuration:

<activity
  android:name="com.nuance.dm_speechkit.composeuisample.reports.ComposeUIAndroidViewBindingActivity"
  android:configChanges="orientation|screenSize|keyboard|keyboardHidden"
  android:windowSoftInputMode="adjustResize"
  android:exported="false" />

Apply the same configuration to the AndroidView wrapper and pure Compose report activities.

Integrator checklist

  1. Select the XML, mixed Android View and Compose, or pure Compose pattern.

  2. Keep View-based fields inside one VuiController hierarchy.

  3. Use stable IDs for wrapped Android views.

  4. Preserve text and selection state across recomposition.

  5. Forward focus changes for pure Compose controls.

  6. Set controller language and topic explicitly.

  7. Synchronize hosted or custom controls after attachment and lifecycle resume.

  8. Use scrolling and IME insets so fields remain visible with the keyboard and SpeechBar.

  9. Close the controller whenever the report activity exits.

  10. Register report activities as non-exported with adjustResize and the documented configuration changes.

  11. Test dictation, field navigation, rotation, keyboard behavior, SpeechBar visibility, back navigation, and activity resume.