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:
최민영 2026-10-12 17:49:10 +09:00
parent 4dab3a4ba4
commit cd8b1661fa
4 changed files with 140 additions and 49 deletions

View File

@ -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) {

View File

@ -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))

View File

@ -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.

View File

@ -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