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
Expand Up @@ -345,27 +345,25 @@ internal fun UserControlBottomBar(
dispatch = dispatch,
mentionListModifier = Modifier.measured { mentionListHeight = it.height },
)
// Editing swaps the leading control rather than adding a banner above the bar:
// send-cash is not reachable mid-edit anyway, and cancel is what the slot is
// for while the edit is open.
val outsideControl: @Composable () -> Unit = {
// Editing swaps the send-cash button for a cancel control on the other side of
// the field rather than adding a banner above the bar: send-cash is not
// reachable mid-edit anyway, and cancel is what the leading slot is for while
// the edit is open.
val cancelEdit: @Composable () -> Unit = {
CancelEditButton(onClick = { dispatch(ChatViewModel.Event.CancelEdit) })
}
val sendCash: @Composable () -> Unit = {
Row {
if (state.editing != null) {
CancelEditButton(
onClick = { dispatch(ChatViewModel.Event.CancelEdit) },
)
} else {
SendCashButton(
state = state,
hazeState = hazeState,
hazeMaterial = material,
onClick = {
keyboard.hideIfVisible {
dispatch(ChatViewModel.Event.OnSendCash)
}
SendCashButton(
state = state,
hazeState = hazeState,
hazeMaterial = material,
onClick = {
keyboard.hideIfVisible {
dispatch(ChatViewModel.Event.OnSendCash)
}
)
}
}
)
}
}
// Without a composer the send-cash button is the whole bar.
Expand All @@ -374,7 +372,7 @@ internal fun UserControlBottomBar(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = sideInset),
) { outsideControl() }
) { if (state.editing != null) cancelEdit() else sendCash() }
}
Row(
modifier = Modifier
Expand All @@ -401,8 +399,8 @@ internal fun UserControlBottomBar(
modifier = Modifier
.testTag("chat_message_input")
.hazeBlur(HazeInput.Sources(hazeState), material),
outside = outsideControl,
outsideCollapses = state.editing == null,
outside = if (state.editing != null) cancelEdit else null,
trailingOutside = if (state.editing == null) sendCash else null,
focusRequester = focusRequester,
hint = stringResource(
if (state.replyingTo != null) R.string.hint_chatReply
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ internal data class AttachMetrics(
val menuCorner: Dp,
/** The composer field's padding: the menu's leading edge sits this far out from "+"'s. */
val fieldPadding: Dp,
/** The menu reaches out past the field to this inset from the screen's edge, over the "$" button. */
/** The menu reaches out past the field to this inset from the screen's edge. */
val menuLeadingInset: Dp,
/** Height of a one-line composer field plus its padding: iOS `BarMetrics.contentHeight`. */
val barContentHeight: Dp,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.zIndex
import androidx.compose.ui.unit.dp
import com.getcode.theme.CodeTheme
import com.getcode.theme.DesignSystem
Expand Down Expand Up @@ -286,11 +287,12 @@ fun ChatInput(
leading: (@Composable () -> Unit)? = null,
// Content in the field above the text, such as staged photos. Null for none.
header: (@Composable () -> Unit)? = null,
// A control beside the field, outside it (send cash, cancel edit). Null for none.
// A control before the field, outside it (cancel edit). Stays while the field holds text. Null
// for none.
outside: (@Composable () -> Unit)? = null,
// Whether [outside] makes way while the field holds text. Off for a control that must stay,
// like cancelling an edit.
outsideCollapses: Boolean = true,
// A control after the field, outside it (send cash). Makes way while the field holds text.
// Null for none.
trailingOutside: (@Composable () -> Unit)? = null,
) {
val textStyle = CodeTheme.typography.textMedium.copy(
fontSize = 16.sp,
Expand All @@ -303,13 +305,18 @@ fun ChatInput(
val sendAlpha by animateFloatAsState(if (sendVisible) 1f else 0f, sendSpec, label = "send alpha")
val sendScale by animateFloatAsState(if (sendVisible) 1f else 0.6f, sendSpec, label = "send scale")

val outsideShown = outside != null && !(outsideCollapses && hasText)
// spring(duration 0.4, bounce 0.3)
val outsideSpec = spring<Float>(dampingRatio = 0.7f, stiffness = 247f)
val outsideFraction by animateFloatAsState(
targetValue = if (outsideShown) 1f else 0f,
animationSpec = spring(dampingRatio = 0.7f, stiffness = 247f),
targetValue = if (outside != null) 1f else 0f,
animationSpec = outsideSpec,
label = "outside fraction",
)
val trailingOutsideFraction by animateFloatAsState(
targetValue = if (trailingOutside != null && !hasText) 1f else 0f,
animationSpec = outsideSpec,
label = "trailing outside fraction",
)

var fieldWidth by remember { mutableIntStateOf(0) }
var leadingWidth by remember { mutableIntStateOf(0) }
Expand Down Expand Up @@ -355,27 +362,13 @@ fun ChatInput(
verticalAlignment = Alignment.Bottom,
) {
if (outside != null) {
Box(
modifier = Modifier
.layout { measurable, constraints ->
val size = outsideSize.roundToPx()
val f = outsideFraction.coerceAtLeast(0f)
val p = measurable.measure(Constraints.fixed(size, size))
val gap = (metrics.outsideSpacing.roundToPx() * f).roundToInt()
layout(((size * f).roundToInt() + gap).coerceAtLeast(0), size) {
p.placeWithLayer(0, 0) {
val k = 0.4f + 0.6f * f.coerceAtMost(1f)
scaleX = k
scaleY = k
alpha = f.coerceIn(0f, 1f)
}
}
},
) { outside() }
OutsideSlot(outsideFraction, outsideSize, metrics.outsideSpacing, outside)
}
Layout(
modifier = Modifier
.weight(1f)
// Above the trailing control, which the field slides over as it widens.
.zIndex(1f)
.onSizeChanged { fieldWidth = it.width }
.clip(shape)
.then(modifier)
Expand Down Expand Up @@ -458,9 +451,72 @@ fun ChatInput(
)
},
)
if (trailingOutside != null) {
TrailingOutsideSlot(trailingOutsideFraction, outsideSize, metrics.outsideSpacing, trailingOutside)
}
}
}

/**
* The control before the field. It scales and fades in with [fraction] and takes its width, and
* the [spacing] between it and the field, from the row as it does.
*/
@Composable
private fun OutsideSlot(
fraction: Float,
size: Dp,
spacing: Dp,
content: @Composable () -> Unit,
) {
Box(
modifier = Modifier.layout { measurable, _ ->
val sizePx = size.roundToPx()
val f = fraction.coerceAtLeast(0f)
val p = measurable.measure(Constraints.fixed(sizePx, sizePx))
val gap = (spacing.roundToPx() * f).roundToInt()
layout(((sizePx * f).roundToInt() + gap).coerceAtLeast(0), sizePx) {
p.placeWithLayer(0, 0) {
val k = 0.4f + 0.6f * f.coerceAtMost(1f)
scaleX = k
scaleY = k
alpha = f.coerceIn(0f, 1f)
}
}
},
) { content() }
}

/**
* The control after the field. The row gives its width up to the field as [fraction] falls, but the
* control stays where it is: it fades out and shrinks in place while the field, drawn above it,
* widens across it, and plays the same in reverse as [fraction] rises.
*/
@Composable
private fun TrailingOutsideSlot(
fraction: Float,
size: Dp,
spacing: Dp,
content: @Composable () -> Unit,
) {
Box(
modifier = Modifier.layout { measurable, _ ->
val sizePx = size.roundToPx()
val f = fraction.coerceAtLeast(0f)
val p = measurable.measure(Constraints.fixed(sizePx, sizePx))
val width = ((sizePx + spacing.roundToPx()) * f).roundToInt().coerceAtLeast(0)
layout(width, sizePx) {
// Pinned to the row's trailing edge however much width the slot reports.
p.placeWithLayer(width - sizePx, 0) {
val k = 0.5f + 0.5f * f.coerceIn(0f, 1f)
scaleX = k
scaleY = k
alpha = f.coerceIn(0f, 1f)
}
}
},
) { content() }
}

/**
* Lays the composer's four parts out in one of two arrangements without recomposing them, so the
* text field keeps its place in the tree (and its focus and IME session) when the layout changes.
Expand Down
Loading