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

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.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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>