Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
package com.flipcash.app.messenger.internal.screens

import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.testTag
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.collectAsLazyPagingItems
Expand All @@ -18,6 +16,8 @@ import com.flipcash.app.messenger.internal.screens.components.MessageList
import com.flipcash.app.messenger.internal.screens.components.UserControlBottomBar
import com.flipcash.shared.chat.models.ChatAction
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.ui.theme.CodeScaffold
import com.getcode.ui.theme.ScaffoldBarPlacement
import com.getcode.ui.utils.rememberKeyboardController
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
Expand Down Expand Up @@ -75,7 +75,12 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
Unit
}

ChatInputScaffold(
CodeScaffold(
// The input bar rides the keyboard; the message list is inset by it either way.
modifier = Modifier.imePadding(),
// The list runs the full height and passes under both bars, each of which fades it out
// against the background at its own edge.
barPlacement = ScaffoldBarPlacement.Overlay,
topBar = { ChatTopBar(navigator, state, chatActionHandler) },
bottomBar = {
UserControlBottomBar(
Expand All @@ -100,45 +105,3 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
)
}
}

@Composable
private fun ChatInputScaffold(
topBar: @Composable () -> Unit = {},
bottomBar: @Composable () -> Unit = {},
content: @Composable (PaddingValues) -> Unit,
) {
// Overlay scaffold: the content fills the whole area (drawn behind the blurred bars) and is
// inset by the bar heights. SubcomposeLayout measures the bars BEFORE the content in the same
// layout pass, so the content receives correct padding on the very first frame. The previous
// onSizeChanged approach fed 0 padding on frame 1 and snapped to the real heights on frame 2,
// which made the message list visibly jump on every open and every pop-back.
SubcomposeLayout(
modifier = Modifier
.imePadding(),
) { constraints ->
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)

val topPlaceables = subcompose(ChatScaffoldSlot.Top, topBar).map { it.measure(looseConstraints) }
val bottomPlaceables = subcompose(ChatScaffoldSlot.Bottom, bottomBar).map { it.measure(looseConstraints) }
val topHeight = topPlaceables.maxOfOrNull { it.height } ?: 0
val bottomHeight = bottomPlaceables.maxOfOrNull { it.height } ?: 0

val padding = PaddingValues(
top = topHeight.toDp(),
bottom = bottomHeight.toDp(),
)

val contentPlaceables = subcompose(ChatScaffoldSlot.Content) { content(padding) }
.map { it.measure(constraints) }

layout(constraints.maxWidth, constraints.maxHeight) {
contentPlaceables.forEach { it.place(0, 0) }
topPlaceables.forEach { it.place((constraints.maxWidth - it.width) / 2, 0) }
bottomPlaceables.forEach {
it.place((constraints.maxWidth - it.width) / 2, constraints.maxHeight - it.height)
}
}
}
}

private enum class ChatScaffoldSlot { Top, Bottom, Content }
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
Expand All @@ -15,16 +16,19 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.chat.ChatIdentifier
Expand All @@ -42,6 +46,7 @@ import com.getcode.ui.components.AppBarDefaults
import com.getcode.ui.components.AppBarWithTitle
import com.getcode.ui.core.verticalScrollStateGradient
import com.getcode.ui.theme.CodeScaffold
import com.getcode.ui.theme.ScaffoldBarPlacement

/**
* The tip DM conversation list — always a step in the tipping [TippingFlowScreen] flow, so it shares
Expand All @@ -55,9 +60,31 @@ fun TipsScreen() {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val navigator = LocalCodeNavigator.current

val chats = state.tipChats
val listState = rememberLazyListState()

CodeScaffold(
// The list runs the full height and passes under the title bar, which fades it out against
// the background at its own edge — the same treatment the chat screen gives its message
// list, rather than cutting the list off at the bar.
barPlacement = ScaffoldBarPlacement.Overlay,
topBar = {
val backgroundColor = CodeTheme.colors.background
AppBarWithTitle(
// Drawn behind the bar rather than as a box sized to it, so the scrim's reach past
// the bar's bottom edge stays out of the bar's own measurement — which is what the
// list is padded by.
modifier = Modifier.drawBehind {
val scrimHeight = size.height + ScrimTail.toPx()
drawRect(
brush = Brush.verticalGradient(
colors = listOf(backgroundColor, Color.Transparent),
startY = 0f,
endY = scrimHeight,
),
size = size.copy(height = scrimHeight),
)
},
title = stringResource(R.string.title_chats),
// Centred rather than flush-start: an empty leading slot reserves no width, so a
// Start title sits at the inset and reads as off-centre against the Add button.
Expand All @@ -69,25 +96,28 @@ fun TipsScreen() {
AppBarDefaults.Add { navigator.push(AppRoute.Messaging.NewChat) }
},
)
}
) { padding ->
val chats = state.tipChats
val listState = rememberLazyListState()
},
) { barPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
// Scroll anchor for UI tests: `send_contact_row` addresses a single row, this
// addresses the scrollable list itself.
.testTag("chat_list")
.padding(padding)
// After the padding so the fade lands on the list viewport — flush under the app
// bar — rather than on the bar's own space. Rows dissolve into the background at
// whichever edge is still scrollable instead of being cut off.
.verticalScrollStateGradient(scrollState = listState),
// End edge only — the start edge is the bar's scrim now, and a second fade there
// would darken rows twice over as they pass under the title.
.verticalScrollStateGradient(scrollState = listState, showAtStart = false),
state = listState,
// Clears the hoisted tab bar: keeps the last row reachable and centers the empty state
// in the space the bar leaves visible.
contentPadding = LocalTabBarPadding.current,
contentPadding = PaddingValues(
// The bar's height as content padding rather than as a layout inset: the viewport
// runs the full height and rows scroll under the bar, but at rest the first row
// still sits clear of it.
top = barPadding.calculateTopPadding(),
// Clears the hoisted tab bar: keeps the last row reachable. Both paddings are
// measured out of `fillParentMaxSize`, so the empty state stays centered in the
// space the two bars leave visible.
bottom = LocalTabBarPadding.current.calculateBottomPadding(),
),
) {
// Once the feed has loaded and there's nothing to show, the list is replaced by a
// centered prompt.
Expand All @@ -102,6 +132,12 @@ fun TipsScreen() {
}
}

/**
* How far past the bar's bottom edge the scrim reaches before it is fully transparent. Rows begin
* dissolving this far below the title rather than only once they meet it.
*/
private val ScrimTail = 48.dp

/**
* The "Chats" tab empty state (node 9340:2746) — bubble mark, title and prompt, centered in the
* space the caller gives it (the list viewport).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.flipcash.app.tipping

import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.unit.dp
import com.flipcash.app.theme.FlipcashPreview
import com.getcode.ui.theme.CodeScaffold
import com.getcode.ui.theme.ScaffoldBarPlacement
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode

/**
* The Chats tab draws its list under the title bar and pads it by the bar's height, so rows scroll
* beneath the title and fade into it rather than being cut at its edge. That is
* [ScaffoldBarPlacement.Overlay], and both halves of it are checked here: the list's viewport spans
* the whole screen (it is not inset by either bar), and the first row still rests below the top bar
* — on the first frame, with no measurement pass to jump from.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi")
class ChatsTabLayoutTest {

@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()

@Test
fun listFillsScreenAndFirstRowClearsTheBar() {
composeRule.setContent {
FlipcashPreview(showBackground = true) {
CodeScaffold(
barPlacement = ScaffoldBarPlacement.Overlay,
topBar = { Box(Modifier.fillMaxWidth().height(56.dp).testTag("bar")) },
bottomBar = { Box(Modifier.fillMaxWidth().height(80.dp).testTag("bottom")) },
) { barPadding ->
LazyColumn(
modifier = Modifier.fillMaxSize().testTag("list"),
contentPadding = PaddingValues(top = barPadding.calculateTopPadding()),
) {
items((0 until 40).toList()) {
Box(Modifier.fillMaxWidth().height(40.dp).testTag("row_$it"))
}
}
}
}
}
composeRule.waitForIdle()

assertEquals("list starts at the top of the screen", 0f, top("list"), 0.5f)
assertEquals(
"list runs past the bottom bar",
top("bottom") + height("bottom"),
top("list") + height("list"),
0.5f,
)
assertEquals(
"first row rests below the top bar",
top("bar") + height("bar"),
top("row_0"),
0.5f,
)
}

private fun top(tag: String): Float =
composeRule.onNode(hasTestTag(tag)).fetchSemanticsNode().positionInRoot.y

private fun height(tag: String): Float =
composeRule.onNode(hasTestTag(tag)).fetchSemanticsNode().size.height.toFloat()
}
78 changes: 74 additions & 4 deletions ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,25 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.unit.Dp
import com.getcode.theme.CodeTheme
import com.getcode.theme.extraLarge

/** Where a scaffold's bars sit relative to its content. */
enum class ScaffoldBarPlacement {
/** The bars take their own space and the content is laid out between them. */
Inset,

/**
* The bars are drawn over the content, which fills the whole area and is handed their heights
* as padding to inset itself by. Applied as content padding on a scrolling list, rows run
* underneath the bars — fading into whatever fade the bar draws over them — instead of being
* cut off at the bar's edge, while still coming to rest clear of it.
*/
Overlay,
}

@Composable
fun CodeScaffold(
modifier: Modifier = Modifier,
Expand All @@ -40,13 +55,18 @@ fun CodeScaffold(
drawerScrimColor: Color = CodeTheme.colors.brandLight,
backgroundColor: Color = CodeTheme.colors.background,
contentColor: Color = CodeTheme.colors.onBackground,
barPlacement: ScaffoldBarPlacement = ScaffoldBarPlacement.Inset,
content: @Composable (PaddingValues) -> Unit
) {
val isOverlay = barPlacement == ScaffoldBarPlacement.Overlay
Scaffold(
modifier = modifier,
scaffoldState = scaffoldState,
topBar = topBar,
bottomBar = bottomBar,
// Overlay hands the bars to [OverlayBars] below instead: [Scaffold]'s own slots shorten
// the body by the bars' heights and place it between them, which is the placement Overlay
// exists to avoid.
topBar = if (isOverlay) ({}) else topBar,
bottomBar = if (isOverlay) ({}) else bottomBar,
snackbarHost = snackbarHost,
floatingActionButton = floatingActionButton,
floatingActionButtonPosition = floatingActionButtonPosition,
Expand All @@ -60,6 +80,56 @@ fun CodeScaffold(
drawerScrimColor = drawerScrimColor,
backgroundColor = backgroundColor,
contentColor = contentColor,
content = content
content = { padding ->
if (isOverlay) {
OverlayBars(topBar = topBar, bottomBar = bottomBar, content = content)
} else {
content(padding)
}
}
)
}
}

/**
* Draws [topBar] and [bottomBar] over a full-size [content], which is handed their heights as
* padding.
*
* The bars are subcomposed and measured BEFORE the content in the same layout pass, so the content
* receives correct padding on the very first frame. Measuring them with `onSizeChanged` instead fed
* 0 padding on frame 1 and snapped to the real heights on frame 2, which made the chat's message
* list visibly jump on every open and every pop-back.
*/
@Composable
private fun OverlayBars(
topBar: @Composable () -> Unit,
bottomBar: @Composable () -> Unit,
content: @Composable (PaddingValues) -> Unit,
) {
SubcomposeLayout { constraints ->
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)

val topPlaceables = subcompose(OverlaySlot.Top, topBar).map { it.measure(looseConstraints) }
val bottomPlaceables =
subcompose(OverlaySlot.Bottom, bottomBar).map { it.measure(looseConstraints) }
val topHeight = topPlaceables.maxOfOrNull { it.height } ?: 0
val bottomHeight = bottomPlaceables.maxOfOrNull { it.height } ?: 0

val padding = PaddingValues(
top = topHeight.toDp(),
bottom = bottomHeight.toDp(),
)

val contentPlaceables = subcompose(OverlaySlot.Content) { content(padding) }
.map { it.measure(constraints) }

layout(constraints.maxWidth, constraints.maxHeight) {
contentPlaceables.forEach { it.place(0, 0) }
topPlaceables.forEach { it.place((constraints.maxWidth - it.width) / 2, 0) }
bottomPlaceables.forEach {
it.place((constraints.maxWidth - it.width) / 2, constraints.maxHeight - it.height)
}
}
}
}

private enum class OverlaySlot { Top, Bottom, Content }
Loading