diff --git a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/local/LocalLibraryScreen.kt b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/local/LocalLibraryScreen.kt index d48e88867..e428f8812 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/local/LocalLibraryScreen.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/local/LocalLibraryScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon @@ -36,16 +35,16 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.kiwix.kiwixmobile.R.string import org.kiwix.kiwixmobile.core.R @@ -55,7 +54,9 @@ import org.kiwix.kiwixmobile.core.ui.components.KiwixAppBar import org.kiwix.kiwixmobile.core.ui.components.KiwixButton import org.kiwix.kiwixmobile.core.ui.components.KiwixSnackbarHost import org.kiwix.kiwixmobile.core.ui.components.ProgressBarStyle +import org.kiwix.kiwixmobile.core.ui.components.ScrollDirection import org.kiwix.kiwixmobile.core.ui.components.SwipeRefreshLayout +import org.kiwix.kiwixmobile.core.ui.components.rememberLazyListScrollListener import org.kiwix.kiwixmobile.core.ui.theme.Black import org.kiwix.kiwixmobile.core.ui.theme.KiwixTheme import org.kiwix.kiwixmobile.core.ui.theme.White @@ -81,19 +82,13 @@ fun LocalLibraryScreen( onMultiSelect: ((BookOnDisk) -> Unit)? = null, navigationIcon: @Composable () -> Unit ) { - val lazyListState = rememberLazyListState() - val bottomNavHeightInDp = with(LocalDensity.current) { state.bottomNavigationHeight.toDp() } - val bottomNavHeight = remember { mutableStateOf(bottomNavHeightInDp) } - LaunchedEffect(lazyListState) { - snapshotFlow { lazyListState.firstVisibleItemScrollOffset } - .collect { scrollOffset -> - bottomNavHeight.value = if (scrollOffset > 0) ZERO.dp else bottomNavHeightInDp - } - } + val (bottomNavHeight, lazyListState) = rememberScrollBehavior(state) KiwixTheme { Scaffold( snackbarHost = { KiwixSnackbarHost(snackbarHostState = state.snackBarHostState) }, - topBar = { KiwixAppBar(R.string.library, navigationIcon, state.actionMenuItems) }, + topBar = { + KiwixAppBar(R.string.library, navigationIcon, state.actionMenuItems, lazyListState) + }, modifier = Modifier.systemBarsPadding() ) { contentPadding -> SwipeRefreshLayout( @@ -134,6 +129,31 @@ fun LocalLibraryScreen( } } +@Composable +private fun rememberScrollBehavior( + state: LocalLibraryScreenState +): Pair, LazyListState> { + val bottomNavHeightInDp = with(LocalDensity.current) { state.bottomNavigationHeight.toDp() } + val bottomNavHeight = remember { mutableStateOf(bottomNavHeightInDp) } + val lazyListState = rememberLazyListScrollListener( + onScrollChanged = { direction -> + when (direction) { + ScrollDirection.SCROLL_UP -> { + bottomNavHeight.value = bottomNavHeightInDp + } + + ScrollDirection.SCROLL_DOWN -> { + bottomNavHeight.value = ZERO.dp + } + + ScrollDirection.IDLE -> {} + } + } + ) + + return bottomNavHeight to lazyListState +} + @Composable private fun BookItemList( state: FileSelectListState, diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/KiwixAppBar.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/KiwixAppBar.kt index de3d696ea..d282c9feb 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/KiwixAppBar.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/KiwixAppBar.kt @@ -19,6 +19,8 @@ package org.kiwix.kiwixmobile.core.ui.components import androidx.annotation.StringRes +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Row @@ -26,16 +28,26 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import org.kiwix.kiwixmobile.core.ui.models.ActionMenuItem import org.kiwix.kiwixmobile.core.ui.models.toPainter import org.kiwix.kiwixmobile.core.ui.theme.Black @@ -53,14 +65,23 @@ fun KiwixAppBar( @StringRes titleId: Int, navigationIcon: @Composable () -> Unit, actionMenuItems: List = emptyList(), + // If this state is provided, the app bar will automatically hide on scroll down and show + // on scroll up, same like scrollingToolbar. + lazyListState: LazyListState? = null, // Optional search bar, used in fragments that require it searchBar: (@Composable () -> Unit)? = null ) { + val isToolbarVisible = rememberToolbarVisibility(lazyListState) + + val appBarHeight by animateDpAsState( + targetValue = if (isToolbarVisible) KIWIX_APP_BAR_HEIGHT else 0.dp, + animationSpec = tween(durationMillis = 250) + ) KiwixTheme { Row( modifier = Modifier .fillMaxWidth() - .height(KIWIX_APP_BAR_HEIGHT) + .height(appBarHeight) .background(color = Black), verticalAlignment = Alignment.CenterVertically ) { @@ -116,3 +137,23 @@ private fun ActionMenu(actionMenuItems: List) { } } } + +@Composable +private fun rememberToolbarVisibility(lazyListState: LazyListState?): Boolean { + var isToolbarVisible by remember { mutableStateOf(true) } + var lastScrollIndex by remember { mutableIntStateOf(0) } + val updatedLazyListState = rememberUpdatedState(lazyListState) + + LaunchedEffect(updatedLazyListState) { + updatedLazyListState.value?.let { state -> + snapshotFlow { state.firstVisibleItemIndex } + .collect { newScrollIndex -> + if (newScrollIndex != lastScrollIndex) { + isToolbarVisible = newScrollIndex < lastScrollIndex + lastScrollIndex = newScrollIndex + } + } + } + } + return isToolbarVisible +} diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/LazyListScrollListener.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/LazyListScrollListener.kt new file mode 100644 index 000000000..89ad535b3 --- /dev/null +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/LazyListScrollListener.kt @@ -0,0 +1,75 @@ +/* + * Kiwix Android + * Copyright (c) 2025 Kiwix + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package org.kiwix.kiwixmobile.core.ui.components + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow + +const val ONE_THOUSAND = 1000 + +@Composable +fun rememberLazyListScrollListener( + onScrollChanged: (ScrollDirection) -> Unit, + scrollThreshold: Int = 20 +): LazyListState { + val lazyListState = rememberLazyListState() + val updatedOnScrollChanged = rememberUpdatedState(onScrollChanged) + + var previousScrollPosition by remember { mutableIntStateOf(0) } + var lastScrollDirection by remember { mutableStateOf(ScrollDirection.IDLE) } + + LaunchedEffect(lazyListState) { + snapshotFlow { + lazyListState.firstVisibleItemIndex to lazyListState.firstVisibleItemScrollOffset + }.collect { (index, offset) -> + val currentScrollPosition = index * ONE_THOUSAND + offset + val scrollDelta = currentScrollPosition - previousScrollPosition + + val scrollDirection = when { + scrollDelta > scrollThreshold -> ScrollDirection.SCROLL_DOWN + scrollDelta < -scrollThreshold -> ScrollDirection.SCROLL_UP + else -> lastScrollDirection + } + + if (scrollDirection != lastScrollDirection) { + lastScrollDirection = scrollDirection + updatedOnScrollChanged.value(scrollDirection) + } + + previousScrollPosition = currentScrollPosition + } + } + + return lazyListState +} + +enum class ScrollDirection { + SCROLL_UP, + SCROLL_DOWN, + IDLE +} diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/SwipeRefreshLayout.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/SwipeRefreshLayout.kt index c9da3d0c1..4593dda79 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/SwipeRefreshLayout.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/ui/components/SwipeRefreshLayout.kt @@ -26,8 +26,10 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshState import androidx.compose.material3.pulltorefresh.pullToRefresh import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import kotlinx.coroutines.launch import org.kiwix.kiwixmobile.core.ui.theme.Black import org.kiwix.kiwixmobile.core.ui.theme.White @@ -51,11 +53,17 @@ fun SwipeRefreshLayout( }, content: @Composable BoxScope.() -> Unit ) { + val coroutineScope = rememberCoroutineScope() Box( modifier.pullToRefresh( state = state, isRefreshing = isRefreshing, - onRefresh = onRefresh, + onRefresh = { + coroutineScope.launch { + state.animateToHidden() + onRefresh.invoke() + } + }, enabled = isEnabled ), contentAlignment = contentAlignment