feat: auto-scroll long chat title once on load + on tap (#612)
* feat: auto-scroll long chat title once on load + on tap * fix: resolve state delegate + suspension compile errors in AutoScrollingTitleText * fix: use Int scroll offset for animateScrollTo * fix: measure real container width for title marquee (was always Infinity) * fix: ease the title marquee scroll (FastOutSlowInEasing + glided return) * fix: address agy review — cancel overlapping scrolls + one-shot on-load guard + clickable on box * fix(ui): smooth out top app bar collapse transition Use dynamic layout padding based on scroll offset to prevent blank gap at the top of content when app bar collapses. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
228431d57a
commit
3030e431f2
3 files changed
+199
-4
No files matched your search
@@ -133,6 +133,7 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus
|
|||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.notification.NotificationHelper
|
import com.m57.hermescontrol.notification.NotificationHelper
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
|
import com.m57.hermescontrol.ui.common.AutoScrollingTitleText
|
||||||
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
|
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
|
||||||
import com.m57.hermescontrol.ui.common.EmptyState
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
@@ -346,14 +347,13 @@ fun ChatScreen(
|
|||||||
pinTopBar = true,
|
pinTopBar = true,
|
||||||
title = {
|
title = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
AutoScrollingTitleText(
|
||||||
text = state.chatTitle,
|
text = state.chatTitle,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography.titleMedium.copy(
|
MaterialTheme.typography.titleMedium.copy(
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
),
|
),
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
)
|
||||||
// Connection status dot — red when offline, hidden when connected
|
// Connection status dot — red when offline, hidden when connected
|
||||||
if (!state.isConnected) {
|
if (!state.isConnected) {
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package com.m57.hermescontrol.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single-line title [Text] that, when its content is wider than the available
|
||||||
|
* space, performs a one-shot horizontal auto-scroll (marquee) so the user can
|
||||||
|
* read the whole string.
|
||||||
|
*
|
||||||
|
* The scroll fires automatically once when the title first lays out and
|
||||||
|
* overflows, and again every time the title is tapped. If the text fits within
|
||||||
|
* the available width there is no animation — it simply renders statically.
|
||||||
|
*
|
||||||
|
* @param text The title string.
|
||||||
|
* @param onClick Optional callback fired on tap in addition to re-triggering the
|
||||||
|
* scroll (e.g. focus the input, open details).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AutoScrollingTitleText(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
color: Color = Color.Unspecified,
|
||||||
|
fontSize: TextUnit = TextUnit.Unspecified,
|
||||||
|
fontStyle: FontStyle? = null,
|
||||||
|
fontWeight: FontWeight? = null,
|
||||||
|
fontFamily: FontFamily? = null,
|
||||||
|
letterSpacing: TextUnit = TextUnit.Unspecified,
|
||||||
|
textDecoration: TextDecoration? = null,
|
||||||
|
textAlign: TextAlign? = null,
|
||||||
|
lineHeight: TextUnit = TextUnit.Unspecified,
|
||||||
|
style: TextStyle = LocalTextStyle.current,
|
||||||
|
) {
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Real available width (px) of the title slot — captured from the outer
|
||||||
|
// container, NOT from inside horizontalScroll (which would report Infinity).
|
||||||
|
var availableWidthPx by remember { mutableStateOf(0) }
|
||||||
|
var textWidthPx by remember { mutableStateOf(0) }
|
||||||
|
var hasLaidOut by remember { mutableStateOf(false) }
|
||||||
|
// Guards the on-first-load auto-scroll so it fires exactly once.
|
||||||
|
var hasAutoScrolled by remember { mutableStateOf(false) }
|
||||||
|
// Tracks the active scroll animation so rapid taps can't overlap.
|
||||||
|
var scrollJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
|
||||||
|
// True only when the text actually overflows its container.
|
||||||
|
val overflows = availableWidthPx > 0 && textWidthPx > availableWidthPx
|
||||||
|
|
||||||
|
fun triggerScroll() {
|
||||||
|
if (!overflows) return
|
||||||
|
val maxScroll = textWidthPx - availableWidthPx
|
||||||
|
// Eased reveal + eased return so the motion glides instead of snapping.
|
||||||
|
val revealSpec = tween<Float>(durationMillis = 2600, easing = FastOutSlowInEasing)
|
||||||
|
val returnSpec = tween<Float>(durationMillis = 700, easing = FastOutSlowInEasing)
|
||||||
|
// Cancel any in-flight animation so taps can't stack on the same state.
|
||||||
|
scrollJob?.cancel()
|
||||||
|
scrollJob =
|
||||||
|
coroutineScope.launch {
|
||||||
|
// Restart from the beginning so each trigger reads left-to-right.
|
||||||
|
scrollState.scrollTo(0)
|
||||||
|
delay(350)
|
||||||
|
scrollState.animateScrollTo(maxScroll, animationSpec = revealSpec)
|
||||||
|
delay(900)
|
||||||
|
// Glide back to the start so the title rests at its beginning.
|
||||||
|
scrollState.animateScrollTo(0, animationSpec = returnSpec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset position whenever the title text itself changes (e.g. session rename),
|
||||||
|
// so a fresh title starts clean and can re-measure.
|
||||||
|
LaunchedEffect(text) {
|
||||||
|
scrollState.scrollTo(0)
|
||||||
|
hasAutoScrolled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scroll once when the title first lays out and overflows.
|
||||||
|
LaunchedEffect(hasLaidOut, overflows) {
|
||||||
|
if (hasLaidOut && overflows && !hasAutoScrolled) {
|
||||||
|
hasAutoScrolled = true
|
||||||
|
delay(600)
|
||||||
|
triggerScroll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clipToBounds()
|
||||||
|
.onSizeChanged { availableWidthPx = it.width }
|
||||||
|
.clickable {
|
||||||
|
triggerScroll()
|
||||||
|
onClick?.invoke()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.horizontalScroll(scrollState, enabled = true)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
color = color,
|
||||||
|
fontSize = fontSize,
|
||||||
|
fontStyle = fontStyle,
|
||||||
|
fontWeight = fontWeight,
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
letterSpacing = letterSpacing,
|
||||||
|
textDecoration = textDecoration,
|
||||||
|
textAlign = textAlign,
|
||||||
|
lineHeight = lineHeight,
|
||||||
|
overflow = TextOverflow.Visible,
|
||||||
|
softWrap = false,
|
||||||
|
maxLines = 1,
|
||||||
|
style = style,
|
||||||
|
onTextLayout = { result: TextLayoutResult ->
|
||||||
|
textWidthPx = result.size.width
|
||||||
|
hasLaidOut = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package com.m57.hermescontrol.ui.common
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.calculateEndPadding
|
||||||
|
import androidx.compose.foundation.layout.calculateStartPadding
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -16,13 +18,18 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/** Sealed interface for navigation icon types — eliminates boolean-flag anti-pattern. */
|
/** Sealed interface for navigation icon types — eliminates boolean-flag anti-pattern. */
|
||||||
sealed interface NavIcon {
|
sealed interface NavIcon {
|
||||||
@@ -125,12 +132,17 @@ fun HermesScaffold(
|
|||||||
},
|
},
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
val refreshContent: @Composable () -> Unit = {
|
val refreshContent: @Composable () -> Unit = {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues),
|
.padding(
|
||||||
|
start = paddingValues.calculateStartPadding(layoutDirection),
|
||||||
|
end = paddingValues.calculateEndPadding(layoutDirection),
|
||||||
|
bottom = paddingValues.calculateBottomPadding(),
|
||||||
|
).dynamicTopBarPadding(scrollBehavior, paddingValues.calculateTopPadding()),
|
||||||
) {
|
) {
|
||||||
content(paddingValues)
|
content(paddingValues)
|
||||||
}
|
}
|
||||||
@@ -149,3 +161,26 @@ fun HermesScaffold(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
private fun Modifier.dynamicTopBarPadding(
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
|
baseTopPadding: Dp,
|
||||||
|
): Modifier =
|
||||||
|
this.layout { measurable, constraints ->
|
||||||
|
val baseTopPaddingPx = baseTopPadding.roundToPx()
|
||||||
|
val heightOffset = scrollBehavior.state.heightOffset.roundToInt()
|
||||||
|
val activeTopPadding = (baseTopPaddingPx + heightOffset).coerceAtLeast(0)
|
||||||
|
|
||||||
|
val placeable =
|
||||||
|
measurable.measure(
|
||||||
|
constraints.copy(
|
||||||
|
maxHeight = (constraints.maxHeight - activeTopPadding).coerceAtLeast(0),
|
||||||
|
minHeight = (constraints.minHeight - activeTopPadding).coerceAtLeast(0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
layout(placeable.width, placeable.height + activeTopPadding) {
|
||||||
|
placeable.placeRelative(0, activeTopPadding)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user