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.activity.viewModels
import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith 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
@ -46,6 +48,7 @@ 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
@ -93,6 +96,7 @@ 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.NoResize
import com.ubisam.ubgitea.ui.components.ScreenPushMillis
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
@ -439,7 +443,8 @@ class MainActivity : ComponentActivity() {
onViewSettings = { push(Route.Settings) }, 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 // 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. // not replay it. The four tabs are one kind: between them the pager below glides.
val stackDepth = routeStack.size val stackDepth = routeStack.size
@ -453,15 +458,16 @@ class MainActivity : ComponentActivity() {
targetState = currentRoute.takeIf { sessionReady } ?: Route.Repositories, targetState = currentRoute.takeIf { sessionReady } ?: Route.Repositories,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
transitionSpec = { 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 // The session has just been found: the first screen takes the
// waiting one's place where it stands. Nothing was navigated, so // waiting one's place where it stands. Nothing was navigated, so
// nothing slides. // nothing slides.
fadeIn(tween(200)).togetherWith(fadeOut(tween(90))).using(NoResize) fadeIn(tween(200)).togetherWith(fadeOut(tween(90))).using(NoResize)
} else { } else {
val direction = (stackDepth - shownDepth[0]).coerceIn(-1, 1) val push = tween<IntOffset>(ScreenPushMillis, easing = FastOutSlowInEasing)
(fadeIn(tween(200, delayMillis = 50)) + slideInHorizontally(tween(260)) { it / 10 * direction }) slideInHorizontally(push) { it * direction }
.togetherWith(fadeOut(tween(90))) .togetherWith(slideOutHorizontally(push) { -it * direction })
.using(NoResize) .using(NoResize)
} }
}, },

View File

@ -34,6 +34,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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 // 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. // 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) } 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) { LaunchedEffect(state) {
if (state != RepositoryListUiState.Loading) requestedMode = null 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 = { val showAllRepositories = {
requestedMode = RepositoryListMode.ALL requestedMode = RepositoryListMode.ALL
@ -130,11 +152,13 @@ fun RepositoryListScreen(
RepositoryListUiState.Loading, RepositoryListUiState.Loading,
is RepositoryListUiState.Empty, is RepositoryListUiState.Empty,
is RepositoryListUiState.Content -> { is RepositoryListUiState.Content -> {
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout) val loadingMode = requestedMode ?: seenMode
val mode = when (state) { val shownState = if (state == RepositoryListUiState.Loading) loadingMode?.let { seen[it] } ?: state else state
is RepositoryListUiState.Content -> state.mode BrandTopBar(shownState.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
is RepositoryListUiState.Empty -> state.mode val mode = when (shownState) {
else -> requestedMode is RepositoryListUiState.Content -> shownState.mode
is RepositoryListUiState.Empty -> shownState.mode
else -> loadingMode
} }
if (mode == null) { if (mode == null) {
ListSkeleton(label = stringResource(R.string.repositories_loading)) ListSkeleton(label = stringResource(R.string.repositories_loading))
@ -147,14 +171,14 @@ fun RepositoryListScreen(
// No title: the selected tab already says this is 저장소. // No title: the selected tab already says this is 저장소.
Column(Modifier.padding(bottom = 12.dp)) { Column(Modifier.padding(bottom = 12.dp)) {
ModeTabs(mode, showAllRepositories, showFavorites) ModeTabs(mode, showAllRepositories, showFavorites)
if (state is RepositoryListUiState.Content && if (shownState is RepositoryListUiState.Content &&
mode == RepositoryListMode.ALL && state.organizations.isNotEmpty() 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()) { Column(Modifier.fillMaxSize()) {
when (shown) { when (shown) {
is RepositoryListUiState.Content -> RepositoryContent( is RepositoryListUiState.Content -> RepositoryContent(

View File

@ -1,5 +1,8 @@
package com.ubisam.ubgitea.ui.components 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.AnimatedContent
import androidx.compose.animation.SizeTransform import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.Animatable 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.PullToRefreshState
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue 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.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.abs 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 -> // Every tab stays composed, so a glide never waits for a screen to be built. Only the tab
content(tabs[page]) // 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 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 const val SplashLongestWaitMillis = 1500L
private val SplashGradient = listOf(Color(0xFF4DB0FF), Color(0xFF2F98F9), Color(0xFF0A6FD8)) 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. 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). - Agents: chips and the exit dialog were written by implementer (sonnet); the rest by main (opus).
- Checks and results: the five required checks pass. - 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 # 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 Owner: Claude
Reviewer: human product owner 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 작업 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 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. 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 ## 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 ## 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 - [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