I have a long and philosophical question about the...
# compose
m
I have a long and philosophical question about the architecture to use with Jetpack Compose. The use case will be intentionally complicated. Consider that you implement a lesson in Brilliant, where each lesson consists of a couple of steps. There are many step kinds, like a raw text, a video, a single-answer question, a multiple-choice question, a programming exercise, and much more. In one lesson, you move from step to step. Here, I showed moving between them, but Brilliant is even more complicated, as it shows all those steps one below another (when you finish one step, another one appears below this one). (rest in comment)
In all the cases, I assume the domain should be modelled as follows:
Copy code
data class Lesson(
    val lessonId: String,
    val name: String,
    val steps: List<LessonStep>,
)
sealed interface LessonStep {
    data class Text(val text: String) : LessonStep
    
    data class SingleChoiceQuestion(
        val question: String,
        val answers: ImmutableList<String>,
        val correctAnswer: Int,
    ) : LessonStep

    data class MultipleChoiceQuestion(
        val question: String,
        val answers: ImmutableList<String>,
        val correctAnswers: ImmutableSet<Int>,
    ) : LessonStep

    ...
}
Most of the approaches I've seen would be very hard to apply to this use case. A view model with StateFlow properties for each option, like optionChosen, seen unimaginable to me, with such a huge variation of different "modes" of what can be displayed. Here are the options I see for now for modeling it: 1. React-like hierarchy of views with defining state as low as possible. (pseudocode to make it shorter and to only show key concepts as simply as possible)
Copy code
LessonScreen(repository)
    val loading by remember { mutableStateOf<Boolean>(true) }
    val lessons by remember { mutableStateOf<...>(...) }
    val currentLesson by remember { mutableStateOf(0) }
    val answer by remember { mutableStateOf<AnserStatus>(NotGiven) }
    LaunchEffect { lessons = repository.fetchLessons() }
    if (loading) Loading() else Lesson(lessons[currentLesson], onAnswer = { currentLesson++ })

Lesson(lesson, onAnswer)
    when(lesson) {
        is Text -> TextLesson(lesson)
        is SingleQuestion -> SingleQuestionLesson(lesson, onAnswer)
    }

SingleQuestion(lesson, onAnswer)
    val answer by remember { mutableStateOf<Int>(-1) }
    val answerGiven by remember { mutableStateOf<Boolean>(false) }
    lesson.options.forEachIndexed { i, v -> Option(v.text, onChosen = { if(!answerGiven) answer = i }) }
    Button(onClick = {
        answerGiven = true
        onAnswer(if(answer = lesson.correctAnswer) Correct else Incorrect)
    ) { Text("Submit") }

...
2. ViewModel representing view as a contract that shows what should be displayed, and accepting generic actions as function calls. Stateless view.
Copy code
LessonViewModel
    uiState = StateFlow<UiState>(Loading)
    currentLesson: Lesson? 
    
    init {
        viewModelScope.launch {
            val lesson = courseRepository.getLesson(courseId, lessonId)
            currentLesson = lesson
            uiState = UiState.LessonStep(step = lesson.steps.first().toUiStep())
        }
    }
    fun onOptionClicked(option: String) {
        when(currentLesson) {
            is SingleChoiceQuestion -> uiState.update { /* Changing question so only the clicked one is selected */ }
            is Text -> error("Impossible")
        }
    }
    fun onChosenOptionClicked(option: String) ...
    fun onOptionSubmitActionClicked(option: String) ...

sealed interface UiState {
    data object Loading: UiState
    data class LessonStep(
        val step: LessonStepUi
    ): UiState {
        sealed interface LessonStepUi

        data class Text(
            val text: String,
        ) : LessonStepUi

        data class SingleChoiceQuestion(
            val question: String,
            val options: ImmutableList<ChoiceQuestionOption>,
        ) : LessonStepUi

        ...
    }
    ...
}

LessonScreen(viewModel)
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    when(uiState) {
        is Loading -> Loading()
        is LessonStep -> Lesson(uiState.lesson, onOptionClicked=viewModel::onOptionClicked, ...)
    }

Lesson(lesson, onOptionClicked, onSubmitClicked)
    when(lesson) {
        is LessonStep.Text -> TextLesson(lesson)
        is LessonStep.SingleQuestion -> SingleQuestionLesson(lesson, onOptionClicked)
    }

SingleQuestion(lesson, onOptionClicked, onSubmitClicked)
    lesson.options.forEachIndexed { i, v -> Option(v.text, onChosen = onOptionClicked) }
    Button(onClick = onSubmitClicked) { Text("Submit") }
3. ViewModel representing view as a contract that shows what should be displayed, and accepting one action with possible UI actions. Stateless view.
Copy code
// Same as before, but
ViewModel
    fun onAction(uiAction: UiAction) {
        when(uiAction) {
            is OnOptionClicked -> when(currentLessing) { 
                is LessonStep.Text -> TextLesson(lesson)
                is LessonStep.SingleQuestion -> SingleQuestionLesson(lesson, onOptionClicked)
                ...
            ...
        }
    }

sealed class UiAction
class OnOptionClicked(option: String)
...

LessonScreen(viewModel)
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    when(uiState) {
        is Loading -> Loading()
        is LessonStep -> Lesson(uiState.lesson, onAction=viewModel::onAction)
    }
    
// How deep should it go?
4. A different view models for the screen and different simple view models for different lessons. (How should they communicate?) Something else? How would you see that?
It also might be a case where Decompose would shine 🤔
d
I'd exclude 1. right away. You dont want to keep and manage all the state in the ui generation logic. There should be a view model collecting the data and generating the UI state while processing ui interactions. So its number 2 or 3, while most would go with 2 (me included). Its a matter of prefference I'd say. Also you can have multiple ViewModels if necessary. They can share data across data sources specific to the feature they belong to. Complex logic I'd put into use cases.