Skip to content

Commit 53cf82f

Browse files
jonnyandrewElementBot
and
ElementBot
authored
[Rich text editor] Add full screen mode (#1447)
- Add full screen mode for the rich text editor (RTE). When text formatting options are enabled, the editor can be dragged to full screen. - Remove `ConstraintLayout` from `textcomposer` module, now made much simpler now the RTE supports being called in multiple layouts matrix-org/matrix-rich-text-editor#822 - Part of element-hq/element-meta#1973 - Includes design from #1315 - Fixes #1293 (through new layout) - Fixes #1394 (through inclusion of matrix-org/matrix-rich-text-editor#824) - Fixes #1259 (through inclusion of matrix-org/matrix-rich-text-editor#820) --------- Co-authored-by: ElementBot <[email protected]>
1 parent fa82639 commit 53cf82f

File tree

36 files changed

+895
-458
lines changed

36 files changed

+895
-458
lines changed

changelog.d/1447.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[Rich text editor] Add full screen mode
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/*
2+
* Copyright (c) 2023 New Vector Ltd
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
@file:OptIn(ExperimentalMaterial3Api::class)
18+
19+
package io.element.android.features.messages.impl
20+
21+
import androidx.compose.foundation.layout.PaddingValues
22+
import androidx.compose.foundation.layout.fillMaxHeight
23+
import androidx.compose.material3.BottomSheetScaffold
24+
import androidx.compose.material3.ExperimentalMaterial3Api
25+
import androidx.compose.material3.SheetState
26+
import androidx.compose.material3.SheetValue
27+
import androidx.compose.material3.rememberBottomSheetScaffoldState
28+
import androidx.compose.material3.rememberStandardBottomSheetState
29+
import androidx.compose.runtime.Composable
30+
import androidx.compose.runtime.LaunchedEffect
31+
import androidx.compose.runtime.derivedStateOf
32+
import androidx.compose.runtime.getValue
33+
import androidx.compose.runtime.mutableStateOf
34+
import androidx.compose.runtime.remember
35+
import androidx.compose.runtime.setValue
36+
import androidx.compose.ui.Modifier
37+
import androidx.compose.ui.graphics.Shape
38+
import androidx.compose.ui.layout.Layout
39+
import androidx.compose.ui.layout.Measurable
40+
import androidx.compose.ui.layout.SubcomposeLayout
41+
import androidx.compose.ui.unit.Constraints
42+
import androidx.compose.ui.unit.Dp
43+
import androidx.compose.ui.unit.dp
44+
import androidx.compose.ui.unit.min
45+
import kotlin.math.roundToInt
46+
47+
/**
48+
* A [BottomSheetScaffold] that allows the sheet to be expanded the screen height
49+
* of the sheet contents.
50+
*
51+
* @param content The main content.
52+
* @param sheetContent The sheet content.
53+
* @param sheetDragHandle The drag handle for the sheet.
54+
* @param sheetSwipeEnabled Whether the sheet can be swiped. This value is ignored and swipe is disabled if the sheet content overflows.
55+
* @param sheetShape The shape of the sheet.
56+
* @param sheetTonalElevation The tonal elevation of the sheet.
57+
* @param sheetShadowElevation The shadow elevation of the sheet.
58+
* @param modifier The modifier for the layout.
59+
* @param sheetContentKey The key for the sheet content. If the key changes, the sheet will be remeasured.
60+
*/
61+
@Composable
62+
internal fun ExpandableBottomSheetScaffold(
63+
content: @Composable (padding: PaddingValues) -> Unit,
64+
sheetContent: @Composable (subcomposing: Boolean) -> Unit,
65+
sheetDragHandle: @Composable () -> Unit,
66+
sheetSwipeEnabled: Boolean,
67+
sheetShape: Shape,
68+
sheetTonalElevation: Dp,
69+
sheetShadowElevation: Dp,
70+
modifier: Modifier = Modifier,
71+
sheetContentKey: Int? = null,
72+
) {
73+
val scaffoldState = rememberBottomSheetScaffoldState(
74+
bottomSheetState = rememberStandardBottomSheetState(
75+
initialValue = SheetValue.PartiallyExpanded,
76+
skipHiddenState = true,
77+
)
78+
)
79+
80+
// If the content overflows, we disable swipe to prevent the sheet from intercepting
81+
// scroll events of the sheet content.
82+
var contentOverflows by remember { mutableStateOf(false) }
83+
val sheetSwipeEnabledIfPossible by remember(contentOverflows, sheetSwipeEnabled) {
84+
derivedStateOf {
85+
sheetSwipeEnabled && !contentOverflows
86+
}
87+
}
88+
89+
LaunchedEffect(sheetSwipeEnabledIfPossible) {
90+
if (!sheetSwipeEnabledIfPossible) {
91+
scaffoldState.bottomSheetState.partialExpand()
92+
}
93+
}
94+
95+
@Composable
96+
fun Scaffold(
97+
sheetContent: @Composable () -> Unit,
98+
dragHandle: @Composable () -> Unit,
99+
peekHeight: Dp,
100+
) {
101+
BottomSheetScaffold(
102+
modifier = Modifier,
103+
scaffoldState = scaffoldState,
104+
sheetPeekHeight = peekHeight,
105+
sheetSwipeEnabled = sheetSwipeEnabledIfPossible,
106+
sheetDragHandle = dragHandle,
107+
sheetShape = sheetShape,
108+
content = content,
109+
sheetContent = { sheetContent() },
110+
sheetTonalElevation = sheetTonalElevation,
111+
sheetShadowElevation = sheetShadowElevation,
112+
)
113+
}
114+
115+
SubcomposeLayout(
116+
modifier = modifier,
117+
measurePolicy = { constraints: Constraints ->
118+
val sheetContentSub = subcompose(Slot.SheetContent(sheetContentKey)) { sheetContent(true) }.map {
119+
it.measure(Constraints(maxWidth = constraints.maxWidth))
120+
}.first()
121+
val dragHandleSub = subcompose(Slot.DragHandle) { sheetDragHandle() }.map {
122+
it.measure(Constraints(maxWidth = constraints.maxWidth))
123+
}.firstOrNull()
124+
val dragHandleHeight = dragHandleSub?.height?.toDp() ?: 0.dp
125+
126+
val maxHeight = constraints.maxHeight.toDp()
127+
val contentHeight = sheetContentSub.height.toDp() + dragHandleHeight
128+
129+
contentOverflows = contentHeight > maxHeight
130+
131+
val peekHeight = min(
132+
maxHeight, // prevent the sheet from expanding beyond the screen
133+
contentHeight
134+
)
135+
136+
val scaffoldPlaceables = subcompose(Slot.Scaffold) {
137+
Scaffold({
138+
Layout(
139+
modifier = Modifier.fillMaxHeight(),
140+
measurePolicy = { measurables, constraints ->
141+
val constraintHeight = constraints.maxHeight
142+
val offset = scaffoldState.bottomSheetState.getOffset() ?: 0
143+
val height = Integer.max(0, constraintHeight - offset)
144+
val top = measurables[0].measure(
145+
constraints.copy(
146+
minHeight = height,
147+
maxHeight = height
148+
)
149+
)
150+
layout(constraints.maxWidth, constraints.maxHeight) {
151+
top.place(x = 0, y = 0)
152+
}
153+
},
154+
content = { sheetContent(false) })
155+
}, sheetDragHandle, peekHeight)
156+
}.map { measurable: Measurable ->
157+
measurable.measure(constraints)
158+
}
159+
val scaffoldPlaceable = scaffoldPlaceables.first()
160+
layout(constraints.maxWidth, constraints.maxHeight) {
161+
scaffoldPlaceable.place(0, 0)
162+
}
163+
})
164+
}
165+
166+
private fun SheetState.getOffset(): Int? = try {
167+
requireOffset().roundToInt()
168+
} catch (e: IllegalStateException) {
169+
null
170+
}
171+
172+
private sealed class Slot {
173+
data class SheetContent(val key: Int?) : Slot()
174+
data object DragHandle : Slot()
175+
data object Scaffold : Slot()
176+
}
177+

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
4545
roomName = Async.Uninitialized,
4646
roomAvatar = Async.Uninitialized,
4747
),
48+
aMessagesState().copy(composerState = aMessageComposerState().copy(showTextFormatting = true)),
4849
)
4950
}
5051

@@ -55,9 +56,7 @@ fun aMessagesState() = MessagesState(
5556
userHasPermissionToSendMessage = true,
5657
userHasPermissionToRedact = false,
5758
composerState = aMessageComposerState().copy(
58-
richTextEditorState = RichTextEditorState("Hello", fake = true).apply {
59-
requestFocus()
60-
},
59+
richTextEditorState = RichTextEditorState("Hello", initialFocus = true),
6160
isFullScreen = false,
6261
mode = MessageComposerMode.Normal("Hello"),
6362
),

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt

Lines changed: 47 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ package io.element.android.features.messages.impl
1919
import androidx.compose.foundation.background
2020
import androidx.compose.foundation.clickable
2121
import androidx.compose.foundation.layout.Arrangement
22+
import androidx.compose.foundation.layout.Box
2223
import androidx.compose.foundation.layout.Column
23-
import androidx.compose.foundation.layout.ExperimentalLayoutApi
2424
import androidx.compose.foundation.layout.Row
2525
import androidx.compose.foundation.layout.Spacer
2626
import androidx.compose.foundation.layout.WindowInsets
@@ -32,13 +32,13 @@ import androidx.compose.foundation.layout.navigationBarsPadding
3232
import androidx.compose.foundation.layout.padding
3333
import androidx.compose.foundation.layout.statusBars
3434
import androidx.compose.foundation.layout.width
35-
import androidx.compose.foundation.layout.wrapContentHeight
3635
import androidx.compose.material3.ExperimentalMaterial3Api
3736
import androidx.compose.material3.MaterialTheme
3837
import androidx.compose.runtime.Composable
3938
import androidx.compose.runtime.LaunchedEffect
4039
import androidx.compose.ui.Alignment
4140
import androidx.compose.ui.Modifier
41+
import androidx.compose.ui.graphics.RectangleShape
4242
import androidx.compose.ui.platform.LocalView
4343
import androidx.compose.ui.res.stringResource
4444
import androidx.compose.ui.text.font.FontStyle
@@ -71,8 +71,9 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
7171
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
7272
import io.element.android.libraries.designsystem.components.button.BackButton
7373
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
74-
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
7574
import io.element.android.libraries.designsystem.preview.ElementPreview
75+
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
76+
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
7677
import io.element.android.libraries.designsystem.theme.components.Scaffold
7778
import io.element.android.libraries.designsystem.theme.components.Text
7879
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@@ -86,7 +87,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
8687
import kotlinx.collections.immutable.ImmutableList
8788
import timber.log.Timber
8889

89-
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
9090
@Composable
9191
fun MessagesView(
9292
state: MessagesState,
@@ -277,40 +277,53 @@ private fun MessagesViewContent(
277277
modifier: Modifier = Modifier,
278278
onSwipeToReply: (TimelineItem.Event) -> Unit,
279279
) {
280-
Column(
280+
Box(
281281
modifier = modifier
282282
.fillMaxSize()
283283
.navigationBarsPadding()
284-
.imePadding()
284+
.imePadding(),
285285
) {
286-
// Hide timeline if composer is full screen
287-
if (!state.composerState.isFullScreen) {
288-
TimelineView(
289-
state = state.timelineState,
290-
modifier = Modifier.weight(1f),
291-
onMessageClicked = onMessageClicked,
292-
onMessageLongClicked = onMessageLongClicked,
293-
onUserDataClicked = onUserDataClicked,
294-
onTimestampClicked = onTimestampClicked,
295-
onReactionClicked = onReactionClicked,
296-
onReactionLongClicked = onReactionLongClicked,
297-
onMoreReactionsClicked = onMoreReactionsClicked,
298-
onSwipeToReply = onSwipeToReply,
299-
)
300-
}
301-
if (state.userHasPermissionToSendMessage) {
302-
MessageComposerView(
303-
state = state.composerState,
304-
onSendLocationClicked = onSendLocationClicked,
305-
onCreatePollClicked = onCreatePollClicked,
306-
enableTextFormatting = state.enableTextFormatting,
307-
modifier = Modifier
308-
.fillMaxWidth()
309-
.wrapContentHeight(Alignment.Bottom)
310-
)
311-
} else {
312-
CantSendMessageBanner()
313-
}
286+
ExpandableBottomSheetScaffold(
287+
sheetDragHandle = if (state.composerState.showTextFormatting) {
288+
@Composable { BottomSheetDragHandle() }
289+
} else {
290+
@Composable {}
291+
},
292+
sheetSwipeEnabled = state.composerState.showTextFormatting,
293+
sheetShape = if (state.composerState.showTextFormatting) MaterialTheme.shapes.large else RectangleShape,
294+
content = { paddingValues ->
295+
TimelineView(
296+
modifier = Modifier.padding(paddingValues),
297+
state = state.timelineState,
298+
onMessageClicked = onMessageClicked,
299+
onMessageLongClicked = onMessageLongClicked,
300+
onUserDataClicked = onUserDataClicked,
301+
onTimestampClicked = onTimestampClicked,
302+
onReactionClicked = onReactionClicked,
303+
onReactionLongClicked = onReactionLongClicked,
304+
onMoreReactionsClicked = onMoreReactionsClicked,
305+
onSwipeToReply = onSwipeToReply,
306+
)
307+
},
308+
sheetContent = { subcomposing: Boolean ->
309+
if (state.userHasPermissionToSendMessage) {
310+
MessageComposerView(
311+
state = state.composerState,
312+
subcomposing = subcomposing,
313+
onSendLocationClicked = onSendLocationClicked,
314+
onCreatePollClicked = onCreatePollClicked,
315+
enableTextFormatting = state.enableTextFormatting,
316+
modifier = Modifier
317+
.fillMaxWidth(),
318+
)
319+
} else {
320+
CantSendMessageBanner()
321+
}
322+
},
323+
sheetContentKey = state.composerState.richTextEditorState.lineCount,
324+
sheetTonalElevation = 0.dp,
325+
sheetShadowElevation = 0.dp,
326+
)
314327
}
315328
}
316329

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,9 @@ class MessageComposerPresenter @Inject constructor(
155155
when (event) {
156156
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
157157
MessageComposerEvents.CloseSpecialMode -> {
158-
richTextEditorState.setHtml("")
158+
localCoroutineScope.launch {
159+
richTextEditorState.setHtml("")
160+
}
159161
messageComposerContext.composerMode = MessageComposerMode.Normal("")
160162
}
161163
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,13 @@
1717
package io.element.android.features.messages.impl.messagecomposer
1818

1919
import androidx.compose.runtime.Immutable
20+
import androidx.compose.runtime.Stable
2021
import io.element.android.features.messages.impl.attachments.Attachment
2122
import io.element.android.libraries.textcomposer.MessageComposerMode
2223
import io.element.android.wysiwyg.compose.RichTextEditorState
2324
import kotlinx.collections.immutable.ImmutableList
2425

25-
@Immutable
26+
@Stable
2627
data class MessageComposerState(
2728
val richTextEditorState: RichTextEditorState,
2829
val isFullScreen: Boolean,
@@ -34,7 +35,6 @@ data class MessageComposerState(
3435
val attachmentsState: AttachmentsState,
3536
val eventSink: (MessageComposerEvents) -> Unit,
3637
) {
37-
val canSendMessage: Boolean = richTextEditorState.messageHtml.isNotEmpty()
3838
val hasFocus: Boolean = richTextEditorState.hasFocus
3939
}
4040

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
2828
}
2929

3030
fun aMessageComposerState(
31-
requestFocus: Boolean = true,
32-
composerState: RichTextEditorState = RichTextEditorState("", fake = true),
31+
composerState: RichTextEditorState = RichTextEditorState(""),
3332
isFullScreen: Boolean = false,
3433
mode: MessageComposerMode = MessageComposerMode.Normal(content = ""),
3534
showTextFormatting: Boolean = false,
@@ -38,7 +37,7 @@ fun aMessageComposerState(
3837
canCreatePoll: Boolean = true,
3938
attachmentsState: AttachmentsState = AttachmentsState.None,
4039
) = MessageComposerState(
41-
richTextEditorState = composerState.apply { if(requestFocus) requestFocus() },
40+
richTextEditorState = composerState,
4241
isFullScreen = isFullScreen,
4342
mode = mode,
4443
showTextFormatting = showTextFormatting,

0 commit comments

Comments
 (0)