diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4cc22ace..79e537fb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -45,7 +45,6 @@ dependencies { implementation(Dependencies.ThirdParty.accompanistCoil) implementation(Dependencies.ThirdParty.accompanistFlow) implementation(Dependencies.ThirdParty.Moshi.lib) - implementation(Dependencies.ThirdParty.pullToRefresh) implementation(Dependencies.ThirdParty.Retrofit.moshi) implementation(Dependencies.ThirdParty.SQLDelight.androidDriver) testImplementation(kotlin("test-junit")) diff --git a/app/src/main/java/com/puculek/pulltorefresh/PullToRefresh.kt b/app/src/main/java/com/puculek/pulltorefresh/PullToRefresh.kt new file mode 100644 index 00000000..aea80edb --- /dev/null +++ b/app/src/main/java/com/puculek/pulltorefresh/PullToRefresh.kt @@ -0,0 +1,214 @@ +package com.puculek.pulltorefresh + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.CombinedModifier +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +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.Velocity +import androidx.compose.ui.unit.dp + + +private const val MAX_OFFSET = 400f +private const val MIN_REFRESH_OFFSET = 250f +private const val PERCENT_INDICATOR_PROGRESS_ON_DRAG = 0.85f +private const val BASE_OFFSET = -48 + + +/** + * A layout composable with [content]. + * + * Example usage: + * @sample com.puculek.pulltorefresh.samples.PullToRefreshWithColumn + * @sample com.puculek.pulltorefresh.samples.PullToRefreshWithLazyColumn + * + * @param modifier The modifier to be applied to the layout. + * @param maxOffset How many pixels can the progress indicator can be dragged down + * @param minRefreshOffset Minimum drag value to trigger [onRefresh] + * @param isRefreshing Flag describing if [PullToRefresh] is refreshing. + * @param progressColor Color of progress drawable. + * @param backgroundColor Background color of progress indicator. + * @param onRefresh Callback to be called if layout is pulled to refresh. + * @param content The content of the [PullToRefresh]. + */ +@Composable +fun PullToRefresh( + modifier: Modifier = Modifier, + maxOffset: Float = MAX_OFFSET, + minRefreshOffset: Float = MIN_REFRESH_OFFSET, + isRefreshing: Boolean, + progressColor: Color = MaterialTheme.colors.primary, + backgroundColor: Color = MaterialTheme.colors.surface, + onRefresh: () -> Unit, + content: @Composable () -> Unit +) { + var indicatorOffset by remember { mutableStateOf(0f) } + + // check if isRefreshing has been changed + var isRefreshingInternal by remember { mutableStateOf(false) } + + // User cancelled dragging before reaching MAX_REFRESH_OFFSET + var isResettingScroll by remember { mutableStateOf(false) } + + // How much should indicator scroll to reset its position + var scrollToReset by remember { mutableStateOf(0f) } + + // Trigger for scaling animation + var isFinishingRefresh by remember { mutableStateOf(false) } + + val scaleAnimation by animateFloatAsState( + targetValue = if (isFinishingRefresh) 0f else 1f, + finishedListener = { + indicatorOffset = 0f + isFinishingRefresh = false + }) + val offsetAnimation by animateFloatAsState( + targetValue = if (isRefreshing || isFinishingRefresh) { + indicatorOffset - minRefreshOffset + } else { + 0f + } + ) + val resettingScrollOffsetAnimation by animateFloatAsState( + targetValue = if (isResettingScroll) { + scrollToReset + } else { + 0f + }, + finishedListener = { + if (isResettingScroll) { + indicatorOffset = 0f + isResettingScroll = false + } + }) + + if (isResettingScroll) { + indicatorOffset -= resettingScrollOffsetAnimation + } + if (!isRefreshing && isRefreshingInternal) { + isFinishingRefresh = true + isRefreshingInternal = false + } + if (isRefreshing && !isRefreshingInternal) { + isRefreshingInternal = true + } + + val nestedScrollConnection = object : NestedScrollConnection { + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + if (!isRefreshing && source == NestedScrollSource.Drag) { + val diff = if (indicatorOffset + available.y > maxOffset) { + available.y - (indicatorOffset + available.y - maxOffset) + } else if (indicatorOffset + available.y < 0) { + 0f + } else { + available.y + } + indicatorOffset += diff + return Offset(0f, diff) + } + return super.onPostScroll(consumed, available, source) + } + + override fun onPreScroll( + available: Offset, + source: NestedScrollSource + ): Offset { + if (!isRefreshing && source == NestedScrollSource.Drag) { + if (available.y < 0 && indicatorOffset > 0) { + val diff = if (indicatorOffset + available.y < 0) { + indicatorOffset = 0f + indicatorOffset + } else { + indicatorOffset += available.y + available.y + } + isFinishingRefresh = false + return Offset.Zero.copy(y = diff) + } + } + return super.onPreScroll(available, source) + } + + override suspend fun onPostFling( + consumed: Velocity, + available: Velocity + ): Velocity { + if (!isRefreshing) { + if (indicatorOffset > minRefreshOffset) { + onRefresh() + isRefreshingInternal = true + } else { + isResettingScroll = true + scrollToReset = indicatorOffset + } + } + return super.onPostFling(consumed, available) + } + } + + Box( + modifier = CombinedModifier( + inner = Modifier + .nestedScroll(nestedScrollConnection) + .clip(RectangleShape), + outer = modifier + ) + ) { + content() + + val offsetPx = if (isRefreshing || isFinishingRefresh) { + offsetAnimation + } else { + 0f + } + val absoluteOffset = BASE_OFFSET.dp + with(LocalDensity.current) { + val diffedOffset = indicatorOffset - offsetPx + val calculated = calculateAbsoluteOffset(diffedOffset, MAX_OFFSET) + calculated.toDp() + } + val progressFromOffset = with(LocalDensity.current) { + val coeff = MAX_OFFSET / (MAX_OFFSET - BASE_OFFSET) + (indicatorOffset - BASE_OFFSET) / maxOffset * coeff + } + PullToRefreshProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .absoluteOffset(y = absoluteOffset) + .scale(scaleAnimation) + .rotate(indicatorOffset / MAX_OFFSET * 180 + 110), + progressColor = progressColor, + backgroundColor = backgroundColor, + progress = when { + !isRefreshing && !isFinishingRefresh -> progressFromOffset * PERCENT_INDICATOR_PROGRESS_ON_DRAG + else -> null + }, + ) + } +} + +private fun calculateAbsoluteOffset(absoluteOffset: Float, maxOffset: Float): Float { + // TODO: Match best function to imitate physics + return absoluteOffset +} diff --git a/app/src/main/java/com/puculek/pulltorefresh/PullToRefreshProgressIndicator.kt b/app/src/main/java/com/puculek/pulltorefresh/PullToRefreshProgressIndicator.kt new file mode 100644 index 00000000..280f2a46 --- /dev/null +++ b/app/src/main/java/com/puculek/pulltorefresh/PullToRefreshProgressIndicator.kt @@ -0,0 +1,129 @@ +package com.puculek.pulltorefresh + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ProgressIndicatorDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + + +private val CircularIndicatorDiameter = 40.dp +private const val strokeWidthPx = 2.5f + + +@Composable +internal fun PullToRefreshProgressIndicator( + modifier: Modifier = Modifier, + progressColor: Color, + backgroundColor: Color, + progress: Float? = null +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.Center + ) { + Card( + modifier = Modifier + .width(CircularIndicatorDiameter) + .height(CircularIndicatorDiameter), + shape = CircleShape, + elevation = 6.dp, + backgroundColor = backgroundColor, + ) { + val padding = Modifier.padding(8.dp) + val strokeWidth = with(LocalDensity.current) { + (strokeWidthPx * this.density).toDp() + } + + if (progress == null) { + CircularProgressIndicator( + modifier = padding, + color = progressColor, + strokeWidth = strokeWidth + ) + } else { + ProgressIndicatorWithArrow( + modifier = padding, + progress = progress, + color = progressColor, + strokeWidth = strokeWidth + ) + } + } + } +} + + +@Composable +fun ProgressIndicatorWithArrow( + progress: Float, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colors.primary, + strokeWidth: Dp = ProgressIndicatorDefaults.StrokeWidth, +) { + + val strokeWidthPx = with(LocalDensity.current) { + strokeWidth.toPx() + } + val arrowWidth = 2.5f * strokeWidthPx * (0.5f + progress * 0.5f) + val stroke = Stroke(width = strokeWidthPx, cap = StrokeCap.Butt) + val diameterOffset = stroke.width / 2 + + val arrowPath = Path().apply { + moveTo(0f, -arrowWidth) + lineTo(arrowWidth, 0f) + lineTo(0f, arrowWidth) + close() + } + Box(modifier = modifier) { + Canvas(modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), onDraw = { + val arcDimen = size.width - 2 * diameterOffset + withTransform({ + translate(top = strokeWidthPx / 2, left = size.width / 2) + rotate( + degrees = progress * 360, + pivot = Offset(x = 0f, y = size.height / 2 - diameterOffset) + ) + }) { + drawPath( + path = arrowPath, + color = color + ) + } + + drawArc( + color = color, + startAngle = -90f, + sweepAngle = 360 * progress, + useCenter = false, + topLeft = Offset(diameterOffset, diameterOffset), + size = Size(arcDimen, arcDimen), + style = stroke + ) + }) + } + +}