This commit is contained in:
tom5079
2024-03-20 14:28:33 -07:00
parent 9e9a5998cd
commit 7a5c3ae2ed
6 changed files with 275 additions and 33 deletions

View File

@@ -404,20 +404,28 @@ fun MainScreen(
val itemsPerPage by remember { mutableIntStateOf(20) } 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) { if (uiState.currentRange != IntRange.EMPTY) {
uiState.currentRange uiState.currentRange.first / itemsPerPage
} else { } else 0
0 ..< itemsPerPage
}
} }
val search = remember(currentRange) {{ loadSearchResult(currentRange) }} val maxPage = remember(itemsPerPage, uiState) {
if (uiState.galleryCount != null) {
LaunchedEffect(Unit) { uiState.galleryCount / itemsPerPage + if (uiState.galleryCount % itemsPerPage != 0) 1 else 0
search() } else 0
} }
val loadResult: (Int) -> Unit = remember(loadSearchResult) {{ page ->
loadSearchResult(pageToRange(page))
}}
LaunchedEffect(Unit) { loadSearchResult(pageToRange(0)) }
if (contentType == ContentType.DUAL_PANE) { if (contentType == ContentType.DUAL_PANE) {
TwoPane( TwoPane(
first = { first = {
@@ -425,11 +433,13 @@ fun MainScreen(
contentType = contentType, contentType = contentType,
galleries = uiState.galleries, galleries = uiState.galleries,
query = uiState.query, query = uiState.query,
currentPage = currentPage,
maxPage = maxPage,
loading = uiState.loading, loading = uiState.loading,
error = uiState.error, error = uiState.error,
galleryLazyListState = galleryLazyListState, galleryLazyListState = galleryLazyListState,
onQueryChange = onQueryChange, onQueryChange = onQueryChange,
search = search onPageChange = loadResult
) )
}, },
second = { second = {
@@ -443,11 +453,13 @@ fun MainScreen(
contentType = contentType, contentType = contentType,
galleries = uiState.galleries, galleries = uiState.galleries,
query = uiState.query, query = uiState.query,
currentPage = currentPage,
maxPage = maxPage,
loading = uiState.loading, loading = uiState.loading,
error = uiState.error, error = uiState.error,
galleryLazyListState = galleryLazyListState, galleryLazyListState = galleryLazyListState,
onQueryChange = onQueryChange, onQueryChange = onQueryChange,
search = search onPageChange = loadResult
) )
} }
} }
@@ -457,9 +469,12 @@ fun GalleryList(
contentType: ContentType, contentType: ContentType,
galleries: List<GalleryInfo>, galleries: List<GalleryInfo>,
query: SearchQuery?, query: SearchQuery?,
currentPage: Int,
maxPage: Int,
loading: Boolean = false, loading: Boolean = false,
error: Boolean = false, error: Boolean = false,
openedGallery: GalleryInfo? = null, openedGallery: GalleryInfo? = null,
onPageChange: (Int) -> Unit,
onQueryChange: (SearchQuery?) -> Unit = {}, onQueryChange: (SearchQuery?) -> Unit = {},
search: () -> Unit = {}, search: () -> Unit = {},
selectedGalleryIds: Set<Int> = emptySet(), selectedGalleryIds: Set<Int> = emptySet(),
@@ -477,12 +492,12 @@ fun GalleryList(
topOffset = topOffset, topOffset = topOffset,
onTopOffsetChange = { topOffset = it }, onTopOffsetChange = { topOffset = it },
) { ) {
AnimatedVisibility (loading) { AnimatedVisibility (loading, enter = fadeIn(), exit = fadeOut()) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
CircularProgressIndicator(Modifier.align(Alignment.Center)) CircularProgressIndicator(Modifier.align(Alignment.Center))
} }
} }
AnimatedVisibility(error) { AnimatedVisibility(error, enter = fadeIn(), exit = fadeOut()) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Column( Column(
Modifier.align(Alignment.Center), Modifier.align(Alignment.Center),
@@ -494,20 +509,26 @@ fun GalleryList(
} }
} }
} }
AnimatedVisibility(!loading && !error) { AnimatedVisibility(!loading && !error, enter = fadeIn(), exit = fadeOut()) {
LazyColumn( OverscrollPager(
contentPadding = WindowInsets.systemBars.asPaddingValues().let { systemBarPaddingValues -> prevPage = if (currentPage != 0) currentPage else null,
val layoutDirection = LocalLayoutDirection.current nextPage = if (currentPage < maxPage) currentPage + 2 else null,
PaddingValues( onPageTurn = { onPageChange(it-1) }
top = systemBarPaddingValues.calculateTopPadding() + 96.dp,
bottom = systemBarPaddingValues.calculateBottomPadding(),
start = systemBarPaddingValues.calculateStartPadding(layoutDirection),
end = systemBarPaddingValues.calculateEndPadding(layoutDirection),
)
}
) { ) {
items(galleries) {galleryInfo -> LazyColumn(
Text(galleryInfo.title) 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)
}
} }
} }
} }

View File

@@ -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()
}
}
}

View File

@@ -2,18 +2,17 @@ package xyz.quaver.pupil.ui.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.observeOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import xyz.quaver.pupil.networking.GalleryInfo import xyz.quaver.pupil.networking.GalleryInfo
import xyz.quaver.pupil.networking.GallerySearchSource import xyz.quaver.pupil.networking.GallerySearchSource
import xyz.quaver.pupil.networking.SearchQuery import xyz.quaver.pupil.networking.SearchQuery
import xyz.quaver.pupil.ui.composable.MainDestination import xyz.quaver.pupil.ui.composable.MainDestination
import xyz.quaver.pupil.ui.composable.mainDestinations import xyz.quaver.pupil.ui.composable.mainDestinations
import kotlin.math.max
import kotlin.math.min
class MainViewModel : ViewModel() { class MainViewModel : ViewModel() {
private val _uiState = MutableStateFlow(MainUIState()) private val _uiState = MutableStateFlow(MainUIState())
@@ -36,7 +35,7 @@ class MainViewModel : ViewModel() {
fun onQueryChange(query: SearchQuery?) { fun onQueryChange(query: SearchQuery?) {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
query = query, query = query,
validRange = IntRange.EMPTY, galleryCount = null,
currentRange = IntRange.EMPTY currentRange = IntRange.EMPTY
) )
@@ -46,9 +45,10 @@ class MainViewModel : ViewModel() {
fun loadSearchResult(range: IntRange) { fun loadSearchResult(range: IntRange) {
job?.cancel() job?.cancel()
job = viewModelScope.launch { job = viewModelScope.launch {
val sanitizedRange = max(range.first, 0) .. min(range.last, uiState.value.galleryCount ?: Int.MAX_VALUE)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
loading = true, loading = true,
currentRange = range currentRange = sanitizedRange
) )
var error = false var error = false
@@ -60,7 +60,7 @@ class MainViewModel : ViewModel() {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
galleries = galleries, galleries = galleries,
validRange = IntRange(1, galleryCount), galleryCount = galleryCount,
error = error, error = error,
loading = false loading = false
) )
@@ -78,7 +78,7 @@ data class MainUIState(
val galleries: List<GalleryInfo> = emptyList(), val galleries: List<GalleryInfo> = emptyList(),
val loading: Boolean = false, val loading: Boolean = false,
val error: Boolean = false, val error: Boolean = false,
val validRange: IntRange = IntRange.EMPTY, val galleryCount: Int? = null,
val currentRange: IntRange = IntRange.EMPTY, val currentRange: IntRange = IntRange.EMPTY,
val openedGallery: GalleryInfo? = null, val openedGallery: GalleryInfo? = null,
val isDetailOnlyOpen: Boolean = false val isDetailOnlyOpen: Boolean = false

View File

@@ -164,5 +164,6 @@
<string name="main_close_navigation_drawer">メニューを閉じる</string> <string name="main_close_navigation_drawer">メニューを閉じる</string>
<string name="search_remove_query_item_description">検索構文を除去</string> <string name="search_remove_query_item_description">検索構文を除去</string>
<string name="search_add_query_item_tag">タグ</string> <string name="search_add_query_item_tag">タグ</string>
<string name="move_to_page">%1$d ページへ移動</string>
<string name="search_bar_edit_tag">タッチして編集</string> <string name="search_bar_edit_tag">タッチして編集</string>
</resources> </resources>

View File

@@ -164,5 +164,6 @@
<string name="main_close_navigation_drawer">메뉴 닫기</string> <string name="main_close_navigation_drawer">메뉴 닫기</string>
<string name="search_remove_query_item_description">검색 구문 제거</string> <string name="search_remove_query_item_description">검색 구문 제거</string>
<string name="search_add_query_item_tag">태그</string> <string name="search_add_query_item_tag">태그</string>
<string name="move_to_page">%1$d 페이지로 이동</string>
<string name="search_bar_edit_tag">터치하여 수정</string> <string name="search_bar_edit_tag">터치하여 수정</string>
</resources> </resources>

View File

@@ -112,6 +112,8 @@
<string name="galleryblock_language">Language: %1$s</string> <string name="galleryblock_language">Language: %1$s</string>
<string name="galleryblock_pagecount" translatable="false">%dP</string> <string name="galleryblock_pagecount" translatable="false">%dP</string>
<string name="move_to_page">Move to page %1$d</string>
<!-- SEARCH BAR --> <!-- SEARCH BAR -->
<string name="search_bar_edit_tag">Touch to edit</string> <string name="search_bar_edit_tag">Touch to edit</string>