TASK-069: bottom tabs glide past the tabs between

The four tab screens are pages of one pager that a tab press scrolls, so 홈
to 알림 passes 저장소 and 작업 instead of sliding straight there. Swiping is
off; entering and leaving other screens is unchanged.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-10-12 16:21:42 +09:00
parent a28feb1a47
commit bb546421fc
4 changed files with 60 additions and 19 deletions

View File

@ -18,12 +18,10 @@ 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
@ -48,7 +46,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,6 +92,7 @@ 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.SplashOverlay import com.ubisam.ubgitea.ui.components.SplashOverlay
import com.ubisam.ubgitea.ui.components.TabPager
import com.ubisam.ubgitea.ui.navigation.Route import com.ubisam.ubgitea.ui.navigation.Route
import com.ubisam.ubgitea.ui.navigation.RouteStackSaver import com.ubisam.ubgitea.ui.navigation.RouteStackSaver
import com.ubisam.ubgitea.ui.shell.AppShell import com.ubisam.ubgitea.ui.shell.AppShell
@ -437,9 +435,9 @@ class MainActivity : ComponentActivity() {
onViewSettings = { push(Route.Settings) }, onViewSettings = { push(Route.Settings) },
) )
} }
// Entering a screen slides it in from the side it was opened towards, and a // Entering a screen slides it in from the side it was opened towards. Keyed by
// tab switch from the side that tab sits on in the bar. Keyed by the kind of // the kind of screen, so moving between code tabs or pages of one screen does
// screen, so moving between code tabs or pages of one screen does not replay it. // not replay it. The four tabs are one kind: between them the pager below glides.
val stackDepth = routeStack.size val stackDepth = routeStack.size
val shownDepth = remember { intArrayOf(stackDepth) } val shownDepth = remember { intArrayOf(stackDepth) }
val shownReady = remember { booleanArrayOf(sessionReady) } val shownReady = remember { booleanArrayOf(sessionReady) }
@ -451,30 +449,21 @@ class MainActivity : ComponentActivity() {
targetState = currentRoute.takeIf { sessionReady } ?: Route.Repositories, targetState = currentRoute.takeIf { sessionReady } ?: Route.Repositories,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
transitionSpec = { transitionSpec = {
val fromTab = initialState.tab
val toTab = targetState.tab
val tabSwitch = stackDepth == shownDepth[0] && fromTab != null && toTab != null
if (!shownReady[0]) { if (!shownReady[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))) fadeIn(tween(200)).togetherWith(fadeOut(tween(90)))
} else if (tabSwitch) {
// Tabs sit side by side: the old one glides out as the new one
// glides in, the whole width, like turning to the next page.
val direction = (toTab.ordinal - fromTab.ordinal).coerceIn(-1, 1)
val glide = tween<IntOffset>(320, easing = FastOutSlowInEasing)
slideInHorizontally(glide) { it * direction }
.togetherWith(slideOutHorizontally(glide) { -it * direction })
} else { } else {
val direction = (stackDepth - shownDepth[0]).coerceIn(-1, 1) val direction = (stackDepth - shownDepth[0]).coerceIn(-1, 1)
(fadeIn(tween(200, delayMillis = 50)) + slideInHorizontally(tween(260)) { it / 10 * direction }) (fadeIn(tween(200, delayMillis = 50)) + slideInHorizontally(tween(260)) { it / 10 * direction })
.togetherWith(fadeOut(tween(90))) .togetherWith(fadeOut(tween(90)))
} }
}, },
contentKey = { it::class }, contentKey = { if (it.isTab) AppTab::class else it::class },
label = "route", label = "route",
) { route -> ) { shown ->
val screen: @Composable (Route) -> Unit = { route ->
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
when (route) { when (route) {
Route.Home -> HomeScreen( Route.Home -> HomeScreen(
@ -634,6 +623,14 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
// 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) { screen(it.route) }
} else {
screen(shown)
}
}
} }
} }
LaunchedEffect(viewModel) { LaunchedEffect(viewModel) {

View File

@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -67,6 +69,7 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
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.sin import kotlin.math.sin
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -314,6 +317,31 @@ fun <S> TabGlide(
const val TabGlideMillis = 320 const val TabGlideMillis = 320
/**
* The bottom tabs' screens, laid side by side in the order of [tabs]. Changing [current] glides
* to it past whatever lies between, a little longer for each tab passed. Only a tab press moves
* it: swiping stays with what is inside the screens.
*/
@Composable
fun <T> TabPager(tabs: List<T>, current: T, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
val pager = rememberPagerState(initialPage = tabs.indexOf(current)) { tabs.size }
LaunchedEffect(current) {
val page = tabs.indexOf(current)
val passed = abs(page - pager.currentPage)
if (passed > 0 || pager.isScrollInProgress) {
pager.animateScrollToPage(
page,
animationSpec = tween(TabGlideMillis + TabGlideStepMillis * (passed - 1).coerceAtLeast(0), easing = FastOutSlowInEasing),
)
}
}
HorizontalPager(pager, modifier.fillMaxSize(), userScrollEnabled = false, key = { it }) { page ->
content(tabs[page])
}
}
const val TabGlideStepMillis = 50
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

@ -1629,3 +1629,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl
- Checks and results: the five required checks pass. - Checks and results: the five required checks pass.
- Caution: diagnosed from the code, not seen on a device. If the stutter is still there, the - Caution: diagnosed from the code, not seen on a device. If the stutter is still there, the
remaining suspect is the cost of Home's first frames on that phone. remaining suspect is the cost of Home's first frames on that phone.
## 2026-10-12 (continued) — TASK-069: bottom tabs pass the tabs between
- Request: going from 홈 to 알림 went straight there; the product owner wants it to glide
quickly past 저장소 and 작업.
- What changed: the four tab screens are pages of one `TabPager` (`Motion.kt`), which a tab
press scrolls with 320 ms plus 50 ms per extra tab passed; swiping is off, so gestures inside
the screens are unchanged. In `MainActivity` the tab routes share one `AnimatedContent` key,
and the two-screen tab glide there is removed. Entering and leaving other screens is as before.
- Design decision: a tab that is only passed shows whatever its ViewModel last held (its list,
or its loading state if never opened); it is not loaded for the pass.
- Checks and results: the five required checks pass.
- Caution: not seen on a device.

View File

@ -26,6 +26,9 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c
screen was gliding in sideways underneath, as if a tab had been switched, while the cover was screen was gliding in sideways underneath, as if a tab had been switched, while the cover was
fading. The first screen now fades in where it stands, and the cover waits for it (at most fading. The first screen now fades in where it stands, and the cover waits for it (at most
1.5 seconds longer) before it leaves. 1.5 seconds longer) before it leaves.
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.
## Scope ## Scope
@ -50,7 +53,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 ten items above is in the code and the app builds. - [x] Each of the eleven 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