From bb546421fc717a13731b8781d09fa33056644ab2 Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Mon, 12 Oct 2026 16:21:42 +0900 Subject: [PATCH] TASK-069: bottom tabs glide past the tabs between MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../java/com/ubisam/ubgitea/MainActivity.kt | 33 +++++++++---------- .../ubisam/ubgitea/ui/components/Motion.kt | 28 ++++++++++++++++ docs/development-log.md | 13 ++++++++ tasks/069-in-app-motion.md | 5 ++- 4 files changed, 60 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index c396566..b27cb13 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -18,12 +18,10 @@ 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 @@ -48,7 +46,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,6 +92,7 @@ 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.SplashOverlay +import com.ubisam.ubgitea.ui.components.TabPager import com.ubisam.ubgitea.ui.navigation.Route import com.ubisam.ubgitea.ui.navigation.RouteStackSaver import com.ubisam.ubgitea.ui.shell.AppShell @@ -437,9 +435,9 @@ class MainActivity : ComponentActivity() { onViewSettings = { push(Route.Settings) }, ) } - // Entering a screen slides it in from the side it was opened towards, and a - // tab switch from the side that tab sits on in the bar. Keyed by the kind of - // screen, so moving between code tabs or pages of one screen does not replay it. + // Entering a screen slides it in from the side it was opened towards. 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 val shownDepth = remember { intArrayOf(stackDepth) } val shownReady = remember { booleanArrayOf(sessionReady) } @@ -451,30 +449,21 @@ class MainActivity : ComponentActivity() { targetState = currentRoute.takeIf { sessionReady } ?: Route.Repositories, modifier = Modifier.fillMaxSize(), transitionSpec = { - val fromTab = initialState.tab - val toTab = targetState.tab - val tabSwitch = stackDepth == shownDepth[0] && fromTab != null && toTab != null if (!shownReady[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))) - } 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(320, easing = FastOutSlowInEasing) - slideInHorizontally(glide) { it * direction } - .togetherWith(slideOutHorizontally(glide) { -it * direction }) } else { val direction = (stackDepth - shownDepth[0]).coerceIn(-1, 1) (fadeIn(tween(200, delayMillis = 50)) + slideInHorizontally(tween(260)) { it / 10 * direction }) .togetherWith(fadeOut(tween(90))) } }, - contentKey = { it::class }, + contentKey = { if (it.isTab) AppTab::class else it::class }, label = "route", - ) { route -> + ) { shown -> + val screen: @Composable (Route) -> Unit = { route -> Box(Modifier.fillMaxSize()) { when (route) { 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) { 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 06bcd3d..2b2c1b4 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 @@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding 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.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api @@ -67,6 +69,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.ubisam.ubgitea.ui.theme.UBGiteaTheme import kotlin.math.PI +import kotlin.math.abs import kotlin.math.sin import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first @@ -314,6 +317,31 @@ fun TabGlide( 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 TabPager(tabs: List, 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 private val SplashGradient = listOf(Color(0xFF4DB0FF), Color(0xFF2F98F9), Color(0xFF0A6FD8)) diff --git a/docs/development-log.md b/docs/development-log.md index fa2e437..bdc5963 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -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. - 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. + +## 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. diff --git a/tasks/069-in-app-motion.md b/tasks/069-in-app-motion.md index ccf4dfa..1d7bc93 100644 --- a/tasks/069-in-app-motion.md +++ b/tasks/069-in-app-motion.md @@ -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 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. +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 @@ -50,7 +53,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 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 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