An Intent
represents the View's desire to perform an action. It does not contain any logic to perform the action, rather it states what the View wishes to do.
Immutable Data Type
Intent
View
- Returns a Flow
of emitted Intents
through its intents()
function.
Presenter
- Listens to Intents
by subscribing to a Views
intents()
function.
Action
- Converts Intents
to Changes
.
None
The outer sealed class name should begin with a Screen description, end with the word Intent
, and exclude any Screen monikers ("Screen", "Fragment", "Activity").
The inner classes should be a declaration of an intention without the Intent
suffix.
sealed class FeedIntent : Intent {
object LoadInitial : FeedIntent()
data class LoadMore(val currentItems: List<Item>) : FeedIntent()
data class Refresh(val currentItems: List<Item>) : FeedIntent()
}
An Action
converts an Intent
to a Flow
of Changes
by performing application and business logic. Actions
connect the business logic with application.
Note that Actions
shouldn't directly perform business logic but rather delegate to the business layer components (UseCases, Repositories, etc) and coordinate and map the results to application layer components (ViewModels, States, etc).
Single Focus Functional Type
Action
Presenter
- Calls Actions
with Intents
from a Views
intents()
function.
Intent
- Takes in an Intent
to produce a Flow
of Changes
.
Change
- Outputs a Flow
of Changes
from an Intent
.
Mapper
- Maps business components to application components.
An Action
class should be named specifically to describe the action that it performs, and ends with the word Action
.
class LoadInitialAction(
private val repository: FeedRepository,
private val mapper: Mapper<FeedItem, FeedItemViewModel>
): Action<FeedIntent, FeedChange> {
override fun perform(intent: FeedIntent): Flow<FeedChange> =
repository.loadItems()
.map { items -> items.map { mapper.map(it) } }
.map { FeedChange.Loaded(items = it) }
.startWith(FeedChange.StartedLoading(currentItems = intent.currentItems))
}
A Change
represents the result of performing an action and is used by a Reducer
, along with a previous State
, to determine a new State
. A Change
affects a State
to transform it into a new State
.
However, a Change
does not contain any logic to convert a State
to another State
, that is the responsibility of the Reducer
.
Immutable Data Type
Change
Action
- Converts an Intent
into a Flow
of Changes
.
Presenter
- Provides the resulting Change
from an Action
to a Reducer
.
Reducer
- Converts a previous State
and a Change
into a new State
.
None
The outer sealed class name should begin with a Screen description, end with the word Change
, and exclude any Screen monikers ("Screen", "Fragment", "Activity").
The inner classes should be in a past tense verb manner without the Change
suffix.
sealed class FeedChange : Change {
data class Loaded(val items: List<Item>) : FeedChange()
data class StartedLoading(val currentItems: List<Item>) : FeedChange()
data class StartedRefreshing(val currentItems: List<Item>): FeedChange()
}
A ViewModel
is an immutable model object whose properties define the appearance a piece of UI. A ViewModel
contains no logic and most properties are pre-formatted represent the UI.
Note: A State
extends from the ViewModel
interface, so a State
is a ViewModel
, but a ViewModel
doesn't have to be a State
. Typically, States
represent larger UIs, such as, a whole Screen.
Note: There is some terminology conflation between this ViewModel
class and components in other popular frameworks, such as, the Android Architecture Component's ViewModel
class. Here a ViewModel
should perform no logic, and just be a model representation of a UI.
Immutable Data Type
ViewModel
Mapper
- Maps business logic entities to ViewModels
.
None
A ViewModel
should be named specifically after the piece of UI that it represents, ending with the word ViewModel
.
data class FeedItemViewModel(
val id: ID,
val formattedTimestamp: String,
val title: String,
val description: String,
val textColor: Color
): ViewModel
A State
is a special ViewModel
that contains all necessary information to represent a whole piece of UI and can be affected by a Change
to derive a new State
by a Reducer
. It is an immutable class that performs no logic and can contain ViewModels
as properties.
Immutable Data Type
State
Reducer
- Converts a provided previous State
and a Change
into a new State
.
Presenter
- Stores the current State
and coordinates the State
emitted from a Reducer
to be rendered by the View
.
View
- Renders a visual representation of the State
.
None
The outer sealed class name should begin with a Screen description, end with the word State
, and exclude any Screen monikers ("Screen", "Fragment", "Activity").
The inner classes should be in a present tense verb manner without the State
suffix.
sealed class FeedState : State {
object LoadingInitial : FeedState()
data class LoadingMore(val currentItems: List<Item>) : FeedState()
data class Refreshing(val currentItems: List<Item>) : FeedState()
object DisplayingEmpty : FeedState()
data class DisplayingLoaded(val items: List<Item>) : FeedState()
}
A Reducer
is responsible for determining the State
that is displayed in the UI. A Reducer
takes the previous State
and a Change
and derives a new State
. The Reducer
performs application logic.
Single Focus Functional Type
Reducer
Presenter
- Provides a Change
from an Action
to the Reducer
to derive a new State
.
State
- Derives a new State
from a previous State
and a Change
.
Change
- Derives a new State
from a previous State
and a Change
.
A Reducer
class should be named specifically to describe the corresponding State
, excluding the word State
, and ends with the word Reducer
.
class FeedReducer : Reducer<FeedState, FeedChange> {
override suspend fun reduce(previous: FeedState, change: FeedChange): FeedState =
when {
change is FeedChange.Loaded && change.items.isNullOrEmpty() -> FeedState.DisplayingEmpty
change is FeedChange.Loaded -> FeedState.DisplayingLoaded(items = change.items)
previous is FeedState.DisplayingEmpty && change is FeedChange.StartedLoading -> FeedState.LoadingMore(
currentItems = change.currentItems
)
previous is FeedState.DisplayingLoaded && change is FeedChange.StartedLoading -> FeedState.LoadingMore(
currentItems = change.currentItems
)
previous is FeedState.DisplayingEmpty && change is FeedChange.StartedRefreshing -> FeedState.Refreshing(
currentItems = change.currentItems
)
previous is FeedState.DisplayingLoaded && change is FeedChange.StartedRefreshing -> FeedState.Refreshing(
currentItems = change.currentItems
)
else -> previous
}
}
A View
represents a piece of UI, such as, a Screen. It provides a Flow
of Intents
via the intents()
function and renders a visual representation of a State
via the render(state: State)
function.
Implementations of the View
interface are responsible for mapping UI events to Intents
and displaying States
. Implementations may be framework components, such as, Activities and Fragments in Android.
Functional UI Type
View
Presenter
- Listens to Intents
by subscribing to the View.intents()
function.
None
The outer sealed class name should begin with a Screen description, end with the word State
, and exclude any Screen monikers ("Screen", "Fragment", "Activity").
The inner classes should be in a present tense verb manner without the State
suffix.
class FeedFragment : BaseFragment<FeedIntent, FeedState, FeedChange, FeedScreen>() {
private val loadMoreIntents: Flow<HomeIntent>
get() = feedRecyclerView.loadMoreEvents()
.map { FeedIntent.LoadMore(currentItems = currentState.currentItems) }
private val refreshIntents: Flow<HomeIntent>
get() = feedSwipeRefreshLayout.refreshes()
.map { FeedIntent.Refresh(currentItems = currentState.currentItems) }
override fun intents(): Flow<HomeIntent> =
merge(
loadMoreIntents,
refreshIntents
).startWith(FeedIntent.LoadInitial)
override fun render(state: FeedState) {
super.render(state)
when (state) {
is FeedState.DisplayingEmpty -> renderEmptyState()
is FeedState.DisplayingLoaded -> renderLoadedState(state)
is FeedState.LoadingInitial -> renderLoadingInitial()
is FeedState.LoadingMore -> renderLoadingMore(state)
is FeedState.Refreshing -> renderRefreshing(state)
}
}
}
A Presenter
handles the presentation logic for a View
. It coordinates the different presentation components, such as, the Actions
and the Reducer
.
Single Focus Functional Type
Presenter
None
View
- Listens to Intents
by subscribing to the View.intents()
function and invokes the render(state: State)
function.
Action
- Invokes Actions
by providing Intents
.
Reducer
- Invokes a Reducer
by providing the current State
and a Change
from an Action
.
A Presenter
class should be named specifically to describe the View
, excluding the word View
, exclude any Screen monikers ("Screen", "Fragment", "Activity"), and ends with the word Presenter
.
class FeedPresenter @Inject constructor(
dispatchers: CoroutineDispatchers,
override val view: View<FeedIntent, FeedState>,
override val reducer: FeedReducer,
private val loadInitialAction: FeedLoadInitialAction,
private val loadMoreAction: FeedLoadMoreAction,
private val refreshAction: FeedRefreshAction
) : BasePresenter<FeedIntent, FeedState, FeedChange>(
initialState = FeedState.LoadingInitial,
dispatchers = dispatchers
) {
override fun onBind() {
super.onBind()
view.intents()
.flowOn(dispatchers.main)
.perform {
when (it) {
is FeedIntent.LoadInitial -> loadInitialAction(it)
is FeedIntent.Refresh -> refreshAction(it)
is FeedIntent.LoadMore -> loadMoreAction(it)
}
}
.reduceAndRender()
.launchIn(this)
}
}
A Mapper
converts between entities and resulting classes from invoking business logic and ViewModels
.
Single Focus Functional Type
Mapper
Action
- An Action
invokes business logic components and maps their results to ViewModels
and includes those in emitted Changes
.
ViewModel
- Creates ViewModels
out of entities.
A Mapper
class should be named specifically to describe the types it maps, and ends with the word Mapper
.
class FeedItemMapper(private val timeFormatter: TimeFormatter) : Mapper<FeedItem, FeedItemViewModel> {
override fun map(model: FeedItem): FeedItemViewModel =
FeedItemViewModel(
id = model.id,
formattedTimestamp = timeFormatter.format(model.time),
title = model.title,
description = model.description,
textColor = defaultTextColor
)
}
A ScreenIntent
is similar to an Intent
but is focused on navigation and represents a desire to load a Screen.
Immutable Data Type
ScreenIntent
Navigator
- A Navigator
takes ScreenIntents
and moves to the desired Screen.
None
The outer sealed class name should begin with a Screen description, end with the words ScreenIntent
.
The inner classes should be a name of a Screen without the ScreenIntent
suffix.
sealed class HomeScreenIntent : ScreenIntent {
object Feed : HomeScreenIntent()
}
A Navigator
is responsible for changing and showing the different Screens in the application. When it receives ScreenIntent
s from calls to the goTo(screen: ScreenIntent)
function, it loads the appropriate Screen.
Single Focus Functional Type
Navigator
None
ScreenIntent
- ScreenIntents
indicate which Screen the Navigator
should go to.
A Navigator
class should be named specifically to describe the current Screen, and ends with the word Navigator
.
Note: Typically, the Navigator
and View
implementations are the same object, in which case, the implementation class should follow the View
naming convention.
class HomeFragment() : BaseFragment<HomeIntent, HomeState, HomeChange, HomeScreenIntent>() {
override fun goTo(screen: HomeScreenIntent) =
when (screen) {
is HomeScreenIntent.Feed -> goToFragment(FeedFragment.newInstance())
}
}