From cd8b1661fa49a375aebf9d94bfdd1cd6002841ab Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Mon, 12 Oct 2026 17:49:10 +0900 Subject: [PATCH] TASK-069: tabs stay under a screen opened from them, and its content waits for the push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../java/com/ubisam/ubgitea/MainActivity.kt | 57 +++------- .../ubisam/ubgitea/ui/components/Motion.kt | 105 ++++++++++++++++-- docs/development-log.md | 20 ++++ tasks/069-in-app-motion.md | 7 +- 4 files changed, 140 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index 5cfbd26..6a82231 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -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(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) { diff --git a/app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt b/app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt index 8eedb59..f6d66ee 100644 --- a/app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt +++ b/app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt @@ -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 TabPager(tabs: List, 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 TabPager(tabs: List, 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 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(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 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)) diff --git a/docs/development-log.md b/docs/development-log.md index 50aac18..4c22e90 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -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. + diff --git a/tasks/069-in-app-motion.md b/tasks/069-in-app-motion.md index ce3ad0c..71562b2 100644 --- a/tasks/069-in-app-motion.md +++ b/tasks/069-in-app-motion.md @@ -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