Paging
This commit is contained in:
@@ -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<GalleryInfo>,
|
||||
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<Int> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<GalleryInfo> = 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
|
||||
|
||||
@@ -164,5 +164,6 @@
|
||||
<string name="main_close_navigation_drawer">メニューを閉じる</string>
|
||||
<string name="search_remove_query_item_description">検索構文を除去</string>
|
||||
<string name="search_add_query_item_tag">タグ</string>
|
||||
<string name="move_to_page">%1$d ページへ移動</string>
|
||||
<string name="search_bar_edit_tag">タッチして編集</string>
|
||||
</resources>
|
||||
@@ -164,5 +164,6 @@
|
||||
<string name="main_close_navigation_drawer">메뉴 닫기</string>
|
||||
<string name="search_remove_query_item_description">검색 구문 제거</string>
|
||||
<string name="search_add_query_item_tag">태그</string>
|
||||
<string name="move_to_page">%1$d 페이지로 이동</string>
|
||||
<string name="search_bar_edit_tag">터치하여 수정</string>
|
||||
</resources>
|
||||
@@ -112,6 +112,8 @@
|
||||
<string name="galleryblock_language">Language: %1$s</string>
|
||||
<string name="galleryblock_pagecount" translatable="false">%dP</string>
|
||||
|
||||
<string name="move_to_page">Move to page %1$d</string>
|
||||
|
||||
<!-- SEARCH BAR -->
|
||||
<string name="search_bar_edit_tag">Touch to edit</string>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user