Simplify message composer layout (#4884)
Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
parent
6a088396ed
commit
a10734de02
17 changed files with 574 additions and 435 deletions
|
|
@ -7,8 +7,10 @@
|
|||
|
||||
package io.element.android.libraries.androidutils.ui
|
||||
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.WindowInsets
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.core.content.getSystemService
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
|
@ -23,8 +25,13 @@ fun View.showKeyboard(andRequestFocus: Boolean = false) {
|
|||
if (andRequestFocus) {
|
||||
requestFocus()
|
||||
}
|
||||
val imm = context?.getSystemService<InputMethodManager>()
|
||||
imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
windowInsetsController?.show(WindowInsets.Type.ime())
|
||||
} else {
|
||||
val imm = context?.getSystemService<InputMethodManager>()
|
||||
imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
}
|
||||
|
||||
fun View.isKeyboardVisible(): Boolean {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,280 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.view.MotionEvent
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EditText
|
||||
import androidx.appcompat.app.ActionBar.LayoutParams
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun ExpandableBottomSheetLayout(
|
||||
sheetDragHandle: @Composable BoxScope.() -> Unit,
|
||||
bottomSheetContent: @Composable ColumnScope.() -> Unit,
|
||||
state: ExpandableBottomSheetLayoutState,
|
||||
maxBottomSheetContentHeight: Dp,
|
||||
isSwipeGestureEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
sheetShape: Shape = RectangleShape,
|
||||
backgroundColor: Color = Color.Transparent,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var minBottomContentHeightPx by remember { mutableIntStateOf(0) }
|
||||
var currentBottomContentHeightPx by remember { mutableIntStateOf(minBottomContentHeightPx) }
|
||||
val maxBottomContentHeightPx = with(LocalDensity.current) { maxBottomSheetContentHeight.roundToPx() }
|
||||
var calculatedMaxBottomContentHeightPx by remember(maxBottomContentHeightPx) { mutableIntStateOf(maxBottomContentHeightPx) }
|
||||
val animatable = remember { Animatable(0f) }
|
||||
|
||||
fun calculatePercentage(currentPos: Int, minPos: Int, maxPos: Int): Float {
|
||||
val currentProgress = currentPos - minPos
|
||||
if (currentProgress < 0) {
|
||||
Timber.e("Invalid current progress: $currentProgress, minPos: $minPos, maxPos: $maxPos")
|
||||
return 0f
|
||||
}
|
||||
val total = (maxPos - minPos).toFloat()
|
||||
if (total <= 0) {
|
||||
Timber.e("Invalid total space: $total, minPos: $minPos, maxPos: $maxPos")
|
||||
return 0f
|
||||
}
|
||||
return currentProgress / total
|
||||
}
|
||||
|
||||
LaunchedEffect(animatable.value) {
|
||||
if (animatable.isRunning && animatable.value != animatable.targetValue) {
|
||||
currentBottomContentHeightPx = animatable.value.roundToInt()
|
||||
state.internalDraggingPercentage = calculatePercentage(
|
||||
currentPos = currentBottomContentHeightPx,
|
||||
minPos = minBottomContentHeightPx,
|
||||
maxPos = calculatedMaxBottomContentHeightPx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val composables = @Composable {
|
||||
content()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(sheetShape)
|
||||
.background(backgroundColor)
|
||||
.run {
|
||||
if (isSwipeGestureEnabled) {
|
||||
pointerInput(maxBottomSheetContentHeight) {
|
||||
detectVerticalDragGestures(
|
||||
onVerticalDrag = { _, dragAmount ->
|
||||
val calculatedHeight = max(minBottomContentHeightPx, currentBottomContentHeightPx - dragAmount.roundToInt())
|
||||
val newHeight = min(calculatedMaxBottomContentHeightPx, calculatedHeight)
|
||||
state.internalPosition = when (newHeight) {
|
||||
calculatedMaxBottomContentHeightPx -> ExpandableBottomSheetLayoutState.Position.EXPANDED
|
||||
minBottomContentHeightPx -> ExpandableBottomSheetLayoutState.Position.COLLAPSED
|
||||
else -> ExpandableBottomSheetLayoutState.Position.DRAGGING
|
||||
}
|
||||
state.internalDraggingPercentage = calculatePercentage(
|
||||
currentPos = newHeight,
|
||||
minPos = minBottomContentHeightPx,
|
||||
maxPos = calculatedMaxBottomContentHeightPx,
|
||||
)
|
||||
currentBottomContentHeightPx = newHeight
|
||||
},
|
||||
onDragEnd = {
|
||||
coroutineScope.launch {
|
||||
val middle = (calculatedMaxBottomContentHeightPx + minBottomContentHeightPx) / 2
|
||||
animatable.snapTo(currentBottomContentHeightPx.toFloat())
|
||||
|
||||
val destination = if (currentBottomContentHeightPx > middle) {
|
||||
state.internalPosition = ExpandableBottomSheetLayoutState.Position.EXPANDED
|
||||
calculatedMaxBottomContentHeightPx
|
||||
} else {
|
||||
state.internalPosition = ExpandableBottomSheetLayoutState.Position.COLLAPSED
|
||||
minBottomContentHeightPx
|
||||
}.toFloat()
|
||||
|
||||
animatable.animateTo(destination)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
sheetDragHandle()
|
||||
}
|
||||
bottomSheetContent()
|
||||
}
|
||||
}
|
||||
Layout(
|
||||
content = composables,
|
||||
modifier = modifier,
|
||||
measurePolicy = { measurables, constraints ->
|
||||
calculatedMaxBottomContentHeightPx = min(constraints.maxHeight, maxBottomContentHeightPx)
|
||||
|
||||
val contentMeasurables = measurables[0]
|
||||
val bottomContentMeasurables = measurables[1]
|
||||
|
||||
val minIntrinsicHeight = bottomContentMeasurables.minIntrinsicHeight(constraints.maxWidth)
|
||||
val lastMinBottomContentHeightPx = minBottomContentHeightPx
|
||||
minBottomContentHeightPx = min(minIntrinsicHeight, calculatedMaxBottomContentHeightPx)
|
||||
|
||||
val isExpanded = state.position == ExpandableBottomSheetLayoutState.Position.EXPANDED
|
||||
if (lastMinBottomContentHeightPx != minBottomContentHeightPx && !isExpanded) {
|
||||
currentBottomContentHeightPx = minBottomContentHeightPx
|
||||
}
|
||||
|
||||
val measuredBottomContent = bottomContentMeasurables.measure(
|
||||
Constraints.fixed(
|
||||
constraints.maxWidth,
|
||||
max(minBottomContentHeightPx, currentBottomContentHeightPx)
|
||||
)
|
||||
)
|
||||
|
||||
var remainingHeight = constraints.maxHeight - currentBottomContentHeightPx
|
||||
if (remainingHeight < 0) {
|
||||
Timber.e("Remaining height is negative: $remainingHeight, resetting to 0")
|
||||
remainingHeight = 0
|
||||
}
|
||||
|
||||
val contentPlaceable = contentMeasurables.measure(
|
||||
Constraints.fixed(constraints.maxWidth, remainingHeight)
|
||||
)
|
||||
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
contentPlaceable.place(0, 0)
|
||||
measuredBottomContent.place(IntOffset(0, constraints.maxHeight - currentBottomContentHeightPx), zIndex = 10f)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
@Suppress("UnusedPrivateMember")
|
||||
internal fun ExpandableBottomSheetLayoutPreview() {
|
||||
ExpandableBottomSheetLayout(
|
||||
sheetDragHandle = {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(vertical = 6.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.align(Alignment.Center)
|
||||
.size(100.dp, 8.dp)
|
||||
.background(Color.Gray)
|
||||
)
|
||||
},
|
||||
content = {
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
Text("This is the main content", modifier = Modifier.padding(16.dp).align(Alignment.Center))
|
||||
}
|
||||
},
|
||||
bottomSheetContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = true)
|
||||
.padding(horizontal = 10.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color.Blue)
|
||||
) {
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color.LightGray),
|
||||
factory = { context ->
|
||||
PreviewEditText(context).apply {
|
||||
val initialText = "1111\n2222\n3333\n4444\n5555\n6666"
|
||||
setText(initialText)
|
||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Text("A footer", modifier = Modifier.padding(vertical = 6.dp, horizontal = 16.dp))
|
||||
},
|
||||
maxBottomSheetContentHeight = 1800.dp,
|
||||
isSwipeGestureEnabled = true,
|
||||
backgroundColor = Color.White,
|
||||
state = rememberExpandableBottomSheetLayoutState(),
|
||||
sheetShape = RoundedCornerShape(20.dp, 20.dp, 0.dp, 0.dp),
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.windowInsetsPadding(WindowInsets.ime)
|
||||
.fillMaxSize()
|
||||
.background(Color.Red.copy(alpha = 0.2f)),
|
||||
)
|
||||
}
|
||||
|
||||
// This is just for preview purposes
|
||||
@SuppressLint("AppCompatCustomView")
|
||||
private class PreviewEditText(context: Context) : EditText(context) {
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
|
||||
parent?.requestDisallowInterceptTouchEvent(true)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent?): Boolean {
|
||||
super.onTouchEvent(event)
|
||||
parent?.requestDisallowInterceptTouchEvent(true)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
|
||||
return super.dispatchTouchEvent(event)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/**
|
||||
* Creates and remembers an [ExpandableBottomSheetLayoutState].
|
||||
*/
|
||||
@Composable
|
||||
fun rememberExpandableBottomSheetLayoutState(): ExpandableBottomSheetLayoutState {
|
||||
return remember { ExpandableBottomSheetLayoutState() }
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the [ExpandableBottomSheetLayout].
|
||||
*
|
||||
* This state holds the current position of the bottom sheet layout and the percentage of the layout that is being dragged.
|
||||
*/
|
||||
@Stable
|
||||
class ExpandableBottomSheetLayoutState {
|
||||
internal var internalPosition: Position by mutableStateOf(Position.COLLAPSED)
|
||||
internal var internalDraggingPercentage: Float by mutableFloatStateOf(
|
||||
if (internalPosition == Position.EXPANDED) 1f else 0f
|
||||
)
|
||||
|
||||
/**
|
||||
* The current position of the bottom sheet layout.
|
||||
*/
|
||||
val position = internalPosition
|
||||
|
||||
/**
|
||||
* The percentage of the bottom sheet layout that is currently being dragged.
|
||||
* This value ranges from `0f` for [Position.COLLAPSED] to `1f` for [Position.EXPANDED].
|
||||
*/
|
||||
val draggingPercentage = internalDraggingPercentage
|
||||
|
||||
/**
|
||||
* The position of the bottom sheet layout.
|
||||
*/
|
||||
enum class Position {
|
||||
/** The bottom sheet is collapsed to its minimum visible height. */
|
||||
COLLAPSED,
|
||||
|
||||
/** The bottom sheet is being dragged by user input. */
|
||||
DRAGGING,
|
||||
|
||||
/** The bottom sheet is expanded to its maximum visible height. */
|
||||
EXPANDED
|
||||
}
|
||||
}
|
||||
|
|
@ -7,8 +7,6 @@
|
|||
|
||||
package io.element.android.libraries.textcomposer
|
||||
|
||||
import android.os.Build
|
||||
import android.view.WindowInsets
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -44,12 +42,8 @@ internal fun <T> SoftKeyboardEffect(
|
|||
view.awaitWindowFocus()
|
||||
|
||||
if (!view.isKeyboardVisible()) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
view.windowInsetsController?.show(WindowInsets.Type.ime())
|
||||
} else {
|
||||
// Show the keyboard, temporarily using the root view for focus
|
||||
view.showKeyboard(andRequestFocus = true)
|
||||
}
|
||||
// Show the keyboard, temporarily using the root view for focus
|
||||
view.showKeyboard(andRequestFocus = true)
|
||||
|
||||
// Refocus to the correct view
|
||||
latestOnRequestFocus()
|
||||
|
|
|
|||
|
|
@ -12,38 +12,41 @@ import android.net.Uri
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredHeightIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.androidutils.ui.showKeyboard
|
||||
import io.element.android.libraries.designsystem.components.media.createFakeWaveform
|
||||
import io.element.android.libraries.designsystem.preview.DAY_MODE_NAME
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
|
|
@ -84,6 +87,7 @@ import io.element.android.wysiwyg.display.TextDisplay
|
|||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.launch
|
||||
import uniffi.wysiwyg_composer.MenuAction
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
|
|
@ -110,7 +114,6 @@ fun TextComposer(
|
|||
resolveAtRoomMentionDisplay: () -> TextDisplay,
|
||||
modifier: Modifier = Modifier,
|
||||
showTextFormatting: Boolean = false,
|
||||
subcomposing: Boolean = false,
|
||||
) {
|
||||
val markdown = when (state) {
|
||||
is TextEditorState.Markdown -> state.state.text.value()
|
||||
|
|
@ -170,20 +173,30 @@ fun TextComposer(
|
|||
} else {
|
||||
when (state) {
|
||||
is TextEditorState.Rich -> {
|
||||
remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val view = LocalView.current
|
||||
remember(state.richTextEditorState, composerMode, onResetComposerMode, onError) {
|
||||
@Composable {
|
||||
TextInputBox(
|
||||
modifier = Modifier.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
state.requestFocus()
|
||||
view.showKeyboard()
|
||||
}
|
||||
}.semantics {
|
||||
hideFromAccessibility()
|
||||
},
|
||||
composerMode = composerMode,
|
||||
onResetComposerMode = onResetComposerMode,
|
||||
isTextEmpty = state.richTextEditorState.messageHtml.isEmpty(),
|
||||
subcomposing = subcomposing,
|
||||
) {
|
||||
RichTextEditor(
|
||||
state = state.richTextEditorState,
|
||||
placeholder = placeholder,
|
||||
// Disable most of the editor functionality if it's just being measured for a subcomposition.
|
||||
// This prevents it gaining focus and mutating the state.
|
||||
registerStateUpdates = !subcomposing,
|
||||
registerStateUpdates = true,
|
||||
modifier = Modifier
|
||||
.padding(top = 6.dp, bottom = 6.dp)
|
||||
.fillMaxWidth(),
|
||||
|
|
@ -205,13 +218,11 @@ fun TextComposer(
|
|||
composerMode = composerMode,
|
||||
onResetComposerMode = onResetComposerMode,
|
||||
isTextEmpty = state.state.text.value().isEmpty(),
|
||||
subcomposing = subcomposing,
|
||||
) {
|
||||
MarkdownTextInput(
|
||||
state = state.state,
|
||||
placeholder = placeholder,
|
||||
placeholderColor = ElementTheme.colors.textSecondary,
|
||||
subcomposing = subcomposing,
|
||||
onTyping = onTyping,
|
||||
onReceiveSuggestion = onReceiveSuggestion,
|
||||
richTextEditorStyle = style,
|
||||
|
|
@ -326,14 +337,12 @@ fun TextComposer(
|
|||
)
|
||||
}
|
||||
|
||||
if (!subcomposing) {
|
||||
SoftKeyboardEffect(composerMode, onRequestFocus) {
|
||||
it is MessageComposerMode.Special
|
||||
}
|
||||
|
||||
SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it }
|
||||
SoftKeyboardEffect(composerMode, onRequestFocus) {
|
||||
it is MessageComposerMode.Special
|
||||
}
|
||||
|
||||
SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it }
|
||||
|
||||
val latestOnReceiveSuggestion by rememberUpdatedState(onReceiveSuggestion)
|
||||
if (state is TextEditorState.Rich) {
|
||||
val menuAction = state.richTextEditorState.menuAction
|
||||
|
|
@ -445,11 +454,8 @@ private fun TextFormattingLayout(
|
|||
sendButton: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val bottomPadding = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() + 8.dp }
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(vertical = 4.dp)
|
||||
.padding(bottom = bottomPadding),
|
||||
modifier = modifier.padding(vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (isRoomEncrypted == false) {
|
||||
|
|
@ -493,7 +499,7 @@ private fun TextInputBox(
|
|||
composerMode: MessageComposerMode,
|
||||
onResetComposerMode: () -> Unit,
|
||||
isTextEmpty: Boolean,
|
||||
subcomposing: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
textInput: @Composable () -> Unit,
|
||||
) {
|
||||
val bgColor = ElementTheme.colors.bgSubtleSecondary
|
||||
|
|
@ -501,12 +507,13 @@ private fun TextInputBox(
|
|||
val roundedCorners = textInputRoundedCornerShape(composerMode = composerMode)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.clip(roundedCorners)
|
||||
.border(0.5.dp, borderColor, roundedCorners)
|
||||
.background(color = bgColor)
|
||||
.requiredHeightIn(min = 42.dp)
|
||||
.fillMaxSize(),
|
||||
.fillMaxSize()
|
||||
.then(modifier),
|
||||
) {
|
||||
if (composerMode is MessageComposerMode.Special) {
|
||||
ComposerModeView(
|
||||
|
|
@ -517,8 +524,7 @@ private fun TextInputBox(
|
|||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 12.dp)
|
||||
// Apply test tag only once, otherwise 2 nodes will have it (both the normal and subcomposing one) and tests will fail
|
||||
.then(if (!subcomposing) Modifier.testTag(TestTags.textEditor) else Modifier),
|
||||
.then(Modifier.testTag(TestTags.textEditor)),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
textInput()
|
||||
|
|
|
|||
|
|
@ -48,14 +48,11 @@ fun MarkdownTextInput(
|
|||
state: MarkdownTextEditorState,
|
||||
placeholder: String,
|
||||
placeholderColor: androidx.compose.ui.graphics.Color,
|
||||
subcomposing: Boolean,
|
||||
onTyping: (Boolean) -> Unit,
|
||||
onReceiveSuggestion: (Suggestion?) -> Unit,
|
||||
richTextEditorStyle: RichTextEditorStyle,
|
||||
onSelectRichContent: ((Uri) -> Unit)?,
|
||||
) {
|
||||
val canUpdateState = !subcomposing
|
||||
|
||||
// Copied from io.element.android.wysiwyg.internal.utils.UriContentListener
|
||||
class ReceiveUriContentListener(
|
||||
private val onContent: (uri: Uri) -> Unit,
|
||||
|
|
@ -98,39 +95,32 @@ fun MarkdownTextInput(
|
|||
InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or
|
||||
InputType.TYPE_TEXT_FLAG_MULTI_LINE or
|
||||
InputType.TYPE_TEXT_FLAG_AUTO_CORRECT
|
||||
if (canUpdateState) {
|
||||
val textRange = 0..text.length
|
||||
setSelection(state.selection.first.coerceIn(textRange), state.selection.last.coerceIn(textRange))
|
||||
setOnFocusChangeListener { _, hasFocus ->
|
||||
state.hasFocus = hasFocus
|
||||
}
|
||||
addTextChangedListener { editable ->
|
||||
onTyping(!editable.isNullOrEmpty())
|
||||
state.text.update(editable, false)
|
||||
state.lineCount = lineCount
|
||||
|
||||
state.currentSuggestion = editable?.checkSuggestionNeeded()
|
||||
onReceiveSuggestion(state.currentSuggestion)
|
||||
}
|
||||
onSelectionChangeListener = { selStart, selEnd ->
|
||||
state.selection = selStart..selEnd
|
||||
state.currentSuggestion = editableText.checkSuggestionNeeded()
|
||||
onReceiveSuggestion(state.currentSuggestion)
|
||||
}
|
||||
if (onSelectRichContent != null) {
|
||||
ViewCompat.setOnReceiveContentListener(
|
||||
this,
|
||||
arrayOf("image/*"),
|
||||
ReceiveUriContentListener { onSelectRichContent(it) }
|
||||
)
|
||||
}
|
||||
state.requestFocusAction = { this.requestFocus() }
|
||||
} else {
|
||||
isEnabled = false
|
||||
isFocusable = false
|
||||
isFocusableInTouchMode = false
|
||||
isClickable = false
|
||||
val textRange = 0..text.length
|
||||
setSelection(state.selection.first.coerceIn(textRange), state.selection.last.coerceIn(textRange))
|
||||
setOnFocusChangeListener { _, hasFocus ->
|
||||
state.hasFocus = hasFocus
|
||||
}
|
||||
addTextChangedListener { editable ->
|
||||
onTyping(!editable.isNullOrEmpty())
|
||||
state.text.update(editable, false)
|
||||
state.lineCount = lineCount
|
||||
|
||||
state.currentSuggestion = editable?.checkSuggestionNeeded()
|
||||
onReceiveSuggestion(state.currentSuggestion)
|
||||
}
|
||||
onSelectionChangeListener = { selStart, selEnd ->
|
||||
state.selection = selStart..selEnd
|
||||
state.currentSuggestion = editableText.checkSuggestionNeeded()
|
||||
onReceiveSuggestion(state.currentSuggestion)
|
||||
}
|
||||
if (onSelectRichContent != null) {
|
||||
ViewCompat.setOnReceiveContentListener(
|
||||
this,
|
||||
arrayOf("image/*"),
|
||||
ReceiveUriContentListener { onSelectRichContent(it) }
|
||||
)
|
||||
}
|
||||
state.requestFocusAction = { this.requestFocus() }
|
||||
}
|
||||
},
|
||||
update = { editText ->
|
||||
|
|
@ -139,19 +129,15 @@ fun MarkdownTextInput(
|
|||
mentionSpanUpdater.updateMentionSpans(text)
|
||||
if (state.text.needsDisplaying()) {
|
||||
editText.updateEditableText(text)
|
||||
if (canUpdateState) {
|
||||
state.text.update(editText.editableText, false)
|
||||
}
|
||||
state.text.update(editText.editableText, false)
|
||||
}
|
||||
if (canUpdateState) {
|
||||
val newSelectionStart = state.selection.first
|
||||
val newSelectionEnd = state.selection.last
|
||||
val currentTextRange = 0..editText.editableText.length
|
||||
val didSelectionChange = { editText.selectionStart != newSelectionStart || editText.selectionEnd != newSelectionEnd }
|
||||
val isNewSelectionValid = { newSelectionStart in currentTextRange && newSelectionEnd in currentTextRange }
|
||||
if (didSelectionChange() && isNewSelectionValid()) {
|
||||
editText.setSelection(state.selection.first, state.selection.last)
|
||||
}
|
||||
val newSelectionStart = state.selection.first
|
||||
val newSelectionEnd = state.selection.last
|
||||
val currentTextRange = 0..editText.editableText.length
|
||||
val didSelectionChange = { editText.selectionStart != newSelectionStart || editText.selectionEnd != newSelectionEnd }
|
||||
val isNewSelectionValid = { newSelectionStart in currentTextRange && newSelectionEnd in currentTextRange }
|
||||
if (didSelectionChange() && isNewSelectionValid()) {
|
||||
editText.setSelection(state.selection.first, state.selection.last)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -198,7 +184,6 @@ internal fun MarkdownTextInputPreview() {
|
|||
state = aMarkdownTextEditorState(initialText = "Hello, World!"),
|
||||
placeholder = "Placeholder",
|
||||
placeholderColor = ElementTheme.colors.textSecondary,
|
||||
subcomposing = false,
|
||||
onTyping = {},
|
||||
onReceiveSuggestion = {},
|
||||
richTextEditorStyle = style,
|
||||
|
|
|
|||
|
|
@ -168,7 +168,6 @@ class MarkdownTextInputTest {
|
|||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMarkdownTextInput(
|
||||
state: MarkdownTextEditorState = aMarkdownTextEditorState(),
|
||||
subcomposing: Boolean = false,
|
||||
onTyping: (Boolean) -> Unit = {},
|
||||
onSuggestionReceived: (Suggestion?) -> Unit = {},
|
||||
) {
|
||||
|
|
@ -178,7 +177,6 @@ class MarkdownTextInputTest {
|
|||
state = state,
|
||||
placeholder = "Placeholder",
|
||||
placeholderColor = ElementTheme.colors.textSecondary,
|
||||
subcomposing = subcomposing,
|
||||
onTyping = onTyping,
|
||||
onReceiveSuggestion = onSuggestionReceived,
|
||||
richTextEditorStyle = style,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue