Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Rich text editor] Add full screen mode #1447

Merged
merged 9 commits into from
Sep 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1447.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[Rich text editor] Add full screen mode
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

@file:OptIn(ExperimentalMaterial3Api::class)

package io.element.android.features.messages.impl

import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetState
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 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.
*/
@Composable
internal fun ExpandableBottomSheetScaffold(
content: @Composable (padding: PaddingValues) -> Unit,
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,
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.height.toDp() + dragHandleHeight

contentOverflows = contentHeight > maxHeight

val peekHeight = min(
maxHeight, // prevent the sheet from expanding beyond the screen
contentHeight
)

val scaffoldPlaceables = subcompose(Slot.Scaffold) {
Scaffold({
Layout(
modifier = Modifier.fillMaxHeight(),
measurePolicy = { measurables, constraints ->
val constraintHeight = constraints.maxHeight
val offset = scaffoldState.bottomSheetState.getOffset() ?: 0
val height = Integer.max(0, constraintHeight - offset)
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 fun SheetState.getOffset(): Int? = try {
requireOffset().roundToInt()
} catch (e: IllegalStateException) {
null
}

private sealed class Slot {
data class SheetContent(val key: Int?) : Slot()
data object DragHandle : Slot()
data object Scaffold : Slot()
}

Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
roomName = Async.Uninitialized,
roomAvatar = Async.Uninitialized,
),
aMessagesState().copy(composerState = aMessageComposerState().copy(showTextFormatting = true)),
)
}

Expand All @@ -55,9 +56,7 @@ fun aMessagesState() = MessagesState(
userHasPermissionToSendMessage = true,
userHasPermissionToRedact = false,
composerState = aMessageComposerState().copy(
richTextEditorState = RichTextEditorState("Hello", fake = true).apply {
requestFocus()
},
richTextEditorState = RichTextEditorState("Hello", initialFocus = true),
isFullScreen = false,
mode = MessageComposerMode.Normal("Hello"),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ package io.element.android.features.messages.impl
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
Expand All @@ -32,13 +32,13 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
Expand Down Expand Up @@ -71,8 +71,9 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
Expand All @@ -86,7 +87,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import timber.log.Timber

@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
fun MessagesView(
state: MessagesState,
Expand Down Expand Up @@ -277,40 +277,53 @@ private fun MessagesViewContent(
modifier: Modifier = Modifier,
onSwipeToReply: (TimelineItem.Event) -> Unit,
) {
Column(
Box(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding()
.imePadding()
.imePadding(),
) {
// Hide timeline if composer is full screen
if (!state.composerState.isFullScreen) {
TimelineView(
state = state.timelineState,
modifier = Modifier.weight(1f),
onMessageClicked = onMessageClicked,
onMessageLongClicked = onMessageLongClicked,
onUserDataClicked = onUserDataClicked,
onTimestampClicked = onTimestampClicked,
onReactionClicked = onReactionClicked,
onReactionLongClicked = onReactionLongClicked,
onMoreReactionsClicked = onMoreReactionsClicked,
onSwipeToReply = onSwipeToReply,
)
}
if (state.userHasPermissionToSendMessage) {
MessageComposerView(
state = state.composerState,
onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked,
enableTextFormatting = state.enableTextFormatting,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(Alignment.Bottom)
)
} else {
CantSendMessageBanner()
}
ExpandableBottomSheetScaffold(
sheetDragHandle = if (state.composerState.showTextFormatting) {
@Composable { BottomSheetDragHandle() }
} else {
@Composable {}
},
sheetSwipeEnabled = state.composerState.showTextFormatting,
sheetShape = if (state.composerState.showTextFormatting) MaterialTheme.shapes.large else RectangleShape,
content = { paddingValues ->
TimelineView(
modifier = Modifier.padding(paddingValues),
state = state.timelineState,
onMessageClicked = onMessageClicked,
onMessageLongClicked = onMessageLongClicked,
onUserDataClicked = onUserDataClicked,
onTimestampClicked = onTimestampClicked,
onReactionClicked = onReactionClicked,
onReactionLongClicked = onReactionLongClicked,
onMoreReactionsClicked = onMoreReactionsClicked,
onSwipeToReply = onSwipeToReply,
)
},
sheetContent = { subcomposing: Boolean ->
if (state.userHasPermissionToSendMessage) {
MessageComposerView(
state = state.composerState,
subcomposing = subcomposing,
onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked,
enableTextFormatting = state.enableTextFormatting,
modifier = Modifier
.fillMaxWidth(),
)
} else {
CantSendMessageBanner()
}
},
sheetContentKey = state.composerState.richTextEditorState.lineCount,
sheetTonalElevation = 0.dp,
sheetShadowElevation = 0.dp,
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,9 @@ class MessageComposerPresenter @Inject constructor(
when (event) {
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
MessageComposerEvents.CloseSpecialMode -> {
richTextEditorState.setHtml("")
localCoroutineScope.launch {
richTextEditorState.setHtml("")
}
messageComposerContext.composerMode = MessageComposerMode.Normal("")
}
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@
package io.element.android.features.messages.impl.messagecomposer

import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList

@Immutable
@Stable
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

richTextEditorState is mutable

data class MessageComposerState(
val richTextEditorState: RichTextEditorState,
val isFullScreen: Boolean,
Expand All @@ -34,7 +35,6 @@ data class MessageComposerState(
val attachmentsState: AttachmentsState,
val eventSink: (MessageComposerEvents) -> Unit,
) {
val canSendMessage: Boolean = richTextEditorState.messageHtml.isNotEmpty()
val hasFocus: Boolean = richTextEditorState.hasFocus
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
}

fun aMessageComposerState(
requestFocus: Boolean = true,
composerState: RichTextEditorState = RichTextEditorState("", fake = true),
composerState: RichTextEditorState = RichTextEditorState(""),
isFullScreen: Boolean = false,
mode: MessageComposerMode = MessageComposerMode.Normal(content = ""),
showTextFormatting: Boolean = false,
Expand All @@ -38,7 +37,7 @@ fun aMessageComposerState(
canCreatePoll: Boolean = true,
attachmentsState: AttachmentsState = AttachmentsState.None,
) = MessageComposerState(
richTextEditorState = composerState.apply { if(requestFocus) requestFocus() },
richTextEditorState = composerState,
isFullScreen = isFullScreen,
mode = mode,
showTextFormatting = showTextFormatting,
Expand Down
Loading