From 7a5c3ae2ede5142a0c4f9eb4f6dbebf76455e888 Mon Sep 17 00:00:00 2001 From: tom5079 <7948651+tom5079@users.noreply.github.com> Date: Wed, 20 Mar 2024 14:28:33 -0700 Subject: [PATCH] Paging --- .../quaver/pupil/ui/composable/MainScreen.kt | 73 +++--- .../pupil/ui/composable/OverscrollPager.kt | 217 ++++++++++++++++++ .../pupil/ui/viewmodel/MainViewModel.kt | 14 +- app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values/strings.xml | 2 + 6 files changed, 275 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/xyz/quaver/pupil/ui/composable/OverscrollPager.kt diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/MainScreen.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/MainScreen.kt index 6fbd306c..b23042f5 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/MainScreen.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/MainScreen.kt @@ -404,20 +404,28 @@ fun MainScreen( val itemsPerPage by remember { mutableIntStateOf(20) } - val currentRange = remember(uiState) { + val pageToRange: (Int) -> IntRange = remember(itemsPerPage) {{ page -> + page * itemsPerPage ..< (page+1) * itemsPerPage + }} + + val currentPage = remember(uiState) { if (uiState.currentRange != IntRange.EMPTY) { - uiState.currentRange - } else { - 0 ..< itemsPerPage - } + uiState.currentRange.first / itemsPerPage + } else 0 } - val search = remember(currentRange) {{ loadSearchResult(currentRange) }} - - LaunchedEffect(Unit) { - search() + val maxPage = remember(itemsPerPage, uiState) { + if (uiState.galleryCount != null) { + uiState.galleryCount / itemsPerPage + if (uiState.galleryCount % itemsPerPage != 0) 1 else 0 + } else 0 } + val loadResult: (Int) -> Unit = remember(loadSearchResult) {{ page -> + loadSearchResult(pageToRange(page)) + }} + + LaunchedEffect(Unit) { loadSearchResult(pageToRange(0)) } + if (contentType == ContentType.DUAL_PANE) { TwoPane( first = { @@ -425,11 +433,13 @@ fun MainScreen( contentType = contentType, galleries = uiState.galleries, query = uiState.query, + currentPage = currentPage, + maxPage = maxPage, loading = uiState.loading, error = uiState.error, galleryLazyListState = galleryLazyListState, onQueryChange = onQueryChange, - search = search + onPageChange = loadResult ) }, second = { @@ -443,11 +453,13 @@ fun MainScreen( contentType = contentType, galleries = uiState.galleries, query = uiState.query, + currentPage = currentPage, + maxPage = maxPage, loading = uiState.loading, error = uiState.error, galleryLazyListState = galleryLazyListState, onQueryChange = onQueryChange, - search = search + onPageChange = loadResult ) } } @@ -457,9 +469,12 @@ fun GalleryList( contentType: ContentType, galleries: List, query: SearchQuery?, + currentPage: Int, + maxPage: Int, loading: Boolean = false, error: Boolean = false, openedGallery: GalleryInfo? = null, + onPageChange: (Int) -> Unit, onQueryChange: (SearchQuery?) -> Unit = {}, search: () -> Unit = {}, selectedGalleryIds: Set = emptySet(), @@ -477,12 +492,12 @@ fun GalleryList( topOffset = topOffset, onTopOffsetChange = { topOffset = it }, ) { - AnimatedVisibility (loading) { + AnimatedVisibility (loading, enter = fadeIn(), exit = fadeOut()) { Box(Modifier.fillMaxSize()) { CircularProgressIndicator(Modifier.align(Alignment.Center)) } } - AnimatedVisibility(error) { + AnimatedVisibility(error, enter = fadeIn(), exit = fadeOut()) { Box(Modifier.fillMaxSize()) { Column( Modifier.align(Alignment.Center), @@ -494,20 +509,26 @@ fun GalleryList( } } } - AnimatedVisibility(!loading && !error) { - LazyColumn( - contentPadding = WindowInsets.systemBars.asPaddingValues().let { systemBarPaddingValues -> - val layoutDirection = LocalLayoutDirection.current - PaddingValues( - top = systemBarPaddingValues.calculateTopPadding() + 96.dp, - bottom = systemBarPaddingValues.calculateBottomPadding(), - start = systemBarPaddingValues.calculateStartPadding(layoutDirection), - end = systemBarPaddingValues.calculateEndPadding(layoutDirection), - ) - } + AnimatedVisibility(!loading && !error, enter = fadeIn(), exit = fadeOut()) { + OverscrollPager( + prevPage = if (currentPage != 0) currentPage else null, + nextPage = if (currentPage < maxPage) currentPage + 2 else null, + onPageTurn = { onPageChange(it-1) } ) { - items(galleries) {galleryInfo -> - Text(galleryInfo.title) + LazyColumn( + contentPadding = WindowInsets.systemBars.asPaddingValues().let { systemBarPaddingValues -> + val layoutDirection = LocalLayoutDirection.current + PaddingValues( + top = systemBarPaddingValues.calculateTopPadding() + 96.dp, + bottom = systemBarPaddingValues.calculateBottomPadding(), + start = systemBarPaddingValues.calculateStartPadding(layoutDirection), + end = systemBarPaddingValues.calculateEndPadding(layoutDirection), + ) + } + ) { + items(galleries) {galleryInfo -> + Text(galleryInfo.title) + } } } } diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/OverscrollPager.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/OverscrollPager.kt new file mode 100644 index 00000000..a9a67715 --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/OverscrollPager.kt @@ -0,0 +1,217 @@ +package xyz.quaver.pupil.ui.composable + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.NavigateBefore +import androidx.compose.material.icons.automirrored.filled.NavigateNext +import androidx.compose.material3.Icon +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize +import androidx.compose.ui.util.fastFirstOrNull +import xyz.quaver.pupil.R +import xyz.quaver.pupil.ui.theme.Blue300 +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +@Composable +fun OverscrollPager( + prevPage: Int?, + nextPage: Int?, + onPageTurn: (Int) -> Unit, + prevPageTurnIndicatorOffset: Dp = 0.dp, + nextPageTurnIndicatorOffset: Dp = 0.dp, + content: @Composable () -> Unit +) { + val haptic = LocalHapticFeedback.current + val pageTurnIndicatorHeight = LocalDensity.current.run { 64.dp.toPx() } + + var overscroll: Float? by remember { mutableStateOf(null) } + + var size: Size? by remember { mutableStateOf(null) } + val circleRadius = (size?.width ?: 0f) / 2 + + val topCircleRadius by animateFloatAsState(if (overscroll?.let { it >= pageTurnIndicatorHeight } == true) circleRadius else 0f, label = "topCircleRadius") + val bottomCircleRadius by animateFloatAsState(if (overscroll?.let { it <= -pageTurnIndicatorHeight } == true) circleRadius else 0f, label = "bottomCircleRadius") + + val prevPageTurnIndicatorOffsetPx = LocalDensity.current.run { prevPageTurnIndicatorOffset.toPx() } + val nextPageTurnIndicatorOffsetPx = LocalDensity.current.run { nextPageTurnIndicatorOffset.toPx() } + + if (topCircleRadius != 0f || bottomCircleRadius != 0f) + Canvas(modifier = Modifier.fillMaxSize()) { + drawCircle( + Blue300, + center = Offset(this.center.x, prevPageTurnIndicatorOffsetPx), + radius = topCircleRadius + ) + drawCircle( + Blue300, + center = Offset(this.center.x, this.size.height-nextPageTurnIndicatorOffsetPx), + radius = bottomCircleRadius + ) + } + + val isOverscrollOverHeight = overscroll?.let { abs(it) >= pageTurnIndicatorHeight } == true + LaunchedEffect(isOverscrollOverHeight) { + if (isOverscrollOverHeight) haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + + Box( + Modifier + .fillMaxHeight() + .onGloballyPositioned { + size = it.size.toSize() + } + ) { + overscroll?.let { overscroll -> + if (overscroll > 0f && prevPage != null) { + Row( + modifier = Modifier + .align(Alignment.TopCenter) + .offset(0.dp, prevPageTurnIndicatorOffset), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateBefore, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(48.dp) + ) + Text(stringResource(R.string.move_to_page, prevPage)) + } + } + + if (overscroll < 0f && nextPage != null) { + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .offset(0.dp, -nextPageTurnIndicatorOffset), + verticalAlignment = Alignment.CenterVertically + ) { + Text(stringResource(R.string.move_to_page, nextPage)) + Icon( + Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(48.dp) + ) + } + } + } + + Box( + modifier = Modifier + .offset( + 0.dp, + overscroll + ?.coerceIn(-pageTurnIndicatorHeight, pageTurnIndicatorHeight) + ?.let { overscroll -> LocalDensity.current.run { overscroll.toDp() } } + ?: 0.dp) + .nestedScroll(object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource + ): Offset { + val overscrollSnapshot = overscroll + + return if (overscrollSnapshot == null || overscrollSnapshot == 0f) { + Offset.Zero + } else { + val newOverscroll = + if (overscrollSnapshot > 0f && available.y < 0f) + max(overscrollSnapshot + available.y, 0f) + else if (overscrollSnapshot < 0f && available.y > 0f) + min(overscrollSnapshot + available.y, 0f) + else + overscrollSnapshot + + Offset(0f, newOverscroll - overscrollSnapshot).also { + overscroll = newOverscroll + } + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + if ( + available.y == 0f || + prevPage == null && available.y > 0f || + nextPage == null && available.y < 0f + ) return Offset.Zero + + return overscroll?.let { + overscroll = it + available.y + Offset(0f, available.y) + } ?: Offset.Zero + } + }) + .pointerInput(prevPage, nextPage) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var pointer = down.id + overscroll = 0f + + while (true) { + val event = awaitPointerEvent() + val dragEvent = + event.changes.fastFirstOrNull { it.id == pointer }!! + + if (dragEvent.changedToUpIgnoreConsumed()) { + val otherDown = event.changes.fastFirstOrNull { it.pressed } + if (otherDown == null) { + if (dragEvent.positionChange() != Offset.Zero) dragEvent.consume() + overscroll?.let { + if (abs(it) > pageTurnIndicatorHeight) { + if (it > 0 && prevPage != null) onPageTurn(prevPage) + if (it < 0 && nextPage != null) onPageTurn(nextPage) + } + } + overscroll = null + break + } else + pointer = otherDown.id + } + } + } + } + ) { + content() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt b/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt index 66592ddb..1eff43c7 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt @@ -2,18 +2,17 @@ package xyz.quaver.pupil.ui.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.observeOn -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import xyz.quaver.pupil.networking.GalleryInfo import xyz.quaver.pupil.networking.GallerySearchSource import xyz.quaver.pupil.networking.SearchQuery import xyz.quaver.pupil.ui.composable.MainDestination import xyz.quaver.pupil.ui.composable.mainDestinations +import kotlin.math.max +import kotlin.math.min class MainViewModel : ViewModel() { private val _uiState = MutableStateFlow(MainUIState()) @@ -36,7 +35,7 @@ class MainViewModel : ViewModel() { fun onQueryChange(query: SearchQuery?) { _uiState.value = _uiState.value.copy( query = query, - validRange = IntRange.EMPTY, + galleryCount = null, currentRange = IntRange.EMPTY ) @@ -46,9 +45,10 @@ class MainViewModel : ViewModel() { fun loadSearchResult(range: IntRange) { job?.cancel() job = viewModelScope.launch { + val sanitizedRange = max(range.first, 0) .. min(range.last, uiState.value.galleryCount ?: Int.MAX_VALUE) _uiState.value = _uiState.value.copy( loading = true, - currentRange = range + currentRange = sanitizedRange ) var error = false @@ -60,7 +60,7 @@ class MainViewModel : ViewModel() { _uiState.value = _uiState.value.copy( galleries = galleries, - validRange = IntRange(1, galleryCount), + galleryCount = galleryCount, error = error, loading = false ) @@ -78,7 +78,7 @@ data class MainUIState( val galleries: List = emptyList(), val loading: Boolean = false, val error: Boolean = false, - val validRange: IntRange = IntRange.EMPTY, + val galleryCount: Int? = null, val currentRange: IntRange = IntRange.EMPTY, val openedGallery: GalleryInfo? = null, val isDetailOnlyOpen: Boolean = false diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index d1ca5fcc..ff46ca27 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -164,5 +164,6 @@ メニューを閉じる 検索構文を除去 タグ + %1$d ページへ移動 タッチして編集 \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 6c2c049b..d0c0518e 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -164,5 +164,6 @@ 메뉴 닫기 검색 구문 제거 태그 + %1$d 페이지로 이동 터치하여 수정 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2f64a77a..1ecd2b75 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -112,6 +112,8 @@ Language: %1$s %dP + Move to page %1$d + Touch to edit