Skip to content

Renderer

Note

App Platform has a generic Renderer interface that can be used for multiple UI layer implementations. Compose Multiplatform and Android Views are stable and supported out of the box. However, Compose Multiplatform is an opt-in feature through the Gradle DSL and must be explicitly enabled. The default value is false.

appPlatform {
  enableComposeUi true
}

Renderer basics

A Renderer is the counterpart to a Presenter. It consumes Models and turns them into UI, which is shown on screen.

interface Renderer<in ModelT : BaseModel> {
  fun render(model: ModelT)
}

The Renderer interface is rarely used directly, instead platform specific implementations like ComposeRenderer for Compose Multiplatform and ViewRenderer for Android are used. App Platform doesn’t provide any other implementations for now, e.g. a SwiftUI or UIKit implementation for iOS is missing.

ComposeRenderer
@ContributesRenderer
class LoginRenderer : ComposeRenderer<Model>() {
  @Composable
  override fun Compose(model: Model) {
    if (model.loginInProgress) {
      CircularProgressIndicator()
    } else {
      Text("Login")
    }
  }
}
ViewRenderer
@ContributesRenderer
class LoginRenderer : ViewRenderer<Model>() {
    private lateinit var textView: TextView

    override fun inflate(
        activity: Activity,
        parent: ViewGroup,
        layoutInflater: LayoutInflater,
        initialModel: Model,
    ): View {
        return TextView(activity).also { textView = it }
    }

    override fun renderModel(model: Model) {
        textView.text = "Login"
    }
}

Warning

Note that ComposeRenderer like ViewRenderer implements the common Renderer interface, but calling the render(model) function is an error. Instead, ComposeRenderer defines its own function to preserve the composable context:

@Composable
fun renderCompose(model: ModelT)

In practice this is less of a concern, because the render(model) function is deprecated and hidden and callers only see the renderCompose(model) function.

Renderers are composable and can build hierarchies similar to Presenters. The parent renderer is responsible for calling render() on the child renderer:

data class ParentModel(
  val childModel: ChildModel
): BaseModel

class ParentRenderer(
  private val childRenderer: ChildRenderer
): Renderer<ParentModel> {
  override fun render(model: ParentModel) {
    childRenderer.render(model.childModel)
  }
}

Note

Injecting concrete child Renderers is possible, but less common. More frequently RendererFactory is injected to obtain a Renderer instance for a Model.

A Renderer sends events back to the Presenter through the onEvent lambda on a Model.

@ContributesRenderer
class LoginRenderer : ComposeRenderer<Model>() {
  @Composable
  override fun Compose(model: Model) {
    Button(
      onClick = { model.onEvent(LoginPresenter.Event.Login("Demo")) },
    ) {
      Text("Login")
    }
  }
}
Sample

The sample app implements multiple ComposeRenderers, e.g. LoginRenderer, UserPageListRenderer and UserPageDetailRenderer.

RendererFactory

How Renderers are initialized depends on RendererFactory, which only responsibility is to create and cache Renderers based on the given model. App Platform comes with three different implementations:

ComposeRendererFactory

ComposeRendererFactory is an implementation for Compose Multiplatform and can be used on all supported platforms. It can only create instances of ComposeRenderer.

AndroidRendererFactory

AndroidRendererFactory is only suitable for Android. It can be used to create ViewRenderer instances and its subtypes. It does not support ComposeRenderer. Use ComposeAndroidRendererFactory if you need to mix and match ViewRenderer with ComposeRenderer.

ComposeAndroidRendererFactory

ComposeAndroidRendererFactory is only suitable for Android when using ComposeRenderer together with ViewRenderer. The factory wraps the Renderers for seamless interop.

@ContributesRenderer

All factory implementations rely on the dependency injection framework kotlin-inject-anvil to discover and initialize renderers. When the factory is created, it builds the RendererComponent, which parent is the app component. The RendererComponent lazily provides all renderers using the multibindings feature. To participate in the lookup, renderers must tell kotlin-inject-anvil which models they can render. This is done through a component interface, which automatically gets generated and added to the renderer scope by using the @ContributesRenderer annotation.

Which Model type is used for the binding is determined based on the super type. In the following example LoginPresenter.Model is used.

@ContributesRenderer
class LoginRenderer : ComposeRenderer<LoginPresenter.Model>()
Generated code

The @ContributesRenderer annotation generates following code.

@ContributesTo(RendererScope::class)
interface LoginRendererComponent {
  @Provides
  public fun provideSoftwareAmazonAppPlatformSampleLoginLoginRenderer(): LoginRenderer = LoginRenderer()

  @Provides
  @IntoMap
  public fun provideSoftwareAmazonAppPlatformSampleLoginLoginRendererLoginPresenterModel(renderer: () -> LoginRenderer): Pair<KClass<out BaseModel>, () -> Renderer<*>> = LoginPresenter.Model::class to renderer

  @Provides
  @IntoMap
  @ForScope(scope = RendererScope::class)
  public fun provideSoftwareAmazonAppPlatformSampleLoginLoginRendererLoginPresenterModelKey(): Pair<KClass<out BaseModel>, KClass<out Renderer<*>>> = LoginPresenter.Model::class to LoginRenderer::class
}

Creating RendererFactory

The RendererFactory should be created and cached in the platform specific UI context, e.g. an iOS UIViewController or Android Activity.

iOS Compose Multiplatform
fun mainViewController(rootScopeProvider: RootScopeProvider): UIViewController =
  ComposeUIViewController {
    // Only a single factory is needed.
    val rendererFactory = remember { ComposeRendererFactory(rootScopeProvider) }
    ...
  }
Android Activity
class MainActivity : ComponentActivity() {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    val rendererFactory =
      ComposeAndroidRendererFactory(
        rootScopeProvider = application as RootScopeProvider,
        activity = this,
        parent = findViewById(R.id.main_container),
      )
    ...
  }
}
Sample

The sample app uses ComposeAndroidRendererFactory in Android application and ComposeRendererFactory for iOS and Desktop.

Creating Renderers

Based on a Model instance or Model type a RendererFactory can create a new Renderer instance. The getRenderer() function creates a Renderer only once and caches the instance after that. This makes the caller side simpler. Whenever a new Model is available get the Renderer for the Model and render the content on screen.

iOS Compose Multiplatform
fun mainViewController(rootScopeProvider: RootScopeProvider): UIViewController =
  ComposeUIViewController {
    // Only a single factory is needed.
    val rendererFactory = remember { ComposeRendererFactory(rootScopeProvider) }

    val model = presenter.present(Unit)

    val renderer = factory.getComposeRenderer(model)
    renderer.renderCompose(model)
  }

Note

Note that getRenderer() for ComposeRendererFactory returns a ComposeRenderer. For a ComposeRenderer the renderCompose(model) function must be called and not render(model).

Android Activity
class MainActivity : ComponentActivity() {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    val rendererFactory = ComposeAndroidRendererFactory(...)
    val models: StateFlow<Model> = ...
    ...

    lifecycleScope.launch {
      repeatOnLifecycle(Lifecycle.State.STARTED) {
        models.collect { model ->
          val renderer = rendererFactory.getRenderer(model)
          renderer.render(model)
        }
      }
    }
  }
}

Injecting RendererFactory

The RendererFactory is provided in the RendererComponent, meaning it can be injected by any Renderer. This allows you to create child renderers without knowing the concrete type of the model and injecting the child renderers ahead of time:

@Inject
@ContributesRenderer
class SampleRenderer(
  private val rendererFactory: RendererFactory
) : ComposeRenderer<Model>() {

  @Composable
  override fun Compose(model: Model) {
    val childRenderer = rendererFactory.getComposeRenderer(model.childModel)
    childRenderer.renderCompose(model.childModel)
  }
}
Sample

The sample app injects RendererFactory in ComposeSampleAppTemplateRenderer to create Renderers dynamically for unknown Model types. There is also an Android sample implementation.

Note

Whenever a Renderer has an injected constructor parameter like rendererFactory in the sample above, then the class must be annotated with @Inject in addition to @ContributesRenderer.

Android support

Android Views are supported out of the box using ViewRenderer.

Compose interop

If an Android app uses only Compose UI with ComposeRenderer, then it can use ComposeRendererFactory similar to iOS and Desktop to create ComposeRenderer instances. However, if interop with Android Views is needed, then ComposeAndroidRendererFactory must be used. ComposeAndroidRendererFactory makes it transparent which Renderer implementation is used and interop is seamless. A ComposeRenderer that has a child ViewRenderer wraps the Android view within a AndroidView composable function call. A ViewRenderer that has a child ComposeRenderer wraps the Compose UI within a ComposeView Android View.

val rendererFactory = ComposeAndroidRendererFactory(...)

val renderer = rendererFactory.getRenderer(model)
render.render(model)

In this example the returned Renderer can be a ComposeRenderer or ViewRenderer, it would not matter and either the Compose UI or Android Views would be rendered on screen. With the seamless interop it becomes easier to migrate from Android Views to Compose UI by simply migrating renderers one by one.

ViewRenderer subtypes

ViewBindingRenderer.

View binding is supported out of the box using ViewBindingRenderer.

RecyclerViewViewHolderRenderer

RecyclerViewViewHolderRenderer allows you to implement elements of a RecyclerView as a Renderer.

Unit tests

ComposeRenderer can easily be tested as unit tests on Desktop and iOS. In particular tests for Desktop are helpful due to the fast build times. Various fake Models can be passed to the Renderer and the UI state based on the model verified.

Testing ComposeRenderer or ViewRenderer for Android requires an Android device or emulator.

This test runs as a unit test on iOS and Desktop.

class LoginRendererTest {

  @Test
  fun `the login button is rendered when not logging in`() {
    runComposeUiTest {
      setContent {
        val renderer = LoginRenderer()
        renderer.renderCompose(LoginPresenter.Model(loginInProgress = false, onEvent = {}))
      }

      onNodeWithTag("loginProgress").assertDoesNotExist()
      onNodeWithTag("loginButton").assertIsDisplayed()
    }
  }
}
Sample

The sample app demonstrates this with the LoginRendererTest. To avoid duplicating the test in the desktopTest and iosTest source folders, the sample app has a custom source set appleAndDesktop, which is a shared parent source set for apple and desktop.