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.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) {
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user