Implementing a Feature Module with Jetpack Compose in Sunflower Clone
Feature modules are isolated parts of an application’s functionality, typically representing screens. In this article, we’ll explore how to implement a feature module that aligns with the architecture design of the Sunflower Clone project. Note: Edited in 2024/8/21.
This is a sub-article to the Android Application Architecture Showcase : Sunflower Clone.
Feature Module Overview
Key Components
A feature module represents a distinct piece of functionality within an application and consists of several key components, each with specific responsibilities:
- Destination: The destination manages navigation arguments, defines the navigation route, generates a link by replacing route slots with real arguments, and supports deep link patterns for external access.
- Stateful Composable: This serves as the screen in your feature module. It accesses the view model to observe and manage state, handle user interactions, and act as UI state holder.
- View Model: The view model is responsible for managing UI state, handling business logic, and managing data operations. It accesses navigation arguments to create the initial state and interacts with repositories, transforms state based on user inputs.
- Stateless Composable: This is the pure UI component focused solely on rendering the UI based on the data they receive. It is easy to support rapid design iterations through Android Studio’s preview feature.
UI State vs View Model State vs Data State
Properly separating UI state, view model state, and data state is essential. Misplacing these states can lead to tightly coupled components and confusing code. Here’s how each type of state should be managed:
- UI State: UI state controls the visual aspects of the screen, such as widget colors, scroll positions, and selected items. It is directly tied to how the interface is presented and should be managed within the stateful composable. Mixing UI state into the view model can lead to bloated logic and tightly coupled code that’s difficult to maintain. The UI state is purely concerned with presentation and is best managed within stateful composable.
- View Model State: View model state is responsible for managing the business logic and screen behavior. This includes things like query parameters, selected item IDs, or navigation actions. The view model state focuses on the logic needed to drive the screen without worrying about how the information is displayed. For example, navigating to a details screen or processing user actions would be handled here. The view model bridges the data state and UI by transforming and exposing the relevant information.
- Data State: Data state represents the persistent information stored in databases, retrieved from APIs, or stored in preferences. It remains isolated from UI and business logic, and is typically expressed through flows or other reactive streams driven by the view model state. The view model handles transforming this raw data into a format suitable for the UI, but the data state itself remains encapsulated within its own layer.
Avoiding State Misplacement:
- UI state should remain within the stateful composable, ensuring that the view model doesn’t manage presentation concerns.
- The view model state should be focused on logic and behavior, without dipping into UI or data storage concerns.
- Data state should be isolated and reactive, feeding the view model the information it needs without direct interaction with the UI.
The Implementation
Destination
class GalleryDestination(private val name: String) {
val route = "$name/{$KEY_PLANT_NAME}"
fun createLink(plantName: String) = "$name/$plantName"
companion object {
private const val TAG = "GalleryDestination"
private const val KEY_PLANT_NAME = "$TAG.KEY_PLANT_NAME"
val args = listOf(navArgument(KEY_PLANT_NAME) {
type = NavType.StringType
})
internal val SavedStateHandle.plantName: String get() = get<String>(KEY_PLANT_NAME)!!
}
}
- Navigation Arguments: The
args
property defines the navigation arguments expected by this destination. It specifies that theplantName
argument is of typeString
, which is required when navigating to this destination. - Navigation Route: The
route
property specifies the path used for navigation, using a placeholder ({$KEY_PLANT_NAME}
) that is dynamically replaced with the actual argument during navigation. - Creation of Navigation Link: The
createLink
function generates the full navigation path by replacing the placeholder in the route with the actual plant name. - Deep Link Patterns: Although deep link patterns are not explicitly handled in this code snippet, the structure of the route and argument handling sets up a foundation that can be extended to support deep linking.
Additionally, the SavedStateHandle.plantName
extension allows the view model to access the navigation arguments from the saved state, enabling the feature to set up its initial state based on the passed data.
View Model
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class GalleryViewModel @Inject constructor(
private val photoRepository: PhotoRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private var state: State
get() = savedStateHandle[KEY_STATE] ?: State(plantName = savedStateHandle.plantName)
set(value) {
savedStateHandle[KEY_STATE] = value
}
val stateFlow = savedStateHandle.getStateFlow(KEY_STATE, state)
val photoPagingDataFlow = createPhotoPagingDataFlow().cachedIn(viewModelScope)
fun goBack() {
state = state.copy(actions = state.actions + State.Action.GoBack)
}
fun onAction(action: State.Action) {
state = state.copy(actions = state.actions - action)
}
fun openWebLink(url: String) {
state = state.copy(actions = state.actions + State.Action.OpenWebLink(url = url))
}
private fun createPhotoPagingDataFlow() =
stateFlow.map { it.plantName }.flatMapLatest { plantId ->
photoRepository.getPhotoPagingDataFlow(plantId)
}
companion object {
private const val TAG = "GalleryViewModel"
private const val KEY_STATE = "$TAG.KEY_STATE"
}
@Parcelize
data class State(val actions: List<Action> = emptyList(), val plantName: String) : Parcelable {
sealed interface Action : Parcelable {
@Parcelize
data object GoBack : Action
@Parcelize
data class OpenWebLink(val url: String) : Action
}
}
}
- State Management: The view model uses a
State
class to hold the current state, which includes navigation actions and the plant name. This state is persisted through theSavedStateHandle
, ensuring that it remains intact even after state restoration. ThestateFlow
exposes this state as a flow, making it observable by the UI and allowing the UI to react to state changes automatically. - Business Logic: The view model processes user interactions through functions like
goBack
,onAction
, andopenWebLink
. These functions modify the state by adding or removing actions. For example,goBack
adds a navigation action to the state, whileonAction
removes the action once it’s processed. - Data Handling: The view model handles data operations using a paginated flow created by
createPhotoPagingDataFlow
, which depends on the current plant name in the state. This flow fetches photo data from thePhotoRepository
. The use offlatMapLatest
ensures that the data flow updates dynamically based on changes to the plant name.
Additionally, the view model initializes its state based on the navigation arguments passed through the SavedStateHandle
.
Stateful Composable
@Composable
fun GalleryScreen(
onGoBack: () -> Unit,
viewModel: GalleryViewModel = hiltViewModel()
) {
val context = LocalContext.current
val actions = viewModel.stateFlow.collectAsState().value.actions
LaunchedEffect(actions) {
actions.forEach { action ->
when (action) {
GalleryViewModel.State.Action.GoBack -> onGoBack()
is GalleryViewModel.State.Action.OpenWebLink -> openWebLink(context, action.url)
}
viewModel.onAction(action)
}
}
GalleryLayout(
photos = viewModel.photoPagingDataFlow.collectAsLazyPagingItems(),
onBackClick = { viewModel.goBack() },
onPhotoClick = { viewModel.openWebLink(url = it.attributionUrl) })
}
private fun openWebLink(context: Context, url: String) {
try {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
} catch (e: ActivityNotFoundException) {
Toast.makeText(
context,
R.string.gallery_toast_unable_to_open_link,
Toast.LENGTH_LONG
).show()
}
}
- View Model Integration: The
GalleryScreen
composable directly connects to theGalleryViewModel
using thehiltViewModel()
delegate. This integration allows the composable to access the view model’s state and business logic, making it the central point for managing UI interactions and data flow. - State Observation: The composable observes the
stateFlow
from the view model usingcollectAsState()
. This ensures that any changes in the state are immediately reflected in the UI. - User Interaction Handling: The composable manages user interactions returned from
GalleryLayout
. It handles inputs like clicking the back button or selecting a photo. The back button triggers theviewModel.goBack()
function, adding an action to the state. When a photo is clicked, theviewModel.openWebLink(url)
function is called, opening the corresponding web link. - UI State Holder: The composable can hold UI-specific state, such as managing scroll state or other dynamic UI elements. While the provided code doesn’t include a scroll state, the
GalleryScreen
composable is prepared to handle such UI states if needed.
Additionally, side effects are handled using LaunchedEffect
, which reacts to updates in the action list. After processing an action, the composable calls viewModel.onAction(action)
to remove the action from the list.
Stateless Composable
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GalleryLayout(
photos: LazyPagingItems<PhotoDO>,
onBackClick: () -> Unit,
onPhotoClick: (PhotoDO) -> Unit
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
CenterAlignedTopAppBar(
navigationIcon = {
IconButton(
onBackClick
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.gallery_back)
)
}
},
scrollBehavior = scrollBehavior,
title = {
Text(
text = stringResource(id = R.string.gallery_title),
style = MaterialTheme.typography.titleLarge,
)
},
modifier = Modifier
.statusBarsPadding()
.background(color = MaterialTheme.colorScheme.surface)
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
) { padding ->
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier
.fillMaxSize()
.imePadding()
.padding(padding),
contentPadding = PaddingValues(
horizontal = dimensionResource(id = tw.com.deathhit.core.app_ui.R.dimen.card_side_margin),
vertical = dimensionResource(id = tw.com.deathhit.core.app_ui.R.dimen.header_margin)
)
) {
items(
photos.itemCount
) { index ->
val photo = photos[index]
if (photo != null)
GalleryItem(
imageUrl = photo.imageUrl,
photographer = photo.authorName,
onClick = {
onPhotoClick(photo)
})
}
}
}
}
@Preview
@Composable
private fun GalleryPreview(
@PreviewParameter(GalleryPreviewParamProvider::class) photos: List<PhotoDO>
) {
val context = LocalContext.current
val toast = Toast.makeText(context, "", Toast.LENGTH_LONG)
SunflowerCloneTheme {
GalleryLayout(
photos = flowOf(PagingData.from(photos)).collectAsLazyPagingItems(),
onBackClick = {
toast.apply { setText("Clicked Back!") }.show()
},
onPhotoClick = {
toast.apply { setText("Clicked Photo ${it.photoId}!") }.show()
}
)
}
}
private class GalleryPreviewParamProvider :
PreviewParameterProvider<List<PhotoDO>> {
override val values: Sequence<List<PhotoDO>> =
sequenceOf(
emptyList(),
listOf(
PhotoDO(
attributionUrl = "",
authorId = "1",
authorName = "Apple",
imageUrl = "",
photoId = "1",
plantName = "Apple"
),
PhotoDO(
attributionUrl = "",
authorId = "2",
authorName = "Banana",
imageUrl = "",
photoId = "2",
plantName = "Banana"
),
PhotoDO(
attributionUrl = "",
authorId = "3",
authorName = "Carrot",
imageUrl = "",
photoId = "3",
plantName = "Carrot"
),
PhotoDO(
attributionUrl = "",
authorId = "4",
authorName = "Dill",
imageUrl = "",
photoId = "4",
plantName = "Dill"
)
)
)
}
- Pure UI Representation: The primary role of the composable is to represent the UI. It uses a
Scaffold
to structure the layout, wrapping the top app bar and grid for photo display. The top app bar, implemented withCenterAlignedTopAppBar
, includes a scroll behavior that adjusts as the user scrolls through the content. TheLazyVerticalGrid
displays the gallery photos in a two-column grid, with each photo rendered using theGalleryItem
composable. - UI Preview: The
GalleryPreview
composable provides a preview of the layout in Android Studio. By simulating different photo datasets, the preview allows developers to see how the UI will look and respond to interactions, such as pressing the back button or selecting a photo, without needing to run the app. This preview feature supports rapid design iterations and helps refine the UI without the overhead of running the full application.
Handling Nested Stateful Composable
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NavigationScreen(
onGoToPlantDetailsScreen: (plantId: String) -> Unit,
viewModel: NavigationViewModel = hiltViewModel()
) {
val context = LocalContext.current
val pageList = listOf(NavigationPage.PLANT_LIST, NavigationPage.MY_GARDEN)
val actions = viewModel.stateFlow.collectAsState().value.actions
LaunchedEffect(actions) {
actions.forEach { action ->
when (action) {
is NavigationViewModel.State.Action.GoToPlantDetailsScreen -> onGoToPlantDetailsScreen(
action.plantId
)
}
viewModel.onAction(action)
}
}
NavigationLayout(
myGardenPage = {
GardenPlantingListScreen(
onGoToPlantDetailsScreen = { viewModel.goToPlantDetailsScreen(plantId = it) },
viewModel = hiltViewModel()
)
},
pageList = pageList,
pagerState = rememberPagerState(pageCount = { pageList.size }),
plantListPage = {
PlantListScreen(
onGoToPlantDetailsScreen = { viewModel.goToPlantDetailsScreen(plantId = it) },
viewModel = hiltViewModel()
)
},
title = stringResource(id = context.applicationInfo.labelRes)
)
}
- Passing Stateful Composable to Stateless Composable: The
NavigationLayout
serves as a stateless container that receivesGardenPlantingListScreen
andPlantListScreen
as stateful composable lambdas. This approach preserves the preview capability ofNavigationLayout
. - Avoiding Accidental View Model Reuse: When reusing the same stateful composable more than once within a single destination, it’s crucial to provide a unique key for each child’s view model through
hiltViewModel(key)
. This ensures that each instance of the child composable receives its own separate view model instance, preventing them from unintentionally sharing the same state.
Conclusion
Implementing a feature module in the Sunflower Clone project involves a careful blend of key components: destinations for navigation, stateful composable for handling interactions and state, view model for managing business logic and data, and stateless composable for rendering the UI.
This approach offers several advantages. Separating UI logic into stateless composable allows for real-time previews in Android Studio, speeding up development and enabling quicker design iterations.
With each module being self-contained yet seamlessly integrated into the larger application, you gain the flexibility to enhance or extend your app’s features without compromising maintainability.