mirror of
https://github.com/msfjarvis/compose-lobsters
synced 2025-08-17 09:47:03 +05:30
Merge remote-tracking branches 'origin/newest-posts' and 'origin/vendor-pulltorefresh' into develop
* origin/newest-posts: app: update screenshot tests app: add missing ui-tooling dependency app: add newest posts screen app: add support for fetching newest posts app: start qualifying infra for hottest posts app: rename HottestPosts to NetworkPosts api: add support for fetching newest posts * origin/vendor-pulltorefresh: app: add missing ui-tooling dependency app: vendor PullToRefresh for Compose beta03 ABI compatibility
This commit is contained in:
commit
0e11815206
3 changed files with 343 additions and 1 deletions
|
@ -46,7 +46,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"))
|
||||
|
|
214
app/src/main/java/com/puculek/pulltorefresh/PullToRefresh.kt
Normal file
214
app/src/main/java/com/puculek/pulltorefresh/PullToRefresh.kt
Normal file
|
@ -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
|
||||
}
|
|
@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue