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
-
ComposeRendererFactory
is an implementation for Compose Multiplatform and can be used on all supported platforms. It can only create instances ofComposeRenderer
. AndroidRendererFactory
-
AndroidRendererFactory
is only suitable for Android. It can be used to createViewRenderer
instances and its subtypes. It does not supportComposeRenderer
. UseComposeAndroidRendererFactory
if you need to mix and matchViewRenderer
withComposeRenderer
. ComposeAndroidRendererFactory
-
ComposeAndroidRendererFactory
is only suitable for Android when usingComposeRenderer
together withViewRenderer
. 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
.
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
-
RecyclerViewViewHolderRenderer
allows you to implement elements of aRecyclerView
as 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, 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
.