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:
parent
a28feb1a47
commit
bb546421fc
@ -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<IntOffset>(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) {
|
||||
|
||||
@ -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 <S> 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 <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
|
||||
|
||||
private val SplashGradient = listOf(Color(0xFF4DB0FF), Color(0xFF2F98F9), Color(0xFF0A6FD8))
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user