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.
@ContributesRenderer
class LoginRenderer : ComposeRenderer<Model>() {
  @Composable
  override fun Compose(model: Model) {
    if (model.loginInProgress) {
      CircularProgressIndicator()
    } else {
      Text("Login")
    }
  }
}
@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
- 
ComposeRendererFactoryis an implementation for Compose Multiplatform and can be used on all supported platforms. It can only create instances ofComposeRenderer.
- AndroidRendererFactory
- 
AndroidRendererFactoryis only suitable for Android. It can be used to createViewRendererinstances and its subtypes. It does not supportComposeRenderer. UseComposeAndroidRendererFactoryif you need to mix and matchViewRendererwithComposeRenderer.
- ComposeAndroidRendererFactory
- 
ComposeAndroidRendererFactoryis only suitable for Android when usingComposeRenderertogether withViewRenderer. The factory wraps the Renderers for seamless interop.
@ContributesRenderer¶
All factory implementations rely on the dependency injection framework kotlin-inject-anvil or Metro 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 or Metro 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
}
The @ContributesRenderer annotation generates following code.
@ContributesTo(RendererScope::class)
interface LoginRendererGraph {
  @Provides
  public fun provideSoftwareAmazonAppPlatformSampleLoginLoginRenderer(): LoginRenderer = LoginRenderer()
  @Provides
  @IntoMap
  @RendererKey(LoginPresenter.Model::class)
  public fun provideSoftwareAmazonAppPlatformSampleLoginLoginRendererLoginPresenterModel(renderer: Provider<LoginRenderer>): Renderer<*> = renderer()
  @Provides
  @IntoMap
  @ForScope(scope = RendererScope::class)
  @RendererKey(LoginPresenter.Model::class)
  public fun provideSoftwareAmazonAppPlatformSampleLoginLoginRendererLoginPresenterModelKey(): KClass<out Renderer<*>> = 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.
fun mainViewController(rootScopeProvider: RootScopeProvider): UIViewController =
  ComposeUIViewController {
    // Only a single factory is needed.
    val rendererFactory = remember { ComposeRendererFactory(rootScopeProvider) }
    ...
  }
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.
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).
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
- 
RecyclerViewViewHolderRendererallows you to implement elements of aRecyclerViewas aRenderer.
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) {})
      }
      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.