From 20d25f9ad4a08ceac8ceab30cd365b53020effff Mon Sep 17 00:00:00 2001 From: rumboalla Date: Fri, 14 Jul 2023 12:08:23 +0200 Subject: [PATCH] Implement pull to refresh --- .../main/java/com/apkupdater/di/MainModule.kt | 10 +- .../main/java/com/apkupdater/prefs/Prefs.kt | 16 +- .../apkupdater/repository/AppsRepository.kt | 2 + .../apkupdater/ui/component/UiComponents.kt | 4 +- .../com/apkupdater/ui/screen/AppsScreen.kt | 7 +- .../com/apkupdater/ui/screen/MainScreen.kt | 56 ++++- .../com/apkupdater/ui/screen/SearchScreen.kt | 5 - .../com/apkupdater/ui/screen/UpdatesScreen.kt | 7 +- .../com/apkupdater/viewmodel/AppsViewModel.kt | 6 +- ...BottomBarViewModel.kt => MainViewModel.kt} | 17 +- .../apkupdater/viewmodel/SearchViewModel.kt | 8 +- .../apkupdater/viewmodel/UpdatesViewModel.kt | 8 +- .../material3/pullrefresh/PullRefresh.kt | 116 +++++++++ .../pullrefresh/PullRefreshIndicator.kt | 238 ++++++++++++++++++ .../PullRefreshIndicatorTransform.kt | 73 ++++++ .../material3/pullrefresh/PullRefreshState.kt | 228 +++++++++++++++++ 16 files changed, 748 insertions(+), 53 deletions(-) rename app/src/main/java/com/apkupdater/viewmodel/{BottomBarViewModel.kt => MainViewModel.kt} (67%) create mode 100644 app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefresh.kt create mode 100644 app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt create mode 100644 app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt create mode 100644 app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshState.kt diff --git a/app/src/main/java/com/apkupdater/di/MainModule.kt b/app/src/main/java/com/apkupdater/di/MainModule.kt index 862ed6eb..219406f0 100644 --- a/app/src/main/java/com/apkupdater/di/MainModule.kt +++ b/app/src/main/java/com/apkupdater/di/MainModule.kt @@ -9,7 +9,7 @@ import com.apkupdater.repository.ApkMirrorRepository import com.apkupdater.repository.AppsRepository import com.apkupdater.service.ApkMirrorService import com.apkupdater.viewmodel.AppsViewModel -import com.apkupdater.viewmodel.BottomBarViewModel +import com.apkupdater.viewmodel.MainViewModel import com.apkupdater.viewmodel.SearchViewModel import com.apkupdater.viewmodel.SettingsViewModel import com.apkupdater.viewmodel.UpdatesViewModel @@ -69,14 +69,14 @@ val mainModule = module { single { Prefs(get()) } - viewModel { AppsViewModel(get(), get()) } + viewModel { parameters -> AppsViewModel(parameters.get(), get(), get()) } - viewModel { BottomBarViewModel() } + viewModel { MainViewModel() } - viewModel { UpdatesViewModel(get(), get(), get()) } + viewModel { parameters -> UpdatesViewModel(parameters.get(), get(), get()) } viewModel { SettingsViewModel(get()) } - viewModel { SearchViewModel(get(), get()) } + viewModel { parameters -> SearchViewModel(parameters.get(), get()) } } diff --git a/app/src/main/java/com/apkupdater/prefs/Prefs.kt b/app/src/main/java/com/apkupdater/prefs/Prefs.kt index 9d625a56..cc142b4a 100644 --- a/app/src/main/java/com/apkupdater/prefs/Prefs.kt +++ b/app/src/main/java/com/apkupdater/prefs/Prefs.kt @@ -6,12 +6,12 @@ import com.kryptoprefs.preferences.KryptoPrefs class Prefs(prefs: KryptoPrefs): KryptoContext(prefs) { - val ignoredApps = json("ignoredApps", emptyList()) - val excludeSystem = boolean("excludeSystem", true) - val excludeDisabled = boolean("excludeDisabled", true) - val excludeStore = boolean("excludeStore", false) - val portraitColumns = int("portraitColumns", 2) - val landscapeColumns = int("landscapeColumns", 4) - val ignoreAlpha = boolean("ignoreAlpha", true) - val ignoreBeta = boolean("ignoreBeta", true) + val ignoredApps = json("ignoredApps", emptyList(), true) + val excludeSystem = boolean("excludeSystem", defValue = true, backed = true) + val excludeDisabled = boolean("excludeDisabled", defValue = true, backed = true) + val excludeStore = boolean("excludeStore", defValue = false, backed = true) + val portraitColumns = int("portraitColumns", 2, true) + val landscapeColumns = int("landscapeColumns", 4, true) + val ignoreAlpha = boolean("ignoreAlpha", defValue = true, backed = true) + val ignoreBeta = boolean("ignoreBeta", defValue = true, backed = true) } diff --git a/app/src/main/java/com/apkupdater/repository/AppsRepository.kt b/app/src/main/java/com/apkupdater/repository/AppsRepository.kt index c65138ee..265eced6 100644 --- a/app/src/main/java/com/apkupdater/repository/AppsRepository.kt +++ b/app/src/main/java/com/apkupdater/repository/AppsRepository.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.os.Build +import android.util.Log import com.apkupdater.prefs.Prefs import com.apkupdater.transform.toAppInstalled import com.apkupdater.util.orFalse @@ -30,6 +31,7 @@ class AppsRepository( .toList() emit(Result.success(apps)) }.catch { + Log.e("AppsRepository", "Error getting apps.", it) emit(Result.failure(it)) } diff --git a/app/src/main/java/com/apkupdater/ui/component/UiComponents.kt b/app/src/main/java/com/apkupdater/ui/component/UiComponents.kt index 7175c7cc..31a7b16f 100644 --- a/app/src/main/java/com/apkupdater/ui/component/UiComponents.kt +++ b/app/src/main/java/com/apkupdater/ui/component/UiComponents.kt @@ -229,7 +229,7 @@ fun AppImage(app: AppInstalled, onIgnore: (String) -> Unit = {}) = Box { TextBubble(app.versionCode.toString(), Modifier.align(Alignment.BottomStart)) IgnoreIcon( app.ignored, - { onIgnore(app.packageName) }, + { onIgnore(app.packageName) }, Modifier.align(Alignment.TopEnd).padding(4.dp) ) } @@ -239,7 +239,7 @@ fun UpdateImage(app: AppUpdate, onInstall: (String) -> Unit = {}) = Box { LoadingImageApp(app.packageName) TextBubble(app.versionCode.toString(), Modifier.align(Alignment.BottomStart)) InstallIcon( - { onInstall(app.link) }, + { onInstall(app.link) }, Modifier.align(Alignment.TopEnd).padding(4.dp) ) } diff --git a/app/src/main/java/com/apkupdater/ui/screen/AppsScreen.kt b/app/src/main/java/com/apkupdater/ui/screen/AppsScreen.kt index 4877f3dc..5987108c 100644 --- a/app/src/main/java/com/apkupdater/ui/screen/AppsScreen.kt +++ b/app/src/main/java/com/apkupdater/ui/screen/AppsScreen.kt @@ -12,30 +12,25 @@ import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.apkupdater.R import com.apkupdater.data.ui.AppsUiState -import com.apkupdater.ui.component.InstalledItem import com.apkupdater.ui.component.DefaultErrorScreen import com.apkupdater.ui.component.DefaultLoadingScreen import com.apkupdater.ui.component.ExcludeSystemIcon import com.apkupdater.ui.component.InstalledGrid +import com.apkupdater.ui.component.InstalledItem import com.apkupdater.ui.component.RefreshIcon import com.apkupdater.viewmodel.AppsViewModel -import com.apkupdater.viewmodel.BottomBarViewModel import org.koin.androidx.compose.koinViewModel @Composable fun AppsScreen( - barViewModel: BottomBarViewModel, viewModel: AppsViewModel = koinViewModel() ) { viewModel.state().collectAsStateWithLifecycle().value.onLoading { - barViewModel.changeAppsBadge("") AppsScreenLoading() }.onError { - barViewModel.changeAppsBadge("!") AppsScreenError() }.onSuccess { - barViewModel.changeAppsBadge(it.apps.count().toString()) AppsScreenSuccess(viewModel, it) } } diff --git a/app/src/main/java/com/apkupdater/ui/screen/MainScreen.kt b/app/src/main/java/com/apkupdater/ui/screen/MainScreen.kt index b3166487..57fb1f8c 100644 --- a/app/src/main/java/com/apkupdater/ui/screen/MainScreen.kt +++ b/app/src/main/java/com/apkupdater/ui/screen/MainScreen.kt @@ -1,5 +1,6 @@ package com.apkupdater.ui.screen +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.padding @@ -10,10 +11,16 @@ import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.pullrefresh.PullRefreshIndicator +import androidx.compose.material3.pullrefresh.pullRefresh +import androidx.compose.material3.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController @@ -23,20 +30,44 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.apkupdater.data.ui.Screen import com.apkupdater.ui.component.BadgeText -import com.apkupdater.viewmodel.BottomBarViewModel +import com.apkupdater.viewmodel.AppsViewModel +import com.apkupdater.viewmodel.MainViewModel +import com.apkupdater.viewmodel.SearchViewModel +import com.apkupdater.viewmodel.SettingsViewModel +import com.apkupdater.viewmodel.UpdatesViewModel import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf @Composable -fun MainScreen(viewModel: BottomBarViewModel = koinViewModel()) { +fun MainScreen(mainViewModel: MainViewModel = koinViewModel()) { + // ViewModels + val appsViewModel: AppsViewModel = koinViewModel(parameters = { parametersOf(mainViewModel) }) + val updatesViewModel: UpdatesViewModel = koinViewModel(parameters = { parametersOf(mainViewModel) }) + val searchViewModel: SearchViewModel = koinViewModel(parameters = { parametersOf(mainViewModel) }) + + // Navigation val navController = rememberNavController() - Scaffold(bottomBar = { BottomBar(navController, viewModel) }) { padding -> - NavHost(navController, padding, viewModel) + + // Pull to refresh + val isRefreshing = mainViewModel.isRefreshing.collectAsStateWithLifecycle() + val pullToRefresh = rememberPullRefreshState(isRefreshing.value, { + mainViewModel.refresh(appsViewModel, updatesViewModel) + }) + LaunchedEffect(pullToRefresh) { + mainViewModel.refresh(appsViewModel, updatesViewModel) + } + + Scaffold(bottomBar = { BottomBar(navController, mainViewModel) }) { padding -> + Box(modifier = Modifier.pullRefresh(pullToRefresh)) { + NavHost(navController, padding, appsViewModel, updatesViewModel, searchViewModel) + PullRefreshIndicator(isRefreshing.value, pullToRefresh, Modifier.align(Alignment.TopCenter)) + } } } @Composable -fun BottomBar(navController: NavController, viewModel: BottomBarViewModel) = BottomAppBar { +fun BottomBar(navController: NavController, viewModel: MainViewModel) = BottomAppBar { val badges = viewModel.badges.collectAsState().value viewModel.screens.forEach { screen -> val state = navController.currentBackStackEntryAsState().value @@ -57,7 +88,7 @@ fun RowScope.BottomBarItem( BadgedBox({ BadgeText(badge) }) { Icon(if (selected) screen.iconSelected else screen.icon, contentDescription = null) } - }, + }, label = { Text(stringResource(screen.resourceId)) }, selected = selected, onClick = { navigateTo(navController, screen.route) } @@ -67,16 +98,19 @@ fun RowScope.BottomBarItem( fun NavHost( navController: NavHostController, padding: PaddingValues, - viewModel: BottomBarViewModel + appsViewModel: AppsViewModel, + updatesViewModel: UpdatesViewModel, + searchViewModel: SearchViewModel, + settingsViewModel: SettingsViewModel = koinViewModel() ) = NavHost( navController = navController, startDestination = Screen.Apps.route, modifier = Modifier.padding(padding) ) { - composable(Screen.Apps.route) { AppsScreen(viewModel) } - composable(Screen.Search.route) { SearchScreen(viewModel) } - composable(Screen.Updates.route) { UpdatesScreen(viewModel) } - composable(Screen.Settings.route) { SettingsScreen() } + composable(Screen.Apps.route) { AppsScreen(appsViewModel) } + composable(Screen.Search.route) { SearchScreen(searchViewModel) } + composable(Screen.Updates.route) { UpdatesScreen(updatesViewModel) } + composable(Screen.Settings.route) { SettingsScreen(settingsViewModel) } } fun navigateTo(navController: NavController, route: String) = navController.navigate(route) { diff --git a/app/src/main/java/com/apkupdater/ui/screen/SearchScreen.kt b/app/src/main/java/com/apkupdater/ui/screen/SearchScreen.kt index f80a9873..15e9bfde 100644 --- a/app/src/main/java/com/apkupdater/ui/screen/SearchScreen.kt +++ b/app/src/main/java/com/apkupdater/ui/screen/SearchScreen.kt @@ -26,24 +26,19 @@ import com.apkupdater.ui.component.DefaultErrorScreen import com.apkupdater.ui.component.DefaultLoadingScreen import com.apkupdater.ui.component.InstalledGrid import com.apkupdater.ui.component.SearchItem -import com.apkupdater.viewmodel.BottomBarViewModel import com.apkupdater.viewmodel.SearchViewModel import org.koin.androidx.compose.koinViewModel @Composable fun SearchScreen( - barViewModel: BottomBarViewModel, viewModel: SearchViewModel = koinViewModel() ) = Column { SearchTopBar(viewModel) viewModel.state().collectAsStateWithLifecycle().value.onError { - barViewModel.changeSearchBadge("!") DefaultErrorScreen() }.onSuccess { - barViewModel.changeSearchBadge(it.updates.count().toString()) SearchScreenSuccess(it) }.onLoading { - barViewModel.changeSearchBadge("") DefaultLoadingScreen() } } diff --git a/app/src/main/java/com/apkupdater/ui/screen/UpdatesScreen.kt b/app/src/main/java/com/apkupdater/ui/screen/UpdatesScreen.kt index c812ba20..7b2d7653 100644 --- a/app/src/main/java/com/apkupdater/ui/screen/UpdatesScreen.kt +++ b/app/src/main/java/com/apkupdater/ui/screen/UpdatesScreen.kt @@ -17,24 +17,19 @@ import com.apkupdater.ui.component.DefaultLoadingScreen import com.apkupdater.ui.component.InstalledGrid import com.apkupdater.ui.component.RefreshIcon import com.apkupdater.ui.component.UpdateItem -import com.apkupdater.viewmodel.BottomBarViewModel import com.apkupdater.viewmodel.UpdatesViewModel import org.koin.androidx.compose.koinViewModel @Composable fun UpdatesScreen( - barViewModel: BottomBarViewModel, - viewModel: UpdatesViewModel = koinViewModel() + viewModel: UpdatesViewModel = koinViewModel() ) { viewModel.state().collectAsStateWithLifecycle().value.onLoading { - barViewModel.changeUpdatesBadge("") UpdatesScreenLoading() }.onError { - barViewModel.changeUpdatesBadge("!") UpdatesScreenError() }.onSuccess { - barViewModel.changeUpdatesBadge(it.updates.count().toString()) UpdatesScreenSuccess(viewModel, it.updates) } } diff --git a/app/src/main/java/com/apkupdater/viewmodel/AppsViewModel.kt b/app/src/main/java/com/apkupdater/viewmodel/AppsViewModel.kt index b372f93d..674db97a 100644 --- a/app/src/main/java/com/apkupdater/viewmodel/AppsViewModel.kt +++ b/app/src/main/java/com/apkupdater/viewmodel/AppsViewModel.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.sync.Mutex class AppsViewModel( + private val mainViewModel: MainViewModel, private val repository: AppsRepository, private val prefs: Prefs ) : ViewModel() { @@ -20,17 +21,18 @@ class AppsViewModel( private val mutex = Mutex() private val state = MutableStateFlow(AppsUiState.Loading) - init { refresh() } - fun state(): StateFlow = state fun refresh(load: Boolean = true) = viewModelScope.launchWithMutex(mutex, Dispatchers.IO) { if (load) state.value = AppsUiState.Loading + mainViewModel.changeAppsBadge("") repository.getApps().collect { it.onSuccess { apps -> state.value = AppsUiState.Success(apps, prefs.excludeSystem.get()) + mainViewModel.changeAppsBadge(apps.size.toString()) }.onFailure { ex -> state.value = AppsUiState.Error + mainViewModel.changeAppsBadge("!") Log.e("InstalledViewModel", "Error getting apps.", ex) } } diff --git a/app/src/main/java/com/apkupdater/viewmodel/BottomBarViewModel.kt b/app/src/main/java/com/apkupdater/viewmodel/MainViewModel.kt similarity index 67% rename from app/src/main/java/com/apkupdater/viewmodel/BottomBarViewModel.kt rename to app/src/main/java/com/apkupdater/viewmodel/MainViewModel.kt index 20850a2d..b620659e 100644 --- a/app/src/main/java/com/apkupdater/viewmodel/BottomBarViewModel.kt +++ b/app/src/main/java/com/apkupdater/viewmodel/MainViewModel.kt @@ -1,10 +1,12 @@ package com.apkupdater.viewmodel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.apkupdater.data.ui.Screen import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch -class BottomBarViewModel : ViewModel() { +class MainViewModel : ViewModel() { val screens = listOf(Screen.Apps, Screen.Search, Screen.Updates, Screen.Settings) @@ -15,6 +17,19 @@ class BottomBarViewModel : ViewModel() { Screen.Settings.route to "" )) + val isRefreshing = MutableStateFlow(false) + + fun refresh( + appsViewModel: AppsViewModel, + updatesViewModel: UpdatesViewModel + ) = viewModelScope.launch { + isRefreshing.value = true + appsViewModel.refresh(false) + updatesViewModel.refresh(false).invokeOnCompletion { + isRefreshing.value = false + } + } + fun changeSearchBadge(number: String) = changeBadge(Screen.Search.route, number) fun changeAppsBadge(number: String) = changeBadge(Screen.Apps.route, number) diff --git a/app/src/main/java/com/apkupdater/viewmodel/SearchViewModel.kt b/app/src/main/java/com/apkupdater/viewmodel/SearchViewModel.kt index 1573a33c..106af766 100644 --- a/app/src/main/java/com/apkupdater/viewmodel/SearchViewModel.kt +++ b/app/src/main/java/com/apkupdater/viewmodel/SearchViewModel.kt @@ -3,7 +3,6 @@ package com.apkupdater.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.apkupdater.data.ui.SearchUiState -import com.apkupdater.prefs.Prefs import com.apkupdater.repository.ApkMirrorRepository import com.apkupdater.util.launchWithMutex import kotlinx.coroutines.Dispatchers @@ -12,8 +11,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.sync.Mutex class SearchViewModel( - private val apkMirrorRepository: ApkMirrorRepository, - private val prefs: Prefs + private val mainViewModel: MainViewModel, + private val apkMirrorRepository: ApkMirrorRepository ) : ViewModel() { private val mutex = Mutex() @@ -23,10 +22,13 @@ class SearchViewModel( fun search(text: String) = viewModelScope.launchWithMutex(mutex, Dispatchers.IO) { state.value = SearchUiState.Loading + mainViewModel.changeSearchBadge("") apkMirrorRepository.search(text).collect { it.onSuccess { apps -> state.value = SearchUiState.Success(apps) + mainViewModel.changeSearchBadge(apps.size.toString()) }.onFailure { + mainViewModel.changeSearchBadge("!") state.value = SearchUiState.Error } } diff --git a/app/src/main/java/com/apkupdater/viewmodel/UpdatesViewModel.kt b/app/src/main/java/com/apkupdater/viewmodel/UpdatesViewModel.kt index b7153ca5..abc631c2 100644 --- a/app/src/main/java/com/apkupdater/viewmodel/UpdatesViewModel.kt +++ b/app/src/main/java/com/apkupdater/viewmodel/UpdatesViewModel.kt @@ -3,7 +3,6 @@ package com.apkupdater.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.apkupdater.data.ui.UpdatesUiState -import com.apkupdater.prefs.Prefs import com.apkupdater.repository.ApkMirrorRepository import com.apkupdater.repository.AppsRepository import com.apkupdater.util.launchWithMutex @@ -13,26 +12,27 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.sync.Mutex class UpdatesViewModel( + private val mainViewModel: MainViewModel, private val appsRepository: AppsRepository, private val apkMirrorRepository: ApkMirrorRepository, - private val prefs: Prefs ) : ViewModel() { private val mutex = Mutex() private val state = MutableStateFlow(UpdatesUiState.Loading) - init { refresh() } - fun state(): StateFlow = state fun refresh(load: Boolean = true) = viewModelScope.launchWithMutex(mutex, Dispatchers.IO) { if (load) state.value = UpdatesUiState.Loading + mainViewModel.changeUpdatesBadge("") appsRepository.getApps().collect { response -> response.onSuccess { apps -> apkMirrorRepository.getUpdates(apps).collect { updates -> state.value = UpdatesUiState.Success(updates) + mainViewModel.changeUpdatesBadge(updates.size.toString()) } }.onFailure { + mainViewModel.changeUpdatesBadge("!") state.value = UpdatesUiState.Error } } diff --git a/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefresh.kt b/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefresh.kt new file mode 100644 index 00000000..5b8b8fd8 --- /dev/null +++ b/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefresh.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.material3.pullrefresh + +import androidx.compose.ui.Modifier +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.NestedScrollSource.Companion.Drag +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.platform.inspectable +import androidx.compose.ui.unit.Velocity + +/** + * A nested scroll modifier that provides scroll events to [state]. + * + * Note that this modifier must be added above a scrolling container, such as a lazy column, in + * order to receive scroll events. For example: + * + * @sample androidx.compose.material.samples.PullRefreshSample + * + * @param state The [PullRefreshState] associated with this pull-to-refresh component. + * The state will be updated by this modifier. + * @param enabled If not enabled, all scroll delta and fling velocity will be ignored. + */ +// TODO(b/244423199): Move pullRefresh into its own material library similar to material-ripple. +fun Modifier.pullRefresh( + state: PullRefreshState, + enabled: Boolean = true, +) = inspectable(inspectorInfo = debugInspectorInfo { + name = "pullRefresh" + properties["state"] = state + properties["enabled"] = enabled +}) { + Modifier.pullRefresh(state::onPull, state::onRelease, enabled) +} + +/** + * A nested scroll modifier that provides [onPull] and [onRelease] callbacks to aid building custom + * pull refresh components. + * + * Note that this modifier must be added above a scrolling container, such as a lazy column, in + * order to receive scroll events. For example: + * + * @sample androidx.compose.material.samples.CustomPullRefreshSample + * + * @param onPull Callback for dispatching vertical scroll delta, takes float pullDelta as argument. + * Positive delta (pulling down) is dispatched only if the child does not consume it (i.e. pulling + * down despite being at the top of a scrollable component), whereas negative delta (swiping up) is + * dispatched first (in case it is needed to push the indicator back up), and then the unconsumed + * delta is passed on to the child. The callback returns how much delta was consumed. + * @param onRelease Callback for when drag is released, takes float flingVelocity as argument. + * The callback returns how much velocity was consumed - in most cases this should only consume + * velocity if pull refresh has been dragged already and the velocity is positive (the fling is + * downwards), as an upwards fling should typically still scroll a scrollable component beneath the + * pullRefresh. This is invoked before any remaining velocity is passed to the child. + * @param enabled If not enabled, all scroll delta and fling velocity will be ignored and neither + * [onPull] nor [onRelease] will be invoked. + */ +fun Modifier.pullRefresh( + onPull: (pullDelta: Float) -> Float, + onRelease: suspend (flingVelocity: Float) -> Float, + enabled: Boolean = true, +) = inspectable(inspectorInfo = debugInspectorInfo { + name = "pullRefresh" + properties["onPull"] = onPull + properties["onRelease"] = onRelease + properties["enabled"] = enabled +}) { + Modifier.nestedScroll(PullRefreshNestedScrollConnection(onPull, onRelease, enabled)) +} + +private class PullRefreshNestedScrollConnection( + private val onPull: (pullDelta: Float) -> Float, + private val onRelease: suspend (flingVelocity: Float) -> Float, + private val enabled: Boolean, +) : NestedScrollConnection { + + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset = when { + !enabled -> Offset.Zero + source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up + else -> Offset.Zero + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset = when { + !enabled -> Offset.Zero + source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down + else -> Offset.Zero + } + + override suspend fun onPreFling(available: Velocity): Velocity { + return Velocity(0f, onRelease(available.y)) + } +} diff --git a/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt b/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt new file mode 100644 index 00000000..12ac2b68 --- /dev/null +++ b/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt @@ -0,0 +1,238 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.material3.pullrefresh + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow + +/** + * The default indicator for Compose pull-to-refresh, based on Android's SwipeRefreshLayout. + * + * @sample androidx.compose.material.samples.PullRefreshSample + * + * @param refreshing A boolean representing whether a refresh is occurring. + * @param state The [PullRefreshState] which controls where and how the indicator will be drawn. + * @param modifier Modifiers for the indicator. + * @param backgroundColor The color of the indicator's background. + * @param contentColor The color of the indicator's arc and arrow. + * @param scale A boolean controlling whether the indicator's size scales with pull progress or not. + */ +@Composable +// TODO(b/244423199): Consider whether the state parameter should be replaced with lambdas to +// enable people to use this indicator with custom pull-to-refresh components. +fun PullRefreshIndicator( + refreshing: Boolean, + state: PullRefreshState, + modifier: Modifier = Modifier, + backgroundColor: Color = MaterialTheme.colorScheme.surface, + contentColor: Color = contentColorFor(backgroundColor), + scale: Boolean = false, +) { + val showElevation by remember(refreshing, state) { + derivedStateOf { refreshing || state.position > 0.5f } + } + + Surface( + modifier = modifier + .size(IndicatorSize) + .pullRefreshIndicatorTransform(state, scale), + shape = SpinnerShape, + color = backgroundColor, + shadowElevation = if (showElevation) Elevation else 0.dp, + ) { + Crossfade( + targetState = refreshing, + animationSpec = tween(durationMillis = CrossfadeDurationMs) + ) { refreshing -> + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + val spinnerSize = (ArcRadius + StrokeWidth).times(2) + + if (refreshing) { + CircularProgressIndicator( + color = contentColor, + strokeWidth = StrokeWidth, + modifier = Modifier.size(spinnerSize), + ) + } else { + CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize)) + } + } + } + } +} + +/** + * Modifier.size MUST be specified. + */ +@Composable +private fun CircularArrowIndicator( + state: PullRefreshState, + color: Color, + modifier: Modifier, +) { + val path = remember { Path().apply { fillType = PathFillType.EvenOdd } } + + val targetAlpha by remember(state) { + derivedStateOf { + if (state.progress >= 1f) MaxAlpha else MinAlpha + } + } + + val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween) + + // Empty semantics for tests + Canvas(modifier.semantics {}) { + val values = ArrowValues(state.progress) + val alpha = alphaState.value + + rotate(degrees = values.rotation) { + val arcRadius = ArcRadius.toPx() + StrokeWidth.toPx() / 2f + val arcBounds = Rect( + size.center.x - arcRadius, + size.center.y - arcRadius, + size.center.x + arcRadius, + size.center.y + arcRadius + ) + drawArc( + color = color, + alpha = alpha, + startAngle = values.startAngle, + sweepAngle = values.endAngle - values.startAngle, + useCenter = false, + topLeft = arcBounds.topLeft, + size = arcBounds.size, + style = Stroke( + width = StrokeWidth.toPx(), + cap = StrokeCap.Square + ) + ) + drawArrow(path, arcBounds, color, alpha, values) + } + } +} + +@Immutable +private class ArrowValues( + val rotation: Float, + val startAngle: Float, + val endAngle: Float, + val scale: Float, +) + +private fun ArrowValues(progress: Float): ArrowValues { + // Discard first 40% of progress. Scale remaining progress to full range between 0 and 100%. + val adjustedPercent = max(min(1f, progress) - 0.4f, 0f) * 5 / 3 + // 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 + + // Calculations based on SwipeRefreshLayout specification. + val endTrim = adjustedPercent * MaxProgressArc + val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f + val startAngle = rotation * 360 + val endAngle = (rotation + endTrim) * 360 + val scale = min(1f, adjustedPercent) + + return ArrowValues(rotation, startAngle, endAngle, scale) +} + +private fun DrawScope.drawArrow( + arrow: Path, + bounds: Rect, + color: Color, + alpha: Float, + values: ArrowValues, +) { + arrow.reset() + arrow.moveTo(0f, 0f) // Move to left corner + arrow.lineTo(x = ArrowWidth.toPx() * values.scale, y = 0f) // Line to right corner + + // Line to tip of arrow + arrow.lineTo( + x = ArrowWidth.toPx() * values.scale / 2, + y = ArrowHeight.toPx() * values.scale + ) + + val radius = min(bounds.width, bounds.height) / 2f + val inset = ArrowWidth.toPx() * values.scale / 2f + arrow.translate( + Offset( + x = radius + bounds.center.x - inset, + y = bounds.center.y + StrokeWidth.toPx() / 2f + ) + ) + arrow.close() + rotate(degrees = values.endAngle) { + drawPath(path = arrow, color = color, alpha = alpha) + } +} + +private const val CrossfadeDurationMs = 100 +private const val MaxProgressArc = 0.8f + +private val IndicatorSize = 40.dp +private val SpinnerShape = CircleShape +private val ArcRadius = 7.5.dp +private val StrokeWidth = 2.5.dp +private val ArrowWidth = 10.dp +private val ArrowHeight = 5.dp +private val Elevation = 6.dp + +// Values taken from SwipeRefreshLayout +private const val MinAlpha = 0.3f +private const val MaxAlpha = 1f +private val AlphaTween = tween(300, easing = LinearEasing) diff --git a/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt b/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt new file mode 100644 index 00000000..afbaa0e6 --- /dev/null +++ b/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.material3.pullrefresh + +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.platform.inspectable + +/** + * A modifier for translating the position and scaling the size of a pull-to-refresh indicator + * based on the given [PullRefreshState]. + * + * @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample + * + * @param state The [PullRefreshState] which determines the position of the indicator. + * @param scale A boolean controlling whether the indicator's size scales with pull progress or not. + */ +// TODO: Consider whether the state parameter should be replaced with lambdas. +fun Modifier.pullRefreshIndicatorTransform( + state: PullRefreshState, + scale: Boolean = false, +) = inspectable(inspectorInfo = debugInspectorInfo { + name = "pullRefreshIndicatorTransform" + properties["state"] = state + properties["scale"] = scale +}) { + Modifier + // Essentially we only want to clip the at the top, so the indicator will not appear when + // the position is 0. It is preferable to clip the indicator as opposed to the layout that + // contains the indicator, as this would also end up clipping shadows drawn by items in a + // list for example - so we leave the clipping to the scrolling container. We use MAX_VALUE + // for the other dimensions to allow for more room for elevation / arbitrary indicators - we + // only ever really want to clip at the top edge. + .drawWithContent { + clipRect( + top = 0f, + left = -Float.MAX_VALUE, + right = Float.MAX_VALUE, + bottom = Float.MAX_VALUE + ) { + this@drawWithContent.drawContent() + } + } + .graphicsLayer { + translationY = state.position - size.height + + if (scale && !state.refreshing) { + val scaleFraction = LinearOutSlowInEasing + .transform(state.position / state.threshold) + .coerceIn(0f, 1f) + scaleX = scaleFraction + scaleY = scaleFraction + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshState.kt b/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshState.kt new file mode 100644 index 00000000..aeab7bbe --- /dev/null +++ b/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshState.kt @@ -0,0 +1,228 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.material3.pullrefresh + +import androidx.compose.animation.core.animate +import androidx.compose.foundation.MutatorMutex +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.pow + +/** + * Creates a [PullRefreshState] that is remembered across compositions. + * + * Changes to [refreshing] will result in [PullRefreshState] being updated. + * + * @sample androidx.compose.material.samples.PullRefreshSample + * + * @param refreshing A boolean representing whether a refresh is currently occurring. + * @param onRefresh The function to be called to trigger a refresh. + * @param refreshThreshold The threshold below which, if a release + * occurs, [onRefresh] will be called. + * @param refreshingOffset The offset at which the indicator will be drawn while refreshing. This + * offset corresponds to the position of the bottom of the indicator. + */ +@Composable +fun rememberPullRefreshState( + refreshing: Boolean, + onRefresh: () -> Unit, + refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold, + refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset, +): PullRefreshState { + require(refreshThreshold > 0.dp) { "The refresh trigger must be greater than zero!" } + + val scope = rememberCoroutineScope() + val onRefreshState = rememberUpdatedState(onRefresh) + val thresholdPx: Float + val refreshingOffsetPx: Float + + with(LocalDensity.current) { + thresholdPx = refreshThreshold.toPx() + refreshingOffsetPx = refreshingOffset.toPx() + } + + val state = remember(scope) { + PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx) + } + + SideEffect { + state.setRefreshing(refreshing) + state.setThreshold(thresholdPx) + state.setRefreshingOffset(refreshingOffsetPx) + } + + return state +} + +/** + * A state object that can be used in conjunction with [pullRefresh] to add pull-to-refresh + * behaviour to a scroll component. Based on Android's SwipeRefreshLayout. + * + * Provides [progress], a float representing how far the user has pulled as a percentage of the + * refreshThreshold. Values of one or less indicate that the user has not yet pulled past the + * threshold. Values greater than one indicate how far past the threshold the user has pulled. + * + * Can be used in conjunction with [pullRefreshIndicatorTransform] to implement Android-like + * pull-to-refresh behaviour with a custom indicator. + * + * Should be created using [rememberPullRefreshState]. + */ +class PullRefreshState internal constructor( + private val animationScope: CoroutineScope, + private val onRefreshState: State<() -> Unit>, + refreshingOffset: Float, + threshold: Float, +) { + /** + * A float representing how far the user has pulled as a percentage of the refreshThreshold. + * + * If the component has not been pulled at all, progress is zero. If the pull has reached + * halfway to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has + * gone beyond the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to + * two times the refreshThreshold. + */ + val progress get() = adjustedDistancePulled / threshold + + internal val refreshing get() = _refreshing + internal val position get() = _position + internal val threshold get() = _threshold + + private val adjustedDistancePulled by derivedStateOf { distancePulled * DragMultiplier } + + private var _refreshing by mutableStateOf(false) + private var _position by mutableStateOf(0f) + private var distancePulled by mutableStateOf(0f) + private var _threshold by mutableStateOf(threshold) + private var _refreshingOffset by mutableStateOf(refreshingOffset) + + internal fun onPull(pullDelta: Float): Float { + if (_refreshing) return 0f // Already refreshing, do nothing. + + val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f) + val dragConsumed = newOffset - distancePulled + distancePulled = newOffset + _position = calculateIndicatorPosition() + return dragConsumed + } + + internal fun onRelease(velocity: Float): Float { + if (refreshing) return 0f // Already refreshing, do nothing + + if (adjustedDistancePulled > threshold) { + onRefreshState.value() + } + animateIndicatorTo(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 + } + + internal fun setRefreshing(refreshing: Boolean) { + if (_refreshing != refreshing) { + _refreshing = refreshing + distancePulled = 0f + animateIndicatorTo(if (refreshing) _refreshingOffset else 0f) + } + } + + internal fun setThreshold(threshold: Float) { + _threshold = threshold + } + + internal fun setRefreshingOffset(refreshingOffset: Float) { + if (_refreshingOffset != refreshingOffset) { + _refreshingOffset = refreshingOffset + if (refreshing) animateIndicatorTo(refreshingOffset) + } + } + + // Make sure to cancel any existing animations when we launch a new one. We use this instead of + // Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra + // overhead of running through the animation pipeline instead of directly mutating the state. + private val mutatorMutex = MutatorMutex() + + private fun animateIndicatorTo(offset: Float) = animationScope.launch { + mutatorMutex.mutate { + animate(initialValue = _position, targetValue = offset) { value, _ -> + _position = value + } + } + } + + private fun calculateIndicatorPosition(): Float = when { + // If drag hasn't gone past the threshold, the position is the adjustedDistancePulled. + adjustedDistancePulled <= threshold -> 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 = threshold * tensionPercent + threshold + extraOffset + } + } +} + +/** + * Default parameter values for [rememberPullRefreshState]. + */ +object PullRefreshDefaults { + /** + * If the indicator is below this threshold offset when it is released, a refresh + * will be triggered. + */ + val RefreshThreshold = 80.dp + + /** + * The offset at which the indicator should be rendered whilst a refresh is occurring. + */ + val RefreshingOffset = 56.dp +} + +/** + * The distance pulled is multiplied by this value to give us the adjusted distance pulled, which + * is used in calculating the indicator position (when the adjusted distance pulled is less than + * the refresh threshold, it is the indicator position, otherwise the indicator position is + * derived from the progress). + */ +private const val DragMultiplier = 0.5f