Skip to content

Commit

Permalink
Fix LargeTopAppBar scroll behavior
Browse files Browse the repository at this point in the history
  • Loading branch information
Taewan-P committed May 29, 2024
1 parent 5e90215 commit 0328518
Show file tree
Hide file tree
Showing 3 changed files with 173 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
Expand Down Expand Up @@ -62,6 +62,7 @@ import dev.chungjungsoo.gptmobile.data.model.ApiType
import dev.chungjungsoo.gptmobile.presentation.common.PlatformCheckBoxItem
import dev.chungjungsoo.gptmobile.util.collectManagedState
import dev.chungjungsoo.gptmobile.util.getPlatformTitleResources
import dev.chungjungsoo.gptmobile.util.pinnedExitUntilCollapsedScrollBehavior

@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
Expand All @@ -72,8 +73,10 @@ fun HomeScreen(
navigateToNewChat: () -> Unit
) {
val platformTitles = getPlatformTitleResources()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val listState = rememberLazyListState()
val scrollBehavior = pinnedExitUntilCollapsedScrollBehavior(
canScroll = { listState.canScrollForward || listState.canScrollBackward }
)
val chatList by homeViewModel.chatList.collectManagedState()
val showSelectModelDialog by homeViewModel.showSelectModelDialog.collectManagedState()
val platformState by homeViewModel.platformState.collectManagedState()
Expand All @@ -97,7 +100,6 @@ fun HomeScreen(
modifier = Modifier.padding(innerPadding),
state = listState
) {
item { ChatsTitle() }
items(chatList, key = { it.id }) { chatRoom ->
val usingPlatform = chatRoom.enabledPlatform.joinToString(", ") { platformTitles[it] ?: "" }
ListItem(
Expand Down Expand Up @@ -137,7 +139,7 @@ fun HomeTopAppBar(
scrollBehavior: TopAppBarScrollBehavior,
actionOnClick: () -> Unit
) {
TopAppBar(
LargeTopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
titleContentColor = MaterialTheme.colorScheme.onBackground
Expand All @@ -146,7 +148,6 @@ fun HomeTopAppBar(
Text(
modifier = Modifier.padding(4.dp),
text = stringResource(R.string.chats),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = scrollBehavior.state.overlappedFraction),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Expand Down Expand Up @@ -181,17 +182,6 @@ private fun LazyListState.isScrollingUp(): Boolean {
}.value
}

@Composable
private fun ChatsTitle() {
Text(
modifier = Modifier
.padding(top = 32.dp)
.padding(horizontal = 20.dp, vertical = 16.dp),
text = stringResource(R.string.chats),
style = MaterialTheme.typography.headlineLarge
)
}

@Preview
@Composable
fun NewChatButton(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
Expand All @@ -43,6 +43,7 @@ import dev.chungjungsoo.gptmobile.util.ThemePreference
import dev.chungjungsoo.gptmobile.util.collectManagedState
import dev.chungjungsoo.gptmobile.util.getDynamicThemeTitle
import dev.chungjungsoo.gptmobile.util.getThemeModeTitle
import dev.chungjungsoo.gptmobile.util.pinnedExitUntilCollapsedScrollBehavior

@OptIn(ExperimentalMaterial3Api::class)
@Composable
Expand All @@ -51,7 +52,10 @@ fun SettingScreen(
settingViewModel: SettingViewModel = hiltViewModel(),
navigationOnClick: () -> Unit
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val scrollState = rememberScrollState()
val scrollBehavior = pinnedExitUntilCollapsedScrollBehavior(
canScroll = { scrollState.canScrollForward || scrollState.canScrollBackward }
)
val isThemeDialogOpen by settingViewModel.isThemeDialogOpen.collectManagedState()

Scaffold(
Expand All @@ -67,9 +71,8 @@ fun SettingScreen(
Column(
Modifier
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.verticalScroll(scrollState)
) {
SettingTitle()
ThemeSetting { settingViewModel.openThemeDialog() }
SettingItem(title = "OpenAI Settings", "API Key, Model", {}, true)
SettingItem(title = "Anthropic Settings", "API Key, Model", {}, true)
Expand All @@ -88,7 +91,7 @@ private fun SettingTopBar(
scrollBehavior: TopAppBarScrollBehavior,
navigationOnClick: () -> Unit
) {
TopAppBar(
LargeTopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
titleContentColor = MaterialTheme.colorScheme.onBackground
Expand All @@ -97,7 +100,6 @@ private fun SettingTopBar(
Text(
modifier = Modifier.padding(4.dp),
text = stringResource(R.string.settings),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = scrollBehavior.state.overlappedFraction),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package dev.chungjungsoo.gptmobile.util

import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDecay
import androidx.compose.animation.core.animateTo
import androidx.compose.animation.core.spring
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.TopAppBarState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.unit.Velocity
import kotlin.math.abs

/**
* Special thanks to @BenjyTec: https://stackoverflow.com/a/78538564/8606428
*/
@ExperimentalMaterial3Api
@Composable
fun pinnedExitUntilCollapsedScrollBehavior(
state: TopAppBarState = rememberTopAppBarState(),
canScroll: () -> Boolean = { true },
snapAnimationSpec: AnimationSpec<Float>? = spring(stiffness = Spring.StiffnessMediumLow),
flingAnimationSpec: DecayAnimationSpec<Float>? = rememberSplineBasedDecay()
): TopAppBarScrollBehavior =
PinnedExitUntilCollapsedScrollBehavior(
state = state,
snapAnimationSpec = snapAnimationSpec,
flingAnimationSpec = flingAnimationSpec,
canScroll = canScroll
)

@OptIn(ExperimentalMaterial3Api::class)
private class PinnedExitUntilCollapsedScrollBehavior(
override val state: TopAppBarState,
override val snapAnimationSpec: AnimationSpec<Float>?,
override val flingAnimationSpec: DecayAnimationSpec<Float>?,
val canScroll: () -> Boolean = { true }
) : TopAppBarScrollBehavior {
override val isPinned: Boolean = true
override var nestedScrollConnection =
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// Don't intercept if scrolling down.
if (!canScroll() || available.y > 0f) return Offset.Zero

val prevHeightOffset = state.heightOffset
state.heightOffset = state.heightOffset + available.y
return if (prevHeightOffset != state.heightOffset) {
// We're in the middle of top app bar collapse or expand.
// Consume only the scroll on the Y axis.
available.copy(x = 0f)
} else {
Offset.Zero
}
}

override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
if (!canScroll()) return Offset.Zero
state.contentOffset += consumed.y

if (available.y < 0f || consumed.y < 0f) {
// When scrolling up, just update the state's height offset.
val oldHeightOffset = state.heightOffset
state.heightOffset = state.heightOffset + consumed.y
return Offset(0f, state.heightOffset - oldHeightOffset)
}

if (consumed.y == 0f && available.y > 0) {
// Reset the total content offset to zero when scrolling all the way down. This
// will eliminate some float precision inaccuracies.
state.contentOffset = 0f
}

if (available.y > 0f) {
// Adjust the height offset in case the consumed delta Y is less than what was
// recorded as available delta Y in the pre-scroll.
val oldHeightOffset = state.heightOffset
state.heightOffset = state.heightOffset + available.y
return Offset(0f, state.heightOffset - oldHeightOffset)
}
return Offset.Zero
}

override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
val superConsumed = super.onPostFling(consumed, available)
return superConsumed + settleAppBar(
state,
available.y,
flingAnimationSpec,
snapAnimationSpec
)
}
}
}

@OptIn(ExperimentalMaterial3Api::class)
private suspend fun settleAppBar(
state: TopAppBarState,
velocity: Float,
flingAnimationSpec: DecayAnimationSpec<Float>?,
snapAnimationSpec: AnimationSpec<Float>?
): Velocity {
// Check if the app bar is completely collapsed/expanded. If so, no need to settle the app bar,
// and just return Zero Velocity.
// Note that we don't check for 0f due to float precision with the collapsedFraction
// calculation.
if (state.collapsedFraction < 0.01f || state.collapsedFraction == 1f) {
return Velocity.Zero
}
var remainingVelocity = velocity
// In case there is an initial velocity that was left after a previous user fling, animate to
// continue the motion to expand or collapse the app bar.
if (flingAnimationSpec != null && abs(velocity) > 1f) {
var lastValue = 0f
AnimationState(
initialValue = 0f,
initialVelocity = velocity
)
.animateDecay(flingAnimationSpec) {
val delta = value - lastValue
val initialHeightOffset = state.heightOffset
state.heightOffset = initialHeightOffset + delta
val consumed = abs(initialHeightOffset - state.heightOffset)
lastValue = value
remainingVelocity = this.velocity
// avoid rounding errors and stop if anything is unconsumed
if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
}
}
// Snap if animation specs were provided.
if (snapAnimationSpec != null) {
if (state.heightOffset < 0 &&
state.heightOffset > state.heightOffsetLimit
) {
AnimationState(initialValue = state.heightOffset).animateTo(
if (state.collapsedFraction < 0.5f) {
0f
} else {
state.heightOffsetLimit
},
animationSpec = snapAnimationSpec
) { state.heightOffset = value }
}
}

return Velocity(0f, remainingVelocity)
}

0 comments on commit 0328518

Please sign in to comment.