TASK-069: a move no longer starts by drawing every screen again

Measured on the product owner's phone over adb. The code workbench value,
provided through a static composition local, was rebuilt on each root
recomposition, which turned skipping off for all four tab screens on every
move. It is remembered now. Looping animations stop while their page is off
screen, the two repository lists stay composed side by side, and a
repository's branches open beside the list instead of replacing it.

First frame of a bottom tab move: 80-118 ms to 23-34 ms. The branches
slide has not been measured on the device yet.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-10-12 17:15:07 +09:00
parent db8fc0574e
commit e1abdfc4ca
5 changed files with 345 additions and 218 deletions

View File

@ -272,20 +272,28 @@ class MainActivity : ComponentActivity() {
homeViewModel.close()
workViewModel.close()
}
fun signOut() {
closeFeatures()
selectTab(AppTab.HOME)
viewModel.logout()
// Held, not made again on each pass: the screens are given these, and a screen given
// the same callbacks as before is left alone when something else here changes.
val signOut: () -> Unit = remember {
{
closeFeatures()
selectTab(AppTab.HOME)
viewModel.logout()
}
}
fun leaveBranchDetail() {
codeBrowserViewModel.close()
historyViewModel.close()
pop()
val leaveBranchDetail: () -> Unit = remember {
{
codeBrowserViewModel.close()
historyViewModel.close()
pop()
}
}
var confirmExit by remember { mutableStateOf(false) }
val instanceHost = UBGiteaOAuthDefaults.config.baseUri.host.orEmpty()
val codeViewerPreferences = CodeViewerPreferences(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp)
val codeViewerPreferences = remember(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp) {
CodeViewerPreferences(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp)
}
val showNavigation = sessionReady && when (currentRoute) {
is Route.BranchDetail -> !isReadingFullScreen(codeBrowserState, historyState)
Route.Settings, Route.Compare -> false
@ -322,23 +330,28 @@ class MainActivity : ComponentActivity() {
SavedCodeTab(route.owner, route.name, it.target.kind.name, it.target.ref.value, it.path.value)
}
}
val workbench = if (sessionReady) {
CodeWorkbench(
tabs = workbenchState.tabs,
pending = workbenchState.pending,
fetching = workbenchState.fetching,
onToggleTab = workbenchViewModel::toggleTab,
onPin = workbenchViewModel::pin,
onCompareWith = workbenchViewModel::compareWith,
onClearPin = workbenchViewModel::clearPin,
onUseCommitFile = { commitId, shortId, path, side ->
(currentRoute as? Route.BranchDetail)?.let { route ->
workbenchViewModel.useCommitFile(RepositoryCoordinates.of(route.owner, route.name), commitId, shortId, path, side)
}
},
)
} else {
null
// Made again only when what it holds changes. Every screen is composed under it, and
// a new one, even one that says the same, has every screen on every tab drawn again:
// that was a stall of several frames at the start of each tab change and each push.
val workbench = remember(sessionReady, workbenchState.tabs, workbenchState.pending, workbenchState.fetching) {
if (sessionReady) {
CodeWorkbench(
tabs = workbenchState.tabs,
pending = workbenchState.pending,
fetching = workbenchState.fetching,
onToggleTab = workbenchViewModel::toggleTab,
onPin = workbenchViewModel::pin,
onCompareWith = workbenchViewModel::compareWith,
onClearPin = workbenchViewModel::clearPin,
onUseCommitFile = { commitId, shortId, path, side ->
(routeStack.last() as? Route.BranchDetail)?.let { route ->
workbenchViewModel.useCommitFile(RepositoryCoordinates.of(route.owner, route.name), commitId, shortId, path, side)
}
},
)
} else {
null
}
}
val showStrip = sessionReady && currentRoute != Route.Settings && currentRoute != Route.Compare &&
(workbenchState.tabs.isNotEmpty() || workbenchState.pending != null || workbenchState.fetching)
@ -406,15 +419,18 @@ class MainActivity : ComponentActivity() {
BackHandler(enabled = sessionReady && currentRoute.isTab) {
if (currentRoute == Route.Home) confirmExit = true else selectTab(AppTab.HOME)
}
val repositoryList: @Composable () -> Unit = {
// The screens are built once and held, and read what changes from state. A
// tab press, which runs everything around them again, then leaves the four tab
// screens exactly as they were instead of drawing each of them again.
val repositoryList: @Composable () -> Unit = remember { {
RepositoryListScreen(
state = state,
instanceHost = instanceHost,
onSignIn = viewModel::signIn,
onCancelSignIn = viewModel::cancelSignIn,
onRetry = viewModel::retry,
onLogout = ::signOut,
onRepositorySelected = viewModel::selectRepository,
onSignIn = remember { viewModel::signIn },
onCancelSignIn = remember { viewModel::cancelSignIn },
onRetry = remember { viewModel::retry },
onLogout = signOut,
onRepositorySelected = remember { viewModel::selectRepository },
onReferenceSelected = { repository, name, kind ->
viewModel.selectReference(name, kind)
val refKind = if (kind == ReferenceKind.BRANCH) CodeReferenceKind.BRANCH else CodeReferenceKind.TAG
@ -427,22 +443,22 @@ class MainActivity : ComponentActivity() {
),
)
},
onReferencesRefresh = viewModel::refreshReferences,
onBackToRepositories = viewModel::backToRepositories,
onNextPage = viewModel::nextPage,
onPreviousPage = viewModel::previousPage,
onRefreshRepositories = viewModel::refreshRepositories,
onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch,
onToggleFavorite = viewModel::toggleFavorite,
onShowAllRepositories = viewModel::showAllRepositories,
onShowFavorites = viewModel::showFavorites,
onReferencesRefresh = remember { viewModel::refreshReferences },
onBackToRepositories = remember { viewModel::backToRepositories },
onNextPage = remember { viewModel::nextPage },
onPreviousPage = remember { viewModel::previousPage },
onRefreshRepositories = remember { viewModel::refreshRepositories },
onRetryLastPushedBranch = remember { viewModel::retryRowLastPushedBranch },
onToggleFavorite = remember { viewModel::toggleFavorite },
onShowAllRepositories = remember { viewModel::showAllRepositories },
onShowFavorites = remember { viewModel::showFavorites },
onViewWorkItems = { repository ->
push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name))
},
onSelectOrganization = viewModel::selectOrganization,
onSelectOrganization = remember { viewModel::selectOrganization },
onViewSettings = { push(Route.Settings) },
)
}
} }
// 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
@ -474,7 +490,7 @@ class MainActivity : ComponentActivity() {
contentKey = { if (it.isTab) AppTab::class else it::class },
label = "route",
) { shown ->
val screen: @Composable (Route) -> Unit = { route ->
val screen: @Composable (Route) -> Unit = remember(isWide, codeViewerPreferences) { { route ->
Box(Modifier.fillMaxSize()) {
when (route) {
Route.Home -> HomeScreen(
@ -502,52 +518,52 @@ class MainActivity : ComponentActivity() {
)
}
},
onSelectSource = homeViewModel::selectSource,
onSelectFilter = homeViewModel::selectFilter,
onSetTodayOnly = homeViewModel::setTodayOnly,
onShowToday = homeViewModel::showToday,
onLoadMore = homeViewModel::loadMore,
onRefresh = homeViewModel::refresh,
onSelectSource = remember { homeViewModel::selectSource },
onSelectFilter = remember { homeViewModel::selectFilter },
onSetTodayOnly = remember { homeViewModel::setTodayOnly },
onShowToday = remember { homeViewModel::showToday },
onLoadMore = remember { homeViewModel::loadMore },
onRefresh = remember { homeViewModel::refresh },
onViewSettings = { push(Route.Settings) },
onLogout = ::signOut,
onAuthenticationExpired = ::signOut,
onLogout = signOut,
onAuthenticationExpired = signOut,
)
Route.Work -> WorkScreen(
state = workState,
accountName = accountName,
instanceHost = instanceHost,
onSelectScope = workViewModel::selectScope,
onSelectState = workViewModel::selectState,
onSelectType = workViewModel::selectType,
onSelectScope = remember { workViewModel::selectScope },
onSelectState = remember { workViewModel::selectState },
onSelectType = remember { workViewModel::selectType },
onItemSelected = { hit ->
push(Route.WorkItems(hit.repository.owner, hit.repository.name, hit.number, hit.isPullRequest))
},
onNextPage = workViewModel::nextPage,
onPreviousPage = workViewModel::previousPage,
onRefresh = workViewModel::refresh,
onNextPage = remember { workViewModel::nextPage },
onPreviousPage = remember { workViewModel::previousPage },
onRefresh = remember { workViewModel::refresh },
onViewSettings = { push(Route.Settings) },
onLogout = ::signOut,
onAuthenticationExpired = ::signOut,
onLogout = signOut,
onAuthenticationExpired = signOut,
)
Route.Settings -> SettingsScreen(
state = settingsState,
onSetReactiveAuthRetryEnabled = settingsViewModel::setReactiveAuthRetryEnabled,
onSetFavoritePushNotificationsEnabled = settingsViewModel::setFavoritePushNotificationsEnabled,
onSetReactiveAuthRetryEnabled = remember { settingsViewModel::setReactiveAuthRetryEnabled },
onSetFavoritePushNotificationsEnabled = remember { settingsViewModel::setFavoritePushNotificationsEnabled },
accountName = accountName,
instanceHost = instanceHost,
onSetCodeWordWrapEnabled = settingsViewModel::setCodeWordWrapEnabled,
onSetCodeFontSizeSp = settingsViewModel::setCodeFontSizeSp,
onSetThemeMode = settingsViewModel::setThemeMode,
onLogout = ::signOut,
onSetCodeWordWrapEnabled = remember { settingsViewModel::setCodeWordWrapEnabled },
onSetCodeFontSizeSp = remember { settingsViewModel::setCodeFontSizeSp },
onSetThemeMode = remember { settingsViewModel::setThemeMode },
onLogout = signOut,
onBack = { pop() },
update = updateState,
onCheckUpdate = updateViewModel::checkNow,
onInstallUpdate = ::startUpdate,
onCheckUpdate = remember { updateViewModel::checkNow },
onInstallUpdate = remember { ::startUpdate },
)
Route.Notifications -> NotificationsScreen(
state = notificationsState,
onShowUnreadOnly = notificationsViewModel::showUnreadOnly,
onShowAll = notificationsViewModel::showAll,
onShowUnreadOnly = remember { notificationsViewModel::showUnreadOnly },
onShowAll = remember { notificationsViewModel::showAll },
onNotificationSelected = { notification ->
push(
Route.WorkItems(
@ -558,15 +574,15 @@ class MainActivity : ComponentActivity() {
),
)
},
onNextPage = notificationsViewModel::nextPage,
onPreviousPage = notificationsViewModel::previousPage,
onRetry = notificationsViewModel::retry,
onNextPage = remember { notificationsViewModel::nextPage },
onPreviousPage = remember { notificationsViewModel::previousPage },
onRetry = remember { notificationsViewModel::retry },
onBack = { selectTab(AppTab.HOME) },
onAuthenticationExpired = ::signOut,
onAuthenticationExpired = signOut,
accountName = accountName,
instanceHost = instanceHost,
onViewSettings = { push(Route.Settings) },
onLogout = ::signOut,
onLogout = signOut,
)
is Route.WorkItems -> {
val leave = {
@ -575,21 +591,21 @@ class MainActivity : ComponentActivity() {
}
WorkItemsScreen(
state = workItemsState,
onShowIssues = workItemsViewModel::showIssues,
onShowPullRequests = workItemsViewModel::showPullRequests,
onIssueSelected = workItemsViewModel::openIssue,
onPullRequestSelected = workItemsViewModel::openPullRequest,
onNextPage = workItemsViewModel::nextPage,
onPreviousPage = workItemsViewModel::previousPage,
onGoToPage = workItemsViewModel::goToPage,
onRetryList = workItemsViewModel::retryList,
onRetryDetail = workItemsViewModel::retryDetail,
onShowIssues = remember { workItemsViewModel::showIssues },
onShowPullRequests = remember { workItemsViewModel::showPullRequests },
onIssueSelected = remember { workItemsViewModel::openIssue },
onPullRequestSelected = remember { workItemsViewModel::openPullRequest },
onNextPage = remember { workItemsViewModel::nextPage },
onPreviousPage = remember { workItemsViewModel::previousPage },
onGoToPage = remember { workItemsViewModel::goToPage },
onRetryList = remember { workItemsViewModel::retryList },
onRetryDetail = remember { workItemsViewModel::retryDetail },
// An item opened directly came from a list elsewhere (a search, the
// timeline, a notification), so back returns there, not to this
// repository's own list.
onBackToList = if (route.number != null) leave else workItemsViewModel::backToList,
onBackToList = if (route.number != null) leave else remember { workItemsViewModel::backToList },
onBack = leave,
onAuthenticationExpired = ::signOut,
onAuthenticationExpired = signOut,
)
}
is Route.BranchDetail -> BranchDetailScreen(
@ -598,46 +614,47 @@ class MainActivity : ComponentActivity() {
ref = route.ref,
codeBrowserState = codeBrowserState,
codeViewerPreferences = codeViewerPreferences,
onEntrySelected = codeBrowserViewModel::openEntry,
onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb,
onRetryCodeBrowser = codeBrowserViewModel::retry,
onEntrySelected = remember { codeBrowserViewModel::openEntry },
onBreadcrumbSelected = remember { codeBrowserViewModel::openBreadcrumb },
onRetryCodeBrowser = remember { codeBrowserViewModel::retry },
onCodeBrowserBack = { if (!codeBrowserViewModel.navigateUp()) leaveBranchDetail() },
onNavigateImage = codeBrowserViewModel::navigateImage,
onSetCodeWordWrap = settingsViewModel::setCodeWordWrapEnabled,
onSetCodeFontSize = settingsViewModel::setCodeFontSizeSp,
onOpenSibling = codeBrowserViewModel::openSibling,
onToggleFolder = codeBrowserViewModel::toggleFolder,
onLoadCounterpart = codeBrowserViewModel::loadCounterpart,
onNavigateImage = remember { codeBrowserViewModel::navigateImage },
onSetCodeWordWrap = remember { settingsViewModel::setCodeWordWrapEnabled },
onSetCodeFontSize = remember { settingsViewModel::setCodeFontSizeSp },
onOpenSibling = remember { codeBrowserViewModel::openSibling },
onToggleFolder = remember { codeBrowserViewModel::toggleFolder },
onLoadCounterpart = remember { codeBrowserViewModel::loadCounterpart },
historyState = historyState,
onCommitSelected = historyViewModel::openCommit,
onHistoryNextPage = historyViewModel::nextPage,
onHistoryPreviousPage = historyViewModel::previousPage,
onRetryHistoryList = historyViewModel::retryList,
onRetryHistoryDetail = historyViewModel::retryDetail,
onRetryPatch = historyViewModel::retryPatch,
onFileSelected = historyViewModel::openFileDiff,
onShowAdjacentFile = historyViewModel::openAdjacentFileDiff,
onClearFileSelection = historyViewModel::clearFileSelection,
onHistoryBackToList = historyViewModel::backToList,
onHistoryBack = ::leaveBranchDetail,
onCommitSelected = remember { historyViewModel::openCommit },
onHistoryNextPage = remember { historyViewModel::nextPage },
onHistoryPreviousPage = remember { historyViewModel::previousPage },
onRetryHistoryList = remember { historyViewModel::retryList },
onRetryHistoryDetail = remember { historyViewModel::retryDetail },
onRetryPatch = remember { historyViewModel::retryPatch },
onFileSelected = remember { historyViewModel::openFileDiff },
onShowAdjacentFile = remember { historyViewModel::openAdjacentFileDiff },
onClearFileSelection = remember { historyViewModel::clearFileSelection },
onHistoryBackToList = remember { historyViewModel::backToList },
onHistoryBack = leaveBranchDetail,
onViewWorkItems = { push(Route.WorkItems(route.owner, route.name)) },
onClose = ::leaveBranchDetail,
onAuthenticationExpired = ::signOut,
onClose = leaveBranchDetail,
onAuthenticationExpired = signOut,
)
Route.Repositories -> repositoryList()
Route.Compare -> CompareScreen(
state = workbenchState.compare,
fontSizeSp = settingsState.codeFontSizeSp,
onSwap = workbenchViewModel::swapSides,
onClose = workbenchViewModel::closeCompare,
onSwap = remember { workbenchViewModel::swapSides },
onClose = remember { workbenchViewModel::closeCompare },
)
}
}
}
} }
val tabScreen: @Composable (AppTab) -> Unit = remember(screen) { { screen(it.route) } }
// 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) }
TabPager(AppTab.entries, shownTab, content = tabScreen)
} else {
screen(shown)
}

View File

@ -69,7 +69,9 @@ import com.ubisam.ubgitea.ui.components.ScreenTopBar
import com.ubisam.ubgitea.ui.components.ScrollTopLazyColumn
import com.ubisam.ubgitea.ui.components.SectionTitle
import com.ubisam.ubgitea.ui.components.StatusBadge
import com.ubisam.ubgitea.ui.components.TabGlide
import com.ubisam.ubgitea.ui.components.TabGlideMillis
import com.ubisam.ubgitea.ui.components.TabPager
import kotlinx.coroutines.delay
import com.ubisam.ubgitea.ui.components.TeaPullToRefreshBox
import com.ubisam.ubgitea.ui.components.UBIcon
import com.ubisam.ubgitea.ui.components.UbiSamLogo
@ -110,6 +112,13 @@ fun RepositoryListScreen(
// 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) }
// A repository's branches open beside the list, not in place of it. The list stays built
// underneath and the branches stay built on the way out, so going in and coming back are
// both a slide, and neither starts by building a screen of rows.
var lastReferences by remember { mutableStateOf<RepositoryListUiState?>(null) }
val inReferences = state is RepositoryListUiState.ReferencesLoading ||
state is RepositoryListUiState.ReferencesContent ||
state is RepositoryListUiState.ReferencesError
LaunchedEffect(state) {
if (state != RepositoryListUiState.Loading) requestedMode = null
when (state) {
@ -125,10 +134,24 @@ fun RepositoryListScreen(
RepositoryListUiState.SignedOut -> {
seen.clear()
seenMode = null
lastReferences = null
}
is RepositoryListUiState.ReferencesLoading,
is RepositoryListUiState.ReferencesContent,
is RepositoryListUiState.ReferencesError -> lastReferences = state
else -> Unit
}
}
// Branches that arrive while their screen is still sliding in wait for it to stop: rows built
// in the middle of the slide made it stall.
var slidingIn by remember { mutableStateOf(false) }
LaunchedEffect(inReferences) {
if (inReferences) {
slidingIn = true
delay(TabGlideMillis.toLong())
slidingIn = false
}
}
val showAllRepositories = {
requestedMode = RepositoryListMode.ALL
onShowAllRepositories()
@ -149,73 +172,6 @@ fun RepositoryListScreen(
stringResource(R.string.repositories_cancel_login),
onCancelSignIn,
)
RepositoryListUiState.Loading,
is RepositoryListUiState.Empty,
is RepositoryListUiState.Content -> {
val loadingMode = requestedMode ?: seenMode
val shownState = if (state == RepositoryListUiState.Loading) loadingMode?.let { seen[it] } ?: state else state
BrandTopBar(shownState.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
val mode = when (shownState) {
is RepositoryListUiState.Content -> shownState.mode
is RepositoryListUiState.Empty -> shownState.mode
else -> loadingMode
}
if (mode == null) {
ListSkeleton(label = stringResource(R.string.repositories_loading))
} else {
Surface(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
modifier = Modifier.fillMaxWidth(),
) {
// No title: the selected tab already says this is 저장소.
Column(Modifier.padding(bottom = 12.dp)) {
ModeTabs(mode, showAllRepositories, showFavorites)
if (shownState is RepositoryListUiState.Content &&
mode == RepositoryListMode.ALL && shownState.organizations.isNotEmpty()
) {
OrganizationFilterRow(shownState.organization, shownState.organizations, onSelectOrganization)
}
}
}
TabGlide(mode to shownState, { it.first.ordinal }, Modifier.weight(1f).fillMaxWidth()) { (_, shown) ->
Column(Modifier.fillMaxSize()) {
when (shown) {
is RepositoryListUiState.Content -> RepositoryContent(
shown,
onRepositorySelected,
onNextPage,
onPreviousPage,
onRefreshRepositories,
onRetryLastPushedBranch,
showAllRepositories,
showFavorites,
onToggleFavorite,
)
// FAVORITES-mode empty doesn't mean the account has nothing to show -- it
// means nothing is starred *yet*, while 전체 저장소 (the other tab, right
// above this message) is very likely non-empty. The only sensible primary
// action here is "go look at 전체 저장소 and star something", never 로그아웃.
is RepositoryListUiState.Empty -> if (shown.mode == RepositoryListMode.FAVORITES) {
CenteredMessage(
stringResource(R.string.repositories_favorites_empty),
action = stringResource(R.string.repositories_favorites_empty_action),
onAction = showAllRepositories,
decoration = { EmptyCup() },
)
} else {
CenteredMessage(
stringResource(R.string.repositories_empty_account, shown.accountName),
action = stringResource(R.string.repositories_logout),
onAction = onLogout,
)
}
else -> ListSkeleton(label = stringResource(R.string.repositories_loading))
}
}
}
}
}
is RepositoryListUiState.Error -> {
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
val needsLogin = state.type == RepositoryListError.AUTHENTICATION_EXPIRED ||
@ -231,30 +187,123 @@ fun RepositoryListScreen(
onSecondaryAction = onLogout,
)
}
is RepositoryListUiState.ReferencesLoading -> {
BackHandler(onBack = onBackToRepositories)
ScreenTopBar(title = state.repository.name, onBack = onBackToRepositories, subtitle = state.repository.owner)
CenteredLoading(stringResource(R.string.repositories_references_loading))
}
is RepositoryListUiState.ReferencesContent -> {
BackHandler(onBack = onBackToRepositories)
ReferencesContent(state, onReferenceSelected, onReferencesRefresh, onViewWorkItems, onBackToRepositories)
}
is RepositoryListUiState.ReferencesError -> {
BackHandler(onBack = onBackToRepositories)
ScreenTopBar(title = state.repository.name, onBack = onBackToRepositories, subtitle = state.repository.owner)
CenteredMessage(
stringResource(state.type.messageResource),
action = stringResource(R.string.repositories_retry),
onAction = onRetry,
secondaryAction = stringResource(R.string.repositories_back),
onSecondaryAction = onBackToRepositories,
)
RepositoryListUiState.Loading,
is RepositoryListUiState.Empty,
is RepositoryListUiState.Content,
is RepositoryListUiState.ReferencesLoading,
is RepositoryListUiState.ReferencesContent,
is RepositoryListUiState.ReferencesError -> TabPager(
RepositoryDepth.entries,
if (inReferences) RepositoryDepth.REFERENCES else RepositoryDepth.LIST,
) { depth ->
Column(Modifier.fillMaxSize()) {
if (depth == RepositoryDepth.LIST) {
val listState = if (inReferences) seenMode?.let { seen[it] } ?: RepositoryListUiState.Loading else state
val loadingMode = requestedMode ?: seenMode
val shownState = if (listState == RepositoryListUiState.Loading) loadingMode?.let { seen[it] } ?: listState else listState
BrandTopBar(shownState.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
val mode = when (shownState) {
is RepositoryListUiState.Content -> shownState.mode
is RepositoryListUiState.Empty -> shownState.mode
else -> loadingMode
}
if (mode == null) {
ListSkeleton(label = stringResource(R.string.repositories_loading))
} else {
Surface(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
modifier = Modifier.fillMaxWidth(),
) {
// No title: the selected tab already says this is 저장소.
Column(Modifier.padding(bottom = 12.dp)) {
ModeTabs(mode, showAllRepositories, showFavorites)
if (shownState is RepositoryListUiState.Content &&
mode == RepositoryListMode.ALL && shownState.organizations.isNotEmpty()
) {
OrganizationFilterRow(shownState.organization, shownState.organizations, onSelectOrganization)
}
}
}
// Both lists stay composed side by side, so changing tab only slides: building
// a list of rows in the frame the slide starts on is what made it stall.
TabPager(RepositoryListMode.entries, mode, Modifier.weight(1f).fillMaxWidth()) { page ->
val shown = if (page == mode) shownState else seen[page] ?: RepositoryListUiState.Loading
Column(Modifier.fillMaxSize()) {
when (shown) {
is RepositoryListUiState.Content -> RepositoryContent(
shown,
onRepositorySelected,
onNextPage,
onPreviousPage,
onRefreshRepositories,
onRetryLastPushedBranch,
showAllRepositories,
showFavorites,
onToggleFavorite,
)
// FAVORITES-mode empty doesn't mean the account has nothing to show -- it
// means nothing is starred *yet*, while 전체 저장소 (the other tab, right
// above this message) is very likely non-empty. The only sensible primary
// action here is "go look at 전체 저장소 and star something", never 로그아웃.
is RepositoryListUiState.Empty -> if (shown.mode == RepositoryListMode.FAVORITES) {
CenteredMessage(
stringResource(R.string.repositories_favorites_empty),
action = stringResource(R.string.repositories_favorites_empty_action),
onAction = showAllRepositories,
decoration = { EmptyCup() },
)
} else {
CenteredMessage(
stringResource(R.string.repositories_empty_account, shown.accountName),
action = stringResource(R.string.repositories_logout),
onAction = onLogout,
)
}
else -> ListSkeleton(label = stringResource(R.string.repositories_loading))
}
}
}
}
} else {
val arrived = if (inReferences) state else lastReferences
val shown = if (slidingIn && arrived is RepositoryListUiState.ReferencesContent) {
RepositoryListUiState.ReferencesLoading(arrived.accountName, arrived.repository)
} else {
arrived
}
when (shown) {
is RepositoryListUiState.ReferencesLoading -> {
BackHandler(onBack = onBackToRepositories)
ScreenTopBar(title = shown.repository.name, onBack = onBackToRepositories, subtitle = shown.repository.owner)
CenteredLoading(stringResource(R.string.repositories_references_loading))
}
is RepositoryListUiState.ReferencesContent -> {
BackHandler(onBack = onBackToRepositories)
ReferencesContent(shown, onReferenceSelected, onReferencesRefresh, onViewWorkItems, onBackToRepositories)
}
is RepositoryListUiState.ReferencesError -> {
BackHandler(onBack = onBackToRepositories)
ScreenTopBar(title = shown.repository.name, onBack = onBackToRepositories, subtitle = shown.repository.owner)
CenteredMessage(
stringResource(shown.type.messageResource),
action = stringResource(R.string.repositories_retry),
onAction = onRetry,
secondaryAction = stringResource(R.string.repositories_back),
onSecondaryAction = onBackToRepositories,
)
}
else -> Unit
}
}
}
}
}
}
}
private enum class RepositoryDepth { LIST, REFERENCES }
@Composable
private fun SignedOutState(message: String, action: String, onAction: () -> Unit) {
Column(

View File

@ -6,6 +6,7 @@ import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
@ -45,6 +46,7 @@ import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -123,13 +125,31 @@ private fun DrawScope.drawTeaCup(color: Color, steam: Float, steamAlpha: Float =
}
}
/**
* Whether what is composed here can be seen. A tab kept composed beside the one shown is not, and
* nothing in it should keep the display redrawing: looping motion rests while this is false.
*/
val LocalOnScreen = compositionLocalOf { true }
/** A value going round from [from] to [to] for as long as it can be seen; [from] when it cannot. */
@Composable
private fun rememberSteam(): () -> Float {
val transition = rememberInfiniteTransition(label = "steam")
val steam by transition.animateFloat(0f, 1f, infiniteRepeatable(tween(1600, easing = LinearEasing)), label = "steam")
return { steam }
private fun rememberLoop(
from: Float,
to: Float,
millis: Int,
easing: Easing = LinearEasing,
repeatMode: RepeatMode = RepeatMode.Restart,
label: String,
): () -> Float {
if (!LocalOnScreen.current) return { from }
val transition = rememberInfiniteTransition(label = label)
val value by transition.animateFloat(from, to, infiniteRepeatable(tween(millis, easing = easing), repeatMode), label = label)
return { value }
}
@Composable
private fun rememberSteam(): () -> Float = rememberLoop(0f, 1f, 1600, label = "steam")
/** The tea cup with rising steam: the app's "working on it" mark. */
/**
* Holds [content] back for [LoaderDelayMillis] and then fades it in. A wait shorter than that
@ -159,8 +179,7 @@ fun TeaCupLoader(modifier: Modifier = Modifier, size: Dp = 48.dp, color: Color =
*/
@Composable
fun ListSkeleton(modifier: Modifier = Modifier, rows: Int = 6, label: String? = null) {
val transition = rememberInfiniteTransition(label = "shimmer")
val sweep by transition.animateFloat(-1f, 2f, infiniteRepeatable(tween(1300, easing = LinearEasing)), label = "shimmer")
val sweep = rememberLoop(-1f, 2f, 1300, label = "shimmer")
val base = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.07f)
val light = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.16f)
Column(
@ -176,7 +195,7 @@ fun ListSkeleton(modifier: Modifier = Modifier, rows: Int = 6, label: String? =
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val shimmer = Modifier.drawShimmer(base, light) { sweep }
val shimmer = Modifier.drawShimmer(base, light, sweep)
Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).then(shimmer))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Rows differ in length so the block does not read as a table.
@ -205,13 +224,7 @@ private fun Modifier.drawShimmer(base: Color, light: Color, sweep: () -> Float):
/** The cup, bobbing a little: sits above the message on a screen that has nothing to list. */
@Composable
fun EmptyCup(modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition(label = "empty")
val bob by transition.animateFloat(
-1f,
1f,
infiniteRepeatable(tween(1800, easing = FastOutSlowInEasing), RepeatMode.Reverse),
label = "bob",
)
val bob = rememberLoop(-1f, 1f, 1800, FastOutSlowInEasing, RepeatMode.Reverse, label = "bob")
val steam = rememberSteam()
val color = UBGiteaTheme.colors.brand.copy(alpha = 0.55f)
Canvas(
@ -219,8 +232,8 @@ fun EmptyCup(modifier: Modifier = Modifier) {
.padding(bottom = 20.dp)
.size(56.dp, 67.dp)
.graphicsLayer {
translationY = bob * 3.dp.toPx()
rotationZ = bob * 2f
translationY = bob() * 3.dp.toPx()
rotationZ = bob() * 2f
},
) { drawTeaCup(color, steam()) }
}
@ -376,6 +389,11 @@ fun <T> TabPager(tabs: List<T>, current: T, modifier: Modifier = Modifier, conte
override val onBackPressedDispatcher = OnBackPressedDispatcher()
}
}
// What a page reads about the tab shown, it reads from state: a tab press then recomposes the
// two small scopes below and not the screens inside them.
val shown = rememberUpdatedState(current)
// A pager inside a tab: its pages are on screen only while that tab is.
val pagerOnScreen = LocalOnScreen.current
HorizontalPager(
pager,
modifier.fillMaxSize(),
@ -383,8 +401,13 @@ fun <T> TabPager(tabs: List<T>, current: T, modifier: Modifier = Modifier, conte
userScrollEnabled = false,
key = { it },
) { page ->
val owner = if (tabs[page] == current && backOwner != null) backOwner else noBack
CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides owner) { content(tabs[page]) }
val owner = if (tabs[page] == shown.value && backOwner != null) backOwner else noBack
val onScreen by remember(page) {
derivedStateOf { abs(pager.currentPage + pager.currentPageOffsetFraction - page) < 1f }
}
CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides owner, LocalOnScreen provides (pagerOnScreen && onScreen)) {
content(tabs[page])
}
}
}

View File

@ -1675,3 +1675,36 @@ This is the append-only implementation and handoff record shared by Codex and Cl
- 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.
## 2026-10-12 (continued) — TASK-069: the stall at the start of every move
- Request: the product owner asked that the stutter be looked at on their phone directly. It was
driven over adb (taps, Back) and measured with `dumpsys gfxinfo framestats` and method
sampling (`am profile`), on the debug build, on the 120 Hz cover screen.
- What was found: every move began with one frame of 45 to 118 ms (5 to 14 frames lost).
1. `LocalCodeWorkbench` is a static composition local and its value was built again on every
recomposition of the root, which every route change causes. A changed static local turns
skipping off beneath it, so all four tab screens were composed again on each move.
2. The loading placeholder's light and the empty screen's cup kept animating on the tabs kept
composed off screen: the app drew 120 frames a second while nothing moved.
3. Changing repository tab built the incoming list of rows in the frame the slide started on.
4. A repository's branches replaced the list in place, with no movement, and Back built the
whole list again in one frame.
- What changed: the workbench value is remembered and made again only when its contents change.
`LocalOnScreen` tells a page whether it is on screen and the looping animations stop when it
is not. `MainActivity` hands the screens remembered callbacks and a remembered screen lambda.
The two repository lists sit in a `TabPager`, and so do the list and the branches of the
repository opened from it; branches that arrive during the slide wait for it to finish.
- Measured, first frame of the move, before to after: bottom tabs 80–118 ms to 23–34 ms;
repository tab 69–77 ms to 34–52 ms; idle, 120 frames a second to none.
- Not measured: the branches opening beside the list, and Back from them. The phone's screen was
turned off before that run; the change builds and passes the checks but has not been seen.
Before it, opening was a cut followed by a 49–59 ms frame when the branches arrived, and Back
was a cut with one frame of 69–105 ms.
- Design decision: the branches screen now slides in like a pushed screen, where it used to
replace the list at once. This is a visible change made for smoothness and is the product
owner's to keep or reverse.
- Checks and results: the five required checks pass.
- Caution: what remains (about 25 to 50 ms on the first frame) is in good part the debug build;
a non-debuggable build of the same code measured about a third less. R8 is still on hold
under TASK-016.

View File

@ -34,6 +34,11 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c
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.
13. Measured on the product owner's device over adb (2026-10-12), at their request: every tab
change, repository tab change, opening and Back began with one frame of 45 to 118 ms on a
120 Hz screen. A move now starts without every screen being drawn again; the two repository
lists stay built side by side; a repository's branches open beside the list and not in place
of it; nothing that loops is drawn while it is off screen.
## Scope
@ -58,7 +63,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 twelve items above is in the code and the app builds.
- [x] Each of the thirteen 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