TASK-069: screens push in, repository tabs hold their list, tabs stay composed

Opening a screen pushes it in from the right and Back reverses it, in place
of a 90 ms fade that read as a flicker. The repository tabs keep each tab's
last list through a reload, so nothing settles after the glide. The four
bottom tabs stay composed; only the one shown answers Back.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-10-12 16:38:54 +09:00
parent d9baf04a1d
commit db8fc0574e
5 changed files with 96 additions and 18 deletions

View File

@ -18,10 +18,12 @@ 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
@ -46,6 +48,7 @@ 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
@ -93,6 +96,7 @@ 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.SplashOverlay
import com.ubisam.ubgitea.ui.components.TabPager
import com.ubisam.ubgitea.ui.navigation.Route
@ -439,7 +443,8 @@ class MainActivity : ComponentActivity() {
onViewSettings = { push(Route.Settings) },
)
}
// Entering a screen slides it in from the side it was opened towards. Keyed by
// Opening a screen pushes it in from the right as the one behind leaves to the
// left, and going back reverses that; it loads while it arrives. Keyed by
// the kind of screen, so moving between code tabs or pages of one screen does
// not replay it. The four tabs are one kind: between them the pager below glides.
val stackDepth = routeStack.size
@ -453,15 +458,16 @@ class MainActivity : ComponentActivity() {
targetState = currentRoute.takeIf { sessionReady } ?: Route.Repositories,
modifier = Modifier.fillMaxSize(),
transitionSpec = {
if (!shownReady[0]) {
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 direction = (stackDepth - shownDepth[0]).coerceIn(-1, 1)
(fadeIn(tween(200, delayMillis = 50)) + slideInHorizontally(tween(260)) { it / 10 * direction })
.togetherWith(fadeOut(tween(90)))
val push = tween<IntOffset>(ScreenPushMillis, easing = FastOutSlowInEasing)
slideInHorizontally(push) { it * direction }
.togetherWith(slideOutHorizontally(push) { -it * direction })
.using(NoResize)
}
},

View File

@ -34,6 +34,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -104,8 +105,29 @@ fun RepositoryListScreen(
// Changing tab reloads through Loading, which does not say which tab it is loading. Keeping
// the tab that was asked for lets the tab row stay where it is while the list under it glides.
var requestedMode by remember { mutableStateOf<RepositoryListMode?>(null) }
// What each tab last showed. A tab change, a refresh and a page turn all pass through
// Loading; showing the last list meanwhile keeps the header and the rows where they are, so
// the new list replaces the old one in place and nothing appears to settle afterwards.
val seen = remember { mutableStateMapOf<RepositoryListMode, RepositoryListUiState>() }
var seenMode by remember { mutableStateOf<RepositoryListMode?>(null) }
LaunchedEffect(state) {
if (state != RepositoryListUiState.Loading) requestedMode = null
when (state) {
is RepositoryListUiState.Content -> {
seen[state.mode] = state
seenMode = state.mode
}
is RepositoryListUiState.Empty -> {
seen[state.mode] = state
seenMode = state.mode
}
// Another account may sign in next: its lists are not these.
RepositoryListUiState.SignedOut -> {
seen.clear()
seenMode = null
}
else -> Unit
}
}
val showAllRepositories = {
requestedMode = RepositoryListMode.ALL
@ -130,11 +152,13 @@ fun RepositoryListScreen(
RepositoryListUiState.Loading,
is RepositoryListUiState.Empty,
is RepositoryListUiState.Content -> {
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
val mode = when (state) {
is RepositoryListUiState.Content -> state.mode
is RepositoryListUiState.Empty -> state.mode
else -> requestedMode
val loadingMode = requestedMode ?: seenMode
val shownState = if (state == RepositoryListUiState.Loading) loadingMode?.let { seen[it] } ?: state else state
BrandTopBar(shownState.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
val mode = when (shownState) {
is RepositoryListUiState.Content -> shownState.mode
is RepositoryListUiState.Empty -> shownState.mode
else -> loadingMode
}
if (mode == null) {
ListSkeleton(label = stringResource(R.string.repositories_loading))
@ -147,14 +171,14 @@ fun RepositoryListScreen(
// No title: the selected tab already says this is 저장소.
Column(Modifier.padding(bottom = 12.dp)) {
ModeTabs(mode, showAllRepositories, showFavorites)
if (state is RepositoryListUiState.Content &&
mode == RepositoryListMode.ALL && state.organizations.isNotEmpty()
if (shownState is RepositoryListUiState.Content &&
mode == RepositoryListMode.ALL && shownState.organizations.isNotEmpty()
) {
OrganizationFilterRow(state.organization, state.organizations, onSelectOrganization)
OrganizationFilterRow(shownState.organization, shownState.organizations, onSelectOrganization)
}
}
}
TabGlide(mode to state, { it.first.ordinal }, Modifier.weight(1f).fillMaxWidth()) { (_, shown) ->
TabGlide(mode to shownState, { it.first.ordinal }, Modifier.weight(1f).fillMaxWidth()) { (_, shown) ->
Column(Modifier.fillMaxSize()) {
when (shown) {
is RepositoryListUiState.Content -> RepositoryContent(

View File

@ -1,5 +1,8 @@
package com.ubisam.ubgitea.ui.components
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.Animatable
@ -40,6 +43,7 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -69,6 +73,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
import kotlin.math.PI
import kotlin.math.abs
@ -361,13 +366,33 @@ fun <T> TabPager(tabs: List<T>, current: T, modifier: Modifier = Modifier, conte
)
}
}
HorizontalPager(pager, modifier.fillMaxSize(), userScrollEnabled = false, key = { it }) { page ->
content(tabs[page])
// 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()
}
}
HorizontalPager(
pager,
modifier.fillMaxSize(),
beyondViewportPageCount = tabs.size - 1,
userScrollEnabled = false,
key = { it },
) { page ->
val owner = if (tabs[page] == current && backOwner != null) backOwner else noBack
CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides owner) { content(tabs[page]) }
}
}
const val TabGlideStepMillis = 50
/** 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 SplashLongestWaitMillis = 1500L
private val SplashGradient = listOf(Color(0xFF4DB0FF), Color(0xFF2F98F9), Color(0xFF0A6FD8))

View File

@ -1657,3 +1657,21 @@ This is the append-only implementation and handoff record shared by Codex and Cl
question was designed in code in the app's style, not on the Claude Design canvas first.
- Agents: chips and the exit dialog were written by implementer (sonnet); the rest by main (opus).
- Checks and results: the five required checks pass.
## 2026-10-12 (continued) — TASK-069: pushes, steadier repository tabs, tabs kept composed
- Request, from the product owner's device: opening a repository from 활동 중인 저장소 flickered
and should push in from the right while it loads; after sliding between the repository tabs
something seemed to refresh or realign; tab moves should have no stutter at all.
- What changed: `MainActivity` opens and closes screens with a full-width push (300 ms) in place
of the 90 ms fade. `RepositoryListScreen` remembers what each tab last showed and keeps showing
it through Loading, so the account line, the organisation chips and the rows no longer vanish
and return on a tab change, refresh or page turn. `TabPager` keeps all four tabs composed
(`beyondViewportPageCount`), and gives the tabs not being shown a Back dispatcher nothing
presses, so a hidden tab cannot answer Back.
- Design decision: a remembered list may be a moment out of date (a favourite just starred);
the fresh one replaces it in place when it arrives. It is dropped on sign-out.
- Checks and results: the five required checks pass.
- Caution: what is installed for these looks is the debug build, which Compose runs noticeably
less smoothly than a minified release build; the published APK is the same kind (R8 is on hold
under TASK-016). Remaining stutter may be that and not the transitions.

View File

@ -1,6 +1,6 @@
# TASK-069: In-app motion
Status: implemented; required checks pass; not yet seen on a device
Status: implemented; required checks pass; being adjusted from the product owner's look on a device
Owner: Claude
Reviewer: human product owner
@ -29,6 +29,11 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c
11. Asked by the product owner (2026-10-12): going from 홈 to 알림 should pass 저장소 and 작업
on the way, quickly, not jump straight there. The four tab screens now sit side by side in a
pager that only a tab press moves.
12. After the product owner's first look on a device (2026-10-12): opening a screen pushes it in
from the right, the full width, as the one behind leaves to the left, and Back reverses it;
this replaces the short fade of item 5, which read as a flicker. The repository tabs keep
showing each tab's last list while it reloads, so nothing settles after the glide. All four
bottom tabs stay composed, so a glide does not wait for a screen to be built.
## Scope
@ -53,7 +58,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 eleven items above is in the code and the app builds.
- [x] Each of the twelve 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