/helm

A multiplatform navigation and presentation framework based on Jetpack Compose.

Primary LanguageKotlinApache License 2.0Apache-2.0

Helm

Helm is a multiplatform implementation of Cashapp's Broadway architecture based on a talk from Droidcon NYC 2022.

This is an experimental library that's not really intended for public use yet. I'm using it to explore some ideas, and would love feedback, but if you'd like something that feels a bit less experimental-playground-to-test-ideas-for-funsies then perhaps have a look at Circuit.

Usage

Helm is a navigation and presentation framework that allows you to build your UI and Presenter layer using Jetpack Compose all the way down.

The basic implementation consists of a Screen, which is the data that represents a navigation destination, a Presenter, a class containing a single Composable function that takes in a stream of UiEvents and converts them into UiModels, and a Ui, which displays UiModels and emits UiEvents.

These three basic building blocks allow for decoupled layers that are easy to test and wire together.

Example

Define your models, which consist of a Screen used to navigate to your Ui, a UiModel which your presenter produces and your ui consumes, and a UiEvent which is emitted by your Ui and consumed by your presenter.

// Define your counter screen
data class CounterScreen(val start: Int = 0) : Screen

// The UiModel is what your Presenter produces and your Ui consumes
data class CounterUiModel(
  val count: Int,
)

// The UiEvent is what your Ui emits and your Presenter consumes
sealed interface CounterUiEvent {
  data class Increment(val amount: Int = 1)
  data class Decrement(val amount: Int = 1)
}

Next, declare a Ui subclass, using the @HelmInject annotation to bind it to your screen. When using the helm-codegen module for Android projects, your Ui will be bound in the Hilt scope defined in the HelmInject annotation. If using the helm-codegen-kotlin-inject module for multiplatform projects, your Ui will be bound in the Kotlin Inject component passed to that annotation.

// The Ui displays UiModels and emits UiEvents
@Inject
@HelmInject(CounterScreen::class, scope = AppComponent::class)
class CounterUi : Ui<CounterUiModel, CounterUiEvent> {
  @Composable
  override fun Content(model: CounterUiModel, onEvent: (CounterUiEvent) -> Unit) {
    Column(
      verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
      horizontalAlignment = Alignment.CenterHorizontally,
      modifier = Modifier.fillMaxSize(),
    ) {
      Text("Count: ${model.count}")
      Row {
        Button(onClick = { onEvent(Decrement(2)) }) {
          Text("-2")
        }
        Button(onClick = { onEvent(Decrement()) }) {
          Text("-1")
        }
        Button(onClick = { onEvent(Increment()) }) {
          Text("+1")
        }
        Button(onClick = { onEvent(Increment(2)) }) {
          Text("+2")
        }
      }
    }
  }
}

Now declare a Presenter, which uses a Composable function to consume UiEvents and produce UiModels. Adding the HelmInject annotation will take care of wiring up your Presenter and screen.

@Inject
@HelmInject(CounterScreen::class, scope = AppComponent::class)
class CounterPresenter(screen: CounterScreen) : Presenter<CounterUiModel, CounterUiEvent> {
  
  private val count by mutableStateOf(screen.start)
  
  @Composable
  override fun models(events: Flow<CounterUiEvent>): CounterUiModel {
    LaunchedEffect {
      events.collect { event ->
        when (event) {
          is Decrement -> count -= event.amount
          is Increment -> count += event.amount
        }
      }
    }
    
    return CounterUiModel(count = count)
  }
}

Finally, using your optional dependency injector of choice, get a Router to create a Navigator, and use the composable Navigator function.

val appComponent = AppComponent::class.create()

@Composable
fun CounterApp() {
  val navigator = rememberNavigator(appComponent.router, CounterScreen(start = 10))

  ReadmeTheme {
    Surface {
      Navigator(navigator)
    }
  }
}

Note: Navigator(navigator) feels a bit silly, but naming is hard. I might change this.

Using Kotlin Inject

To tie things together with a Kotlin Inject component (for now until the codegen takes care of this) you can add the generated ..._HelmComponent interfaces to your component, and create a Router by injecting the supplied sets of generated Presenter.Factory and Ui.Factorys.

@Component
abstract class AppComponent : CounterUi_HelmComponent, CounterPresenter_HelmComponent {
  abstract val router: Router

  @Provides
  protected fun provideRouter(
    presenterFactories: Set<Presenter.Factory>,
    uiFactories: Set<Ui.Factory>
  ): Router = Router(
    presenterFactories.toList(),
    uiFactories.toList(),
  )
}

License

Copyright 2022 Ryan Harter

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.