TASK-069: tabs stay under a screen opened from them, and its content waits for the push
Pressing a card under 활동 중인 저장소 hitched: the file list was built in one long frame while the screen was still pushing in, and Back began with a frame of about 120 ms because the four tab screens were built again. PushedOver keeps the tab pager composed under a pushed screen, moved off to the left with the push; heldWhilePushedIn shows what loaded during the push once the screen stands still. Agent: main (opus) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4dab3a4ba4
commit
cd8b1661fa
@ -17,14 +17,6 @@ import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@ -48,7 +40,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
|
||||
@ -95,8 +86,8 @@ import com.ubisam.ubgitea.feature.workitems.WorkItemsScreen
|
||||
import com.ubisam.ubgitea.feature.workitems.WorkItemsTarget
|
||||
import com.ubisam.ubgitea.feature.workitems.WorkItemsViewModel
|
||||
import com.ubisam.ubgitea.ui.components.ExitConfirmDialog
|
||||
import com.ubisam.ubgitea.ui.components.NoResize
|
||||
import com.ubisam.ubgitea.ui.components.ScreenPushMillis
|
||||
import com.ubisam.ubgitea.ui.components.PushedOver
|
||||
import com.ubisam.ubgitea.ui.components.heldWhilePushedIn
|
||||
import com.ubisam.ubgitea.ui.components.SplashOverlay
|
||||
import com.ubisam.ubgitea.ui.components.TabPager
|
||||
import com.ubisam.ubgitea.ui.navigation.Route
|
||||
@ -470,26 +461,6 @@ class MainActivity : ComponentActivity() {
|
||||
shownDepth[0] = stackDepth
|
||||
shownReady[0] = sessionReady
|
||||
}
|
||||
AnimatedContent(
|
||||
targetState = currentRoute.takeIf { sessionReady } ?: Route.Repositories,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
transitionSpec = {
|
||||
val direction = (stackDepth - shownDepth[0]).coerceIn(-1, 1)
|
||||
if (!shownReady[0] || direction == 0) {
|
||||
// The session has just been found: the first screen takes the
|
||||
// waiting one's place where it stands. Nothing was navigated, so
|
||||
// nothing slides.
|
||||
fadeIn(tween(200)).togetherWith(fadeOut(tween(90))).using(NoResize)
|
||||
} else {
|
||||
val push = tween<IntOffset>(ScreenPushMillis, easing = FastOutSlowInEasing)
|
||||
slideInHorizontally(push) { it * direction }
|
||||
.togetherWith(slideOutHorizontally(push) { -it * direction })
|
||||
.using(NoResize)
|
||||
}
|
||||
},
|
||||
contentKey = { if (it.isTab) AppTab::class else it::class },
|
||||
label = "route",
|
||||
) { shown ->
|
||||
val screen: @Composable (Route) -> Unit = remember(isWide, codeViewerPreferences) { { route ->
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
when (route) {
|
||||
@ -612,7 +583,7 @@ class MainActivity : ComponentActivity() {
|
||||
owner = route.owner,
|
||||
name = route.name,
|
||||
ref = route.ref,
|
||||
codeBrowserState = codeBrowserState,
|
||||
codeBrowserState = heldWhilePushedIn(codeBrowserState),
|
||||
codeViewerPreferences = codeViewerPreferences,
|
||||
onEntrySelected = remember { codeBrowserViewModel::openEntry },
|
||||
onBreadcrumbSelected = remember { codeBrowserViewModel::openBreadcrumb },
|
||||
@ -624,7 +595,7 @@ class MainActivity : ComponentActivity() {
|
||||
onOpenSibling = remember { codeBrowserViewModel::openSibling },
|
||||
onToggleFolder = remember { codeBrowserViewModel::toggleFolder },
|
||||
onLoadCounterpart = remember { codeBrowserViewModel::loadCounterpart },
|
||||
historyState = historyState,
|
||||
historyState = heldWhilePushedIn(historyState),
|
||||
onCommitSelected = remember { historyViewModel::openCommit },
|
||||
onHistoryNextPage = remember { historyViewModel::nextPage },
|
||||
onHistoryPreviousPage = remember { historyViewModel::previousPage },
|
||||
@ -651,14 +622,18 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
} }
|
||||
val tabScreen: @Composable (AppTab) -> Unit = remember(screen) { { screen(it.route) } }
|
||||
// Tabs sit side by side: going from one to another passes the ones between.
|
||||
val shownTab = shown.tab
|
||||
if (shownTab != null && sessionReady) {
|
||||
TabPager(AppTab.entries, shownTab, content = tabScreen)
|
||||
} else {
|
||||
screen(shown)
|
||||
}
|
||||
}
|
||||
val target = currentRoute.takeIf { sessionReady } ?: Route.Repositories
|
||||
val tabBelow = routeStack.lastOrNull { it.isTab }?.tab ?: AppTab.HOME
|
||||
PushedOver(
|
||||
above = target.takeUnless { sessionReady && it.isTab },
|
||||
aboveKey = { it::class },
|
||||
// The session has just been found: the first screen takes the waiting
|
||||
// one's place where it stands. Nothing was navigated, so nothing slides.
|
||||
direction = { if (shownReady[0]) (stackDepth - shownDepth[0]).coerceIn(-1, 1) else 0 },
|
||||
// Tabs sit side by side: going from one to another passes the ones between.
|
||||
below = if (sessionReady) ({ TabPager(AppTab.entries, tabBelow, content = tabScreen) }) else null,
|
||||
content = screen,
|
||||
)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(viewModel) {
|
||||
|
||||
@ -17,6 +17,9 @@ import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
@ -70,6 +73,7 @@ import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
@ -382,13 +386,7 @@ fun <T> TabPager(tabs: List<T>, current: T, modifier: Modifier = Modifier, conte
|
||||
// Every tab stays composed, so a glide never waits for a screen to be built. Only the tab
|
||||
// being shown may answer Back: the others are handed a dispatcher nothing presses.
|
||||
val backOwner = LocalOnBackPressedDispatcherOwner.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val noBack = remember(lifecycleOwner) {
|
||||
object : OnBackPressedDispatcherOwner {
|
||||
override val lifecycle get() = lifecycleOwner.lifecycle
|
||||
override val onBackPressedDispatcher = OnBackPressedDispatcher()
|
||||
}
|
||||
}
|
||||
val noBack = rememberNoBackOwner()
|
||||
// What a page reads about the tab shown, it reads from state: a tab press then recomposes the
|
||||
// two small scopes below and not the screens inside them.
|
||||
val shown = rememberUpdatedState(current)
|
||||
@ -413,9 +411,102 @@ fun <T> TabPager(tabs: List<T>, current: T, modifier: Modifier = Modifier, conte
|
||||
|
||||
const val TabGlideStepMillis = 50
|
||||
|
||||
/** A Back dispatcher nothing presses, for screens that are composed but not the one shown. */
|
||||
@Composable
|
||||
private fun rememberNoBackOwner(): OnBackPressedDispatcherOwner {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
return remember(lifecycleOwner) {
|
||||
object : OnBackPressedDispatcherOwner {
|
||||
override val lifecycle get() = lifecycleOwner.lifecycle
|
||||
override val onBackPressedDispatcher = OnBackPressedDispatcher()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [below], and [above] it whatever was opened from it. A screen opened pushes in from the right
|
||||
* as the one behind leaves to the left, and Back reverses that. [direction] says which, when
|
||||
* [above] changes: 1 opened, -1 gone back, 0 neither, which fades in place.
|
||||
*
|
||||
* [below] stays composed while it is covered, off to the left, so Back slides back to it as it
|
||||
* was left and does not wait for it to be built. Covered, it answers no Back and nothing in it
|
||||
* loops.
|
||||
*/
|
||||
@Composable
|
||||
fun <T : Any> PushedOver(
|
||||
above: T?,
|
||||
aboveKey: (T) -> Any,
|
||||
direction: () -> Int,
|
||||
below: (@Composable () -> Unit)?,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
val covered = above != null
|
||||
val away by updateTransition(covered, label = "below").animateFloat(
|
||||
transitionSpec = { if (direction() == 0) snap() else tween(ScreenPushMillis, easing = FastOutSlowInEasing) },
|
||||
label = "away",
|
||||
) { if (it) 1f else 0f }
|
||||
if (below != null) {
|
||||
val onScreen by remember { derivedStateOf { away < 1f } }
|
||||
val backOwner = LocalOnBackPressedDispatcherOwner.current
|
||||
val noBack = rememberNoBackOwner()
|
||||
// It takes the place of what waited for the session: it fades in where it stands.
|
||||
val appear = remember { Animatable(0f) }
|
||||
LaunchedEffect(Unit) { appear.animateTo(1f, tween(200)) }
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
translationX = -size.width * away
|
||||
alpha = appear.value
|
||||
}
|
||||
.then(if (covered) Modifier.clearAndSetSemantics { } else Modifier),
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalOnBackPressedDispatcherOwner provides (if (covered || backOwner == null) noBack else backOwner),
|
||||
LocalOnScreen provides onScreen,
|
||||
) { below() }
|
||||
}
|
||||
}
|
||||
AnimatedContent(
|
||||
targetState = above,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
transitionSpec = {
|
||||
val towards = direction()
|
||||
if (towards == 0) {
|
||||
fadeIn(tween(200)).togetherWith(fadeOut(tween(90))).using(NoResize)
|
||||
} else {
|
||||
val push = tween<IntOffset>(ScreenPushMillis, easing = FastOutSlowInEasing)
|
||||
slideInHorizontally(push) { it * towards }
|
||||
.togetherWith(slideOutHorizontally(push) { -it * towards })
|
||||
.using(NoResize)
|
||||
}
|
||||
},
|
||||
contentKey = { it?.let(aboveKey) },
|
||||
label = "route",
|
||||
) { shown -> if (shown != null) content(shown) }
|
||||
}
|
||||
}
|
||||
|
||||
/** How long a screen takes to push in over the one it was opened from, or to leave on Back. */
|
||||
const val ScreenPushMillis = 300
|
||||
|
||||
/**
|
||||
* [value] as it stood when this screen began to push in, until it has arrived. What loads
|
||||
* meanwhile is shown once the screen stands still, so building it does not hold up the push.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> heldWhilePushedIn(value: T): T {
|
||||
val first = remember { value }
|
||||
var arrived by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
// A little past the push: its first frame is the longest, and the push starts after it.
|
||||
delay(ScreenPushMillis + 80L)
|
||||
arrived = true
|
||||
}
|
||||
return if (arrived) value else first
|
||||
}
|
||||
|
||||
const val SplashLongestWaitMillis = 1500L
|
||||
|
||||
private val SplashGradient = listOf(Color(0xFF4DB0FF), Color(0xFF2F98F9), Color(0xFF0A6FD8))
|
||||
|
||||
@ -1726,3 +1726,23 @@ This is the append-only implementation and handoff record shared by Codex and Cl
|
||||
- Measured after: the same capture shows the chips in place during the slide; the repository
|
||||
tab change starts with 35–53 ms and has no frame over 25 ms after it.
|
||||
- Checks and results: the five required checks pass.
|
||||
|
||||
## 2026-10-12 (continued) — TASK-069: opening from 활동 중인 저장소, and Back from it
|
||||
|
||||
- Request: the product owner reported a hitch on pressing a card under 활동 중인 저장소.
|
||||
- Measured on their device over adb, before: opening had a frame of 79–95 ms about 220 ms into
|
||||
the 300 ms push, when the file list arrived and its twelve rows were built at once; Back
|
||||
began with a frame of 119–123 ms. The screen transition dropped the tab pager when a screen
|
||||
covered it, so Back built all four tab screens again in its first frame.
|
||||
- What changed: `PushedOver` in `Motion.kt` takes the transition's place in `MainActivity`. The
|
||||
tab pager stays composed under a screen opened from it, moved off to the left with the same
|
||||
curve as the push; covered, it answers no Back, is hidden from accessibility and nothing in
|
||||
it loops. `heldWhilePushedIn` gives the branch screen its code and history state as they stood
|
||||
when it began to push in until it has arrived.
|
||||
- Measured after: opening has no slow frame between the first (52–72 ms, building the screen)
|
||||
and the end of the push, and the rows are built in one 80–111 ms frame once it stands still;
|
||||
Back begins with 35–66 ms and has no slow frame until it has stopped.
|
||||
- Checks and results: the five required checks pass.
|
||||
- Caution: the first frame of opening is not shorter; it delays the push's start and does not
|
||||
interrupt it.
|
||||
|
||||
|
||||
@ -43,6 +43,11 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c
|
||||
the lines of text in the rows. Each row keeps the branch it was last found to have been
|
||||
pushed to while the list is loaded again, where it went back to "확인 중" and then to the
|
||||
branch, changing the row's height twice.
|
||||
15. Reported by the product owner (2026-10-12): pressing a card under 활동 중인 저장소 hitched.
|
||||
Measured: the file list was built in one long frame while the screen was still pushing in,
|
||||
and Back began with a frame of about 120 ms because the four tab screens, dropped when the
|
||||
screen covered them, were all built again. The tab screens now stay as they were under a
|
||||
screen opened from them, and what loads during a push is shown once the screen stands still.
|
||||
|
||||
## Scope
|
||||
|
||||
@ -68,7 +73,7 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Each of the fourteen items above is in the code and the app builds.
|
||||
- [x] Each of the fifteen items above is in the code and the app builds.
|
||||
- [x] The splash covers the app without delaying it: sign-in, loading and the OAuth callback run
|
||||
underneath, and taps do not pass through the cover.
|
||||
- [x] Moving between code tabs, or between pages of one screen, does not replay the screen
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user