commit 7cdfa78364bc68dd456d2a657bea56c224b16c12 parent a8e74c363c73ec9480fd7d7281c774eb18d9fb94 Author: rhunk <101876869+rhunk@users.noreply.github.com> Date: Thu, 5 Oct 2023 22:13:31 +0200 feat(manager/scripts): pull refresh Diffstat:
8 files changed, 717 insertions(+), 20 deletions(-)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt @@ -52,11 +52,15 @@ class RemoteScriptManager( if (getModuleDataFolder(name) == null) { context.log.warn("Module data folder not found for $name") } - val content = getScriptContent(name) ?: return@forEach - runtime.load(name, content) + loadScript(name) } } + fun loadScript(name: String) { + val content = getScriptContent(name) ?: return + runtime.load(name, content) + } + fun getScriptInterface(scriptName: String, interfaceName: String) = userInterfaces[scriptName]?.get(interfaceName) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/InterfaceManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/InterfaceManager.kt @@ -71,7 +71,7 @@ class InterfaceManager( ) { @JSFunction fun create(name: String, callback: Function) { - logger.info("Creating interface for ${moduleInfo.name}") + logger.info("Creating interface $name for ${moduleInfo.name}") val interfaceBuilder = InterfaceBuilder() callback.call(Context.getCurrentContext(), callback, callback, arrayOf(interfaceBuilder)) registerInterface(name, interfaceBuilder) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/scripting/ScriptsSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/scripting/ScriptsSection.kt @@ -12,11 +12,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import me.rhunk.snapenhance.scripting.impl.ui.components.Node import me.rhunk.snapenhance.scripting.impl.ui.components.NodeType import me.rhunk.snapenhance.scripting.type.ModuleInfo import me.rhunk.snapenhance.ui.manager.Section +import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator +import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh +import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState import kotlin.math.abs class ScriptsSection : Section() { @@ -70,12 +74,14 @@ class ScriptsSection : Section() { checked = enabled, onCheckedChange = { context.modDatabase.setScriptEnabled(script.name, it) + if (it) { + context.scriptManager.loadScript(script.name) + } enabled = it } ) } - if (openSettings) { ScriptSettings(script) } @@ -100,7 +106,11 @@ class ScriptsSection : Section() { val rowColumnModifier = Modifier .then(if (cachedAttributes["fillMaxWidth"] as? Boolean == true) Modifier.fillMaxWidth() else Modifier) .then(if (cachedAttributes["fillMaxHeight"] as? Boolean == true) Modifier.fillMaxHeight() else Modifier) - .padding((cachedAttributes["padding"]?.toString()?.toInt()?.let { abs(it) } ?: 2).dp) + .padding( + (cachedAttributes["padding"] + ?.toString() + ?.toInt() + ?.let { abs(it) } ?: 2).dp) fun runCallbackSafe(callback: () -> Unit) { runCatching { @@ -219,25 +229,56 @@ class ScriptsSection : Section() { @Composable override fun Content() { - val scriptModules = remember { - context.modDatabase.getScripts() + var scriptModules by remember { + mutableStateOf(context.modDatabase.getScripts()) + } + val coroutineScope = rememberCoroutineScope() + + var refreshing by remember { + mutableStateOf(false) } - LazyColumn( + val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = { + refreshing = true + runCatching { + context.scriptManager.sync() + scriptModules = context.modDatabase.getScripts() + }.onFailure { + context.log.error("Failed to sync scripts", it) + } + coroutineScope.launch { + delay(300) + refreshing = false + } + }) + + Box( modifier = Modifier.fillMaxSize() ) { - item { - if (scriptModules.isEmpty()) { - Text( - text = "No scripts found", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(8.dp) - ) + LazyColumn( + modifier = Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState), + ) { + item { + if (scriptModules.isEmpty()) { + Text( + text = "No scripts found", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(8.dp).align(Alignment.Center) + ) + } + } + items(scriptModules.size) { index -> + ModuleItem(scriptModules[index]) } } - items(scriptModules.size) { index -> - ModuleItem(scriptModules[index]) - } + + PullRefreshIndicator( + refreshing = refreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) } } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/pullrefresh/PullRefresh.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/pullrefresh/PullRefresh.kt @@ -0,0 +1,120 @@ +/* + * 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 me.rhunk.snapenhance.ui.util.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/me/rhunk/snapenhance/ui/util/pullrefresh/PullRefreshIndicator.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/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 me.rhunk.snapenhance.ui.util.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. + */ +// 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. +@Composable +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<Float>(300, easing = LinearEasing) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/pullrefresh/PullRefreshIndicatorTransform.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/pullrefresh/PullRefreshIndicatorTransform.kt @@ -0,0 +1,75 @@ +/* + * 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 me.rhunk.snapenhance.ui.util.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 + } + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/pullrefresh/PullRefreshState.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/pullrefresh/PullRefreshState.kt @@ -0,0 +1,219 @@ +/* + * 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 me.rhunk.snapenhance.ui.util.pullrefresh + +import androidx.compose.animation.core.animate +import androidx.compose.foundation.MutatorMutex +import androidx.compose.runtime.* +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 mutableFloatStateOf(0f) + private var distancePulled by mutableFloatStateOf(0f) + private var _threshold by mutableFloatStateOf(threshold) + private var _refreshingOffset by mutableFloatStateOf(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 diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/UnlimitedSnapViewTime.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/UnlimitedSnapViewTime.kt @@ -9,8 +9,8 @@ import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams class UnlimitedSnapViewTime : - Feature("UnlimitedSnapViewTime", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { + Feature("UnlimitedSnapViewTime", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + override fun onActivityCreate() { val state by context.config.messaging.unlimitedSnapViewTime context.event.subscribe(BuildMessageEvent::class, { state }, priority = 101) { event ->