Update voice message recording button behaviour (#1784)
Changes recording button behaviour so that - tapping the record button starts a recording and displays the stop button - tapping the stop button stops the recording - tapping the delete button cancels the recording - 'hold to record' tooltip is removed --------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
ce75f7465d
commit
53f03335c3
37 changed files with 221 additions and 576 deletions
|
|
@ -66,7 +66,7 @@ import io.element.android.libraries.testtags.TestTags
|
|||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.textcomposer.components.ComposerOptionsButton
|
||||
import io.element.android.libraries.textcomposer.components.DismissTextFormattingButton
|
||||
import io.element.android.libraries.textcomposer.components.RecordButton
|
||||
import io.element.android.libraries.textcomposer.components.VoiceMessageRecorderButton
|
||||
import io.element.android.libraries.textcomposer.components.SendButton
|
||||
import io.element.android.libraries.textcomposer.components.TextFormatting
|
||||
import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteButton
|
||||
|
|
@ -75,7 +75,7 @@ import io.element.android.libraries.textcomposer.components.VoiceMessageRecordin
|
|||
import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
|
|
@ -103,7 +103,7 @@ fun TextComposer(
|
|||
onResetComposerMode: () -> Unit = {},
|
||||
onAddAttachment: () -> Unit = {},
|
||||
onDismissTextFormatting: () -> Unit = {},
|
||||
onVoiceRecordButtonEvent: (PressEvent) -> Unit = {},
|
||||
onVoiceRecorderEvent: (VoiceMessageRecorderEvent) -> Unit = {},
|
||||
onVoicePlayerEvent: (VoiceMessagePlayerEvent) -> Unit = {},
|
||||
onSendVoiceMessage: () -> Unit = {},
|
||||
onDeleteVoiceMessage: () -> Unit = {},
|
||||
|
|
@ -167,16 +167,15 @@ fun TextComposer(
|
|||
)
|
||||
}
|
||||
val recordVoiceButton = @Composable {
|
||||
RecordButton(
|
||||
onPressStart = { onVoiceRecordButtonEvent(PressEvent.PressStart) },
|
||||
onLongPressEnd = { onVoiceRecordButtonEvent(PressEvent.LongPressEnd) },
|
||||
onTap = { onVoiceRecordButtonEvent(PressEvent.Tapped) },
|
||||
VoiceMessageRecorderButton(
|
||||
isRecording = voiceMessageState is VoiceMessageState.Recording,
|
||||
onEvent = onVoiceRecorderEvent,
|
||||
)
|
||||
}
|
||||
val sendVoiceButton = @Composable {
|
||||
SendButton(
|
||||
canSendMessage = voiceMessageState is VoiceMessageState.Preview,
|
||||
onClick = { onSendVoiceMessage() },
|
||||
onClick = onSendVoiceMessage,
|
||||
composerMode = composerMode,
|
||||
)
|
||||
}
|
||||
|
|
@ -223,8 +222,12 @@ fun TextComposer(
|
|||
}
|
||||
|
||||
val voiceDeleteButton = @Composable {
|
||||
if (voiceMessageState is VoiceMessageState.Preview) {
|
||||
VoiceMessageDeleteButton(enabled = !voiceMessageState.isSending, onClick = onDeleteVoiceMessage)
|
||||
when (voiceMessageState) {
|
||||
is VoiceMessageState.Preview ->
|
||||
VoiceMessageDeleteButton(enabled = !voiceMessageState.isSending, onClick = onDeleteVoiceMessage)
|
||||
is VoiceMessageState.Recording ->
|
||||
VoiceMessageDeleteButton(enabled = true, onClick = { onVoiceRecorderEvent(VoiceMessageRecorderEvent.Cancel) })
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -286,7 +289,7 @@ private fun StandardLayout(
|
|||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
if (enableVoiceMessages && voiceMessageState !is VoiceMessageState.Idle) {
|
||||
if (voiceMessageState is VoiceMessageState.Preview) {
|
||||
if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Recording) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp)
|
||||
|
|
|
|||
|
|
@ -1,189 +0,0 @@
|
|||
/*
|
||||
* 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.libraries.textcomposer.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TooltipState
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.components.tooltip.ElementTooltipDefaults
|
||||
import io.element.android.libraries.designsystem.components.tooltip.PlainTooltip
|
||||
import io.element.android.libraries.designsystem.components.tooltip.TooltipBox
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.textcomposer.R
|
||||
import io.element.android.libraries.textcomposer.utils.PressState
|
||||
import io.element.android.libraries.textcomposer.utils.PressStateEffects
|
||||
import io.element.android.libraries.textcomposer.utils.rememberPressState
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun RecordButton(
|
||||
modifier: Modifier = Modifier,
|
||||
initialTooltipIsVisible: Boolean = false,
|
||||
onPressStart: () -> Unit = {},
|
||||
onLongPressEnd: () -> Unit = {},
|
||||
onTap: () -> Unit = {},
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pressState = rememberPressState()
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
|
||||
val performHapticFeedback = {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
|
||||
val tooltipState = rememberTooltipState(
|
||||
initialIsVisible = initialTooltipIsVisible
|
||||
)
|
||||
|
||||
PressStateEffects(
|
||||
pressState = pressState.value,
|
||||
onPressStart = {
|
||||
onPressStart()
|
||||
performHapticFeedback()
|
||||
},
|
||||
onLongPressEnd = {
|
||||
onLongPressEnd()
|
||||
performHapticFeedback()
|
||||
},
|
||||
onTap = {
|
||||
onTap()
|
||||
performHapticFeedback()
|
||||
coroutineScope.launch { tooltipState.show() }
|
||||
},
|
||||
)
|
||||
Box(modifier = modifier) {
|
||||
HoldToRecordTooltip(
|
||||
tooltipState = tooltipState,
|
||||
spacingBetweenTooltipAndAnchor = 0.dp, // Accounts for the 48.dp size of the record button
|
||||
anchor = {
|
||||
RecordButtonView(
|
||||
isPressed = pressState.value is PressState.Pressing,
|
||||
modifier = Modifier
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
coroutineScope.launch {
|
||||
when (event.type) {
|
||||
PointerEventType.Press -> pressState.press()
|
||||
PointerEventType.Release -> pressState.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordButtonView(
|
||||
isPressed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
IconButton(
|
||||
modifier = modifier
|
||||
.size(48.dp),
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
resourceId = if (isPressed) {
|
||||
CommonDrawables.ic_compound_mic_on_solid
|
||||
} else {
|
||||
CommonDrawables.ic_compound_mic_on_outline
|
||||
},
|
||||
contentDescription = stringResource(CommonStrings.a11y_voice_message_record),
|
||||
tint = ElementTheme.colors.iconSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HoldToRecordTooltip(
|
||||
tooltipState: TooltipState,
|
||||
spacingBetweenTooltipAndAnchor: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
anchor: @Composable () -> Unit,
|
||||
) {
|
||||
TooltipBox(
|
||||
positionProvider = ElementTooltipDefaults.rememberPlainTooltipPositionProvider(
|
||||
spacingBetweenTooltipAndAnchor = spacingBetweenTooltipAndAnchor,
|
||||
),
|
||||
tooltip = {
|
||||
PlainTooltip {
|
||||
Text(
|
||||
text = stringResource(R.string.screen_room_voice_message_tooltip),
|
||||
color = ElementTheme.colors.textOnSolidPrimary,
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
state = tooltipState,
|
||||
modifier = modifier,
|
||||
focusable = false,
|
||||
enableUserInput = false,
|
||||
content = anchor,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RecordButtonPreview() = ElementPreview {
|
||||
Row {
|
||||
RecordButtonView(isPressed = false)
|
||||
RecordButtonView(isPressed = true)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun HoldToRecordTooltipPreview() = ElementPreview {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
RecordButton(
|
||||
modifier = Modifier.align(Alignment.BottomEnd),
|
||||
initialTooltipIsVisible = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.textcomposer.R
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.ui.utils.time.formatShort
|
||||
|
|
@ -145,14 +145,14 @@ private fun PlayerButton(
|
|||
|
||||
@Composable
|
||||
private fun PauseIcon() = Icon(
|
||||
resourceId = R.drawable.ic_pause,
|
||||
resourceId = CommonDrawables.ic_pause,
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_pause),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun PlayIcon() = Icon(
|
||||
resourceId = R.drawable.ic_play,
|
||||
resourceId = CommonDrawables.ic_play,
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_play),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
|
||||
package io.element.android.libraries.textcomposer.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
internal fun VoiceMessageRecorderButton(
|
||||
isRecording: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onEvent: (VoiceMessageRecorderEvent) -> Unit = {},
|
||||
) {
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
|
||||
val performHapticFeedback = {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
|
||||
if (isRecording) {
|
||||
StopButton(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
performHapticFeedback()
|
||||
onEvent(VoiceMessageRecorderEvent.Stop)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
StartButton(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
performHapticFeedback()
|
||||
onEvent(VoiceMessageRecorderEvent.Start)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StartButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) = IconButton(
|
||||
modifier = modifier.size(48.dp),
|
||||
onClick = onClick,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
resourceId = CommonDrawables.ic_compound_mic_on_outline,
|
||||
contentDescription = stringResource(CommonStrings.a11y_voice_message_record),
|
||||
tint = ElementTheme.colors.iconSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StopButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) = IconButton(
|
||||
modifier = modifier
|
||||
.size(48.dp),
|
||||
onClick = onClick,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.background(
|
||||
color = ElementTheme.colors.bgActionPrimaryRest,
|
||||
shape = CircleShape,
|
||||
)
|
||||
)
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
resourceId = CommonDrawables.ic_stop,
|
||||
contentDescription = stringResource(CommonStrings.a11y_voice_message_stop_recording),
|
||||
tint = ElementTheme.colors.iconOnSolidPrimary,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun VoiceMessageRecorderButtonPreview() = ElementPreview {
|
||||
Row {
|
||||
VoiceMessageRecorderButton(isRecording = false)
|
||||
VoiceMessageRecorderButton(isRecording = true)
|
||||
}
|
||||
}
|
||||
|
|
@ -16,8 +16,8 @@
|
|||
|
||||
package io.element.android.libraries.textcomposer.model
|
||||
|
||||
sealed interface PressEvent {
|
||||
data object PressStart: PressEvent
|
||||
data object Tapped: PressEvent
|
||||
data object LongPressEnd: PressEvent
|
||||
sealed interface VoiceMessageRecorderEvent {
|
||||
data object Start: VoiceMessageRecorderEvent
|
||||
data object Stop: VoiceMessageRecorderEvent
|
||||
data object Cancel: VoiceMessageRecorderEvent
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.textcomposer.utils
|
||||
|
||||
/**
|
||||
* State of a press gesture.
|
||||
*/
|
||||
internal sealed interface PressState {
|
||||
data class Idle(
|
||||
val lastPress: Pressing?
|
||||
) : PressState
|
||||
|
||||
sealed interface Pressing : PressState
|
||||
data object Tapping : Pressing
|
||||
data object LongPressing : Pressing
|
||||
}
|
||||
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.textcomposer.utils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
||||
/**
|
||||
* React to [PressState] changes.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PressStateEffects(
|
||||
pressState: PressState,
|
||||
onPressStart: () -> Unit = {},
|
||||
onLongPressStart: () -> Unit = {},
|
||||
onTap: () -> Unit = {},
|
||||
onLongPressEnd: () -> Unit = {},
|
||||
) {
|
||||
LaunchedEffect(pressState) {
|
||||
when (pressState) {
|
||||
is PressState.Idle ->
|
||||
when (pressState.lastPress) {
|
||||
PressState.Tapping -> onTap()
|
||||
PressState.LongPressing -> onLongPressEnd()
|
||||
null -> {} // Do nothing
|
||||
}
|
||||
is PressState.LongPressing -> onLongPressStart()
|
||||
PressState.Tapping -> onPressStart()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.textcomposer.utils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import timber.log.Timber
|
||||
|
||||
@Composable
|
||||
internal fun rememberPressState(
|
||||
longPressTimeoutMillis: Long = LocalViewConfiguration.current.longPressTimeoutMillis,
|
||||
): PressStateHolder {
|
||||
return remember(longPressTimeoutMillis) {
|
||||
PressStateHolder(longPressTimeoutMillis = longPressTimeoutMillis)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State machine that keeps track of the pressed state.
|
||||
*
|
||||
* When a press is started, the state will transition through:
|
||||
* [PressState.Idle] -> [PressState.Tapping] -> ...
|
||||
*
|
||||
* If a press is held for a longer time, the state will continue through:
|
||||
* ... -> [PressState.LongPressing] -> ...
|
||||
*
|
||||
* When the press is released the states will then transition back to idle.
|
||||
* ... -> [PressState.Idle]
|
||||
*
|
||||
* Whether a press should be considered a tap or a long press can be determined by
|
||||
* looking at the last press when in the idle state.
|
||||
*
|
||||
* @see [PressStateEffects]
|
||||
* @see [rememberPressState]
|
||||
*/
|
||||
internal class PressStateHolder(
|
||||
private val longPressTimeoutMillis: Long,
|
||||
) : State<PressState> {
|
||||
private var state: PressState by mutableStateOf(PressState.Idle(lastPress = null))
|
||||
|
||||
override val value: PressState
|
||||
get() = state
|
||||
|
||||
private var longPressTimer: Job? = null
|
||||
|
||||
suspend fun press() = coroutineScope {
|
||||
when (state) {
|
||||
is PressState.Idle -> {
|
||||
state = PressState.Tapping
|
||||
}
|
||||
is PressState.Pressing ->
|
||||
Timber.e("Pointer pressed but it has not been released")
|
||||
}
|
||||
|
||||
longPressTimer = launch {
|
||||
delay(longPressTimeoutMillis)
|
||||
yield()
|
||||
|
||||
if (isActive && state == PressState.Tapping) {
|
||||
state = PressState.LongPressing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
longPressTimer?.cancel()
|
||||
longPressTimer = null
|
||||
when (val lastState = state) {
|
||||
is PressState.Pressing ->
|
||||
state = PressState.Idle(lastPress = lastState)
|
||||
is PressState.Idle ->
|
||||
Timber.e("Pointer pressed but it has not been released")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path
|
||||
android:pathData="M13.25,15.25C12.837,15.25 12.484,15.103 12.191,14.809C11.897,14.516 11.75,14.163 11.75,13.75V6.25C11.75,5.838 11.897,5.484 12.191,5.191C12.484,4.897 12.837,4.75 13.25,4.75H13.75C14.163,4.75 14.516,4.897 14.809,5.191C15.103,5.484 15.25,5.838 15.25,6.25V13.75C15.25,14.163 15.103,14.516 14.809,14.809C14.516,15.103 14.163,15.25 13.75,15.25H13.25ZM6.25,15.25C5.838,15.25 5.484,15.103 5.191,14.809C4.897,14.516 4.75,14.163 4.75,13.75V6.25C4.75,5.838 4.897,5.484 5.191,5.191C5.484,4.897 5.838,4.75 6.25,4.75H6.75C7.162,4.75 7.516,4.897 7.809,5.191C8.103,5.484 8.25,5.838 8.25,6.25V13.75C8.25,14.163 8.103,14.516 7.809,14.809C7.516,15.103 7.162,15.25 6.75,15.25H6.25Z"
|
||||
android:fillColor="#656D77"/>
|
||||
</vector>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path
|
||||
android:pathData="M8.042,14.958C7.75,15.153 7.451,15.167 7.146,15C6.84,14.833 6.688,14.569 6.688,14.208V5.75C6.688,5.389 6.84,5.125 7.146,4.958C7.451,4.792 7.75,4.806 8.042,5L14.688,9.25C14.965,9.417 15.104,9.66 15.104,9.979C15.104,10.299 14.965,10.542 14.688,10.708L8.042,14.958Z"
|
||||
android:fillColor="#656D77"/>
|
||||
</vector>
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.textcomposer.utils
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.textcomposer.utils.PressState.Idle
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class) class PressStateHolderTest {
|
||||
companion object {
|
||||
const val LONG_PRESS_TIMEOUT_MILLIS = 1L
|
||||
}
|
||||
@Test
|
||||
fun `it starts in idle state`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
assertThat(stateHolder.value).isEqualTo(Idle(lastPress = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when press, it moves to tapping state`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
val press = async { stateHolder.press() }
|
||||
advanceTimeBy(1.milliseconds)
|
||||
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||
press.await()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when release after short delay, it moves through tap states`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
val press = async { stateHolder.press() }
|
||||
advanceTimeBy(1.milliseconds)
|
||||
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||
stateHolder.release()
|
||||
advanceTimeBy(1.milliseconds) // wait for the long press timeout which should not be triggered
|
||||
assertThat(stateHolder.value).isEqualTo(Idle(lastPress = PressState.Tapping))
|
||||
press.await()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when hold, it moves through long press states`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
val press = async { stateHolder.press() }
|
||||
advanceTimeBy(1.milliseconds)
|
||||
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||
advanceTimeBy(1.milliseconds)
|
||||
assertThat(stateHolder.value).isEqualTo(PressState.LongPressing)
|
||||
stateHolder.release()
|
||||
assertThat(stateHolder.value).isEqualTo(Idle(lastPress = PressState.LongPressing))
|
||||
press.await()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when release and repress, it doesn't enter long press states`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
val press1 = async { stateHolder.press() }
|
||||
advanceTimeBy(1.milliseconds)
|
||||
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||
stateHolder.release()
|
||||
val press2 = async { stateHolder.press() }
|
||||
advanceTimeBy(1.milliseconds)
|
||||
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||
press1.await()
|
||||
press2.await()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when press twice without releasing, it doesn't throw an error`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
stateHolder.press()
|
||||
stateHolder.press()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when release without first pressing, it doesn't throw an error`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
stateHolder.release()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when release twice without pressing, it doesn't throw an error `() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
stateHolder.press()
|
||||
stateHolder.release()
|
||||
stateHolder.release()
|
||||
}
|
||||
|
||||
private fun createStateHolder() =
|
||||
PressStateHolder(
|
||||
LONG_PRESS_TIMEOUT_MILLIS,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue