diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt deleted file mode 100644 index 28908498ff..0000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright 2023, 2024 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. - */ - -@file:OptIn(ExperimentalMaterial3Api::class) - -package io.element.android.features.messages.impl - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SheetValue -import androidx.compose.material3.rememberBottomSheetScaffoldState -import androidx.compose.material3.rememberStandardBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.Measurable -import androidx.compose.ui.layout.SubcomposeLayout -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.min -import io.element.android.libraries.core.data.tryOrNull -import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold -import kotlin.math.roundToInt - -/** - * A [BottomSheetScaffold] that allows the sheet to be expanded the screen height - * of the sheet contents. - * - * @param content The main content. - * @param sheetContent The sheet content. - * @param sheetDragHandle The drag handle for the sheet. - * @param sheetSwipeEnabled Whether the sheet can be swiped. This value is ignored and swipe is disabled if the sheet content overflows. - * @param sheetShape The shape of the sheet. - * @param sheetTonalElevation The tonal elevation of the sheet. - * @param sheetShadowElevation The shadow elevation of the sheet. - * @param modifier The modifier for the layout. - * @param sheetContentKey The key for the sheet content. If the key changes, the sheet will be remeasured. - */ -@Suppress( - "ContentTrailingLambda", - // False positive - "MultipleEmitters", -) -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun ExpandableBottomSheetScaffold( - content: @Composable (padding: PaddingValues) -> Unit, - // False positive, it's not being reused - @Suppress("ContentSlotReused") - sheetContent: @Composable (subcomposing: Boolean) -> Unit, - sheetDragHandle: @Composable () -> Unit, - sheetSwipeEnabled: Boolean, - sheetShape: Shape, - sheetTonalElevation: Dp, - sheetShadowElevation: Dp, - modifier: Modifier = Modifier, - sheetContentKey: Int? = null, -) { - val scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = rememberStandardBottomSheetState( - initialValue = SheetValue.PartiallyExpanded, - skipHiddenState = true, - ) - ) - - // If the content overflows, we disable swipe to prevent the sheet from intercepting - // scroll events of the sheet content. - var contentOverflows by remember { mutableStateOf(false) } - val sheetSwipeEnabledIfPossible by remember(contentOverflows, sheetSwipeEnabled) { - derivedStateOf { - sheetSwipeEnabled && !contentOverflows - } - } - - LaunchedEffect(sheetSwipeEnabledIfPossible) { - if (!sheetSwipeEnabledIfPossible) { - scaffoldState.bottomSheetState.partialExpand() - } - } - - @Composable - fun Scaffold( - sheetContent: @Composable () -> Unit, - dragHandle: @Composable () -> Unit, - peekHeight: Dp, - ) { - BottomSheetScaffold( - modifier = Modifier, - scaffoldState = scaffoldState, - sheetPeekHeight = peekHeight, - sheetSwipeEnabled = sheetSwipeEnabledIfPossible, - sheetDragHandle = dragHandle, - sheetShape = sheetShape, - content = content, - sheetContent = { sheetContent() }, - sheetTonalElevation = sheetTonalElevation, - sheetShadowElevation = sheetShadowElevation, - ) - } - - SubcomposeLayout( - modifier = modifier.windowInsetsPadding(WindowInsets.ime), - measurePolicy = { constraints: Constraints -> - val sheetContentSub = subcompose(Slot.SheetContent(sheetContentKey)) { sheetContent(true) }.map { - it.measure(Constraints(maxWidth = constraints.maxWidth)) - }.first() - val dragHandleSub = subcompose(Slot.DragHandle) { sheetDragHandle() }.map { - it.measure(Constraints(maxWidth = constraints.maxWidth)) - }.firstOrNull() - val dragHandleHeight = dragHandleSub?.height?.toDp() ?: 0.dp - - val maxHeight = constraints.maxHeight.toDp() - val contentHeight = sheetContentSub.measuredHeight.toDp() + dragHandleHeight - - contentOverflows = contentHeight > maxHeight - - val peekHeight = min( - // prevent the sheet from expanding beyond the screen - maxHeight, - contentHeight - ) - - val scaffoldPlaceables = subcompose(Slot.Scaffold) { - Scaffold({ - Layout( - modifier = Modifier.fillMaxHeight(), - measurePolicy = { measurables, constraints -> - val constraintHeight = constraints.maxHeight - val offset = tryOrNull { scaffoldState.bottomSheetState.requireOffset() } ?: 0f - val height = Integer.max(peekHeight.roundToPx(), constraintHeight - offset.roundToInt()) - val top = measurables[0].measure( - constraints.copy( - minHeight = height, - maxHeight = height - ) - ) - layout(constraints.maxWidth, constraints.maxHeight) { - top.place(x = 0, y = 0) - } - }, - content = { sheetContent(false) } - ) - }, sheetDragHandle, peekHeight) - }.map { measurable: Measurable -> - measurable.measure(constraints) - } - val scaffoldPlaceable = scaffoldPlaceables.first() - layout(constraints.maxWidth, constraints.maxHeight) { - scaffoldPlaceable.place(0, 0) - } - } - ) -} - -private sealed interface Slot { - data class SheetContent(val key: Int?) : Slot - data object DragHandle : Slot - data object Scaffold : Slot -} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 66fe8723bb..debea82cf0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -26,22 +26,17 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle @@ -83,11 +78,13 @@ import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorVi import io.element.android.features.roomcall.api.RoomCallState import io.element.android.libraries.androidutils.ui.hideKeyboard import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule +import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayout import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.components.rememberExpandableBottomSheetLayoutState import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.toAnnotatedString @@ -112,7 +109,6 @@ import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.wysiwyg.link.Link import kotlinx.collections.immutable.ImmutableList import timber.log.Timber -import kotlin.random.Random import kotlin.time.Duration.Companion.milliseconds @Composable @@ -186,69 +182,114 @@ fun MessagesView( state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event)) } - Scaffold( - modifier = modifier, - contentWindowInsets = WindowInsets.statusBars, - topBar = { - Column { - ConnectivityIndicatorView(isOnline = state.hasNetworkConnection) - MessagesViewTopBar( - roomName = state.roomName, - roomAvatar = state.roomAvatar, - isTombstoned = state.isTombstoned, - heroes = state.heroes, - roomCallState = state.roomCallState, - dmUserIdentityState = state.dmUserVerificationState, - onBackClick = { hidingKeyboard { onBackClick() } }, - onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, - onJoinCallClick = onJoinCallClick, - ) - } + val expandableState = rememberExpandableBottomSheetLayoutState() + ExpandableBottomSheetLayout( + modifier = modifier.fillMaxSize().imePadding().systemBarsPadding(), + content = { + Scaffold( + contentWindowInsets = WindowInsets.statusBars, + topBar = { + Column { + ConnectivityIndicatorView(isOnline = state.hasNetworkConnection) + MessagesViewTopBar( + roomName = state.roomName, + roomAvatar = state.roomAvatar, + isTombstoned = state.isTombstoned, + heroes = state.heroes, + roomCallState = state.roomCallState, + dmUserIdentityState = state.dmUserVerificationState, + onBackClick = { hidingKeyboard { onBackClick() } }, + onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, + onJoinCallClick = onJoinCallClick, + ) + } + }, + content = { padding -> + Box( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + ) { + MessagesViewContent( + state = state, + onContentClick = ::onContentClick, + onMessageLongClick = ::onMessageLongClick, + onUserDataClick = { + hidingKeyboard { + state.eventSink(MessagesEvents.OnUserClicked(it)) + } + }, + onLinkClick = { link, customTab -> + if (customTab) { + onLinkClick(link.url, true) + // Do not check those links, they are internal link only + } else { + state.linkState.eventSink(LinkEvents.OnLinkClick(link)) + } + }, + onReactionClick = ::onEmojiReactionClick, + onReactionLongClick = ::onEmojiReactionLongClick, + onMoreReactionsClick = ::onMoreReactionsClick, + onReadReceiptClick = { event -> + state.readReceiptBottomSheetState.eventSink(ReadReceiptBottomSheetEvents.EventSelected(event)) + }, + onSendLocationClick = onSendLocationClick, + onCreatePollClick = onCreatePollClick, + onSwipeToReply = { targetEvent -> + state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, targetEvent)) + }, + forceJumpToBottomVisibility = forceJumpToBottomVisibility, + onJoinCallClick = onJoinCallClick, + onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick, + knockRequestsBannerView = knockRequestsBannerView, + ) + + SuggestionsPickerView( + modifier = Modifier + .shadow(10.dp) + .background(ElementTheme.colors.bgCanvasDefault) + .align(Alignment.BottomStart) + .heightIn(max = 230.dp), + roomId = state.roomId, + roomName = state.roomName, + roomAvatarData = state.roomAvatar, + suggestions = state.composerState.suggestions, + onSelectSuggestion = { + state.composerState.eventSink(MessageComposerEvents.InsertSuggestion(it)) + } + ) + } + }, + snackbarHost = { + SnackbarHost( + snackbarHostState, + modifier = Modifier.navigationBarsPadding() + ) + }, + ) }, - content = { padding -> - MessagesViewContent( + bottomSheetContent = { + MessagesViewComposerBottomSheetContents( state = state, - modifier = Modifier - .padding(padding) - .consumeWindowInsets(padding), - onContentClick = ::onContentClick, - onMessageLongClick = ::onMessageLongClick, - onUserDataClick = { - hidingKeyboard { - state.eventSink(MessagesEvents.OnUserClicked(it)) - } + onLinkClick = { url, customTab -> onLinkClick(url, customTab) }, + onRoomSuccessorClick = { roomId -> + state.timelineState.eventSink(TimelineEvents.NavigateToRoom(roomId = roomId)) }, - onLinkClick = { link, customTab -> - if (customTab) { - onLinkClick(link.url, true) - // Do not check those links, they are internal link only - } else { - state.linkState.eventSink(LinkEvents.OnLinkClick(link)) - } - }, - onReactionClick = ::onEmojiReactionClick, - onReactionLongClick = ::onEmojiReactionLongClick, - onMoreReactionsClick = ::onMoreReactionsClick, - onReadReceiptClick = { event -> - state.readReceiptBottomSheetState.eventSink(ReadReceiptBottomSheetEvents.EventSelected(event)) - }, - onSendLocationClick = onSendLocationClick, - onCreatePollClick = onCreatePollClick, - onSwipeToReply = { targetEvent -> - state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, targetEvent)) - }, - forceJumpToBottomVisibility = forceJumpToBottomVisibility, - onJoinCallClick = onJoinCallClick, - onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick, - knockRequestsBannerView = knockRequestsBannerView, ) }, - snackbarHost = { - SnackbarHost( - snackbarHostState, - modifier = Modifier.navigationBarsPadding() - ) + sheetDragHandle = if (state.composerState.showTextFormatting) { + @Composable { BottomSheetDragHandle() } + } else { + @Composable {} }, + isSwipeGestureEnabled = state.composerState.showTextFormatting, + state = expandableState, + sheetShape = if (state.composerState.showTextFormatting || state.composerState.suggestions.isNotEmpty()) { + MaterialTheme.shapes.large + } else { + RectangleShape + }, + maxBottomSheetContentHeight = 360.dp, ) ActionListView( @@ -348,88 +389,49 @@ private fun MessagesViewContent( ) } - // This key is used to force the sheet to be remeasured when the content changes. - // Any state change that should trigger a height size should be added to the list of remembered values here. - val sheetResizeContentKey = remember { mutableIntStateOf(0) } - LaunchedEffect( - state.composerState.textEditorState.lineCount, - state.composerState.showTextFormatting, - ) { - sheetResizeContentKey.intValue = Random.nextInt() - } - - ExpandableBottomSheetScaffold( - sheetDragHandle = if (state.composerState.showTextFormatting) { - @Composable { BottomSheetDragHandle() } - } else { - @Composable {} - }, - sheetSwipeEnabled = state.composerState.showTextFormatting, - sheetShape = if (state.composerState.showTextFormatting || state.composerState.suggestions.isNotEmpty()) { - MaterialTheme.shapes.large - } else { - RectangleShape - }, - content = { paddingValues -> - Box(modifier = Modifier.padding(paddingValues)) { - val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberScrollBehavior( - pinnedMessagesCount = state.pinnedMessagesBannerState.pinnedMessagesCount(), + Box { + val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberScrollBehavior( + pinnedMessagesCount = state.pinnedMessagesBannerState.pinnedMessagesCount(), + ) + TimelineView( + state = state.timelineState, + timelineProtectionState = state.timelineProtectionState, + onUserDataClick = onUserDataClick, + onLinkClick = { link -> onLinkClick(link, false) }, + onContentClick = onContentClick, + onMessageLongClick = onMessageLongClick, + onSwipeToReply = onSwipeToReply, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, + onReadReceiptClick = onReadReceiptClick, + forceJumpToBottomVisibility = forceJumpToBottomVisibility, + onJoinCallClick = onJoinCallClick, + nestedScrollConnection = scrollBehavior.nestedScrollConnection, + ) + AnimatedVisibility( + visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + fun focusOnPinnedEvent(eventId: EventId) { + state.timelineState.eventSink( + TimelineEvents.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds) ) - TimelineView( - state = state.timelineState, - timelineProtectionState = state.timelineProtectionState, - onUserDataClick = onUserDataClick, - onLinkClick = { link -> onLinkClick(link, false) }, - onContentClick = onContentClick, - onMessageLongClick = onMessageLongClick, - onSwipeToReply = onSwipeToReply, - onReactionClick = onReactionClick, - onReactionLongClick = onReactionLongClick, - onMoreReactionsClick = onMoreReactionsClick, - onReadReceiptClick = onReadReceiptClick, - forceJumpToBottomVisibility = forceJumpToBottomVisibility, - onJoinCallClick = onJoinCallClick, - nestedScrollConnection = scrollBehavior.nestedScrollConnection, - ) - AnimatedVisibility( - visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - fun focusOnPinnedEvent(eventId: EventId) { - state.timelineState.eventSink( - TimelineEvents.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds) - ) - } - PinnedMessagesBannerView( - state = state.pinnedMessagesBannerState, - onClick = ::focusOnPinnedEvent, - onViewAllClick = onViewAllPinnedMessagesClick, - ) - } - knockRequestsBannerView() } - }, - sheetContent = { subcomposing: Boolean -> - MessagesViewComposerBottomSheetContents( - subcomposing = subcomposing, - state = state, - onRoomSuccessorClick = { roomId -> - state.timelineState.eventSink(TimelineEvents.NavigateToRoom(roomId = roomId)) - }, - onLinkClick = { url, customTab -> onLinkClick(Link(url), customTab) }, + PinnedMessagesBannerView( + state = state.pinnedMessagesBannerState, + onClick = ::focusOnPinnedEvent, + onViewAllClick = onViewAllPinnedMessagesClick, ) - }, - sheetContentKey = sheetResizeContentKey.intValue, - sheetTonalElevation = 0.dp, - sheetShadowElevation = if (state.composerState.suggestions.isNotEmpty()) 16.dp else 0.dp, - ) + } + knockRequestsBannerView() + } } } @Composable private fun MessagesViewComposerBottomSheetContents( - subcomposing: Boolean, state: MessagesState, onRoomSuccessorClick: (RoomId) -> Unit, onLinkClick: (String, Boolean) -> Unit, @@ -440,23 +442,6 @@ private fun MessagesViewComposerBottomSheetContents( } state.userEventPermissions.canSendMessage -> { Column(modifier = Modifier.fillMaxWidth()) { - SuggestionsPickerView( - modifier = Modifier - .heightIn(max = 230.dp) - // Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions - .nestedScroll(object : NestedScrollConnection { - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - return available - } - }), - roomId = state.roomId, - roomName = state.roomName, - roomAvatarData = state.roomAvatar, - suggestions = state.composerState.suggestions, - onSelectSuggestion = { - state.composerState.eventSink(MessageComposerEvents.InsertSuggestion(it)) - } - ) // Do not show the identity change if user is composing a Rich message or is seeing suggestion(s). if (state.composerState.suggestions.isEmpty() && state.composerState.textEditorState is TextEditorState.Markdown) { @@ -474,7 +459,6 @@ private fun MessagesViewComposerBottomSheetContents( MessageComposerView( state = state.composerState, voiceMessageState = state.voiceMessageComposerState, - subcomposing = subcomposing, enableVoiceMessages = state.enableVoiceMessages, modifier = Modifier.fillMaxWidth(), ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index 42c68255e8..b3d8e5bf2d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -33,7 +33,6 @@ import kotlinx.coroutines.launch internal fun MessageComposerView( state: MessageComposerState, voiceMessageState: VoiceMessageComposerState, - subcomposing: Boolean, enableVoiceMessages: Boolean, modifier: Modifier = Modifier, ) { @@ -98,7 +97,6 @@ internal fun MessageComposerView( modifier = modifier, state = state.textEditorState, voiceMessageState = voiceMessageState.voiceMessageState, - subcomposing = subcomposing, onRequestFocus = ::onRequestFocus, onSendMessage = ::sendMessage, composerMode = state.mode, @@ -131,14 +129,12 @@ internal fun MessageComposerViewPreview( state = state, voiceMessageState = aVoiceMessageComposerState(), enableVoiceMessages = true, - subcomposing = false, ) MessageComposerView( modifier = Modifier.height(200.dp), state = state, voiceMessageState = aVoiceMessageComposerState(), enableVoiceMessages = true, - subcomposing = false, ) DisabledComposerView() } @@ -155,7 +151,6 @@ internal fun MessageComposerViewVoicePreview( state = aMessageComposerState(), voiceMessageState = state, enableVoiceMessages = true, - subcomposing = false, ) } } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/ui/View.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/ui/View.kt index cead1384c9..e2dacf58e7 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/ui/View.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/ui/View.kt @@ -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() - 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() + imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) + } } fun View.isKeyboardVisible(): Boolean { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayout.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayout.kt new file mode 100644 index 0000000000..66b404f554 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayout.kt @@ -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) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayoutState.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayoutState.kt new file mode 100644 index 0000000000..eda4cd73b2 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayoutState.kt @@ -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 + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/SoftKeyboardEffect.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/SoftKeyboardEffect.kt index 8f6ca0530d..e98c6453e7 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/SoftKeyboardEffect.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/SoftKeyboardEffect.kt @@ -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 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() diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index e4ba0d2401..b7bd04b282 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -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() diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt index f9baca43ea..ffce571dc4 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt @@ -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, diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt index a6cad521b2..fa003b74d8 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt @@ -168,7 +168,6 @@ class MarkdownTextInputTest { private fun AndroidComposeTestRule.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, diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_6_en.png index 64c9eb1cdf..ff037f124d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f65fa2e0b1172f43b9475b8f46c3efc1086b5cf659038190bd8495b98bc21c82 -size 51551 +oid sha256:61f05b07526302ad051a8df2234a15438f8b2b2889767acb7697f3caecb68aa0 +size 54189 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_6_en.png index ec42814e10..bbccfbac19 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dc219f3f79e2f7e061b541398971c36732346b2770fbeea95a4ecca266eb6ac0 -size 50925 +oid sha256:629506ad32c56d94906a71a4d779290f1328030d79fd05db3754e4113531587e +size 53656 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_ExpandableBottomSheetLayout_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_ExpandableBottomSheetLayout_en.png new file mode 100644 index 0000000000..f8f1c5da7c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_ExpandableBottomSheetLayout_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f33d7b77bbda44a0d044a25b59dbc99a2289bd56884992e1a859743408595b15 +size 16489 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerFormattingNotEncrypted_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerFormattingNotEncrypted_Day_0_en.png index 00b0a7c69a..2fec8ddfe9 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerFormattingNotEncrypted_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerFormattingNotEncrypted_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:40ce5b17c4b499d9e671b7022ee6445abaddf4e14eed547f8c2b0e4d018d75c5 -size 64598 +oid sha256:3a596e77c10b67cd0295b0676c051d712205839b52bf46f831ecaa24d275dbec +size 64470 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerFormattingNotEncrypted_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerFormattingNotEncrypted_Night_0_en.png index e3ce1e8df4..a74efac0ff 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerFormattingNotEncrypted_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerFormattingNotEncrypted_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cf923847e52fc8db3a7a27c5b85e27c4db8bcce9005b8009684e9f3b8b8b8c4b -size 62040 +oid sha256:0a8686422da867abcd6dc0d33e021ecd59499509d85ff8d722ea2a53facc6ccd +size 61934 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerFormatting_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerFormatting_Day_0_en.png index 1a9a40f972..d65c236047 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerFormatting_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerFormatting_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:30e40a4f602f99ca9ba95842b1aa8955450072d8bb317aea2eb61c704be0d8e3 -size 53900 +oid sha256:d552bcec8eb096ae396e545744af9018bcae277597a1098e0477e87e4620e30c +size 53806 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerFormatting_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerFormatting_Night_0_en.png index 18915e427a..838cd73d50 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerFormatting_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerFormatting_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ac727cdf60b1a31c386d223df6f9142990468fdb6e387668eb740335c2caf937 -size 51263 +oid sha256:54b501a2dc902ba27a701fc8539fa2d2eb64511d2f058b4ff189365df63d5665 +size 51093