mirror of
https://github.com/mihonapp/mihon.git
synced 2024-11-15 15:02:49 +01:00
Merge branch 'master' of https://github.com/tachiyomiorg/tachiyomi into sync-part-final
This commit is contained in:
commit
2a69a1eeb0
@ -22,7 +22,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "eu.kanade.tachiyomi"
|
applicationId = "eu.kanade.tachiyomi"
|
||||||
|
|
||||||
versionCode = 109
|
versionCode = 110
|
||||||
versionName = "0.14.7"
|
versionName = "0.14.7"
|
||||||
|
|
||||||
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
|
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
|
||||||
|
@ -73,7 +73,7 @@ fun ExtensionScreen(
|
|||||||
PullRefresh(
|
PullRefresh(
|
||||||
refreshing = state.isRefreshing,
|
refreshing = state.isRefreshing,
|
||||||
onRefresh = onRefresh,
|
onRefresh = onRefresh,
|
||||||
enabled = !state.isLoading,
|
enabled = { !state.isLoading },
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
|
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
|
||||||
|
@ -19,6 +19,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
import dev.icerock.moko.resources.StringResource
|
import dev.icerock.moko.resources.StringResource
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
@ -70,6 +71,7 @@ fun TabbedScreen(
|
|||||||
) {
|
) {
|
||||||
PrimaryTabRow(
|
PrimaryTabRow(
|
||||||
selectedTabIndex = state.currentPage,
|
selectedTabIndex = state.currentPage,
|
||||||
|
modifier = Modifier.zIndex(1f),
|
||||||
) {
|
) {
|
||||||
tabs.forEachIndexed { index, tab ->
|
tabs.forEachIndexed { index, tab ->
|
||||||
Tab(
|
Tab(
|
||||||
|
@ -93,7 +93,7 @@ fun LibraryContent(
|
|||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = notSelectionMode,
|
enabled = { notSelectionMode },
|
||||||
) {
|
) {
|
||||||
LibraryPager(
|
LibraryPager(
|
||||||
state = pagerState,
|
state = pagerState,
|
||||||
|
@ -7,7 +7,9 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
import eu.kanade.presentation.category.visualName
|
import eu.kanade.presentation.category.visualName
|
||||||
import tachiyomi.domain.category.model.Category
|
import tachiyomi.domain.category.model.Category
|
||||||
import tachiyomi.presentation.core.components.material.TabText
|
import tachiyomi.presentation.core.components.material.TabText
|
||||||
@ -19,7 +21,9 @@ internal fun LibraryTabs(
|
|||||||
getNumberOfMangaForCategory: (Category) -> Int?,
|
getNumberOfMangaForCategory: (Category) -> Int?,
|
||||||
onTabItemClick: (Int) -> Unit,
|
onTabItemClick: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column {
|
Column(
|
||||||
|
modifier = Modifier.zIndex(1f),
|
||||||
|
) {
|
||||||
PrimaryScrollableTabRow(
|
PrimaryScrollableTabRow(
|
||||||
selectedTabIndex = pagerState.currentPage,
|
selectedTabIndex = pagerState.currentPage,
|
||||||
edgePadding = 0.dp,
|
edgePadding = 0.dp,
|
||||||
|
@ -266,13 +266,12 @@ private fun MangaScreenSmallImpl(
|
|||||||
) {
|
) {
|
||||||
val chapterListState = rememberLazyListState()
|
val chapterListState = rememberLazyListState()
|
||||||
|
|
||||||
val chapters = remember(state) { state.processedChapters }
|
val (chapters, listItem, isAnySelected) = remember(state) {
|
||||||
val listItem = remember(state) { state.chapterListItems }
|
Triple(
|
||||||
|
first = state.processedChapters,
|
||||||
val isAnySelected by remember {
|
second = state.chapterListItems,
|
||||||
derivedStateOf {
|
third = state.isAnySelected,
|
||||||
chapters.fastAny { it.selected }
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val internalOnBackPressed = {
|
val internalOnBackPressed = {
|
||||||
@ -365,8 +364,8 @@ private fun MangaScreenSmallImpl(
|
|||||||
PullRefresh(
|
PullRefresh(
|
||||||
refreshing = state.isRefreshingData,
|
refreshing = state.isRefreshingData,
|
||||||
onRefresh = onRefresh,
|
onRefresh = onRefresh,
|
||||||
enabled = !isAnySelected,
|
enabled = { !isAnySelected },
|
||||||
indicatorPadding = WindowInsets.systemBars.only(WindowInsetsSides.Top).asPaddingValues(),
|
indicatorPadding = PaddingValues(top = topPadding),
|
||||||
) {
|
) {
|
||||||
val layoutDirection = LocalLayoutDirection.current
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
VerticalFastScroller(
|
VerticalFastScroller(
|
||||||
@ -520,108 +519,108 @@ fun MangaScreenLargeImpl(
|
|||||||
val layoutDirection = LocalLayoutDirection.current
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
||||||
val chapters = remember(state) { state.processedChapters }
|
val (chapters, listItem, isAnySelected) = remember(state) {
|
||||||
val listItem = remember(state) { state.chapterListItems }
|
Triple(
|
||||||
|
first = state.processedChapters,
|
||||||
val isAnySelected by remember {
|
second = state.chapterListItems,
|
||||||
derivedStateOf {
|
third = state.isAnySelected,
|
||||||
chapters.fastAny { it.selected }
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
|
val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
|
||||||
var topBarHeight by remember { mutableIntStateOf(0) }
|
var topBarHeight by remember { mutableIntStateOf(0) }
|
||||||
PullRefresh(
|
|
||||||
refreshing = state.isRefreshingData,
|
|
||||||
onRefresh = onRefresh,
|
|
||||||
enabled = !isAnySelected,
|
|
||||||
indicatorPadding = PaddingValues(
|
|
||||||
start = insetPadding.calculateStartPadding(layoutDirection),
|
|
||||||
top = with(density) { topBarHeight.toDp() },
|
|
||||||
end = insetPadding.calculateEndPadding(layoutDirection),
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
val chapterListState = rememberLazyListState()
|
|
||||||
|
|
||||||
val internalOnBackPressed = {
|
val chapterListState = rememberLazyListState()
|
||||||
if (isAnySelected) {
|
|
||||||
onAllChapterSelected(false)
|
val internalOnBackPressed = {
|
||||||
} else {
|
if (isAnySelected) {
|
||||||
onBackClicked()
|
onAllChapterSelected(false)
|
||||||
}
|
} else {
|
||||||
|
onBackClicked()
|
||||||
}
|
}
|
||||||
BackHandler(onBack = internalOnBackPressed)
|
}
|
||||||
|
BackHandler(onBack = internalOnBackPressed)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
val selectedChapterCount = remember(chapters) {
|
val selectedChapterCount = remember(chapters) {
|
||||||
chapters.count { it.selected }
|
chapters.count { it.selected }
|
||||||
|
}
|
||||||
|
MangaToolbar(
|
||||||
|
modifier = Modifier.onSizeChanged { topBarHeight = it.height },
|
||||||
|
title = state.manga.title,
|
||||||
|
titleAlphaProvider = { if (isAnySelected) 1f else 0f },
|
||||||
|
backgroundAlphaProvider = { 1f },
|
||||||
|
hasFilters = state.filterActive,
|
||||||
|
onBackClicked = internalOnBackPressed,
|
||||||
|
onClickFilter = onFilterButtonClicked,
|
||||||
|
onClickShare = onShareClicked,
|
||||||
|
onClickDownload = onDownloadActionClicked,
|
||||||
|
onClickEditCategory = onEditCategoryClicked,
|
||||||
|
onClickRefresh = onRefresh,
|
||||||
|
onClickMigrate = onMigrateClicked,
|
||||||
|
actionModeCounter = selectedChapterCount,
|
||||||
|
onSelectAll = { onAllChapterSelected(true) },
|
||||||
|
onInvertSelection = { onInvertSelection() },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.BottomEnd,
|
||||||
|
) {
|
||||||
|
val selectedChapters = remember(chapters) {
|
||||||
|
chapters.filter { it.selected }
|
||||||
}
|
}
|
||||||
MangaToolbar(
|
SharedMangaBottomActionMenu(
|
||||||
modifier = Modifier.onSizeChanged { topBarHeight = it.height },
|
selected = selectedChapters,
|
||||||
title = state.manga.title,
|
onMultiBookmarkClicked = onMultiBookmarkClicked,
|
||||||
titleAlphaProvider = { if (isAnySelected) 1f else 0f },
|
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
|
||||||
backgroundAlphaProvider = { 1f },
|
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
|
||||||
hasFilters = state.filterActive,
|
onDownloadChapter = onDownloadChapter,
|
||||||
onBackClicked = internalOnBackPressed,
|
onMultiDeleteClicked = onMultiDeleteClicked,
|
||||||
onClickFilter = onFilterButtonClicked,
|
fillFraction = 0.5f,
|
||||||
onClickShare = onShareClicked,
|
|
||||||
onClickDownload = onDownloadActionClicked,
|
|
||||||
onClickEditCategory = onEditCategoryClicked,
|
|
||||||
onClickRefresh = onRefresh,
|
|
||||||
onClickMigrate = onMigrateClicked,
|
|
||||||
actionModeCounter = selectedChapterCount,
|
|
||||||
onSelectAll = { onAllChapterSelected(true) },
|
|
||||||
onInvertSelection = { onInvertSelection() },
|
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
bottomBar = {
|
},
|
||||||
Box(
|
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
floatingActionButton = {
|
||||||
contentAlignment = Alignment.BottomEnd,
|
val isFABVisible = remember(chapters) {
|
||||||
) {
|
chapters.fastAny { !it.chapter.read } && !isAnySelected
|
||||||
val selectedChapters = remember(chapters) {
|
}
|
||||||
chapters.filter { it.selected }
|
AnimatedVisibility(
|
||||||
}
|
visible = isFABVisible,
|
||||||
SharedMangaBottomActionMenu(
|
enter = fadeIn(),
|
||||||
selected = selectedChapters,
|
exit = fadeOut(),
|
||||||
onMultiBookmarkClicked = onMultiBookmarkClicked,
|
) {
|
||||||
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
|
ExtendedFloatingActionButton(
|
||||||
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
|
text = {
|
||||||
onDownloadChapter = onDownloadChapter,
|
val isReading = remember(state.chapters) {
|
||||||
onMultiDeleteClicked = onMultiDeleteClicked,
|
state.chapters.fastAny { it.chapter.read }
|
||||||
fillFraction = 0.5f,
|
}
|
||||||
)
|
Text(
|
||||||
}
|
text = stringResource(
|
||||||
},
|
if (isReading) MR.strings.action_resume else MR.strings.action_start,
|
||||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
),
|
||||||
floatingActionButton = {
|
)
|
||||||
val isFABVisible = remember(chapters) {
|
},
|
||||||
chapters.fastAny { !it.chapter.read } && !isAnySelected
|
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
|
||||||
}
|
onClick = onContinueReading,
|
||||||
AnimatedVisibility(
|
expanded = chapterListState.isScrollingUp() || chapterListState.isScrolledToEnd(),
|
||||||
visible = isFABVisible,
|
)
|
||||||
enter = fadeIn(),
|
}
|
||||||
exit = fadeOut(),
|
},
|
||||||
) {
|
) { contentPadding ->
|
||||||
ExtendedFloatingActionButton(
|
PullRefresh(
|
||||||
text = {
|
refreshing = state.isRefreshingData,
|
||||||
val isReading = remember(state.chapters) {
|
onRefresh = onRefresh,
|
||||||
state.chapters.fastAny { it.chapter.read }
|
enabled = { !isAnySelected },
|
||||||
}
|
indicatorPadding = PaddingValues(
|
||||||
Text(
|
start = insetPadding.calculateStartPadding(layoutDirection),
|
||||||
text = stringResource(
|
top = with(density) { topBarHeight.toDp() },
|
||||||
if (isReading) MR.strings.action_resume else MR.strings.action_start,
|
end = insetPadding.calculateEndPadding(layoutDirection),
|
||||||
),
|
),
|
||||||
)
|
) {
|
||||||
},
|
|
||||||
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
|
|
||||||
onClick = onContinueReading,
|
|
||||||
expanded = chapterListState.isScrollingUp() || chapterListState.isScrolledToEnd(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { contentPadding ->
|
|
||||||
TwoPanelBox(
|
TwoPanelBox(
|
||||||
modifier = Modifier.padding(
|
modifier = Modifier.padding(
|
||||||
start = contentPadding.calculateStartPadding(layoutDirection),
|
start = contentPadding.calculateStartPadding(layoutDirection),
|
||||||
|
@ -104,7 +104,7 @@ fun UpdateScreen(
|
|||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = !state.selectionMode,
|
enabled = { !state.selectionMode },
|
||||||
indicatorPadding = contentPadding,
|
indicatorPadding = contentPadding,
|
||||||
) {
|
) {
|
||||||
FastScrollLazyColumn(
|
FastScrollLazyColumn(
|
||||||
|
@ -50,7 +50,7 @@ object Migrations {
|
|||||||
backupPreferences: BackupPreferences,
|
backupPreferences: BackupPreferences,
|
||||||
trackerManager: TrackerManager,
|
trackerManager: TrackerManager,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val lastVersionCode = preferenceStore.getInt("last_version_code", 0)
|
val lastVersionCode = preferenceStore.getInt(Preference.appStateKey("last_version_code"), 0)
|
||||||
val oldVersion = lastVersionCode.get()
|
val oldVersion = lastVersionCode.get()
|
||||||
if (oldVersion < BuildConfig.VERSION_CODE) {
|
if (oldVersion < BuildConfig.VERSION_CODE) {
|
||||||
lastVersionCode.set(BuildConfig.VERSION_CODE)
|
lastVersionCode.set(BuildConfig.VERSION_CODE)
|
||||||
@ -396,7 +396,7 @@ object Migrations {
|
|||||||
newKey = { Preference.privateKey(it) },
|
newKey = { Preference.privateKey(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (oldVersion < 108) {
|
if (oldVersion < 110) {
|
||||||
val prefsToReplace = listOf(
|
val prefsToReplace = listOf(
|
||||||
"pref_download_only",
|
"pref_download_only",
|
||||||
"incognito_mode",
|
"incognito_mode",
|
||||||
@ -406,6 +406,9 @@ object Migrations {
|
|||||||
"library_update_last_timestamp",
|
"library_update_last_timestamp",
|
||||||
"library_unseen_updates_count",
|
"library_unseen_updates_count",
|
||||||
"last_used_category",
|
"last_used_category",
|
||||||
|
"last_app_check",
|
||||||
|
"last_ext_check",
|
||||||
|
"last_version_code",
|
||||||
)
|
)
|
||||||
replacePreferences(
|
replacePreferences(
|
||||||
preferenceStore = preferenceStore,
|
preferenceStore = preferenceStore,
|
||||||
|
@ -28,7 +28,7 @@ internal class ExtensionGithubApi {
|
|||||||
private val json: Json by injectLazy()
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
private val lastExtCheck: Preference<Long> by lazy {
|
private val lastExtCheck: Preference<Long> by lazy {
|
||||||
preferenceStore.getLong("last_ext_check", 0)
|
preferenceStore.getLong(Preference.appStateKey("last_ext_check"), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var requiresFallbackSource = false
|
private var requiresFallbackSource = false
|
||||||
|
@ -6,6 +6,7 @@ import androidx.compose.material3.SnackbarResult
|
|||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.ui.util.fastAny
|
||||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||||
import cafe.adriel.voyager.core.model.screenModelScope
|
import cafe.adriel.voyager.core.model.screenModelScope
|
||||||
import eu.kanade.core.preference.asState
|
import eu.kanade.core.preference.asState
|
||||||
@ -1052,6 +1053,10 @@ class MangaScreenModel(
|
|||||||
chapters.applyFilters(manga).toList()
|
chapters.applyFilters(manga).toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isAnySelected by lazy {
|
||||||
|
chapters.fastAny { it.selected }
|
||||||
|
}
|
||||||
|
|
||||||
val chapterListItems by lazy {
|
val chapterListItems by lazy {
|
||||||
processedChapters.insertSeparators { before, after ->
|
processedChapters.insertSeparators { before, after ->
|
||||||
val (lowerChapter, higherChapter) = if (manga.sortDescending()) {
|
val (lowerChapter, higherChapter) = if (manga.sortDescending()) {
|
||||||
|
@ -13,7 +13,7 @@ class GetApplicationRelease(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
private val lastChecked: Preference<Long> by lazy {
|
private val lastChecked: Preference<Long> by lazy {
|
||||||
preferenceStore.getLong("last_app_check", 0)
|
preferenceStore.getLong(Preference.appStateKey("last_app_check"), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun await(arguments: Arguments): Result {
|
suspend fun await(arguments: Arguments): Result {
|
||||||
|
@ -44,7 +44,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.drop
|
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import tachiyomi.presentation.core.components.material.padding
|
import tachiyomi.presentation.core.components.material.padding
|
||||||
@ -126,7 +125,6 @@ private fun <T> WheelPicker(
|
|||||||
snapshotFlow { lazyListState.firstVisibleItemScrollOffset }
|
snapshotFlow { lazyListState.firstVisibleItemScrollOffset }
|
||||||
.map { calculateSnappedItemIndex(lazyListState) }
|
.map { calculateSnappedItemIndex(lazyListState) }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.drop(1)
|
|
||||||
.collectLatest {
|
.collectLatest {
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
internalOnSelectionChanged(it)
|
internalOnSelectionChanged(it)
|
||||||
|
@ -1,17 +1,33 @@
|
|||||||
package tachiyomi.presentation.core.components.material
|
package tachiyomi.presentation.core.components.material
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animate
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
|
||||||
import androidx.compose.material.pullrefresh.pullRefresh
|
import androidx.compose.material3.pulltorefresh.PullToRefreshState
|
||||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
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.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import androidx.compose.ui.unit.Velocity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.pow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param refreshing Whether the layout is currently refreshing
|
* @param refreshing Whether the layout is currently refreshing
|
||||||
@ -19,38 +35,239 @@ import androidx.compose.ui.unit.dp
|
|||||||
* @param enabled Whether the the layout should react to swipe gestures or not.
|
* @param enabled Whether the the layout should react to swipe gestures or not.
|
||||||
* @param indicatorPadding Content padding for the indicator, to inset the indicator in if required.
|
* @param indicatorPadding Content padding for the indicator, to inset the indicator in if required.
|
||||||
* @param content The content containing a vertically scrollable composable.
|
* @param content The content containing a vertically scrollable composable.
|
||||||
*
|
|
||||||
* Code reference: [Accompanist SwipeRefresh](https://github.com/google/accompanist/blob/677bc4ca0ee74677a8ba73793d04d85fe4ab55fb/swiperefresh/src/main/java/com/google/accompanist/swiperefresh/SwipeRefresh.kt#L265-L283)
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PullRefresh(
|
fun PullRefresh(
|
||||||
refreshing: Boolean,
|
refreshing: Boolean,
|
||||||
|
enabled: () -> Boolean,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
enabled: Boolean,
|
modifier: Modifier = Modifier,
|
||||||
indicatorPadding: PaddingValues = PaddingValues(0.dp),
|
indicatorPadding: PaddingValues = PaddingValues(0.dp),
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val state = rememberPullRefreshState(
|
val state = rememberPullToRefreshState(
|
||||||
refreshing = refreshing,
|
extraVerticalOffset = indicatorPadding.calculateTopPadding(),
|
||||||
onRefresh = onRefresh,
|
enabled = enabled,
|
||||||
)
|
)
|
||||||
|
if (state.isRefreshing) {
|
||||||
Box(Modifier.pullRefresh(state, enabled)) {
|
LaunchedEffect(true) {
|
||||||
content()
|
onRefresh()
|
||||||
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.padding(indicatorPadding)
|
|
||||||
.matchParentSize()
|
|
||||||
.clipToBounds(),
|
|
||||||
) {
|
|
||||||
PullRefreshIndicator(
|
|
||||||
refreshing = refreshing,
|
|
||||||
state = state,
|
|
||||||
modifier = Modifier.align(Alignment.TopCenter),
|
|
||||||
backgroundColor = MaterialTheme.colorScheme.primary,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(refreshing) {
|
||||||
|
if (refreshing && !state.isRefreshing) {
|
||||||
|
state.startRefreshAnimated()
|
||||||
|
} else if (!refreshing && state.isRefreshing) {
|
||||||
|
state.endRefreshAnimated()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier.nestedScroll(state.nestedScrollConnection)) {
|
||||||
|
content()
|
||||||
|
|
||||||
|
val contentPadding = remember(indicatorPadding) {
|
||||||
|
object : PaddingValues {
|
||||||
|
override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp =
|
||||||
|
indicatorPadding.calculateLeftPadding(layoutDirection)
|
||||||
|
|
||||||
|
override fun calculateTopPadding(): Dp = 0.dp
|
||||||
|
|
||||||
|
override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp =
|
||||||
|
indicatorPadding.calculateRightPadding(layoutDirection)
|
||||||
|
|
||||||
|
override fun calculateBottomPadding(): Dp =
|
||||||
|
indicatorPadding.calculateBottomPadding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PullToRefreshContainer(
|
||||||
|
state = state,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.padding(contentPadding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberPullToRefreshState(
|
||||||
|
extraVerticalOffset: Dp,
|
||||||
|
positionalThreshold: Dp = 64.dp,
|
||||||
|
enabled: () -> Boolean = { true },
|
||||||
|
): PullToRefreshStateImpl {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val extraVerticalOffsetPx = with(density) { extraVerticalOffset.toPx() }
|
||||||
|
val positionalThresholdPx = with(density) { positionalThreshold.toPx() }
|
||||||
|
return rememberSaveable(
|
||||||
|
extraVerticalOffset,
|
||||||
|
positionalThresholdPx,
|
||||||
|
enabled,
|
||||||
|
saver = PullToRefreshStateImpl.Saver(
|
||||||
|
extraVerticalOffset = extraVerticalOffsetPx,
|
||||||
|
positionalThreshold = positionalThresholdPx,
|
||||||
|
enabled = enabled,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
PullToRefreshStateImpl(
|
||||||
|
initialRefreshing = false,
|
||||||
|
extraVerticalOffset = extraVerticalOffsetPx,
|
||||||
|
positionalThreshold = positionalThresholdPx,
|
||||||
|
enabled = enabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a [PullToRefreshState].
|
||||||
|
*
|
||||||
|
* @param positionalThreshold The positional threshold, in pixels, in which a refresh is triggered
|
||||||
|
* @param extraVerticalOffset Extra vertical offset, in pixels, for the "refreshing" state
|
||||||
|
* @param initialRefreshing The initial refreshing value of [PullToRefreshState]
|
||||||
|
* @param enabled a callback used to determine whether scroll events are to be handled by this
|
||||||
|
* [PullToRefreshState]
|
||||||
|
*/
|
||||||
|
private class PullToRefreshStateImpl(
|
||||||
|
initialRefreshing: Boolean,
|
||||||
|
private val extraVerticalOffset: Float,
|
||||||
|
override val positionalThreshold: Float,
|
||||||
|
enabled: () -> Boolean,
|
||||||
|
) : PullToRefreshState {
|
||||||
|
|
||||||
|
override val progress get() = adjustedDistancePulled / positionalThreshold
|
||||||
|
override var verticalOffset by mutableFloatStateOf(0f)
|
||||||
|
|
||||||
|
override var isRefreshing by mutableStateOf(initialRefreshing)
|
||||||
|
|
||||||
|
override fun startRefresh() {
|
||||||
|
isRefreshing = true
|
||||||
|
verticalOffset = positionalThreshold + extraVerticalOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun startRefreshAnimated() {
|
||||||
|
isRefreshing = true
|
||||||
|
animateTo(positionalThreshold + extraVerticalOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun endRefresh() {
|
||||||
|
verticalOffset = 0f
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun endRefreshAnimated() {
|
||||||
|
animateTo(0f)
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override var nestedScrollConnection = object : NestedScrollConnection {
|
||||||
|
override fun onPreScroll(
|
||||||
|
available: Offset,
|
||||||
|
source: NestedScrollSource,
|
||||||
|
): Offset = when {
|
||||||
|
!enabled() -> Offset.Zero
|
||||||
|
// Swiping up
|
||||||
|
source == NestedScrollSource.Drag && available.y < 0 -> {
|
||||||
|
consumeAvailableOffset(available)
|
||||||
|
}
|
||||||
|
else -> Offset.Zero
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPostScroll(
|
||||||
|
consumed: Offset,
|
||||||
|
available: Offset,
|
||||||
|
source: NestedScrollSource,
|
||||||
|
): Offset = when {
|
||||||
|
!enabled() -> Offset.Zero
|
||||||
|
// Swiping down
|
||||||
|
source == NestedScrollSource.Drag && available.y > 0 -> {
|
||||||
|
consumeAvailableOffset(available)
|
||||||
|
}
|
||||||
|
else -> Offset.Zero
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||||
|
return Velocity(0f, onRelease(available.y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper method for nested scroll connection */
|
||||||
|
fun consumeAvailableOffset(available: Offset): Offset {
|
||||||
|
val y = if (isRefreshing) {
|
||||||
|
0f
|
||||||
|
} else {
|
||||||
|
val newOffset = (distancePulled + available.y).coerceAtLeast(0f)
|
||||||
|
val dragConsumed = newOffset - distancePulled
|
||||||
|
distancePulled = newOffset
|
||||||
|
verticalOffset = calculateVerticalOffset() + (extraVerticalOffset * progress)
|
||||||
|
dragConsumed
|
||||||
|
}
|
||||||
|
return Offset(0f, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper method for nested scroll connection. Calls onRefresh callback when triggered */
|
||||||
|
suspend fun onRelease(velocity: Float): Float {
|
||||||
|
if (isRefreshing) return 0f // Already refreshing, do nothing
|
||||||
|
// Trigger refresh
|
||||||
|
if (adjustedDistancePulled > positionalThreshold) {
|
||||||
|
startRefreshAnimated()
|
||||||
|
} else {
|
||||||
|
animateTo(0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
val consumed = when {
|
||||||
|
// We are flinging without having dragged the pull refresh (for example a fling inside
|
||||||
|
// a list) - don't consume
|
||||||
|
distancePulled == 0f -> 0f
|
||||||
|
// If the velocity is negative, the fling is upwards, and we don't want to prevent the
|
||||||
|
// the list from scrolling
|
||||||
|
velocity < 0f -> 0f
|
||||||
|
// We are showing the indicator, and the fling is downwards - consume everything
|
||||||
|
else -> velocity
|
||||||
|
}
|
||||||
|
distancePulled = 0f
|
||||||
|
return consumed
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun animateTo(offset: Float) {
|
||||||
|
animate(initialValue = verticalOffset, targetValue = offset) { value, _ ->
|
||||||
|
verticalOffset = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provides custom vertical offset behavior for [PullToRefreshContainer] */
|
||||||
|
fun calculateVerticalOffset(): Float = when {
|
||||||
|
// If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
|
||||||
|
adjustedDistancePulled <= positionalThreshold -> adjustedDistancePulled
|
||||||
|
else -> {
|
||||||
|
// How far beyond the threshold pull has gone, as a percentage of the threshold.
|
||||||
|
val overshootPercent = abs(progress) - 1.0f
|
||||||
|
// Limit the overshoot to 200%. Linear between 0 and 200.
|
||||||
|
val linearTension = overshootPercent.coerceIn(0f, 2f)
|
||||||
|
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
|
||||||
|
val tensionPercent = linearTension - linearTension.pow(2) / 4
|
||||||
|
// The additional offset beyond the threshold.
|
||||||
|
val extraOffset = positionalThreshold * tensionPercent
|
||||||
|
positionalThreshold + extraOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The default [Saver] for [PullToRefreshStateImpl]. */
|
||||||
|
fun Saver(
|
||||||
|
extraVerticalOffset: Float,
|
||||||
|
positionalThreshold: Float,
|
||||||
|
enabled: () -> Boolean,
|
||||||
|
) = Saver<PullToRefreshStateImpl, Boolean>(
|
||||||
|
save = { it.isRefreshing },
|
||||||
|
restore = { isRefreshing ->
|
||||||
|
PullToRefreshStateImpl(
|
||||||
|
initialRefreshing = isRefreshing,
|
||||||
|
extraVerticalOffset = extraVerticalOffset,
|
||||||
|
positionalThreshold = positionalThreshold,
|
||||||
|
enabled = enabled,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var distancePulled by mutableFloatStateOf(0f)
|
||||||
|
private val adjustedDistancePulled: Float get() = distancePulled * 0.5f
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user