androidkotlinandroid-jetpack-compose

why isn't a recomposition happening when the state is changed in the viewmodel


i have this code where i have a parent component QuranScreen and child component PagesContainer, the child component contains a horizontal pager inside it, whenever the pager state is changed (page changed), i emit an event to parent and change the currentPage state from the viewModel, this is supposed to cause a recomposition, but nothing happens..especially in the inner components (pagesContainer, benefits and Youtube screen), why is that happening?

@Composable
fun QuranScreen(
    navController: NavController,
    pageNum: String?,
    quranViewModel: QuranViewModel = viewModel()
) {
    if (pageNum != null) {
        quranViewModel.currentPage = pageNum
    };
    val currentPage = quranViewModel.currentPage;
    val page = quranViewModel.quranData.get(Integer.parseInt(currentPage) - 1)
    val selectedTab = quranViewModel.selectedTab

    Column {
        
        when (selectedTab) {
            "page" -> PagesContainer(quranViewModel.quranData, currentPage) { newPage ->
                println("changing viewmodel page to " + newPage) //gives correct output
                quranViewModel.currentPage = newPage
            }
            "benefits" -> Benefits(page.benefits, page.pageNum)
            "yt" -> YouTube(page.ytLink.split("v=").last())
        }
    }
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PagesContainer(
    quranData: List<QuranPage>,
    pageNum: String?,
    onPageChanged: (String) -> Unit
) {
    val pagerState = rememberPagerState(
        initialPage = Integer.parseInt(pageNum) - 1,
        initialPageOffsetFraction = 0f
    ) {
        quranData.size
    }

    LaunchedEffect(pagerState) {
        snapshotFlow { pagerState.currentPage }.collect { page ->
            Log.d("Page change", "Page changed to $page")
            onPageChanged("${page + 1}")
        }
    }


    Box(modifier = Modifier.fillMaxSize()) {
        HorizontalPager(
            state = pagerState,
            key = { quranData[it].pageNum },
            pageSize = PageSize.Fill
        ) { index ->
            SinglePage(quranData[index].pageNum)
        }
    }

}


Edit: Viewmodel code

class QuranViewModel : ViewModel() {
    val quranData: List<QuranPage> = QuranStore.getQuranData();
    private var _selectedTab by mutableStateOf("page")
    var selectedTab:String
        get() = this._selectedTab
        set(value){this._selectedTab=value}

    private var _currentPage by mutableStateOf("1")
    var currentPage:String
        get() = this._currentPage
        set(value){this._currentPage=value}
}

Edit 2: i'm delving deeper into the issue and i logged the changes that happen inside my viewmodel, the recomposition does happen but the state value is then returned to the original value, i can't use remember inside viewmodel so i don't know how exactly can i fix this. there is another point i'm suspecting that is i'm sending the original pageNum in a route parameter that leads to this screen which might be getting reset with each recomposition..i don't have a way around this either


Solution

  • The recomposition would be triggered if a state value changes from the scope of a state hoisting Composable (in your case the QuranScreen should be state hoisting but you are not declaring the variables are states.

    You can use remember and change them to something like:

    @Composable
    fun QuranScreen(
        navController: NavController,
        pageNum: String?,
        quranViewModel: QuranViewModel = viewModel()
    ) {
        val quranData by quranViewModel.quranData.collectAsState(initial = emptyList())
        val selectedTab by quranViewModel.selectedTab.collectAsState(initial = "page")
        val currentPage by quranViewModel.currentPage.collectAsState(initial = "1")
    
        when (selectedTab) {
            "page" -> PagesContainer(quranData, currentPage) { newPage ->
                quranViewModel.setCurrentPage(newPage)
            }
            "benefits" -> {
                val page = quranData.getOrNull(Integer.parseInt(currentPage) - 1)
                if (page != null) {
                    Benefits(page.benefits, page.pageNum)
                }
            }
            "yt" -> {
                val page = quranData.getOrNull(Integer.parseInt(currentPage) - 1)
                if (page != null) {
                    YouTube(page.ytLink.split("v=").last())
                }
            }
        }
    }
    

    But you'd need to also change the ViewModel to expose these values as StateFlows to be collectedAsState:

    class QuranViewModel : ViewModel() {
        private val _quranData = MutableStateFlow(QuranStore.getQuranData())
        val quranData: StateFlow<List<QuranPage>> = _quranData.asStateFlow()
    
        private val _selectedTab = MutableStateFlow("page")
        val selectedTab: StateFlow<String> = _selectedTab.asStateFlow()
    
        private val _currentPage = MutableStateFlow("1")
        val currentPage: StateFlow<String> = _currentPage.asStateFlow()
    
        fun setSelectedTab(value: String) {
            _selectedTab.value = value
        }
    
        fun setCurrentPage(value: String) {
            _currentPage.value = value
        }
    }
    

    I'd also suggest you to try and simplify the state in just one data class since they seem to make sense to be updated together but I'm not sure it would be the best way.

    Now, when the onPageChanged lambda is invoked, the QuranViewModel will update its state flows accordingly and the collectAsState will be observing this new emitted value, thus triggering a new composition with the most recent values.