Simplify message composer layout (#4884)
Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
parent
1f34125a5f
commit
407c7ed70a
17 changed files with 574 additions and 435 deletions
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -26,22 +26,17 @@ import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.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.platform.LocalView
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
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.features.roomcall.api.RoomCallState
|
||||||
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
|
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.Avatar
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
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.avatar.AvatarType
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
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.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.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.text.toAnnotatedString
|
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 io.element.android.wysiwyg.link.Link
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import kotlin.random.Random
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -186,69 +182,114 @@ fun MessagesView(
|
||||||
state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
|
state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
val expandableState = rememberExpandableBottomSheetLayoutState()
|
||||||
modifier = modifier,
|
ExpandableBottomSheetLayout(
|
||||||
contentWindowInsets = WindowInsets.statusBars,
|
modifier = modifier.fillMaxSize().imePadding().systemBarsPadding(),
|
||||||
topBar = {
|
content = {
|
||||||
Column {
|
Scaffold(
|
||||||
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
|
contentWindowInsets = WindowInsets.statusBars,
|
||||||
MessagesViewTopBar(
|
topBar = {
|
||||||
roomName = state.roomName,
|
Column {
|
||||||
roomAvatar = state.roomAvatar,
|
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
|
||||||
isTombstoned = state.isTombstoned,
|
MessagesViewTopBar(
|
||||||
heroes = state.heroes,
|
roomName = state.roomName,
|
||||||
roomCallState = state.roomCallState,
|
roomAvatar = state.roomAvatar,
|
||||||
dmUserIdentityState = state.dmUserVerificationState,
|
isTombstoned = state.isTombstoned,
|
||||||
onBackClick = { hidingKeyboard { onBackClick() } },
|
heroes = state.heroes,
|
||||||
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
|
roomCallState = state.roomCallState,
|
||||||
onJoinCallClick = onJoinCallClick,
|
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 ->
|
bottomSheetContent = {
|
||||||
MessagesViewContent(
|
MessagesViewComposerBottomSheetContents(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = Modifier
|
onLinkClick = { url, customTab -> onLinkClick(url, customTab) },
|
||||||
.padding(padding)
|
onRoomSuccessorClick = { roomId ->
|
||||||
.consumeWindowInsets(padding),
|
state.timelineState.eventSink(TimelineEvents.NavigateToRoom(roomId = roomId))
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
snackbarHost = {
|
sheetDragHandle = if (state.composerState.showTextFormatting) {
|
||||||
SnackbarHost(
|
@Composable { BottomSheetDragHandle() }
|
||||||
snackbarHostState,
|
} else {
|
||||||
modifier = Modifier.navigationBarsPadding()
|
@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(
|
ActionListView(
|
||||||
|
|
@ -348,88 +389,49 @@ private fun MessagesViewContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// This key is used to force the sheet to be remeasured when the content changes.
|
Box {
|
||||||
// Any state change that should trigger a height size should be added to the list of remembered values here.
|
val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberScrollBehavior(
|
||||||
val sheetResizeContentKey = remember { mutableIntStateOf(0) }
|
pinnedMessagesCount = state.pinnedMessagesBannerState.pinnedMessagesCount(),
|
||||||
LaunchedEffect(
|
)
|
||||||
state.composerState.textEditorState.lineCount,
|
TimelineView(
|
||||||
state.composerState.showTextFormatting,
|
state = state.timelineState,
|
||||||
) {
|
timelineProtectionState = state.timelineProtectionState,
|
||||||
sheetResizeContentKey.intValue = Random.nextInt()
|
onUserDataClick = onUserDataClick,
|
||||||
}
|
onLinkClick = { link -> onLinkClick(link, false) },
|
||||||
|
onContentClick = onContentClick,
|
||||||
ExpandableBottomSheetScaffold(
|
onMessageLongClick = onMessageLongClick,
|
||||||
sheetDragHandle = if (state.composerState.showTextFormatting) {
|
onSwipeToReply = onSwipeToReply,
|
||||||
@Composable { BottomSheetDragHandle() }
|
onReactionClick = onReactionClick,
|
||||||
} else {
|
onReactionLongClick = onReactionLongClick,
|
||||||
@Composable {}
|
onMoreReactionsClick = onMoreReactionsClick,
|
||||||
},
|
onReadReceiptClick = onReadReceiptClick,
|
||||||
sheetSwipeEnabled = state.composerState.showTextFormatting,
|
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||||
sheetShape = if (state.composerState.showTextFormatting || state.composerState.suggestions.isNotEmpty()) {
|
onJoinCallClick = onJoinCallClick,
|
||||||
MaterialTheme.shapes.large
|
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
|
||||||
} else {
|
)
|
||||||
RectangleShape
|
AnimatedVisibility(
|
||||||
},
|
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
|
||||||
content = { paddingValues ->
|
enter = expandVertically(),
|
||||||
Box(modifier = Modifier.padding(paddingValues)) {
|
exit = shrinkVertically(),
|
||||||
val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberScrollBehavior(
|
) {
|
||||||
pinnedMessagesCount = state.pinnedMessagesBannerState.pinnedMessagesCount(),
|
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()
|
|
||||||
}
|
}
|
||||||
},
|
PinnedMessagesBannerView(
|
||||||
sheetContent = { subcomposing: Boolean ->
|
state = state.pinnedMessagesBannerState,
|
||||||
MessagesViewComposerBottomSheetContents(
|
onClick = ::focusOnPinnedEvent,
|
||||||
subcomposing = subcomposing,
|
onViewAllClick = onViewAllPinnedMessagesClick,
|
||||||
state = state,
|
|
||||||
onRoomSuccessorClick = { roomId ->
|
|
||||||
state.timelineState.eventSink(TimelineEvents.NavigateToRoom(roomId = roomId))
|
|
||||||
},
|
|
||||||
onLinkClick = { url, customTab -> onLinkClick(Link(url), customTab) },
|
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
sheetContentKey = sheetResizeContentKey.intValue,
|
knockRequestsBannerView()
|
||||||
sheetTonalElevation = 0.dp,
|
}
|
||||||
sheetShadowElevation = if (state.composerState.suggestions.isNotEmpty()) 16.dp else 0.dp,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessagesViewComposerBottomSheetContents(
|
private fun MessagesViewComposerBottomSheetContents(
|
||||||
subcomposing: Boolean,
|
|
||||||
state: MessagesState,
|
state: MessagesState,
|
||||||
onRoomSuccessorClick: (RoomId) -> Unit,
|
onRoomSuccessorClick: (RoomId) -> Unit,
|
||||||
onLinkClick: (String, Boolean) -> Unit,
|
onLinkClick: (String, Boolean) -> Unit,
|
||||||
|
|
@ -440,23 +442,6 @@ private fun MessagesViewComposerBottomSheetContents(
|
||||||
}
|
}
|
||||||
state.userEventPermissions.canSendMessage -> {
|
state.userEventPermissions.canSendMessage -> {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
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).
|
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
|
||||||
if (state.composerState.suggestions.isEmpty() &&
|
if (state.composerState.suggestions.isEmpty() &&
|
||||||
state.composerState.textEditorState is TextEditorState.Markdown) {
|
state.composerState.textEditorState is TextEditorState.Markdown) {
|
||||||
|
|
@ -474,7 +459,6 @@ private fun MessagesViewComposerBottomSheetContents(
|
||||||
MessageComposerView(
|
MessageComposerView(
|
||||||
state = state.composerState,
|
state = state.composerState,
|
||||||
voiceMessageState = state.voiceMessageComposerState,
|
voiceMessageState = state.voiceMessageComposerState,
|
||||||
subcomposing = subcomposing,
|
|
||||||
enableVoiceMessages = state.enableVoiceMessages,
|
enableVoiceMessages = state.enableVoiceMessages,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ import kotlinx.coroutines.launch
|
||||||
internal fun MessageComposerView(
|
internal fun MessageComposerView(
|
||||||
state: MessageComposerState,
|
state: MessageComposerState,
|
||||||
voiceMessageState: VoiceMessageComposerState,
|
voiceMessageState: VoiceMessageComposerState,
|
||||||
subcomposing: Boolean,
|
|
||||||
enableVoiceMessages: Boolean,
|
enableVoiceMessages: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -98,7 +97,6 @@ internal fun MessageComposerView(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
state = state.textEditorState,
|
state = state.textEditorState,
|
||||||
voiceMessageState = voiceMessageState.voiceMessageState,
|
voiceMessageState = voiceMessageState.voiceMessageState,
|
||||||
subcomposing = subcomposing,
|
|
||||||
onRequestFocus = ::onRequestFocus,
|
onRequestFocus = ::onRequestFocus,
|
||||||
onSendMessage = ::sendMessage,
|
onSendMessage = ::sendMessage,
|
||||||
composerMode = state.mode,
|
composerMode = state.mode,
|
||||||
|
|
@ -131,14 +129,12 @@ internal fun MessageComposerViewPreview(
|
||||||
state = state,
|
state = state,
|
||||||
voiceMessageState = aVoiceMessageComposerState(),
|
voiceMessageState = aVoiceMessageComposerState(),
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
subcomposing = false,
|
|
||||||
)
|
)
|
||||||
MessageComposerView(
|
MessageComposerView(
|
||||||
modifier = Modifier.height(200.dp),
|
modifier = Modifier.height(200.dp),
|
||||||
state = state,
|
state = state,
|
||||||
voiceMessageState = aVoiceMessageComposerState(),
|
voiceMessageState = aVoiceMessageComposerState(),
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
subcomposing = false,
|
|
||||||
)
|
)
|
||||||
DisabledComposerView()
|
DisabledComposerView()
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +151,6 @@ internal fun MessageComposerViewVoicePreview(
|
||||||
state = aMessageComposerState(),
|
state = aMessageComposerState(),
|
||||||
voiceMessageState = state,
|
voiceMessageState = state,
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
subcomposing = false,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@
|
||||||
|
|
||||||
package io.element.android.libraries.androidutils.ui
|
package io.element.android.libraries.androidutils.ui
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewTreeObserver
|
import android.view.ViewTreeObserver
|
||||||
|
import android.view.WindowInsets
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
|
@ -23,8 +25,13 @@ fun View.showKeyboard(andRequestFocus: Boolean = false) {
|
||||||
if (andRequestFocus) {
|
if (andRequestFocus) {
|
||||||
requestFocus()
|
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 {
|
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
|
package io.element.android.libraries.textcomposer
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import android.view.WindowInsets
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -44,12 +42,8 @@ internal fun <T> SoftKeyboardEffect(
|
||||||
view.awaitWindowFocus()
|
view.awaitWindowFocus()
|
||||||
|
|
||||||
if (!view.isKeyboardVisible()) {
|
if (!view.isKeyboardVisible()) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
// Show the keyboard, temporarily using the root view for focus
|
||||||
view.windowInsetsController?.show(WindowInsets.Type.ime())
|
view.showKeyboard(andRequestFocus = true)
|
||||||
} else {
|
|
||||||
// Show the keyboard, temporarily using the root view for focus
|
|
||||||
view.showKeyboard(andRequestFocus = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refocus to the correct view
|
// Refocus to the correct view
|
||||||
latestOnRequestFocus()
|
latestOnRequestFocus()
|
||||||
|
|
|
||||||
|
|
@ -12,38 +12,41 @@ import android.net.Uri
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.requiredHeightIn
|
import androidx.compose.foundation.layout.requiredHeightIn
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.systemBars
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.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.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
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.components.media.createFakeWaveform
|
||||||
import io.element.android.libraries.designsystem.preview.DAY_MODE_NAME
|
import io.element.android.libraries.designsystem.preview.DAY_MODE_NAME
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
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.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import uniffi.wysiwyg_composer.MenuAction
|
import uniffi.wysiwyg_composer.MenuAction
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
|
@ -110,7 +114,6 @@ fun TextComposer(
|
||||||
resolveAtRoomMentionDisplay: () -> TextDisplay,
|
resolveAtRoomMentionDisplay: () -> TextDisplay,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
showTextFormatting: Boolean = false,
|
showTextFormatting: Boolean = false,
|
||||||
subcomposing: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
val markdown = when (state) {
|
val markdown = when (state) {
|
||||||
is TextEditorState.Markdown -> state.state.text.value()
|
is TextEditorState.Markdown -> state.state.text.value()
|
||||||
|
|
@ -170,20 +173,30 @@ fun TextComposer(
|
||||||
} else {
|
} else {
|
||||||
when (state) {
|
when (state) {
|
||||||
is TextEditorState.Rich -> {
|
is TextEditorState.Rich -> {
|
||||||
remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) {
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val view = LocalView.current
|
||||||
|
remember(state.richTextEditorState, composerMode, onResetComposerMode, onError) {
|
||||||
@Composable {
|
@Composable {
|
||||||
TextInputBox(
|
TextInputBox(
|
||||||
|
modifier = Modifier.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
state.requestFocus()
|
||||||
|
view.showKeyboard()
|
||||||
|
}
|
||||||
|
}.semantics {
|
||||||
|
hideFromAccessibility()
|
||||||
|
},
|
||||||
composerMode = composerMode,
|
composerMode = composerMode,
|
||||||
onResetComposerMode = onResetComposerMode,
|
onResetComposerMode = onResetComposerMode,
|
||||||
isTextEmpty = state.richTextEditorState.messageHtml.isEmpty(),
|
isTextEmpty = state.richTextEditorState.messageHtml.isEmpty(),
|
||||||
subcomposing = subcomposing,
|
|
||||||
) {
|
) {
|
||||||
RichTextEditor(
|
RichTextEditor(
|
||||||
state = state.richTextEditorState,
|
state = state.richTextEditorState,
|
||||||
placeholder = placeholder,
|
placeholder = placeholder,
|
||||||
// Disable most of the editor functionality if it's just being measured for a subcomposition.
|
registerStateUpdates = true,
|
||||||
// This prevents it gaining focus and mutating the state.
|
|
||||||
registerStateUpdates = !subcomposing,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = 6.dp, bottom = 6.dp)
|
.padding(top = 6.dp, bottom = 6.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
|
|
@ -205,13 +218,11 @@ fun TextComposer(
|
||||||
composerMode = composerMode,
|
composerMode = composerMode,
|
||||||
onResetComposerMode = onResetComposerMode,
|
onResetComposerMode = onResetComposerMode,
|
||||||
isTextEmpty = state.state.text.value().isEmpty(),
|
isTextEmpty = state.state.text.value().isEmpty(),
|
||||||
subcomposing = subcomposing,
|
|
||||||
) {
|
) {
|
||||||
MarkdownTextInput(
|
MarkdownTextInput(
|
||||||
state = state.state,
|
state = state.state,
|
||||||
placeholder = placeholder,
|
placeholder = placeholder,
|
||||||
placeholderColor = ElementTheme.colors.textSecondary,
|
placeholderColor = ElementTheme.colors.textSecondary,
|
||||||
subcomposing = subcomposing,
|
|
||||||
onTyping = onTyping,
|
onTyping = onTyping,
|
||||||
onReceiveSuggestion = onReceiveSuggestion,
|
onReceiveSuggestion = onReceiveSuggestion,
|
||||||
richTextEditorStyle = style,
|
richTextEditorStyle = style,
|
||||||
|
|
@ -326,14 +337,12 @@ fun TextComposer(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!subcomposing) {
|
SoftKeyboardEffect(composerMode, onRequestFocus) {
|
||||||
SoftKeyboardEffect(composerMode, onRequestFocus) {
|
it is MessageComposerMode.Special
|
||||||
it is MessageComposerMode.Special
|
|
||||||
}
|
|
||||||
|
|
||||||
SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it }
|
||||||
|
|
||||||
val latestOnReceiveSuggestion by rememberUpdatedState(onReceiveSuggestion)
|
val latestOnReceiveSuggestion by rememberUpdatedState(onReceiveSuggestion)
|
||||||
if (state is TextEditorState.Rich) {
|
if (state is TextEditorState.Rich) {
|
||||||
val menuAction = state.richTextEditorState.menuAction
|
val menuAction = state.richTextEditorState.menuAction
|
||||||
|
|
@ -445,11 +454,8 @@ private fun TextFormattingLayout(
|
||||||
sendButton: @Composable () -> Unit,
|
sendButton: @Composable () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val bottomPadding = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() + 8.dp }
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier.padding(vertical = 4.dp),
|
||||||
.padding(vertical = 4.dp)
|
|
||||||
.padding(bottom = bottomPadding),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
if (isRoomEncrypted == false) {
|
if (isRoomEncrypted == false) {
|
||||||
|
|
@ -493,7 +499,7 @@ private fun TextInputBox(
|
||||||
composerMode: MessageComposerMode,
|
composerMode: MessageComposerMode,
|
||||||
onResetComposerMode: () -> Unit,
|
onResetComposerMode: () -> Unit,
|
||||||
isTextEmpty: Boolean,
|
isTextEmpty: Boolean,
|
||||||
subcomposing: Boolean,
|
modifier: Modifier = Modifier,
|
||||||
textInput: @Composable () -> Unit,
|
textInput: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val bgColor = ElementTheme.colors.bgSubtleSecondary
|
val bgColor = ElementTheme.colors.bgSubtleSecondary
|
||||||
|
|
@ -501,12 +507,13 @@ private fun TextInputBox(
|
||||||
val roundedCorners = textInputRoundedCornerShape(composerMode = composerMode)
|
val roundedCorners = textInputRoundedCornerShape(composerMode = composerMode)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.clip(roundedCorners)
|
.clip(roundedCorners)
|
||||||
.border(0.5.dp, borderColor, roundedCorners)
|
.border(0.5.dp, borderColor, roundedCorners)
|
||||||
.background(color = bgColor)
|
.background(color = bgColor)
|
||||||
.requiredHeightIn(min = 42.dp)
|
.requiredHeightIn(min = 42.dp)
|
||||||
.fillMaxSize(),
|
.fillMaxSize()
|
||||||
|
.then(modifier),
|
||||||
) {
|
) {
|
||||||
if (composerMode is MessageComposerMode.Special) {
|
if (composerMode is MessageComposerMode.Special) {
|
||||||
ComposerModeView(
|
ComposerModeView(
|
||||||
|
|
@ -517,8 +524,7 @@ private fun TextInputBox(
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 12.dp)
|
.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(Modifier.testTag(TestTags.textEditor)),
|
||||||
.then(if (!subcomposing) Modifier.testTag(TestTags.textEditor) else Modifier),
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
textInput()
|
textInput()
|
||||||
|
|
|
||||||
|
|
@ -48,14 +48,11 @@ fun MarkdownTextInput(
|
||||||
state: MarkdownTextEditorState,
|
state: MarkdownTextEditorState,
|
||||||
placeholder: String,
|
placeholder: String,
|
||||||
placeholderColor: androidx.compose.ui.graphics.Color,
|
placeholderColor: androidx.compose.ui.graphics.Color,
|
||||||
subcomposing: Boolean,
|
|
||||||
onTyping: (Boolean) -> Unit,
|
onTyping: (Boolean) -> Unit,
|
||||||
onReceiveSuggestion: (Suggestion?) -> Unit,
|
onReceiveSuggestion: (Suggestion?) -> Unit,
|
||||||
richTextEditorStyle: RichTextEditorStyle,
|
richTextEditorStyle: RichTextEditorStyle,
|
||||||
onSelectRichContent: ((Uri) -> Unit)?,
|
onSelectRichContent: ((Uri) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val canUpdateState = !subcomposing
|
|
||||||
|
|
||||||
// Copied from io.element.android.wysiwyg.internal.utils.UriContentListener
|
// Copied from io.element.android.wysiwyg.internal.utils.UriContentListener
|
||||||
class ReceiveUriContentListener(
|
class ReceiveUriContentListener(
|
||||||
private val onContent: (uri: Uri) -> Unit,
|
private val onContent: (uri: Uri) -> Unit,
|
||||||
|
|
@ -98,39 +95,32 @@ fun MarkdownTextInput(
|
||||||
InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or
|
InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or
|
||||||
InputType.TYPE_TEXT_FLAG_MULTI_LINE or
|
InputType.TYPE_TEXT_FLAG_MULTI_LINE or
|
||||||
InputType.TYPE_TEXT_FLAG_AUTO_CORRECT
|
InputType.TYPE_TEXT_FLAG_AUTO_CORRECT
|
||||||
if (canUpdateState) {
|
val textRange = 0..text.length
|
||||||
val textRange = 0..text.length
|
setSelection(state.selection.first.coerceIn(textRange), state.selection.last.coerceIn(textRange))
|
||||||
setSelection(state.selection.first.coerceIn(textRange), state.selection.last.coerceIn(textRange))
|
setOnFocusChangeListener { _, hasFocus ->
|
||||||
setOnFocusChangeListener { _, hasFocus ->
|
state.hasFocus = 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
|
|
||||||
}
|
}
|
||||||
|
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 ->
|
update = { editText ->
|
||||||
|
|
@ -139,19 +129,15 @@ fun MarkdownTextInput(
|
||||||
mentionSpanUpdater.updateMentionSpans(text)
|
mentionSpanUpdater.updateMentionSpans(text)
|
||||||
if (state.text.needsDisplaying()) {
|
if (state.text.needsDisplaying()) {
|
||||||
editText.updateEditableText(text)
|
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 newSelectionStart = state.selection.first
|
val newSelectionEnd = state.selection.last
|
||||||
val newSelectionEnd = state.selection.last
|
val currentTextRange = 0..editText.editableText.length
|
||||||
val currentTextRange = 0..editText.editableText.length
|
val didSelectionChange = { editText.selectionStart != newSelectionStart || editText.selectionEnd != newSelectionEnd }
|
||||||
val didSelectionChange = { editText.selectionStart != newSelectionStart || editText.selectionEnd != newSelectionEnd }
|
val isNewSelectionValid = { newSelectionStart in currentTextRange && newSelectionEnd in currentTextRange }
|
||||||
val isNewSelectionValid = { newSelectionStart in currentTextRange && newSelectionEnd in currentTextRange }
|
if (didSelectionChange() && isNewSelectionValid()) {
|
||||||
if (didSelectionChange() && isNewSelectionValid()) {
|
editText.setSelection(state.selection.first, state.selection.last)
|
||||||
editText.setSelection(state.selection.first, state.selection.last)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -198,7 +184,6 @@ internal fun MarkdownTextInputPreview() {
|
||||||
state = aMarkdownTextEditorState(initialText = "Hello, World!"),
|
state = aMarkdownTextEditorState(initialText = "Hello, World!"),
|
||||||
placeholder = "Placeholder",
|
placeholder = "Placeholder",
|
||||||
placeholderColor = ElementTheme.colors.textSecondary,
|
placeholderColor = ElementTheme.colors.textSecondary,
|
||||||
subcomposing = false,
|
|
||||||
onTyping = {},
|
onTyping = {},
|
||||||
onReceiveSuggestion = {},
|
onReceiveSuggestion = {},
|
||||||
richTextEditorStyle = style,
|
richTextEditorStyle = style,
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,6 @@ class MarkdownTextInputTest {
|
||||||
|
|
||||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMarkdownTextInput(
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMarkdownTextInput(
|
||||||
state: MarkdownTextEditorState = aMarkdownTextEditorState(),
|
state: MarkdownTextEditorState = aMarkdownTextEditorState(),
|
||||||
subcomposing: Boolean = false,
|
|
||||||
onTyping: (Boolean) -> Unit = {},
|
onTyping: (Boolean) -> Unit = {},
|
||||||
onSuggestionReceived: (Suggestion?) -> Unit = {},
|
onSuggestionReceived: (Suggestion?) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
|
@ -178,7 +177,6 @@ class MarkdownTextInputTest {
|
||||||
state = state,
|
state = state,
|
||||||
placeholder = "Placeholder",
|
placeholder = "Placeholder",
|
||||||
placeholderColor = ElementTheme.colors.textSecondary,
|
placeholderColor = ElementTheme.colors.textSecondary,
|
||||||
subcomposing = subcomposing,
|
|
||||||
onTyping = onTyping,
|
onTyping = onTyping,
|
||||||
onReceiveSuggestion = onSuggestionReceived,
|
onReceiveSuggestion = onSuggestionReceived,
|
||||||
richTextEditorStyle = style,
|
richTextEditorStyle = style,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f65fa2e0b1172f43b9475b8f46c3efc1086b5cf659038190bd8495b98bc21c82
|
oid sha256:61f05b07526302ad051a8df2234a15438f8b2b2889767acb7697f3caecb68aa0
|
||||||
size 51551
|
size 54189
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:dc219f3f79e2f7e061b541398971c36732346b2770fbeea95a4ecca266eb6ac0
|
oid sha256:629506ad32c56d94906a71a4d779290f1328030d79fd05db3754e4113531587e
|
||||||
size 50925
|
size 53656
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f33d7b77bbda44a0d044a25b59dbc99a2289bd56884992e1a859743408595b15
|
||||||
|
size 16489
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:40ce5b17c4b499d9e671b7022ee6445abaddf4e14eed547f8c2b0e4d018d75c5
|
oid sha256:3a596e77c10b67cd0295b0676c051d712205839b52bf46f831ecaa24d275dbec
|
||||||
size 64598
|
size 64470
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:cf923847e52fc8db3a7a27c5b85e27c4db8bcce9005b8009684e9f3b8b8b8c4b
|
oid sha256:0a8686422da867abcd6dc0d33e021ecd59499509d85ff8d722ea2a53facc6ccd
|
||||||
size 62040
|
size 61934
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:30e40a4f602f99ca9ba95842b1aa8955450072d8bb317aea2eb61c704be0d8e3
|
oid sha256:d552bcec8eb096ae396e545744af9018bcae277597a1098e0477e87e4620e30c
|
||||||
size 53900
|
size 53806
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:ac727cdf60b1a31c386d223df6f9142990468fdb6e387668eb740335c2caf937
|
oid sha256:54b501a2dc902ba27a701fc8539fa2d2eb64511d2f058b4ff189365df63d5665
|
||||||
size 51263
|
size 51093
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue