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.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.browser.customtabs.CustomTabsIntent
|
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.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@ -48,7 +40,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||||
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
|
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.WorkItemsTarget
|
||||||
import com.ubisam.ubgitea.feature.workitems.WorkItemsViewModel
|
import com.ubisam.ubgitea.feature.workitems.WorkItemsViewModel
|
||||||
import com.ubisam.ubgitea.ui.components.ExitConfirmDialog
|
import com.ubisam.ubgitea.ui.components.ExitConfirmDialog
|
||||||
import com.ubisam.ubgitea.ui.components.NoResize
|
import com.ubisam.ubgitea.ui.components.PushedOver
|
||||||
import com.ubisam.ubgitea.ui.components.ScreenPushMillis
|
import com.ubisam.ubgitea.ui.components.heldWhilePushedIn
|
||||||
import com.ubisam.ubgitea.ui.components.SplashOverlay
|
import com.ubisam.ubgitea.ui.components.SplashOverlay
|
||||||
import com.ubisam.ubgitea.ui.components.TabPager
|
import com.ubisam.ubgitea.ui.components.TabPager
|
||||||
import com.ubisam.ubgitea.ui.navigation.Route
|
import com.ubisam.ubgitea.ui.navigation.Route
|
||||||
@ -470,26 +461,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
shownDepth[0] = stackDepth
|
shownDepth[0] = stackDepth
|
||||||
shownReady[0] = sessionReady
|
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 ->
|
val screen: @Composable (Route) -> Unit = remember(isWide, codeViewerPreferences) { { route ->
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
when (route) {
|
when (route) {
|
||||||
@ -612,7 +583,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
owner = route.owner,
|
owner = route.owner,
|
||||||
name = route.name,
|
name = route.name,
|
||||||
ref = route.ref,
|
ref = route.ref,
|
||||||
codeBrowserState = codeBrowserState,
|
codeBrowserState = heldWhilePushedIn(codeBrowserState),
|
||||||
codeViewerPreferences = codeViewerPreferences,
|
codeViewerPreferences = codeViewerPreferences,
|
||||||
onEntrySelected = remember { codeBrowserViewModel::openEntry },
|
onEntrySelected = remember { codeBrowserViewModel::openEntry },
|
||||||
onBreadcrumbSelected = remember { codeBrowserViewModel::openBreadcrumb },
|
onBreadcrumbSelected = remember { codeBrowserViewModel::openBreadcrumb },
|
||||||
@ -624,7 +595,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
onOpenSibling = remember { codeBrowserViewModel::openSibling },
|
onOpenSibling = remember { codeBrowserViewModel::openSibling },
|
||||||
onToggleFolder = remember { codeBrowserViewModel::toggleFolder },
|
onToggleFolder = remember { codeBrowserViewModel::toggleFolder },
|
||||||
onLoadCounterpart = remember { codeBrowserViewModel::loadCounterpart },
|
onLoadCounterpart = remember { codeBrowserViewModel::loadCounterpart },
|
||||||
historyState = historyState,
|
historyState = heldWhilePushedIn(historyState),
|
||||||
onCommitSelected = remember { historyViewModel::openCommit },
|
onCommitSelected = remember { historyViewModel::openCommit },
|
||||||
onHistoryNextPage = remember { historyViewModel::nextPage },
|
onHistoryNextPage = remember { historyViewModel::nextPage },
|
||||||
onHistoryPreviousPage = remember { historyViewModel::previousPage },
|
onHistoryPreviousPage = remember { historyViewModel::previousPage },
|
||||||
@ -651,14 +622,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
} }
|
} }
|
||||||
val tabScreen: @Composable (AppTab) -> Unit = remember(screen) { { screen(it.route) } }
|
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 target = currentRoute.takeIf { sessionReady } ?: Route.Repositories
|
||||||
val shownTab = shown.tab
|
val tabBelow = routeStack.lastOrNull { it.isTab }?.tab ?: AppTab.HOME
|
||||||
if (shownTab != null && sessionReady) {
|
PushedOver(
|
||||||
TabPager(AppTab.entries, shownTab, content = tabScreen)
|
above = target.takeUnless { sessionReady && it.isTab },
|
||||||
} else {
|
aboveKey = { it::class },
|
||||||
screen(shown)
|
// 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) {
|
LaunchedEffect(viewModel) {
|
||||||
|
|||||||
@ -17,6 +17,9 @@ import androidx.compose.animation.core.rememberInfiniteTransition
|
|||||||
import androidx.compose.animation.core.snap
|
import androidx.compose.animation.core.snap
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.core.tween
|
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.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
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.DrawScope
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.Dp
|
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
|
// 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.
|
// being shown may answer Back: the others are handed a dispatcher nothing presses.
|
||||||
val backOwner = LocalOnBackPressedDispatcherOwner.current
|
val backOwner = LocalOnBackPressedDispatcherOwner.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val noBack = rememberNoBackOwner()
|
||||||
val noBack = remember(lifecycleOwner) {
|
|
||||||
object : OnBackPressedDispatcherOwner {
|
|
||||||
override val lifecycle get() = lifecycleOwner.lifecycle
|
|
||||||
override val onBackPressedDispatcher = OnBackPressedDispatcher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// What a page reads about the tab shown, it reads from state: a tab press then recomposes the
|
// 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.
|
// two small scopes below and not the screens inside them.
|
||||||
val shown = rememberUpdatedState(current)
|
val shown = rememberUpdatedState(current)
|
||||||
@ -413,9 +411,102 @@ fun <T> TabPager(tabs: List<T>, current: T, modifier: Modifier = Modifier, conte
|
|||||||
|
|
||||||
const val TabGlideStepMillis = 50
|
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. */
|
/** How long a screen takes to push in over the one it was opened from, or to leave on Back. */
|
||||||
const val ScreenPushMillis = 300
|
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
|
const val SplashLongestWaitMillis = 1500L
|
||||||
|
|
||||||
private val SplashGradient = listOf(Color(0xFF4DB0FF), Color(0xFF2F98F9), Color(0xFF0A6FD8))
|
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
|
- 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.
|
tab change starts with 35–53 ms and has no frame over 25 ms after it.
|
||||||
- Checks and results: the five required checks pass.
|
- 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
|
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
|
pushed to while the list is loaded again, where it went back to "확인 중" and then to the
|
||||||
branch, changing the row's height twice.
|
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
|
## 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
|
## 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
|
- [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.
|
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
|
- [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